diff options
462 files changed, 8524 insertions, 9982 deletions
diff --git a/.ci/azure-pipelines-package.yml b/.ci/azure-pipelines-package.yml index c28b1bf7f..c91a084e5 100644 --- a/.ci/azure-pipelines-package.yml +++ b/.ci/azure-pipelines-package.yml @@ -168,6 +168,7 @@ jobs: - job: CollectArtifacts timeoutInMinutes: 20 displayName: 'Collect Artifacts' + condition: succeededOrFailed() continueOnError: true dependsOn: - BuildPackage diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 000000000..2f789b031 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,12 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "7.0.12", + "commands": [ + "dotnet-ef" + ] + } + } +}
\ No newline at end of file diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index f83b38949..a5f36eab4 100644 --- a/.github/workflows/codeql-analysis.yml +++ b/.github/workflows/codeql-analysis.yml @@ -20,18 +20,18 @@ jobs: steps: - name: Checkout repository - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 - name: Setup .NET uses: actions/setup-dotnet@3447fd6a9f9e57506b15f895c5b76d3b197dc7c2 # v3.2.0 with: dotnet-version: '7.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 + uses: github/codeql-action/init@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 + uses: github/codeql-action/autobuild@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@f6e388ebf0efc915c6c5b165b019ee61a6746a38 # v2.20.1 + uses: github/codeql-action/analyze@0116bc2df50751f9724a2e35ef1f24d22f90e4e1 # v2.22.3 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 178959afc..8055438b5 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -24,7 +24,7 @@ jobs: reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 @@ -51,7 +51,7 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index d3dfd0a6a..c267fdcc2 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -14,7 +14,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -25,7 +25,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 with: name: openapi-head retention-days: 14 @@ -39,7 +39,7 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@c85c95e3d7251135ab7dc9ce3241c5835cc595a9 # v3.5.3 + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} @@ -59,7 +59,7 @@ jobs: - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@0b7f8abb1508181956e8e162db84b466c27e18ce # v3.1.2 + uses: actions/upload-artifact@a8a3f3ad30e3422c9c7b888a15615d19a852ae32 # v3.1.3 with: name: openapi-base retention-days: 14 diff --git a/.github/workflows/repo-bump-version.yaml b/.github/workflows/repo-bump-version.yaml new file mode 100644 index 000000000..e0383afd2 --- /dev/null +++ b/.github/workflows/repo-bump-version.yaml @@ -0,0 +1,82 @@ +name: '🆙 Auto bump_version' + +on: + release: + types: + - published + workflow_dispatch: + inputs: + TAG_BRANCH: + required: true + description: release-x.y.z + NEXT_VERSION: + required: true + description: x.y.z + +jobs: + auto_bump_version: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'release' && !contains(github.event.release.tag_name, 'rc') }} + env: + TAG_BRANCH: ${{ github.event.release.target_commitish }} + steps: + - name: Wait for deploy checks to finish + uses: jitterbit/await-check-suites@292a541bb7618078395b2ce711a0d89cfb8a568a # v1 + with: + ref: ${{ env.TAG_BRANCH }} + intervalSeconds: 60 + timeoutSeconds: 3600 + + - name: Setup YQ + uses: chrisdickinson/setup-yq@latest + with: + yq-version: v4.9.8 + + - name: Checkout Repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + ref: ${{ env.TAG_BRANCH }} + + - name: Setup EnvVars + run: |- + CURRENT_VERSION=$(yq e '.version' build.yaml) + CURRENT_MAJOR_MINOR=${CURRENT_VERSION%.*} + CURRENT_PATCH=${CURRENT_VERSION##*.} + echo "CURRENT_VERSION=${CURRENT_VERSION}" >> $GITHUB_ENV + echo "CURRENT_MAJOR_MINOR=${CURRENT_MAJOR_MINOR}" >> $GITHUB_ENV + echo "CURRENT_PATCH=${CURRENT_PATCH}" >> $GITHUB_ENV + echo "NEXT_VERSION=${CURRENT_MAJOR_MINOR}.$(($CURRENT_PATCH + 1))" >> $GITHUB_ENV + + - name: Run bump_version + run: ./bump_version ${{ env.NEXT_VERSION }} + + - name: Commit Changes + run: |- + git config user.name "jellyfin-bot" + git config user.email "team@jellyfin.org" + git checkout ${{ env.TAG_BRANCH }} + git commit -am "Bump version to ${{ env.NEXT_VERSION }}" + git push origin ${{ env.TAG_BRANCH }} + + manual_bump_version: + runs-on: ubuntu-latest + if: ${{ github.event_name == 'workflow_dispatch' }} + env: + TAG_BRANCH: ${{ github.event.inputs.TAG_BRANCH }} + NEXT_VERSION: ${{ github.event.inputs.NEXT_VERSION }} + steps: + - name: Checkout Repository + uses: actions/checkout@b4ffde65f46336ab88eb53be808477a3936bae11 # v4.1.1 + with: + ref: ${{ env.TAG_BRANCH }} + + - name: Run bump_version + run: ./bump_version ${{ env.NEXT_VERSION }} + + - name: Commit Changes + run: |- + git config user.name "jellyfin-bot" + git config user.email "team@jellyfin.org" + git checkout ${{ env.TAG_BRANCH }} + git commit -am "Bump version to ${{ env.NEXT_VERSION }}" + git push origin ${{ env.TAG_BRANCH }} diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index c753c1600..2b1164116 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -2,16 +2,17 @@ name: Stale Check on: schedule: - - cron: '30 1 * * *' + - cron: '30 */12 * * *' workflow_dispatch: permissions: issues: write pull-requests: write + actions: write jobs: issues: - name: Check issues + name: Check for stale issues runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: @@ -26,11 +27,11 @@ jobs: exempt-issue-labels: regression,security,roadmap,future,feature,enhancement,confirmed stale-issue-label: stale stale-issue-message: |- - 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. + This issue has gone 120 days without an update and will be closed within 21 days if there is no new activity. To prevent this issue from being closed, please confirm the issue has not already been fixed by providing updated examples or logs. - If you're the original submitter of this issue, please comment confirming if this issue still affects you in the latest release or master branch, or close the issue if it has been fixed. If you're another user also affected by this bug, please comment confirming so. Either action will remove the stale label. - - This bot exists to prevent issues from becoming stale and forgotten. Jellyfin is always moving forward, and bugs are often fixed as side effects of other changes. We therefore ask that bug report authors remain vigilant about their issues to ensure they are closed if fixed, or re-confirmed - perhaps with fresh logs or reproduction examples - regularly. If you have any questions you can reach us on [Matrix or Social Media](https://docs.jellyfin.org/general/getting-help.html). + If you have any questions you can use one of several ways to [contact us](https://jellyfin.org/contact). + close-issue-message: |- + This issue was closed due to inactivity. prs-conflicts: name: Check PRs with merge conflicts diff --git a/CONTRIBUTORS.md b/CONTRIBUTORS.md index 009610c41..dc5f99c0c 100644 --- a/CONTRIBUTORS.md +++ b/CONTRIBUTORS.md @@ -168,6 +168,8 @@ - [RealGreenDragon](https://github.com/RealGreenDragon) - [ipitio](https://github.com/ipitio) - [TheTyrius](https://github.com/TheTyrius) + - [tallbl0nde](https://github.com/tallbl0nde) + - [sleepycatcoding](https://github.com/sleepycatcoding) # Emby Contributors @@ -238,3 +240,4 @@ - [Jakob Kukla](https://github.com/jakobkukla) - [Utku Özdemir](https://github.com/utkuozdemir) - [JPUC1143](https://github.com/Jpuc1143/) + - [0x25CBFC4F](https://github.com/0x25CBFC4F) diff --git a/Directory.Packages.props b/Directory.Packages.props index c3532467a..d95cecdbf 100644 --- a/Directory.Packages.props +++ b/Directory.Packages.props @@ -10,26 +10,30 @@ <PackageVersion Include="AutoFixture.Xunit2" Version="4.18.0" /> <PackageVersion Include="AutoFixture" Version="4.18.0" /> <PackageVersion Include="BDInfo" Version="0.7.6.2" /> - <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.2.0" /> - <PackageVersion Include="BlurHashSharp" Version="1.2.0" /> + <PackageVersion Include="BlurHashSharp.SkiaSharp" Version="1.3.0" /> + <PackageVersion Include="BlurHashSharp" Version="1.3.0" /> <PackageVersion Include="CommandLineParser" Version="2.9.1" /> <PackageVersion Include="coverlet.collector" Version="6.0.0" /> <PackageVersion Include="Diacritics" Version="3.3.18" /> <PackageVersion Include="DiscUtils.Udf" Version="0.16.13" /> <PackageVersion Include="DotNet.Glob" Version="3.1.3" /> - <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.2" /> - <PackageVersion Include="FsCheck.Xunit" Version="2.16.5" /> + <PackageVersion Include="EFCoreSecondLevelCacheInterceptor" Version="3.9.4" /> + <PackageVersion Include="FsCheck.Xunit" Version="2.16.6" /> + <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="7.3.0" /> + <PackageVersion Include="IDisposableAnalyzers" Version="4.0.7" /> <PackageVersion Include="Jellyfin.XmlTv" Version="10.8.0" /> <PackageVersion Include="libse" Version="3.6.13" /> <PackageVersion Include="LrcParser" Version="2023.524.0" /> <PackageVersion Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> - <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.8" /> - <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.8" /> + <PackageVersion Include="Microsoft.AspNetCore.Authorization" Version="7.0.12" /> + <PackageVersion Include="Microsoft.AspNetCore.HttpOverrides" Version="2.2.0" /> + <PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="7.0.12" /> <PackageVersion Include="Microsoft.CodeAnalysis.BannedApiAnalyzers" Version="3.3.4" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.8" /> - <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.8" /> + <PackageVersion Include="Microsoft.Data.Sqlite" Version="7.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="7.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.12" /> + <PackageVersion Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.12" /> <PackageVersion Include="Microsoft.Extensions.Caching.Abstractions" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Configuration.Abstractions" Version="7.0.0" /> @@ -38,14 +42,14 @@ <PackageVersion Include="Microsoft.Extensions.Configuration.Json" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection.Abstractions" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.DependencyInjection" Version="7.0.0" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.8" /> - <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.8" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="7.0.12" /> + <PackageVersion Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="7.0.12" /> <PackageVersion Include="Microsoft.Extensions.Hosting.Abstractions" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Http" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Logging.Abstractions" Version="7.0.1" /> <PackageVersion Include="Microsoft.Extensions.Logging" Version="7.0.0" /> <PackageVersion Include="Microsoft.Extensions.Options" Version="7.0.1" /> - <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.6.3" /> + <PackageVersion Include="Microsoft.NET.Test.Sdk" Version="17.7.2" /> <PackageVersion Include="Microsoft.SourceLink.GitHub" Version="1.1.1" /> <PackageVersion Include="MimeTypes" Version="2.4.0" /> <PackageVersion Include="Mono.Nat" Version="3.0.4" /> @@ -53,28 +57,25 @@ <PackageVersion Include="NEbml" Version="0.11.0" /> <PackageVersion Include="Newtonsoft.Json" Version="13.0.3" /> <PackageVersion Include="PlaylistsNET" Version="1.4.0" /> - <PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.0" /> + <PackageVersion Include="prometheus-net.AspNetCore" Version="8.0.1" /> <PackageVersion Include="prometheus-net.DotNetRuntime" Version="4.4.0" /> - <PackageVersion Include="prometheus-net" Version="8.0.0" /> + <PackageVersion Include="prometheus-net" Version="8.0.1" /> <PackageVersion Include="Serilog.AspNetCore" Version="7.0.0" /> <PackageVersion Include="Serilog.Enrichers.Thread" Version="3.1.0" /> - <PackageVersion Include="Serilog.Settings.Configuration" Version="7.0.0" /> + <PackageVersion Include="Serilog.Settings.Configuration" Version="7.0.1" /> <PackageVersion Include="Serilog.Sinks.Async" Version="1.5.0" /> <PackageVersion Include="Serilog.Sinks.Console" Version="4.1.0" /> <PackageVersion Include="Serilog.Sinks.File" Version="5.0.0" /> - <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.0.1" /> + <PackageVersion Include="Serilog.Sinks.Graylog" Version="3.1.0" /> <PackageVersion Include="SerilogAnalyzer" Version="0.15.0" /> - <PackageVersion Include="SharpFuzz" Version="2.1.0" /> - <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.3" /> + <PackageVersion Include="SharpFuzz" Version="2.1.1" /> + <PackageVersion Include="SkiaSharp" Version="2.88.5" /> + <PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.5" /> + <PackageVersion Include="SkiaSharp.NativeAssets.Linux" Version="2.88.5" /> <PackageVersion Include="SkiaSharp.Svg" Version="1.60.0" /> - <PackageVersion Include="SkiaSharp.HarfBuzz" Version="2.88.3" /> - <PackageVersion Include="HarfBuzzSharp.NativeAssets.Linux" Version="2.8.2.3" /> - <PackageVersion Include="SkiaSharp" Version="2.88.3" /> <PackageVersion Include="SmartAnalyzers.MultithreadingAnalyzer" Version="1.1.31" /> - <PackageVersion Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> - <PackageVersion Include="SQLitePCLRaw.bundle_e_sqlite3" Version="2.1.5" /> <PackageVersion Include="StyleCop.Analyzers" Version="1.2.0-beta.507" /> - <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" /> + <PackageVersion Include="Swashbuckle.AspNetCore.ReDoc" Version="6.5.0" /> <PackageVersion Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageVersion Include="System.Globalization" Version="4.3.0" /> <PackageVersion Include="System.Linq.Async" Version="6.0.1" /> @@ -85,8 +86,8 @@ <PackageVersion Include="TMDbLib" Version="2.0.0" /> <PackageVersion Include="UTF.Unknown" Version="2.5.1" /> <PackageVersion Include="Xunit.Priority" Version="1.1.6" /> - <PackageVersion Include="xunit.runner.visualstudio" Version="2.4.5" /> + <PackageVersion Include="xunit.runner.visualstudio" Version="2.5.3" /> <PackageVersion Include="Xunit.SkippableFact" Version="1.4.13" /> - <PackageVersion Include="xunit" Version="2.4.2" /> + <PackageVersion Include="xunit" Version="2.5.3" /> </ItemGroup> </Project> diff --git a/Dockerfile b/Dockerfile index e51d285e1..9be319311 100644 --- a/Dockerfile +++ b/Dockerfile @@ -4,7 +4,7 @@ # https://github.com/multiarch/qemu-user-static#binfmt_misc-register ARG DOTNET_VERSION=7.0 -FROM node:lts-alpine as web-builder +FROM node:20-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ diff --git a/Dockerfile.arm b/Dockerfile.arm index 46a3e9b99..e8ec6398e 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -5,7 +5,7 @@ ARG DOTNET_VERSION=7.0 -FROM node:lts-alpine as web-builder +FROM node:20-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 4f9d5e1fd..83137ee89 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -5,7 +5,7 @@ ARG DOTNET_VERSION=7.0 -FROM node:lts-alpine as web-builder +FROM node:20-alpine as web-builder ARG JELLYFIN_WEB_VERSION=master RUN apk add curl git zlib zlib-dev autoconf g++ make libpng-dev gifsicle alpine-sdk automake libtool make gcc musl-dev nasm python3 \ && curl -L https://github.com/jellyfin/jellyfin-web/archive/${JELLYFIN_WEB_VERSION}.tar.gz | tar zxf - \ diff --git a/Emby.Dlna/Configuration/DlnaOptions.cs b/Emby.Dlna/Configuration/DlnaOptions.cs index e95a878c6..f233468de 100644 --- a/Emby.Dlna/Configuration/DlnaOptions.cs +++ b/Emby.Dlna/Configuration/DlnaOptions.cs @@ -17,7 +17,7 @@ namespace Emby.Dlna.Configuration BlastAliveMessages = true; SendOnlyMatchedHost = true; ClientDiscoveryIntervalSeconds = 60; - AliveMessageIntervalSeconds = 1800; + AliveMessageIntervalSeconds = 180; } /// <summary> diff --git a/Emby.Dlna/Didl/DidlBuilder.cs b/Emby.Dlna/Didl/DidlBuilder.cs index f668dc829..5ed982876 100644 --- a/Emby.Dlna/Didl/DidlBuilder.cs +++ b/Emby.Dlna/Didl/DidlBuilder.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -45,8 +43,8 @@ namespace Emby.Dlna.Didl private readonly DeviceProfile _profile; private readonly IImageProcessor _imageProcessor; private readonly string _serverAddress; - private readonly string _accessToken; - private readonly User _user; + private readonly string? _accessToken; + private readonly User? _user; private readonly IUserDataManager _userDataManager; private readonly ILocalizationManager _localization; private readonly IMediaSourceManager _mediaSourceManager; @@ -56,10 +54,10 @@ namespace Emby.Dlna.Didl public DidlBuilder( DeviceProfile profile, - User user, + User? user, IImageProcessor imageProcessor, string serverAddress, - string accessToken, + string? accessToken, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, @@ -85,7 +83,7 @@ namespace Emby.Dlna.Didl return url + "&dlnaheaders=true"; } - public string GetItemDidl(BaseItem item, User user, BaseItem context, string deviceId, Filter filter, StreamInfo streamInfo) + public string GetItemDidl(BaseItem item, User? user, BaseItem? context, string deviceId, Filter filter, StreamInfo streamInfo) { var settings = new XmlWriterSettings { @@ -140,12 +138,12 @@ namespace Emby.Dlna.Didl public void WriteItemElement( XmlWriter writer, BaseItem item, - User user, - BaseItem context, + User? user, + BaseItem? context, StubType? contextStubType, string deviceId, Filter filter, - StreamInfo streamInfo = null) + StreamInfo? streamInfo = null) { var clientId = GetClientId(item, null); @@ -190,7 +188,7 @@ namespace Emby.Dlna.Didl writer.WriteFullEndElement(); } - private void AddVideoResource(XmlWriter writer, BaseItem video, string deviceId, Filter filter, StreamInfo streamInfo = null) + private void AddVideoResource(XmlWriter writer, BaseItem video, string deviceId, Filter filter, StreamInfo? streamInfo = null) { if (streamInfo is null) { @@ -203,7 +201,7 @@ namespace Emby.Dlna.Didl Profile = _profile, DeviceId = deviceId, MaxBitrate = _profile.MaxStreamingBitrate - }); + }) ?? throw new InvalidOperationException("No optimal video stream found"); } var targetWidth = streamInfo.TargetWidth; @@ -315,7 +313,7 @@ namespace Emby.Dlna.Didl var mediaSource = streamInfo.MediaSource; - if (mediaSource.RunTimeTicks.HasValue) + if (mediaSource?.RunTimeTicks.HasValue == true) { writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture)); } @@ -410,7 +408,7 @@ namespace Emby.Dlna.Didl writer.WriteFullEndElement(); } - private string GetDisplayName(BaseItem item, StubType? itemStubType, BaseItem context) + private string GetDisplayName(BaseItem item, StubType? itemStubType, BaseItem? context) { if (itemStubType.HasValue) { @@ -452,7 +450,7 @@ namespace Emby.Dlna.Didl /// <param name="episode">The episode.</param> /// <param name="context">Current context.</param> /// <returns>Formatted name of the episode.</returns> - private string GetEpisodeDisplayName(Episode episode, BaseItem context) + private string GetEpisodeDisplayName(Episode episode, BaseItem? context) { string[] components; @@ -530,7 +528,7 @@ namespace Emby.Dlna.Didl private bool NotNullOrWhiteSpace(string s) => !string.IsNullOrWhiteSpace(s); - private void AddAudioResource(XmlWriter writer, BaseItem audio, string deviceId, Filter filter, StreamInfo streamInfo = null) + private void AddAudioResource(XmlWriter writer, BaseItem audio, string deviceId, Filter filter, StreamInfo? streamInfo = null) { writer.WriteStartElement(string.Empty, "res", NsDidl); @@ -544,14 +542,14 @@ namespace Emby.Dlna.Didl MediaSources = sources.ToArray(), Profile = _profile, DeviceId = deviceId - }); + }) ?? throw new InvalidOperationException("No optimal audio stream found"); } var url = NormalizeDlnaMediaUrl(streamInfo.ToUrl(_serverAddress, _accessToken)); var mediaSource = streamInfo.MediaSource; - if (mediaSource.RunTimeTicks.HasValue) + if (mediaSource?.RunTimeTicks is not null) { writer.WriteAttributeString("duration", TimeSpan.FromTicks(mediaSource.RunTimeTicks.Value).ToString("c", CultureInfo.InvariantCulture)); } @@ -634,7 +632,7 @@ namespace Emby.Dlna.Didl // Samsung sometimes uses 1 as root || string.Equals(id, "1", StringComparison.OrdinalIgnoreCase); - public void WriteFolderElement(XmlWriter writer, BaseItem folder, StubType? stubType, BaseItem context, int childCount, Filter filter, string requestedId = null) + public void WriteFolderElement(XmlWriter writer, BaseItem folder, StubType? stubType, BaseItem context, int childCount, Filter filter, string? requestedId = null) { writer.WriteStartElement(string.Empty, "container", NsDidl); @@ -678,14 +676,14 @@ namespace Emby.Dlna.Didl writer.WriteFullEndElement(); } - private void AddSamsungBookmarkInfo(BaseItem item, User user, XmlWriter writer, StreamInfo streamInfo) + private void AddSamsungBookmarkInfo(BaseItem item, User? user, XmlWriter writer, StreamInfo? streamInfo) { if (!item.SupportsPositionTicksResume || item is Folder) { return; } - XmlAttribute secAttribute = null; + XmlAttribute? secAttribute = null; foreach (var attribute in _profile.XmlRootAttributes) { if (string.Equals(attribute.Name, "xmlns:sec", StringComparison.OrdinalIgnoreCase)) @@ -695,8 +693,8 @@ namespace Emby.Dlna.Didl } } - // Not a samsung device - if (secAttribute is null) + // Not a samsung device or no user data + if (secAttribute is null || user is null) { return; } @@ -717,7 +715,7 @@ namespace Emby.Dlna.Didl /// <summary> /// Adds fields used by both items and folders. /// </summary> - private void AddCommonFields(BaseItem item, StubType? itemStubType, BaseItem context, XmlWriter writer, Filter filter) + private void AddCommonFields(BaseItem item, StubType? itemStubType, BaseItem? context, XmlWriter writer, Filter filter) { // Don't filter on dc:title because not all devices will include it in the filter // MediaMonkey for example won't display content without a title @@ -795,7 +793,7 @@ namespace Emby.Dlna.Didl if (item.IsDisplayedAsFolder || stubType.HasValue) { - string classType = null; + string? classType = null; if (!_profile.RequiresPlainFolders) { @@ -899,7 +897,7 @@ namespace Emby.Dlna.Didl } } - private void AddGeneralProperties(BaseItem item, StubType? itemStubType, BaseItem context, XmlWriter writer, Filter filter) + private void AddGeneralProperties(BaseItem item, StubType? itemStubType, BaseItem? context, XmlWriter writer, Filter filter) { AddCommonFields(item, itemStubType, context, writer, filter); @@ -975,7 +973,7 @@ namespace Emby.Dlna.Didl private void AddCover(BaseItem item, StubType? stubType, XmlWriter writer) { - ImageDownloadInfo imageInfo = GetImageInfo(item); + ImageDownloadInfo? imageInfo = GetImageInfo(item); if (imageInfo is null) { @@ -1073,7 +1071,7 @@ namespace Emby.Dlna.Didl writer.WriteFullEndElement(); } - private ImageDownloadInfo GetImageInfo(BaseItem item) + private ImageDownloadInfo? GetImageInfo(BaseItem item) { if (item.HasImage(ImageType.Primary)) { @@ -1118,7 +1116,7 @@ namespace Emby.Dlna.Didl return null; } - private BaseItem GetFirstParentWithImageBelowUserRoot(BaseItem item) + private BaseItem? GetFirstParentWithImageBelowUserRoot(BaseItem item) { if (item is null) { @@ -1148,7 +1146,7 @@ namespace Emby.Dlna.Didl private ImageDownloadInfo GetImageInfo(BaseItem item, ImageType type) { var imageInfo = item.GetImageInfo(type, 0); - string tag = null; + string? tag = null; try { @@ -1250,7 +1248,7 @@ namespace Emby.Dlna.Didl { internal Guid ItemId { get; set; } - internal string ImageTag { get; set; } + internal string? ImageTag { get; set; } internal ImageType Type { get; set; } @@ -1260,9 +1258,9 @@ namespace Emby.Dlna.Didl internal bool IsDirectStream { get; set; } - internal string Format { get; set; } + internal required string Format { get; set; } - internal ItemImageInfo ItemImageInfo { get; set; } + internal required ItemImageInfo ItemImageInfo { get; set; } } } } diff --git a/Emby.Dlna/DlnaManager.cs b/Emby.Dlna/DlnaManager.cs index 99b3e6e7e..d67cb67b5 100644 --- a/Emby.Dlna/DlnaManager.cs +++ b/Emby.Dlna/DlnaManager.cs @@ -228,7 +228,7 @@ namespace Emby.Dlna try { return _fileSystem.GetFilePaths(path) - .Where(i => string.Equals(Path.GetExtension(i), ".xml", StringComparison.OrdinalIgnoreCase)) + .Where(i => Path.GetExtension(i.AsSpan()).Equals(".xml", StringComparison.OrdinalIgnoreCase)) .Select(i => ParseProfileFile(i, type)) .Where(i => i is not null) .ToList()!; // We just filtered out all the nulls diff --git a/Emby.Dlna/Extensions/DlnaServiceCollectionExtensions.cs b/Emby.Dlna/Extensions/DlnaServiceCollectionExtensions.cs new file mode 100644 index 000000000..87ec14d95 --- /dev/null +++ b/Emby.Dlna/Extensions/DlnaServiceCollectionExtensions.cs @@ -0,0 +1,69 @@ +using System; +using System.Globalization; +using System.Net; +using System.Net.Http; +using System.Text; +using Emby.Dlna.ConnectionManager; +using Emby.Dlna.ContentDirectory; +using Emby.Dlna.MediaReceiverRegistrar; +using Emby.Dlna.Ssdp; +using MediaBrowser.Common.Net; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Dlna; +using MediaBrowser.Model.Dlna; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; +using Rssdp.Infrastructure; + +namespace Emby.Dlna.Extensions; + +/// <summary> +/// Extension methods for adding DLNA services. +/// </summary> +public static class DlnaServiceCollectionExtensions +{ + /// <summary> + /// Adds DLNA services to the provided <see cref="IServiceCollection"/>. + /// </summary> + /// <param name="services">The <see cref="IServiceCollection"/>.</param> + /// <param name="applicationHost">The <see cref="IServerApplicationHost"/>.</param> + public static void AddDlnaServices( + this IServiceCollection services, + IServerApplicationHost applicationHost) + { + services.AddHttpClient(NamedClient.Dlna, c => + { + c.DefaultRequestHeaders.UserAgent.ParseAdd( + string.Format( + CultureInfo.InvariantCulture, + "{0}/{1} UPnP/1.0 {2}/{3}", + Environment.OSVersion.Platform, + Environment.OSVersion, + applicationHost.Name, + applicationHost.ApplicationVersionString)); + + c.DefaultRequestHeaders.Add("CPFN.UPNP.ORG", applicationHost.FriendlyName); // Required for UPnP DeviceArchitecture v2.0 + c.DefaultRequestHeaders.Add("FriendlyName.DLNA.ORG", applicationHost.FriendlyName); // REVIEW: where does this come from? + }) + .ConfigurePrimaryHttpMessageHandler(_ => new SocketsHttpHandler + { + AutomaticDecompression = DecompressionMethods.All, + RequestHeaderEncodingSelector = (_, _) => Encoding.UTF8 + }); + + services.AddSingleton<IDlnaManager, DlnaManager>(); + services.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); + services.AddSingleton<IContentDirectory, ContentDirectoryService>(); + services.AddSingleton<IConnectionManager, ConnectionManagerService>(); + services.AddSingleton<IMediaReceiverRegistrar, MediaReceiverRegistrarService>(); + + services.AddSingleton<ISsdpCommunicationsServer>(provider => new SsdpCommunicationsServer( + provider.GetRequiredService<ISocketFactory>(), + provider.GetRequiredService<INetworkManager>(), + provider.GetRequiredService<ILogger<SsdpCommunicationsServer>>()) + { + IsShared = true + }); + } +} diff --git a/Emby.Dlna/Main/DlnaEntryPoint.cs b/Emby.Dlna/Main/DlnaEntryPoint.cs index 39cfc2d1d..aa7012487 100644 --- a/Emby.Dlna/Main/DlnaEntryPoint.cs +++ b/Emby.Dlna/Main/DlnaEntryPoint.cs @@ -11,7 +11,7 @@ using System.Threading.Tasks; using Emby.Dlna.PlayTo; using Emby.Dlna.Ssdp; using Jellyfin.Networking.Configuration; -using Jellyfin.Networking.Manager; +using Jellyfin.Networking.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; @@ -23,10 +23,8 @@ using MediaBrowser.Controller.Library; using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Controller.Plugins; using MediaBrowser.Controller.Session; -using MediaBrowser.Controller.TV; using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.Net; using Microsoft.Extensions.Logging; using Rssdp; using Rssdp.Infrastructure; @@ -49,14 +47,13 @@ namespace Emby.Dlna.Main private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; private readonly IDeviceDiscovery _deviceDiscovery; - private readonly ISocketFactory _socketFactory; + private readonly ISsdpCommunicationsServer _communicationsServer; private readonly INetworkManager _networkManager; - private readonly object _syncLock = new object(); + private readonly object _syncLock = new(); private readonly bool _disabled; private PlayToManager _manager; private SsdpDevicePublisher _publisher; - private ISsdpCommunicationsServer _communicationsServer; private bool _disposed; @@ -75,10 +72,8 @@ namespace Emby.Dlna.Main IMediaSourceManager mediaSourceManager, IDeviceDiscovery deviceDiscovery, IMediaEncoder mediaEncoder, - ISocketFactory socketFactory, - INetworkManager networkManager, - IUserViewManager userViewManager, - ITVSeriesManager tvSeriesManager) + ISsdpCommunicationsServer communicationsServer, + INetworkManager networkManager) { _config = config; _appHost = appHost; @@ -93,37 +88,10 @@ namespace Emby.Dlna.Main _mediaSourceManager = mediaSourceManager; _deviceDiscovery = deviceDiscovery; _mediaEncoder = mediaEncoder; - _socketFactory = socketFactory; + _communicationsServer = communicationsServer; _networkManager = networkManager; _logger = loggerFactory.CreateLogger<DlnaEntryPoint>(); - ContentDirectory = new ContentDirectory.ContentDirectoryService( - dlnaManager, - userDataManager, - imageProcessor, - libraryManager, - config, - userManager, - loggerFactory.CreateLogger<ContentDirectory.ContentDirectoryService>(), - httpClientFactory, - localizationManager, - mediaSourceManager, - userViewManager, - mediaEncoder, - tvSeriesManager); - - ConnectionManager = new ConnectionManager.ConnectionManagerService( - dlnaManager, - config, - loggerFactory.CreateLogger<ConnectionManager.ConnectionManagerService>(), - httpClientFactory); - - MediaReceiverRegistrar = new MediaReceiverRegistrar.MediaReceiverRegistrarService( - loggerFactory.CreateLogger<MediaReceiverRegistrar.MediaReceiverRegistrarService>(), - httpClientFactory, - config); - Current = this; - var netConfig = config.GetConfiguration<NetworkConfiguration>(NetworkConfigurationStore.StoreKey); _disabled = appHost.ListenWithHttps && netConfig.RequireHttps; @@ -133,19 +101,6 @@ namespace Emby.Dlna.Main } } - public static DlnaEntryPoint Current { get; private set; } - - /// <summary> - /// Gets a value indicating whether the dlna server is enabled. - /// </summary> - public static bool Enabled { get; private set; } - - public IContentDirectory ContentDirectory { get; private set; } - - public IConnectionManager ConnectionManager { get; private set; } - - public IMediaReceiverRegistrar MediaReceiverRegistrar { get; private set; } - public async Task RunAsync() { await ((DlnaManager)_dlnaManager).InitProfilesAsync().ConfigureAwait(false); @@ -172,9 +127,7 @@ namespace Emby.Dlna.Main private void ReloadComponents() { var options = _config.GetDlnaConfiguration(); - Enabled = options.EnableServer; - - StartSsdpHandler(); + StartDeviceDiscovery(); if (options.EnableServer) { @@ -195,37 +148,11 @@ namespace Emby.Dlna.Main } } - private void StartSsdpHandler() - { - try - { - if (_communicationsServer is null) - { - var enableMultiSocketBinding = OperatingSystem.IsWindows() || - OperatingSystem.IsLinux(); - - _communicationsServer = new SsdpCommunicationsServer(_socketFactory, _networkManager, _logger, enableMultiSocketBinding) - { - IsShared = true - }; - - StartDeviceDiscovery(_communicationsServer); - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error starting ssdp handlers"); - } - } - - private void StartDeviceDiscovery(ISsdpCommunicationsServer communicationsServer) + private void StartDeviceDiscovery() { try { - if (communicationsServer is not null) - { - ((DeviceDiscovery)_deviceDiscovery).Start(communicationsServer); - } + ((DeviceDiscovery)_deviceDiscovery).Start(_communicationsServer); } catch (Exception ex) { @@ -233,26 +160,8 @@ namespace Emby.Dlna.Main } } - private void DisposeDeviceDiscovery() - { - try - { - _logger.LogInformation("Disposing DeviceDiscovery"); - ((DeviceDiscovery)_deviceDiscovery).Dispose(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error stopping device discovery"); - } - } - public void StartDevicePublisher(Configuration.DlnaOptions options) { - if (!options.BlastAliveMessages) - { - return; - } - if (_publisher is not null) { return; @@ -263,7 +172,8 @@ namespace Emby.Dlna.Main _publisher = new SsdpDevicePublisher( _communicationsServer, Environment.OSVersion.Platform.ToString(), - Environment.OSVersion.VersionString, + // Can not use VersionString here since that includes OS and version + Environment.OSVersion.Version.ToString(), _config.GetDlnaConfiguration().SendOnlyMatchedHost) { LogFunction = (msg) => _logger.LogDebug("{Msg}", msg), @@ -272,7 +182,10 @@ namespace Emby.Dlna.Main RegisterServerEndpoints(); - _publisher.StartBroadcastingAliveMessages(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds)); + if (options.BlastAliveMessages) + { + _publisher.StartSendingAliveNotifications(TimeSpan.FromSeconds(options.BlastAliveMessageIntervalSeconds)); + } } catch (Exception ex) { @@ -285,42 +198,33 @@ namespace Emby.Dlna.Main var udn = CreateUuid(_appHost.SystemId); var descriptorUri = "/dlna/" + udn + "/description.xml"; - var bindAddresses = NetworkManager.CreateCollection( - _networkManager.GetInternalBindAddresses() - .Where(i => i.AddressFamily == AddressFamily.InterNetwork || (i.AddressFamily == AddressFamily.InterNetworkV6 && i.Address.ScopeId != 0))); + // Only get bind addresses in LAN + // IPv6 is currently unsupported + var validInterfaces = _networkManager.GetInternalBindAddresses() + .Where(x => x.Address is not null) + .Where(x => x.AddressFamily != AddressFamily.InterNetworkV6) + .ToList(); - if (bindAddresses.Count == 0) + if (validInterfaces.Count == 0) { - // No interfaces returned, so use loopback. - bindAddresses = _networkManager.GetLoopbacks(); + // No interfaces returned, fall back to loopback + validInterfaces = _networkManager.GetLoopbacks().ToList(); } - foreach (IPNetAddress address in bindAddresses) + foreach (var intf in validInterfaces) { - if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - // Not supporting IPv6 right now - continue; - } - - // Limit to LAN addresses only - if (!_networkManager.IsInLocalNetwork(address)) - { - continue; - } - var fullService = "urn:schemas-upnp-org:device:MediaServer:1"; - _logger.LogInformation("Registering publisher for {ResourceName} on {DeviceAddress}", fullService, address); + _logger.LogInformation("Registering publisher for {ResourceName} on {DeviceAddress}", fullService, intf.Address); - var uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(address, false) + descriptorUri); + var uri = new UriBuilder(_appHost.GetApiUrlForLocalAccess(intf.Address, false) + descriptorUri); var device = new SsdpRootDevice { CacheLifetime = TimeSpan.FromSeconds(1800), // How long SSDP clients can cache this info. Location = uri.Uri, // Must point to the URL that serves your devices UPnP description document. - Address = address.Address, - PrefixLength = address.PrefixLength, + Address = intf.Address, + PrefixLength = NetworkExtensions.MaskToCidr(intf.Subnet.Prefix), FriendlyName = "Jellyfin", Manufacturer = "Jellyfin", ModelName = "Jellyfin Server", @@ -328,7 +232,7 @@ namespace Emby.Dlna.Main // This must be a globally unique value that survives reboots etc. Get from storage or embedded hardware etc. }; - SetProperies(device, fullService); + SetProperties(device, fullService); _publisher.AddDevice(device); var embeddedDevices = new[] @@ -349,13 +253,13 @@ namespace Emby.Dlna.Main // This must be a globally unique value that survives reboots etc. Get from storage or embedded hardware etc. }; - SetProperies(embeddedDevice, subDevice); + SetProperties(embeddedDevice, subDevice); device.AddDevice(embeddedDevice); } } } - private string CreateUuid(string text) + private static string CreateUuid(string text) { if (!Guid.TryParse(text, out var guid)) { @@ -365,15 +269,14 @@ namespace Emby.Dlna.Main return guid.ToString("D", CultureInfo.InvariantCulture); } - private void SetProperies(SsdpDevice device, string fullDeviceType) + private static void SetProperties(SsdpDevice device, string fullDeviceType) { - var service = fullDeviceType.Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase).Replace(":1", string.Empty, StringComparison.OrdinalIgnoreCase); - - var serviceParts = service.Split(':'); + var serviceParts = fullDeviceType + .Replace("urn:", string.Empty, StringComparison.OrdinalIgnoreCase) + .Replace(":1", string.Empty, StringComparison.OrdinalIgnoreCase) + .Split(':'); - var deviceTypeNamespace = serviceParts[0].Replace('.', '-'); - - device.DeviceTypeNamespace = deviceTypeNamespace; + device.DeviceTypeNamespace = serviceParts[0].Replace('.', '-'); device.DeviceClass = serviceParts[1]; device.DeviceType = serviceParts[2]; } @@ -454,20 +357,6 @@ namespace Emby.Dlna.Main DisposeDevicePublisher(); DisposePlayToManager(); - DisposeDeviceDiscovery(); - - if (_communicationsServer is not null) - { - _logger.LogInformation("Disposing SsdpCommunicationsServer"); - _communicationsServer.Dispose(); - _communicationsServer = null; - } - - ContentDirectory = null; - ConnectionManager = null; - MediaReceiverRegistrar = null; - Current = null; - _disposed = true; } } diff --git a/Emby.Dlna/PlayTo/Device.cs b/Emby.Dlna/PlayTo/Device.cs index 9c476119d..bb9b8b0fd 100644 --- a/Emby.Dlna/PlayTo/Device.cs +++ b/Emby.Dlna/PlayTo/Device.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -25,7 +23,7 @@ namespace Emby.Dlna.PlayTo private readonly ILogger _logger; private readonly object _timerLock = new object(); - private Timer _timer; + private Timer? _timer; private int _muteVol; private int _volume; private DateTime _lastVolumeRefresh; @@ -40,13 +38,13 @@ namespace Emby.Dlna.PlayTo _logger = logger; } - public event EventHandler<PlaybackStartEventArgs> PlaybackStart; + public event EventHandler<PlaybackStartEventArgs>? PlaybackStart; - public event EventHandler<PlaybackProgressEventArgs> PlaybackProgress; + public event EventHandler<PlaybackProgressEventArgs>? PlaybackProgress; - public event EventHandler<PlaybackStoppedEventArgs> PlaybackStopped; + public event EventHandler<PlaybackStoppedEventArgs>? PlaybackStopped; - public event EventHandler<MediaChangedEventArgs> MediaChanged; + public event EventHandler<MediaChangedEventArgs>? MediaChanged; public DeviceInfo Properties { get; set; } @@ -75,13 +73,13 @@ namespace Emby.Dlna.PlayTo public bool IsStopped => TransportState == TransportState.STOPPED; - public Action OnDeviceUnavailable { get; set; } + public Action? OnDeviceUnavailable { get; set; } - private TransportCommands AvCommands { get; set; } + private TransportCommands? AvCommands { get; set; } - private TransportCommands RendererCommands { get; set; } + private TransportCommands? RendererCommands { get; set; } - public UBaseObject CurrentMediaInfo { get; private set; } + public UBaseObject? CurrentMediaInfo { get; private set; } public void Start() { @@ -131,7 +129,7 @@ namespace Emby.Dlna.PlayTo _volumeRefreshActive = true; var time = immediate ? 100 : 10000; - _timer.Change(time, Timeout.Infinite); + _timer?.Change(time, Timeout.Infinite); } } @@ -149,7 +147,7 @@ namespace Emby.Dlna.PlayTo _volumeRefreshActive = false; - _timer.Change(Timeout.Infinite, Timeout.Infinite); + _timer?.Change(Timeout.Infinite, Timeout.Infinite); } } @@ -199,7 +197,7 @@ namespace Emby.Dlna.PlayTo } } - private DeviceService GetServiceRenderingControl() + private DeviceService? GetServiceRenderingControl() { var services = Properties.Services; @@ -207,7 +205,7 @@ namespace Emby.Dlna.PlayTo services.FirstOrDefault(s => (s.ServiceType ?? string.Empty).StartsWith("urn:schemas-upnp-org:service:RenderingControl", StringComparison.OrdinalIgnoreCase)); } - private DeviceService GetAvTransportService() + private DeviceService? GetAvTransportService() { var services = Properties.Services; @@ -240,7 +238,7 @@ namespace Emby.Dlna.PlayTo Properties.BaseUrl, service, command.Name, - rendererCommands.BuildPost(command, service.ServiceType, value), + rendererCommands!.BuildPost(command, service.ServiceType, value), // null checked above cancellationToken: cancellationToken) .ConfigureAwait(false); @@ -265,12 +263,7 @@ namespace Emby.Dlna.PlayTo return; } - var service = GetServiceRenderingControl(); - - if (service is null) - { - throw new InvalidOperationException("Unable to find service"); - } + var service = GetServiceRenderingControl() ?? throw new InvalidOperationException("Unable to find service"); // Set it early and assume it will succeed // Remote control will perform better @@ -281,7 +274,7 @@ namespace Emby.Dlna.PlayTo Properties.BaseUrl, service, command.Name, - rendererCommands.BuildPost(command, service.ServiceType, value), + rendererCommands!.BuildPost(command, service.ServiceType, value), // null checked above cancellationToken: cancellationToken) .ConfigureAwait(false); } @@ -296,26 +289,20 @@ namespace Emby.Dlna.PlayTo return; } - var service = GetAvTransportService(); - - if (service is null) - { - throw new InvalidOperationException("Unable to find service"); - } - + var service = GetAvTransportService() ?? throw new InvalidOperationException("Unable to find service"); await new DlnaHttpClient(_logger, _httpClientFactory) .SendCommandAsync( Properties.BaseUrl, service, command.Name, - avCommands.BuildPost(command, service.ServiceType, string.Format(CultureInfo.InvariantCulture, "{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME"), + avCommands!.BuildPost(command, service.ServiceType, string.Format(CultureInfo.InvariantCulture, "{0:hh}:{0:mm}:{0:ss}", value), "REL_TIME"), // null checked above cancellationToken: cancellationToken) .ConfigureAwait(false); RestartTimer(true); } - public async Task SetAvTransport(string url, string header, string metaData, CancellationToken cancellationToken) + public async Task SetAvTransport(string url, string? header, string metaData, CancellationToken cancellationToken) { var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); @@ -335,14 +322,8 @@ namespace Emby.Dlna.PlayTo { "CurrentURIMetaData", CreateDidlMeta(metaData) } }; - var service = GetAvTransportService(); - - if (service is null) - { - throw new InvalidOperationException("Unable to find service"); - } - - var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary); + var service = GetAvTransportService() ?? throw new InvalidOperationException("Unable to find service"); + var post = avCommands!.BuildPost(command, service.ServiceType, url, dictionary); // null checked above await new DlnaHttpClient(_logger, _httpClientFactory) .SendCommandAsync( Properties.BaseUrl, @@ -372,7 +353,7 @@ namespace Emby.Dlna.PlayTo * SetNextAvTransport is used to specify to the DLNA device what is the next track to play. * Without that information, the next track command on the device does not work. */ - public async Task SetNextAvTransport(string url, string header, string metaData, CancellationToken cancellationToken = default) + public async Task SetNextAvTransport(string url, string? header, string metaData, CancellationToken cancellationToken = default) { var avCommands = await GetAVProtocolAsync(cancellationToken).ConfigureAwait(false); @@ -380,7 +361,7 @@ namespace Emby.Dlna.PlayTo _logger.LogDebug("{PropertyName} - SetNextAvTransport Uri: {Url} DlnaHeaders: {Header}", Properties.Name, url, header); - var command = avCommands.ServiceActions.FirstOrDefault(c => string.Equals(c.Name, "SetNextAVTransportURI", StringComparison.OrdinalIgnoreCase)); + var command = avCommands?.ServiceActions.FirstOrDefault(c => string.Equals(c.Name, "SetNextAVTransportURI", StringComparison.OrdinalIgnoreCase)); if (command is null) { return; @@ -392,14 +373,8 @@ namespace Emby.Dlna.PlayTo { "NextURIMetaData", CreateDidlMeta(metaData) } }; - var service = GetAvTransportService(); - - if (service is null) - { - throw new InvalidOperationException("Unable to find service"); - } - - var post = avCommands.BuildPost(command, service.ServiceType, url, dictionary); + var service = GetAvTransportService() ?? throw new InvalidOperationException("Unable to find service"); + var post = avCommands!.BuildPost(command, service.ServiceType, url, dictionary); // null checked above await new DlnaHttpClient(_logger, _httpClientFactory) .SendCommandAsync(Properties.BaseUrl, service, command.Name, post, header, cancellationToken) .ConfigureAwait(false); @@ -423,12 +398,7 @@ namespace Emby.Dlna.PlayTo return Task.CompletedTask; } - var service = GetAvTransportService(); - if (service is null) - { - throw new InvalidOperationException("Unable to find service"); - } - + var service = GetAvTransportService() ?? throw new InvalidOperationException("Unable to find service"); return new DlnaHttpClient(_logger, _httpClientFactory).SendCommandAsync( Properties.BaseUrl, service, @@ -460,14 +430,13 @@ namespace Emby.Dlna.PlayTo return; } - var service = GetAvTransportService(); - + var service = GetAvTransportService() ?? throw new InvalidOperationException("Unable to find service"); await new DlnaHttpClient(_logger, _httpClientFactory) .SendCommandAsync( Properties.BaseUrl, service, command.Name, - avCommands.BuildPost(command, service.ServiceType, 1), + avCommands!.BuildPost(command, service.ServiceType, 1), // null checked above cancellationToken: cancellationToken) .ConfigureAwait(false); @@ -484,14 +453,13 @@ namespace Emby.Dlna.PlayTo return; } - var service = GetAvTransportService(); - + var service = GetAvTransportService() ?? throw new InvalidOperationException("Unable to find service"); await new DlnaHttpClient(_logger, _httpClientFactory) .SendCommandAsync( Properties.BaseUrl, service, command.Name, - avCommands.BuildPost(command, service.ServiceType, 1), + avCommands!.BuildPost(command, service.ServiceType, 1), // null checked above cancellationToken: cancellationToken) .ConfigureAwait(false); @@ -500,7 +468,7 @@ namespace Emby.Dlna.PlayTo RestartTimer(true); } - private async void TimerCallback(object sender) + private async void TimerCallback(object? sender) { if (_disposed) { @@ -623,7 +591,7 @@ namespace Emby.Dlna.PlayTo Properties.BaseUrl, service, command.Name, - rendererCommands.BuildPost(command, service.ServiceType), + rendererCommands!.BuildPost(command, service.ServiceType), // null checked above cancellationToken: cancellationToken).ConfigureAwait(false); if (result is null || result.Document is null) @@ -673,7 +641,7 @@ namespace Emby.Dlna.PlayTo Properties.BaseUrl, service, command.Name, - rendererCommands.BuildPost(command, service.ServiceType), + rendererCommands!.BuildPost(command, service.ServiceType), // null checked above cancellationToken: cancellationToken).ConfigureAwait(false); if (result is null || result.Document is null) @@ -728,7 +696,7 @@ namespace Emby.Dlna.PlayTo return null; } - private async Task<UBaseObject> GetMediaInfo(TransportCommands avCommands, CancellationToken cancellationToken) + private async Task<UBaseObject?> GetMediaInfo(TransportCommands avCommands, CancellationToken cancellationToken) { var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetMediaInfo"); if (command is null) @@ -798,7 +766,7 @@ namespace Emby.Dlna.PlayTo return null; } - private async Task<(bool Success, UBaseObject Track)> GetPositionInfo(TransportCommands avCommands, CancellationToken cancellationToken) + private async Task<(bool Success, UBaseObject? Track)> GetPositionInfo(TransportCommands avCommands, CancellationToken cancellationToken) { var command = avCommands.ServiceActions.FirstOrDefault(c => c.Name == "GetPositionInfo"); if (command is null) @@ -871,7 +839,7 @@ namespace Emby.Dlna.PlayTo return (true, null); } - XElement uPnpResponse = null; + XElement? uPnpResponse = null; try { @@ -895,7 +863,7 @@ namespace Emby.Dlna.PlayTo return (true, uTrack); } - private XElement ParseResponse(string xml) + private XElement? ParseResponse(string xml) { // Handle different variations sent back by devices. try @@ -929,7 +897,7 @@ namespace Emby.Dlna.PlayTo return null; } - private static UBaseObject CreateUBaseObject(XElement container, string trackUri) + private static UBaseObject CreateUBaseObject(XElement? container, string? trackUri) { ArgumentNullException.ThrowIfNull(container); @@ -959,20 +927,17 @@ namespace Emby.Dlna.PlayTo var resElement = container.Element(UPnpNamespaces.Res); - if (resElement is not null) - { - var info = resElement.Attribute(UPnpNamespaces.ProtocolInfo); + var info = resElement?.Attribute(UPnpNamespaces.ProtocolInfo); - if (info is not null && !string.IsNullOrWhiteSpace(info.Value)) - { - return info.Value.Split(':'); - } + if (info is not null && !string.IsNullOrWhiteSpace(info.Value)) + { + return info.Value.Split(':'); } return new string[4]; } - private async Task<TransportCommands> GetAVProtocolAsync(CancellationToken cancellationToken) + private async Task<TransportCommands?> GetAVProtocolAsync(CancellationToken cancellationToken) { if (AvCommands is not null) { @@ -1004,7 +969,7 @@ namespace Emby.Dlna.PlayTo return AvCommands; } - private async Task<TransportCommands> GetRenderingProtocolAsync(CancellationToken cancellationToken) + private async Task<TransportCommands?> GetRenderingProtocolAsync(CancellationToken cancellationToken) { if (RendererCommands is not null) { @@ -1054,7 +1019,7 @@ namespace Emby.Dlna.PlayTo return baseUrl + url; } - public static async Task<Device> CreateuPnpDeviceAsync(Uri url, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken cancellationToken) + public static async Task<Device?> CreateuPnpDeviceAsync(Uri url, IHttpClientFactory httpClientFactory, ILogger logger, CancellationToken cancellationToken) { var ssdpHttpClient = new DlnaHttpClient(logger, httpClientFactory); @@ -1171,7 +1136,6 @@ namespace Emby.Dlna.PlayTo return new Device(deviceProperties, httpClientFactory, logger); } -#nullable enable private static DeviceIcon CreateIcon(XElement element) { ArgumentNullException.ThrowIfNull(element); @@ -1287,7 +1251,7 @@ namespace Emby.Dlna.PlayTo } _timer = null; - Properties = null; + Properties = null!; _disposed = true; } diff --git a/Emby.Dlna/PlayTo/DlnaHttpClient.cs b/Emby.Dlna/PlayTo/DlnaHttpClient.cs index 8b983e9e3..255c51f19 100644 --- a/Emby.Dlna/PlayTo/DlnaHttpClient.cs +++ b/Emby.Dlna/PlayTo/DlnaHttpClient.cs @@ -31,6 +31,9 @@ namespace Emby.Dlna.PlayTo _httpClientFactory = httpClientFactory; } + [GeneratedRegex("(&(?![a-z]*;))")] + private static partial Regex EscapeAmpersandRegex(); + private static string NormalizeServiceUrl(string baseUrl, string serviceUrl) { // If it's already a complete url, don't stick anything onto the front of it @@ -52,40 +55,42 @@ namespace Emby.Dlna.PlayTo var client = _httpClientFactory.CreateClient(NamedClient.Dlna); using var response = await client.SendAsync(request, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using MemoryStream ms = new MemoryStream(); - await response.Content.CopyToAsync(ms, cancellationToken).ConfigureAwait(false); - try + Stream stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { - return await XDocument.LoadAsync( - ms, - LoadOptions.None, - cancellationToken).ConfigureAwait(false); - } - catch (XmlException) - { - // try correcting the Xml response with common errors - ms.Position = 0; - using StreamReader sr = new StreamReader(ms); - var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); - - // find and replace unescaped ampersands (&) - xmlString = EscapeAmpersandRegex().Replace(xmlString, "&"); - try { - // retry reading Xml - using var xmlReader = new StringReader(xmlString); return await XDocument.LoadAsync( - xmlReader, + stream, LoadOptions.None, cancellationToken).ConfigureAwait(false); } - catch (XmlException ex) + catch (XmlException) { - _logger.LogError(ex, "Failed to parse response"); - _logger.LogDebug("Malformed response: {Content}\n", xmlString); - - return null; + // try correcting the Xml response with common errors + stream.Position = 0; + using StreamReader sr = new StreamReader(stream); + var xmlString = await sr.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + + // find and replace unescaped ampersands (&) + xmlString = EscapeAmpersandRegex().Replace(xmlString, "&"); + + try + { + // retry reading Xml + using var xmlReader = new StringReader(xmlString); + return await XDocument.LoadAsync( + xmlReader, + LoadOptions.None, + cancellationToken).ConfigureAwait(false); + } + catch (XmlException ex) + { + _logger.LogError(ex, "Failed to parse response"); + _logger.LogDebug("Malformed response: {Content}\n", xmlString); + + return null; + } } } } @@ -128,12 +133,5 @@ namespace Emby.Dlna.PlayTo // Have to await here instead of returning the Task directly, otherwise request would be disposed too soon return await SendRequestAsync(request, cancellationToken).ConfigureAwait(false); } - - /// <summary> - /// Compile-time generated regular expression for escaping ampersands. - /// </summary> - /// <returns>Compiled regular expression.</returns> - [GeneratedRegex("(&(?![a-z]*;))")] - private static partial Regex EscapeAmpersandRegex(); } } diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index 86db36337..b1ad15cdc 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -42,7 +42,7 @@ namespace Emby.Dlna.PlayTo private readonly IDeviceDiscovery _deviceDiscovery; private readonly string _serverAddress; - private readonly string _accessToken; + private readonly string? _accessToken; private readonly List<PlaylistItem> _playlist = new List<PlaylistItem>(); private Device _device; @@ -59,7 +59,7 @@ namespace Emby.Dlna.PlayTo IUserManager userManager, IImageProcessor imageProcessor, string serverAddress, - string accessToken, + string? accessToken, IDeviceDiscovery deviceDiscovery, IUserDataManager userDataManager, ILocalizationManager localization, diff --git a/Emby.Dlna/PlayTo/PlayToManager.cs b/Emby.Dlna/PlayTo/PlayToManager.cs index b469c9cb0..b05e0a095 100644 --- a/Emby.Dlna/PlayTo/PlayToManager.cs +++ b/Emby.Dlna/PlayTo/PlayToManager.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -41,9 +39,9 @@ namespace Emby.Dlna.PlayTo private readonly IMediaSourceManager _mediaSourceManager; private readonly IMediaEncoder _mediaEncoder; + private readonly SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1); + private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); private bool _disposed; - private SemaphoreSlim _sessionLock = new SemaphoreSlim(1, 1); - private CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); public PlayToManager(ILogger logger, ISessionManager sessionManager, ILibraryManager libraryManager, IUserManager userManager, IDlnaManager dlnaManager, IServerApplicationHost appHost, IImageProcessor imageProcessor, IDeviceDiscovery deviceDiscovery, IHttpClientFactory httpClientFactory, IUserDataManager userDataManager, ILocalizationManager localization, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder) { @@ -67,7 +65,7 @@ namespace Emby.Dlna.PlayTo _deviceDiscovery.DeviceDiscovered += OnDeviceDiscoveryDeviceDiscovered; } - private async void OnDeviceDiscoveryDeviceDiscovered(object sender, GenericEventArgs<UpnpDeviceInfo> e) + private async void OnDeviceDiscoveryDeviceDiscovered(object? sender, GenericEventArgs<UpnpDeviceInfo> e) { if (_disposed) { @@ -76,12 +74,12 @@ namespace Emby.Dlna.PlayTo var info = e.Argument; - if (!info.Headers.TryGetValue("USN", out string usn)) + if (!info.Headers.TryGetValue("USN", out string? usn)) { usn = string.Empty; } - if (!info.Headers.TryGetValue("NT", out string nt)) + if (!info.Headers.TryGetValue("NT", out string? nt)) { nt = string.Empty; } @@ -161,7 +159,7 @@ namespace Emby.Dlna.PlayTo var uri = info.Location; _logger.LogDebug("Attempting to create PlayToController from location {0}", uri); - if (info.Headers.TryGetValue("USN", out string uuid)) + if (info.Headers.TryGetValue("USN", out string? uuid)) { uuid = GetUuid(uuid); } @@ -189,7 +187,7 @@ namespace Emby.Dlna.PlayTo _sessionManager.UpdateDeviceName(sessionInfo.Id, deviceName); - string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIpAddress); + string serverAddress = _appHost.GetSmartApiUrl(info.RemoteIPAddress); controller = new PlayToController( sessionInfo, diff --git a/Emby.Dlna/Ssdp/DeviceDiscovery.cs b/Emby.Dlna/Ssdp/DeviceDiscovery.cs index 8a4e5ff45..4fbbc3885 100644 --- a/Emby.Dlna/Ssdp/DeviceDiscovery.cs +++ b/Emby.Dlna/Ssdp/DeviceDiscovery.cs @@ -73,7 +73,11 @@ namespace Emby.Dlna.Ssdp { if (_listenerCount > 0 && _deviceLocator is null && _commsServer is not null) { - _deviceLocator = new SsdpDeviceLocator(_commsServer); + _deviceLocator = new SsdpDeviceLocator( + _commsServer, + Environment.OSVersion.Platform.ToString(), + // Can not use VersionString here since that includes OS and version + Environment.OSVersion.Version.ToString()); // (Optional) Set the filter so we only see notifications for devices we care about // (can be any search target value i.e device type, uuid value etc - any value that appears in the @@ -106,7 +110,7 @@ namespace Emby.Dlna.Ssdp { Location = e.DiscoveredDevice.DescriptionLocation, Headers = headers, - RemoteIpAddress = e.RemoteIpAddress + RemoteIPAddress = e.RemoteIPAddress }); DeviceDiscoveredInternal?.Invoke(this, args); diff --git a/Emby.Naming/Audio/AlbumParser.cs b/Emby.Naming/Audio/AlbumParser.cs index 86a564153..97961778f 100644 --- a/Emby.Naming/Audio/AlbumParser.cs +++ b/Emby.Naming/Audio/AlbumParser.cs @@ -10,7 +10,7 @@ namespace Emby.Naming.Audio /// <summary> /// Helper class to determine if Album is multipart. /// </summary> - public class AlbumParser + public partial class AlbumParser { private readonly NamingOptions _options; @@ -23,6 +23,9 @@ namespace Emby.Naming.Audio _options = options; } + [GeneratedRegex(@"[-\.\(\)\s]+")] + private static partial Regex CleanRegex(); + /// <summary> /// Function that determines if album is multipart. /// </summary> @@ -42,13 +45,9 @@ namespace Emby.Naming.Audio // Normalize // Remove whitespace - filename = filename.Replace('-', ' '); - filename = filename.Replace('.', ' '); - filename = filename.Replace('(', ' '); - filename = filename.Replace(')', ' '); - filename = Regex.Replace(filename, @"\s+", " "); + filename = CleanRegex().Replace(filename, " "); - ReadOnlySpan<char> trimmedFilename = filename.TrimStart(); + ReadOnlySpan<char> trimmedFilename = filename.AsSpan().TrimStart(); foreach (var prefix in _options.AlbumStackingPrefixes) { diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index a069da102..b63c8f10e 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -318,22 +318,24 @@ namespace Emby.Naming.Common new EpisodeExpression(@"[\._ -]()[Ee][Pp]_?([0-9]+)([^\\/]*)$"), // <!-- foo.E01., foo.e01. --> new EpisodeExpression(@"[^\\/]*?()\.?[Ee]([0-9]+)\.([^\\/]*)$"), - new EpisodeExpression("(?<year>[0-9]{4})[\\.-](?<month>[0-9]{2})[\\.-](?<day>[0-9]{2})", true) + new EpisodeExpression("(?<year>[0-9]{4})[._ -](?<month>[0-9]{2})[._ -](?<day>[0-9]{2})", true) { DateTimeFormats = new[] { "yyyy.MM.dd", "yyyy-MM-dd", - "yyyy_MM_dd" + "yyyy_MM_dd", + "yyyy MM dd" } }, - new EpisodeExpression(@"(?<day>[0-9]{2})[.-](?<month>[0-9]{2})[.-](?<year>[0-9]{4})", true) + new EpisodeExpression("(?<day>[0-9]{2})[._ -](?<month>[0-9]{2})[._ -](?<year>[0-9]{4})", true) { DateTimeFormats = new[] { "dd.MM.yyyy", "dd-MM-yyyy", - "dd_MM_yyyy" + "dd_MM_yyyy", + "dd MM yyyy" } }, @@ -374,7 +376,7 @@ namespace Emby.Naming.Common IsNamed = true, SupportsAbsoluteEpisodeNumbers = false }, - new EpisodeExpression("[\\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\\/]*)$") + new EpisodeExpression(@"[\/._ -]p(?:ar)?t[_. -]()([ivx]+|[0-9]+)([._ -][^\/]*)$") { SupportsAbsoluteEpisodeNumbers = true }, @@ -415,7 +417,7 @@ namespace Emby.Naming.Common }, // "1-12 episode title" - new EpisodeExpression(@"([0-9]+)-([0-9]+)"), + new EpisodeExpression("([0-9]+)-([0-9]+)"), // "01 - blah.avi", "01-blah.avi" new EpisodeExpression(@".*(\\|\/)(?<epnumber>[0-9]{1,3})(-(?<endingepnumber>[0-9]{2,3}))*\s?-\s?[^\\\/]*$") @@ -710,7 +712,7 @@ namespace Emby.Naming.Common // Chapter is often beginning of filename "^(?<chapter>[0-9]+)", // Part if often ending of filename - @"(?<!ch(?:apter) )(?<part>[0-9]+)$", + "(?<!ch(?:apter) )(?<part>[0-9]+)$", // Sometimes named as 0001_005 (chapter_part) "(?<chapter>[0-9]+)_(?<part>[0-9]+)", // Some audiobooks are ripped from cd's, and will be named by disk number. diff --git a/Emby.Naming/ExternalFiles/ExternalPathParser.cs b/Emby.Naming/ExternalFiles/ExternalPathParser.cs index 953129671..4080ba10d 100644 --- a/Emby.Naming/ExternalFiles/ExternalPathParser.cs +++ b/Emby.Naming/ExternalFiles/ExternalPathParser.cs @@ -43,7 +43,7 @@ namespace Emby.Naming.ExternalFiles return null; } - var extension = Path.GetExtension(path); + var extension = Path.GetExtension(path.AsSpan()); if (!(_type == DlnaProfileType.Subtitle && _namingOptions.SubtitleFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) && !(_type == DlnaProfileType.Audio && _namingOptions.AudioFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase))) { diff --git a/Emby.Naming/TV/SeriesResolver.cs b/Emby.Naming/TV/SeriesResolver.cs index 307a84096..d8fa41743 100644 --- a/Emby.Naming/TV/SeriesResolver.cs +++ b/Emby.Naming/TV/SeriesResolver.cs @@ -7,14 +7,15 @@ namespace Emby.Naming.TV /// <summary> /// Used to resolve information about series from path. /// </summary> - public static class SeriesResolver + public static partial class SeriesResolver { /// <summary> /// Regex that matches strings of at least 2 characters separated by a dot or underscore. /// Used for removing separators between words, i.e turns "The_show" into "The show" while /// preserving namings like "S.H.O.W". /// </summary> - private static readonly Regex _seriesNameRegex = new Regex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))", RegexOptions.Compiled); + [GeneratedRegex(@"((?<a>[^\._]{2,})[\._]*)|([\._](?<b>[^\._]{2,}))")] + private static partial Regex SeriesNameRegex(); /// <summary> /// Resolve information about series from path. @@ -37,7 +38,7 @@ namespace Emby.Naming.TV if (!string.IsNullOrEmpty(seriesName)) { - seriesName = _seriesNameRegex.Replace(seriesName, "${a} ${b}").Trim(); + seriesName = SeriesNameRegex().Replace(seriesName, "${a} ${b}").Trim(); } return new SeriesInfo(path) diff --git a/Emby.Naming/Video/StubResolver.cs b/Emby.Naming/Video/StubResolver.cs index f7ba606e3..4b9df19b0 100644 --- a/Emby.Naming/Video/StubResolver.cs +++ b/Emby.Naming/Video/StubResolver.cs @@ -26,19 +26,18 @@ namespace Emby.Naming.Video return false; } - var extension = Path.GetExtension(path); + var extension = Path.GetExtension(path.AsSpan()); if (!options.StubFileExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return false; } - path = Path.GetFileNameWithoutExtension(path); - var token = Path.GetExtension(path).TrimStart('.'); + var token = Path.GetExtension(Path.GetFileNameWithoutExtension(path.AsSpan())).TrimStart('.'); foreach (var rule in options.StubTypes) { - if (string.Equals(rule.Token, token, StringComparison.OrdinalIgnoreCase)) + if (token.Equals(rule.Token, StringComparison.OrdinalIgnoreCase)) { stubType = rule.StubType; return true; diff --git a/Emby.Naming/Video/VideoListResolver.cs b/Emby.Naming/Video/VideoListResolver.cs index 6209cd46f..51f29cf08 100644 --- a/Emby.Naming/Video/VideoListResolver.cs +++ b/Emby.Naming/Video/VideoListResolver.cs @@ -12,9 +12,13 @@ namespace Emby.Naming.Video /// <summary> /// Resolves alternative versions and extras from list of video files. /// </summary> - public static class VideoListResolver + public static partial class VideoListResolver { - private static readonly Regex _resolutionRegex = new Regex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase | RegexOptions.Compiled); + [GeneratedRegex("[0-9]{2}[0-9]+[ip]", RegexOptions.IgnoreCase)] + private static partial Regex ResolutionRegex(); + + [GeneratedRegex(@"^\[([^]]*)\]")] + private static partial Regex CheckMultiVersionRegex(); /// <summary> /// Resolves alternative versions and extras from list of video files. @@ -131,7 +135,7 @@ namespace Emby.Naming.Video if (videos.Count > 1) { - var groups = videos.GroupBy(x => _resolutionRegex.IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); + var groups = videos.GroupBy(x => ResolutionRegex().IsMatch(x.Files[0].FileNameWithoutExtension)).ToList(); videos.Clear(); foreach (var group in groups) { @@ -201,7 +205,7 @@ namespace Emby.Naming.Video // The CleanStringParser should have removed common keywords etc. return testFilename.IsEmpty || testFilename[0] == '-' - || Regex.IsMatch(testFilename, @"^\[([^]]*)\]", RegexOptions.Compiled); + || CheckMultiVersionRegex().IsMatch(testFilename); } } } diff --git a/Emby.Photos/PhotoProvider.cs b/Emby.Photos/PhotoProvider.cs index f54066c57..27329a7f2 100644 --- a/Emby.Photos/PhotoProvider.cs +++ b/Emby.Photos/PhotoProvider.cs @@ -61,7 +61,7 @@ namespace Emby.Photos item.SetImagePath(ImageType.Primary, item.Path); // Examples: https://github.com/mono/taglib-sharp/blob/a5f6949a53d09ce63ee7495580d6802921a21f14/tests/fixtures/TagLib.Tests.Images/NullOrientationTest.cs - if (_includeExtensions.Contains(Path.GetExtension(item.Path), StringComparison.OrdinalIgnoreCase)) + if (_includeExtensions.Contains(Path.GetExtension(item.Path.AsSpan()), StringComparison.OrdinalIgnoreCase)) { try { diff --git a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs index 6edfad575..39524be1d 100644 --- a/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs +++ b/Emby.Server.Implementations/AppBase/BaseApplicationPaths.cs @@ -10,8 +10,6 @@ namespace Emby.Server.Implementations.AppBase /// </summary> public abstract class BaseApplicationPaths : IApplicationPaths { - private string _dataPath; - /// <summary> /// Initializes a new instance of the <see cref="BaseApplicationPaths"/> class. /// </summary> @@ -33,7 +31,7 @@ namespace Emby.Server.Implementations.AppBase CachePath = cacheDirectoryPath; WebPath = webDirectoryPath; - _dataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; + DataPath = Directory.CreateDirectory(Path.Combine(ProgramDataPath, "data")).FullName; } /// <summary> @@ -55,7 +53,7 @@ namespace Emby.Server.Implementations.AppBase /// Gets the folder path to the data directory. /// </summary> /// <value>The data directory.</value> - public string DataPath => _dataPath; + public string DataPath { get; } /// <inheritdoc /> public string VirtualDataPath => "%AppDataPath%"; diff --git a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs index a4deeddb7..a2f38c8c2 100644 --- a/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs +++ b/Emby.Server.Implementations/AppBase/BaseConfigurationManager.cs @@ -8,7 +8,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; using MediaBrowser.Common.Extensions; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; @@ -19,14 +18,8 @@ namespace Emby.Server.Implementations.AppBase /// </summary> public abstract class BaseConfigurationManager : IConfigurationManager { - private readonly IFileSystem _fileSystem; - - private readonly ConcurrentDictionary<string, object> _configurations = new ConcurrentDictionary<string, object>(); - - /// <summary> - /// The _configuration sync lock. - /// </summary> - private readonly object _configurationSyncLock = new object(); + private readonly ConcurrentDictionary<string, object> _configurations = new(); + private readonly object _configurationSyncLock = new(); private ConfigurationStore[] _configurationStores = Array.Empty<ConfigurationStore>(); private IConfigurationFactory[] _configurationFactories = Array.Empty<IConfigurationFactory>(); @@ -42,12 +35,13 @@ namespace Emby.Server.Implementations.AppBase /// <param name="applicationPaths">The application paths.</param> /// <param name="loggerFactory">The logger factory.</param> /// <param name="xmlSerializer">The XML serializer.</param> - /// <param name="fileSystem">The file system.</param> - protected BaseConfigurationManager(IApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IXmlSerializer xmlSerializer, IFileSystem fileSystem) + protected BaseConfigurationManager( + IApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + IXmlSerializer xmlSerializer) { CommonApplicationPaths = applicationPaths; XmlSerializer = xmlSerializer; - _fileSystem = fileSystem; Logger = loggerFactory.CreateLogger<BaseConfigurationManager>(); UpdateCachePath(); @@ -272,7 +266,7 @@ namespace Emby.Server.Implementations.AppBase { var file = Path.Combine(path, Guid.NewGuid().ToString()); File.WriteAllText(file, string.Empty); - _fileSystem.DeleteFile(file); + File.Delete(file); } private string GetConfigurationFile(string key) diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 7969577bc..c9bf7f085 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -12,11 +12,8 @@ using System.Linq; using System.Net; using System.Reflection; using System.Security.Cryptography.X509Certificates; -using System.Threading; using System.Threading.Tasks; -using Emby.Dlna; using Emby.Dlna.Main; -using Emby.Dlna.Ssdp; using Emby.Naming.Common; using Emby.Photos; using Emby.Server.Implementations.Channels; @@ -59,7 +56,6 @@ using MediaBrowser.Controller.Chapters; using MediaBrowser.Controller.ClientEvent; using MediaBrowser.Controller.Collections; using MediaBrowser.Controller.Configuration; -using MediaBrowser.Controller.Dlna; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; @@ -83,7 +79,6 @@ using MediaBrowser.LocalMetadata.Savers; using MediaBrowser.MediaEncoding.BdInfo; using MediaBrowser.MediaEncoding.Subtitles; using MediaBrowser.Model.Cryptography; -using MediaBrowser.Model.Dlna; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.MediaInfo; @@ -112,7 +107,7 @@ namespace Emby.Server.Implementations /// <summary> /// Class CompositionRoot. /// </summary> - public abstract class ApplicationHost : IServerApplicationHost, IAsyncDisposable, IDisposable + public abstract class ApplicationHost : IServerApplicationHost, IDisposable { /// <summary> /// The disposable parts. @@ -120,14 +115,12 @@ namespace Emby.Server.Implementations private readonly ConcurrentDictionary<IDisposable, byte> _disposableParts = new(); private readonly DeviceId _deviceId; - private readonly IFileSystem _fileSystemManager; private readonly IConfiguration _startupConfig; private readonly IXmlSerializer _xmlSerializer; private readonly IStartupOptions _startupOptions; private readonly IPluginManager _pluginManager; private List<Type> _creatingInstances; - private ISessionManager _sessionManager; /// <summary> /// Gets or sets all concrete types. @@ -135,7 +128,7 @@ namespace Emby.Server.Implementations /// <value>All concrete types.</value> private Type[] _allConcreteTypes; - private bool _disposed = false; + private bool _disposed; /// <summary> /// Initializes a new instance of the <see cref="ApplicationHost"/> class. @@ -154,10 +147,8 @@ namespace Emby.Server.Implementations LoggerFactory = loggerFactory; _startupOptions = options; _startupConfig = startupConfig; - _fileSystemManager = new ManagedFileSystem(LoggerFactory.CreateLogger<ManagedFileSystem>(), applicationPaths); Logger = LoggerFactory.CreateLogger<ApplicationHost>(); - _fileSystemManager.AddShortcutHandler(new MbLinkShortcutHandler(_fileSystemManager)); _deviceId = new DeviceId(ApplicationPaths, LoggerFactory); ApplicationVersion = typeof(ApplicationHost).Assembly.GetName().Version; @@ -165,13 +156,15 @@ namespace Emby.Server.Implementations ApplicationUserAgent = Name.Replace(' ', '-') + "/" + ApplicationVersionString; _xmlSerializer = new MyXmlSerializer(); - ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer, _fileSystemManager); + ConfigurationManager = new ServerConfigurationManager(ApplicationPaths, LoggerFactory, _xmlSerializer); _pluginManager = new PluginManager( LoggerFactory.CreateLogger<PluginManager>(), this, ConfigurationManager.Configuration, ApplicationPaths.PluginsPath, ApplicationVersion); + + _disposableParts.TryAdd((PluginManager)_pluginManager, byte.MinValue); } /// <summary> @@ -186,23 +179,16 @@ namespace Emby.Server.Implementations public bool CoreStartupHasCompleted { get; private set; } - public virtual bool CanLaunchWebBrowser => Environment.UserInteractive - && !_startupOptions.IsService - && (OperatingSystem.IsWindows() || OperatingSystem.IsMacOS()); - /// <summary> /// Gets the <see cref="INetworkManager"/> singleton instance. /// </summary> public INetworkManager NetManager { get; private set; } - /// <summary> - /// Gets a value indicating whether this instance has changes that require the entire application to restart. - /// </summary> - /// <value><c>true</c> if this instance has pending application restart; otherwise, <c>false</c>.</value> + /// <inheritdoc /> public bool HasPendingRestart { get; private set; } /// <inheritdoc /> - public bool IsShuttingDown { get; private set; } + public bool ShouldRestart { get; set; } /// <summary> /// Gets the logger. @@ -406,11 +392,9 @@ namespace Emby.Server.Implementations /// <summary> /// Runs the startup tasks. /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> /// <returns><see cref="Task" />.</returns> - public async Task RunStartupTasksAsync(CancellationToken cancellationToken) + public async Task RunStartupTasksAsync() { - cancellationToken.ThrowIfCancellationRequested(); Logger.LogInformation("Running startup tasks"); Resolve<ITaskManager>().AddTasks(GetExports<IScheduledTask>(false)); @@ -424,8 +408,6 @@ namespace Emby.Server.Implementations var entryPoints = GetExports<IServerEntryPoint>(); - cancellationToken.ThrowIfCancellationRequested(); - var stopWatch = new Stopwatch(); stopWatch.Start(); @@ -435,8 +417,6 @@ namespace Emby.Server.Implementations Logger.LogInformation("Core startup complete"); CoreStartupHasCompleted = true; - cancellationToken.ThrowIfCancellationRequested(); - stopWatch.Restart(); await Task.WhenAll(StartEntryPoints(entryPoints, false)).ConfigureAwait(false); @@ -466,7 +446,7 @@ namespace Emby.Server.Implementations ConfigurationManager.AddParts(GetExports<IConfigurationFactory>()); - NetManager = new NetworkManager(ConfigurationManager, LoggerFactory.CreateLogger<NetworkManager>()); + NetManager = new NetworkManager(ConfigurationManager, _startupConfig, LoggerFactory.CreateLogger<NetworkManager>()); // Initialize runtime stat collection if (ConfigurationManager.Configuration.EnableMetrics) @@ -475,8 +455,8 @@ namespace Emby.Server.Implementations } var networkConfiguration = ConfigurationManager.GetNetworkConfiguration(); - HttpPort = networkConfiguration.HttpServerPortNumber; - HttpsPort = networkConfiguration.HttpsPortNumber; + HttpPort = networkConfiguration.InternalHttpPort; + HttpsPort = networkConfiguration.InternalHttpsPort; // Safeguard against invalid configuration if (HttpPort == HttpsPort) @@ -509,7 +489,11 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton(_pluginManager); serviceCollection.AddSingleton<IApplicationPaths>(ApplicationPaths); - serviceCollection.AddSingleton(_fileSystemManager); + serviceCollection.AddSingleton<IFileSystem, ManagedFileSystem>(); + serviceCollection.AddSingleton<IShortcutHandler, MbLinkShortcutHandler>(); + + serviceCollection.AddScoped<ISystemManager, SystemManager>(); + serviceCollection.AddSingleton<TmdbClientManager>(); serviceCollection.AddSingleton(NetManager); @@ -575,8 +559,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<ISessionManager, SessionManager>(); - serviceCollection.AddSingleton<IDlnaManager, DlnaManager>(); - serviceCollection.AddSingleton<ICollectionManager, CollectionManager>(); serviceCollection.AddSingleton<IPlaylistManager, PlaylistManager>(); @@ -588,8 +570,6 @@ namespace Emby.Server.Implementations serviceCollection.AddSingleton<IUserViewManager, UserViewManager>(); - serviceCollection.AddSingleton<IDeviceDiscovery, DeviceDiscovery>(); - serviceCollection.AddSingleton<IChapterManager, ChapterManager>(); serviceCollection.AddSingleton<IEncodingManager, MediaEncoder.EncodingManager>(); @@ -633,8 +613,6 @@ namespace Emby.Server.Implementations var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>(); await localizationManager.LoadAll().ConfigureAwait(false); - _sessionManager = Resolve<ISessionManager>(); - SetStaticProperties(); FindParts(); @@ -685,7 +663,7 @@ namespace Emby.Server.Implementations BaseItem.ProviderManager = Resolve<IProviderManager>(); BaseItem.LocalizationManager = Resolve<ILocalizationManager>(); BaseItem.ItemRepository = Resolve<IItemRepository>(); - BaseItem.FileSystem = _fileSystemManager; + BaseItem.FileSystem = Resolve<IFileSystem>(); BaseItem.UserDataManager = Resolve<IUserDataManager>(); BaseItem.ChannelManager = Resolve<IChannelManager>(); Video.LiveTvManager = Resolve<ILiveTvManager>(); @@ -785,8 +763,8 @@ namespace Emby.Server.Implementations if (HttpPort != 0 && HttpsPort != 0) { // Need to restart if ports have changed - if (networkConfiguration.HttpServerPortNumber != HttpPort - || networkConfiguration.HttpsPortNumber != HttpsPort) + if (networkConfiguration.InternalHttpPort != HttpPort + || networkConfiguration.InternalHttpsPort != HttpsPort) { if (ConfigurationManager.Configuration.IsPortAuthorized) { @@ -856,38 +834,6 @@ namespace Emby.Server.Implementations } /// <summary> - /// Restarts this instance. - /// </summary> - public void Restart() - { - if (IsShuttingDown) - { - return; - } - - IsShuttingDown = true; - _pluginManager.UnloadAssemblies(); - - Task.Run(async () => - { - try - { - await _sessionManager.SendServerRestartNotification(CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error sending server restart notification"); - } - - Logger.LogInformation("Calling RestartInternal"); - - RestartInternal(); - }); - } - - protected abstract void RestartInternal(); - - /// <summary> /// Gets the composable part assemblies. /// </summary> /// <returns>IEnumerable{Assembly}.</returns> @@ -942,49 +888,6 @@ namespace Emby.Server.Implementations protected abstract IEnumerable<Assembly> GetAssembliesWithPartsInternal(); - /// <summary> - /// Gets the system status. - /// </summary> - /// <param name="request">Where this request originated.</param> - /// <returns>SystemInfo.</returns> - public SystemInfo GetSystemInfo(HttpRequest request) - { - return new SystemInfo - { - HasPendingRestart = HasPendingRestart, - IsShuttingDown = IsShuttingDown, - Version = ApplicationVersionString, - WebSocketPortNumber = HttpPort, - CompletedInstallations = Resolve<IInstallationManager>().CompletedInstallations.ToArray(), - Id = SystemId, - ProgramDataPath = ApplicationPaths.ProgramDataPath, - WebPath = ApplicationPaths.WebPath, - LogPath = ApplicationPaths.LogDirectoryPath, - ItemsByNamePath = ApplicationPaths.InternalMetadataPath, - InternalMetadataPath = ApplicationPaths.InternalMetadataPath, - CachePath = ApplicationPaths.CachePath, - CanLaunchWebBrowser = CanLaunchWebBrowser, - TranscodingTempPath = ConfigurationManager.GetTranscodePath(), - ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(request), - SupportsLibraryMonitor = true, - PackageName = _startupOptions.PackageName - }; - } - - public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) - { - return new PublicSystemInfo - { - Version = ApplicationVersionString, - ProductName = ApplicationProductName, - Id = SystemId, - ServerName = FriendlyName, - LocalAddress = GetSmartApiUrl(request), - StartupWizardCompleted = ConfigurationManager.CommonConfiguration.IsStartupWizardCompleted - }; - } - /// <inheritdoc/> public string GetSmartApiUrl(IPAddress remoteAddr) { @@ -995,18 +898,20 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - string smart = NetManager.GetBindInterface(remoteAddr, out var port); + string smart = NetManager.GetBindAddress(remoteAddr, out var port); return GetLocalApiUrl(smart.Trim('/'), null, port); } /// <inheritdoc/> public string GetSmartApiUrl(HttpRequest request) { - // Return the host in the HTTP request as the API url + // Return the host in the HTTP request as the API URL if not configured otherwise 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))) + if (requestPort is null + || (requestPort == 80 && string.Equals(request.Scheme, "http", StringComparison.OrdinalIgnoreCase)) + || (requestPort == 443 && string.Equals(request.Scheme, "https", StringComparison.OrdinalIgnoreCase))) { requestPort = -1; } @@ -1027,15 +932,15 @@ namespace Emby.Server.Implementations return PublishedServerUrl.Trim('/'); } - string smart = NetManager.GetBindInterface(hostname, out var port); + string smart = NetManager.GetBindAddress(hostname, out var port); return GetLocalApiUrl(smart.Trim('/'), null, port); } /// <inheritdoc/> - public string GetApiUrlForLocalAccess(IPObject hostname = null, bool allowHttps = true) + public string GetApiUrlForLocalAccess(IPAddress ipAddress = null, bool allowHttps = true) { // With an empty source, the port will be null - var smart = NetManager.GetBindInterface(hostname ?? IPHost.None, out _); + var smart = NetManager.GetBindAddress(ipAddress, out _, false); var scheme = !allowHttps ? Uri.UriSchemeHttp : null; int? port = !allowHttps ? HttpPort : null; return GetLocalApiUrl(smart, scheme, port); @@ -1063,30 +968,6 @@ namespace Emby.Server.Implementations }.ToString().TrimEnd('/'); } - /// <inheritdoc /> - public async Task Shutdown() - { - if (IsShuttingDown) - { - return; - } - - IsShuttingDown = true; - - try - { - await _sessionManager.SendServerShutdownNotification(CancellationToken.None).ConfigureAwait(false); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error sending server shutdown notification"); - } - - ShutdownInternal(); - } - - protected abstract void ShutdownInternal(); - public IEnumerable<Assembly> GetApiPluginAssemblies() { var assemblies = _allConcreteTypes @@ -1150,52 +1031,5 @@ namespace Emby.Server.Implementations _disposed = true; } - - public async ValueTask DisposeAsync() - { - await DisposeAsyncCore().ConfigureAwait(false); - Dispose(false); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Used to perform asynchronous cleanup of managed resources or for cascading calls to <see cref="DisposeAsync"/>. - /// </summary> - /// <returns>A ValueTask.</returns> - protected virtual async ValueTask DisposeAsyncCore() - { - var type = GetType(); - - Logger.LogInformation("Disposing {Type}", type.Name); - - foreach (var (part, _) in _disposableParts) - { - var partType = part.GetType(); - if (partType == type) - { - continue; - } - - Logger.LogInformation("Disposing {Type}", partType.Name); - - try - { - part.Dispose(); - } - catch (Exception ex) - { - Logger.LogError(ex, "Error disposing {Type}", partType.Name); - } - } - - if (_sessionManager != null) - { - // used for closing websockets - foreach (var session in _sessionManager.Sessions) - { - await session.DisposeAsync().ConfigureAwait(false); - } - } - } } } diff --git a/Emby.Server.Implementations/Channels/ChannelManager.cs b/Emby.Server.Implementations/Channels/ChannelManager.cs index 961e225e9..8279acb05 100644 --- a/Emby.Server.Implementations/Channels/ChannelManager.cs +++ b/Emby.Server.Implementations/Channels/ChannelManager.cs @@ -371,8 +371,11 @@ namespace Emby.Server.Implementations.Channels Directory.CreateDirectory(Path.GetDirectoryName(path)); - await using FileStream createStream = File.Create(path); - await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); + FileStream createStream = File.Create(path); + await using (createStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(createStream, mediaSources, _jsonOptions).ConfigureAwait(false); + } } /// <inheritdoc /> @@ -1156,7 +1159,7 @@ namespace Emby.Server.Implementations.Channels if (info.People is not null && info.People.Count > 0) { - _libraryManager.UpdatePeople(item, info.People); + await _libraryManager.UpdatePeopleAsync(item, info.People, cancellationToken).ConfigureAwait(false); } } else if (forceUpdate) diff --git a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs index 6b8b1a620..0ee43ce0a 100644 --- a/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs +++ b/Emby.Server.Implementations/Configuration/ServerConfigurationManager.cs @@ -7,7 +7,6 @@ using MediaBrowser.Common.Configuration; using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.IO; using MediaBrowser.Model.Serialization; using Microsoft.Extensions.Logging; @@ -22,11 +21,13 @@ namespace Emby.Server.Implementations.Configuration /// Initializes a new instance of the <see cref="ServerConfigurationManager" /> class. /// </summary> /// <param name="applicationPaths">The application paths.</param> - /// <param name="loggerFactory">The paramref name="loggerFactory" factory.</param> + /// <param name="loggerFactory">The logger factory.</param> /// <param name="xmlSerializer">The XML serializer.</param> - /// <param name="fileSystem">The file system.</param> - public ServerConfigurationManager(IApplicationPaths applicationPaths, ILoggerFactory loggerFactory, IXmlSerializer xmlSerializer, IFileSystem fileSystem) - : base(applicationPaths, loggerFactory, xmlSerializer, fileSystem) + public ServerConfigurationManager( + IApplicationPaths applicationPaths, + ILoggerFactory loggerFactory, + IXmlSerializer xmlSerializer) + : base(applicationPaths, loggerFactory, xmlSerializer) { UpdateMetadataPath(); } diff --git a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs index d05534ee7..bf079d90c 100644 --- a/Emby.Server.Implementations/Data/BaseSqliteRepository.cs +++ b/Emby.Server.Implementations/Data/BaseSqliteRepository.cs @@ -5,8 +5,8 @@ using System; using System.Collections.Generic; using Jellyfin.Extensions; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { @@ -46,24 +46,6 @@ namespace Emby.Server.Implementations.Data protected ILogger<BaseSqliteRepository> Logger { get; } /// <summary> - /// Gets the default connection flags. - /// </summary> - /// <value>The default connection flags.</value> - protected virtual ConnectionFlags DefaultConnectionFlags => ConnectionFlags.NoMutex; - - /// <summary> - /// Gets the transaction mode. - /// </summary> - /// <value>The transaction mode.</value>> - protected TransactionMode TransactionMode => TransactionMode.Deferred; - - /// <summary> - /// Gets the transaction mode for read-only operations. - /// </summary> - /// <value>The transaction mode.</value> - protected TransactionMode ReadTransactionMode => TransactionMode.Deferred; - - /// <summary> /// Gets the cache size. /// </summary> /// <value>The cache size or null.</value> @@ -107,23 +89,8 @@ namespace Emby.Server.Implementations.Data /// <see cref="SynchronousMode"/> protected virtual SynchronousMode? Synchronous => SynchronousMode.Normal; - /// <summary> - /// Gets or sets the write lock. - /// </summary> - /// <value>The write lock.</value> - protected ConnectionPool WriteConnections { get; set; } - - /// <summary> - /// Gets or sets the write connection. - /// </summary> - /// <value>The write connection.</value> - protected ConnectionPool ReadConnections { get; set; } - public virtual void Initialize() { - WriteConnections = new ConnectionPool(WriteConnectionsCount, CreateWriteConnection); - ReadConnections = new ConnectionPool(ReadConnectionsCount, CreateReadConnection); - // Configuration and pragmas can affect VACUUM so it needs to be last. using (var connection = GetConnection()) { @@ -131,57 +98,10 @@ namespace Emby.Server.Implementations.Data } } - protected ManagedConnection GetConnection(bool readOnly = false) - => readOnly ? ReadConnections.GetConnection() : WriteConnections.GetConnection(); - - protected SQLiteDatabaseConnection CreateWriteConnection() - { - var writeConnection = SQLite3.Open( - DbFilePath, - DefaultConnectionFlags | ConnectionFlags.Create | ConnectionFlags.ReadWrite, - null); - - if (CacheSize.HasValue) - { - writeConnection.Execute("PRAGMA cache_size=" + CacheSize.Value); - } - - if (!string.IsNullOrWhiteSpace(LockingMode)) - { - writeConnection.Execute("PRAGMA locking_mode=" + LockingMode); - } - - if (!string.IsNullOrWhiteSpace(JournalMode)) - { - writeConnection.Execute("PRAGMA journal_mode=" + JournalMode); - } - - if (JournalSizeLimit.HasValue) - { - writeConnection.Execute("PRAGMA journal_size_limit=" + JournalSizeLimit.Value); - } - - if (Synchronous.HasValue) - { - writeConnection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); - } - - if (PageSize.HasValue) - { - writeConnection.Execute("PRAGMA page_size=" + PageSize.Value); - } - - writeConnection.Execute("PRAGMA temp_store=" + (int)TempStore); - - return writeConnection; - } - - protected SQLiteDatabaseConnection CreateReadConnection() + protected SqliteConnection GetConnection() { - var connection = SQLite3.Open( - DbFilePath, - DefaultConnectionFlags | ConnectionFlags.ReadOnly, - null); + var connection = new SqliteConnection($"Filename={DbFilePath}"); + connection.Open(); if (CacheSize.HasValue) { @@ -208,39 +128,38 @@ namespace Emby.Server.Implementations.Data connection.Execute("PRAGMA synchronous=" + (int)Synchronous.Value); } + if (PageSize.HasValue) + { + connection.Execute("PRAGMA page_size=" + PageSize.Value); + } + connection.Execute("PRAGMA temp_store=" + (int)TempStore); return connection; } - public IStatement PrepareStatement(ManagedConnection connection, string sql) - => connection.PrepareStatement(sql); - - public IStatement PrepareStatement(IDatabaseConnection connection, string sql) - => connection.PrepareStatement(sql); + public SqliteCommand PrepareStatement(SqliteConnection connection, string sql) + { + var command = connection.CreateCommand(); + command.CommandText = sql; + return command; + } - protected bool TableExists(ManagedConnection connection, string name) + protected bool TableExists(SqliteConnection connection, string name) { - return connection.RunInTransaction( - db => + using var statement = PrepareStatement(connection, "select DISTINCT tbl_name from sqlite_master"); + foreach (var row in statement.ExecuteQuery()) + { + if (string.Equals(name, row.GetString(0), StringComparison.OrdinalIgnoreCase)) { - using (var statement = PrepareStatement(db, "select DISTINCT tbl_name from sqlite_master")) - { - foreach (var row in statement.ExecuteQuery()) - { - if (string.Equals(name, row.GetString(0), StringComparison.OrdinalIgnoreCase)) - { - return true; - } - } - } - - return false; - }, - ReadTransactionMode); + return true; + } + } + + return false; } - protected List<string> GetColumnNames(IDatabaseConnection connection, string table) + protected List<string> GetColumnNames(SqliteConnection connection, string table) { var columnNames = new List<string>(); @@ -255,7 +174,7 @@ namespace Emby.Server.Implementations.Data return columnNames; } - protected void AddColumn(IDatabaseConnection connection, string table, string columnName, string type, List<string> existingColumnNames) + protected void AddColumn(SqliteConnection connection, string table, string columnName, string type, List<string> existingColumnNames) { if (existingColumnNames.Contains(columnName, StringComparison.OrdinalIgnoreCase)) { @@ -291,12 +210,6 @@ namespace Emby.Server.Implementations.Data return; } - if (dispose) - { - WriteConnections.Dispose(); - ReadConnections.Dispose(); - } - _disposed = true; } } diff --git a/Emby.Server.Implementations/Data/ConnectionPool.cs b/Emby.Server.Implementations/Data/ConnectionPool.cs deleted file mode 100644 index 5ea7e934f..000000000 --- a/Emby.Server.Implementations/Data/ConnectionPool.cs +++ /dev/null @@ -1,79 +0,0 @@ -using System; -using System.Collections.Concurrent; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Data; - -/// <summary> -/// A pool of SQLite Database connections. -/// </summary> -public sealed class ConnectionPool : IDisposable -{ - private readonly BlockingCollection<SQLiteDatabaseConnection> _connections = new(); - private bool _disposed; - - /// <summary> - /// Initializes a new instance of the <see cref="ConnectionPool" /> class. - /// </summary> - /// <param name="count">The number of database connection to create.</param> - /// <param name="factory">Factory function to create the database connections.</param> - public ConnectionPool(int count, Func<SQLiteDatabaseConnection> factory) - { - for (int i = 0; i < count; i++) - { - _connections.Add(factory.Invoke()); - } - } - - /// <summary> - /// Gets a database connection from the pool if one is available, otherwise blocks. - /// </summary> - /// <returns>A database connection.</returns> - public ManagedConnection GetConnection() - { - if (_disposed) - { - ThrowObjectDisposedException(); - } - - return new ManagedConnection(_connections.Take(), this); - - static void ThrowObjectDisposedException() - { - throw new ObjectDisposedException(nameof(ConnectionPool)); - } - } - - /// <summary> - /// Return a database connection to the pool. - /// </summary> - /// <param name="connection">The database connection to return.</param> - public void Return(SQLiteDatabaseConnection connection) - { - if (_disposed) - { - connection.Dispose(); - return; - } - - _connections.Add(connection); - } - - /// <inheritdoc /> - public void Dispose() - { - if (_disposed) - { - return; - } - - foreach (var connection in _connections) - { - connection.Dispose(); - } - - _connections.Dispose(); - - _disposed = true; - } -} diff --git a/Emby.Server.Implementations/Data/ManagedConnection.cs b/Emby.Server.Implementations/Data/ManagedConnection.cs deleted file mode 100644 index e84ed8f91..000000000 --- a/Emby.Server.Implementations/Data/ManagedConnection.cs +++ /dev/null @@ -1,81 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Collections.Generic; -using SQLitePCL.pretty; - -namespace Emby.Server.Implementations.Data -{ - public sealed class ManagedConnection : IDisposable - { - private readonly ConnectionPool _pool; - - private SQLiteDatabaseConnection _db; - - private bool _disposed = false; - - public ManagedConnection(SQLiteDatabaseConnection db, ConnectionPool pool) - { - _db = db; - _pool = pool; - } - - public IStatement PrepareStatement(string sql) - { - return _db.PrepareStatement(sql); - } - - public IEnumerable<IStatement> PrepareAll(string sql) - { - return _db.PrepareAll(sql); - } - - public void ExecuteAll(string sql) - { - _db.ExecuteAll(sql); - } - - public void Execute(string sql, params object[] values) - { - _db.Execute(sql, values); - } - - public void RunQueries(string[] sql) - { - _db.RunQueries(sql); - } - - public void RunInTransaction(Action<IDatabaseConnection> action, TransactionMode mode) - { - _db.RunInTransaction(action, mode); - } - - public T RunInTransaction<T>(Func<IDatabaseConnection, T> action, TransactionMode mode) - { - return _db.RunInTransaction(action, mode); - } - - public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql) - { - return _db.Query(sql); - } - - public IEnumerable<IReadOnlyList<ResultSetValue>> Query(string sql, params object[] values) - { - return _db.Query(sql, values); - } - - public void Dispose() - { - if (_disposed) - { - return; - } - - _pool.Return(_db); - - _db = null!; // Don't dispose it - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/Data/SqliteExtensions.cs b/Emby.Server.Implementations/Data/SqliteExtensions.cs index 4055b0ba1..01b5fdaee 100644 --- a/Emby.Server.Implementations/Data/SqliteExtensions.cs +++ b/Emby.Server.Implementations/Data/SqliteExtensions.cs @@ -1,11 +1,10 @@ -#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Generic; -using System.Diagnostics; +using System.Data; using System.Globalization; -using SQLitePCL.pretty; +using Microsoft.Data.Sqlite; namespace Emby.Server.Implementations.Data { @@ -52,19 +51,29 @@ namespace Emby.Server.Implementations.Data "yy-MM-dd" }; - public static void RunQueries(this SQLiteDatabaseConnection connection, string[] queries) + public static IEnumerable<SqliteDataReader> Query(this SqliteConnection sqliteConnection, string commandText) { - ArgumentNullException.ThrowIfNull(queries); + if (sqliteConnection.State != ConnectionState.Open) + { + sqliteConnection.Open(); + } - connection.RunInTransaction(conn => + using var command = sqliteConnection.CreateCommand(); + command.CommandText = commandText; + using (var reader = command.ExecuteReader()) { - conn.ExecuteAll(string.Join(';', queries)); - }); + while (reader.Read()) + { + yield return reader; + } + } } - public static Guid ReadGuidFromBlob(this ResultSetValue result) + public static void Execute(this SqliteConnection sqliteConnection, string commandText) { - return new Guid(result.ToBlob()); + using var command = sqliteConnection.CreateCommand(); + command.CommandText = commandText; + command.ExecuteNonQuery(); } public static string ToDateTimeParamValue(this DateTime dateValue) @@ -83,27 +92,15 @@ namespace Emby.Server.Implementations.Data private static string GetDateTimeKindFormat(DateTimeKind kind) => (kind == DateTimeKind.Utc) ? DatetimeFormatUtc : DatetimeFormatLocal; - public static DateTime ReadDateTime(this ResultSetValue result) - { - var dateText = result.ToString(); - - return DateTime.ParseExact( - dateText, - _datetimeFormats, - DateTimeFormatInfo.InvariantInfo, - DateTimeStyles.AdjustToUniversal); - } - - public static bool TryReadDateTime(this IReadOnlyList<ResultSetValue> reader, int index, out DateTime result) + public static bool TryReadDateTime(this SqliteDataReader reader, int index, out DateTime result) { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - var dateText = item.ToString(); + var dateText = reader.GetString(index); if (DateTime.TryParseExact(dateText, _datetimeFormats, DateTimeFormatInfo.InvariantInfo, DateTimeStyles.AdjustToUniversal, out var dateTimeResult)) { @@ -115,335 +112,145 @@ namespace Emby.Server.Implementations.Data return false; } - public static bool TryGetGuid(this IReadOnlyList<ResultSetValue> reader, int index, out Guid result) + public static bool TryGetGuid(this SqliteDataReader reader, int index, out Guid result) { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - result = item.ReadGuidFromBlob(); + result = reader.GetGuid(index); return true; } - public static bool IsDbNull(this ResultSetValue result) + public static bool TryGetString(this SqliteDataReader reader, int index, out string result) { - return result.SQLiteType == SQLiteType.Null; - } - - public static string GetString(this IReadOnlyList<ResultSetValue> result, int index) - { - return result[index].ToString(); - } + result = string.Empty; - public static bool TryGetString(this IReadOnlyList<ResultSetValue> reader, int index, out string result) - { - result = null; - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { return false; } - result = item.ToString(); + result = reader.GetString(index); return true; } - public static bool GetBoolean(this IReadOnlyList<ResultSetValue> result, int index) - { - return result[index].ToBool(); - } - - public static bool TryGetBoolean(this IReadOnlyList<ResultSetValue> reader, int index, out bool result) + public static bool TryGetBoolean(this SqliteDataReader reader, int index, out bool result) { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - result = item.ToBool(); + result = reader.GetBoolean(index); return true; } - public static bool TryGetInt32(this IReadOnlyList<ResultSetValue> reader, int index, out int result) + public static bool TryGetInt32(this SqliteDataReader reader, int index, out int result) { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - result = item.ToInt(); + result = reader.GetInt32(index); return true; } - public static long GetInt64(this IReadOnlyList<ResultSetValue> result, int index) + public static bool TryGetInt64(this SqliteDataReader reader, int index, out long result) { - return result[index].ToInt64(); - } - - public static bool TryGetInt64(this IReadOnlyList<ResultSetValue> reader, int index, out long result) - { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - result = item.ToInt64(); + result = reader.GetInt64(index); return true; } - public static bool TryGetSingle(this IReadOnlyList<ResultSetValue> reader, int index, out float result) + public static bool TryGetSingle(this SqliteDataReader reader, int index, out float result) { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - result = item.ToFloat(); + result = reader.GetFloat(index); return true; } - public static bool TryGetDouble(this IReadOnlyList<ResultSetValue> reader, int index, out double result) + public static bool TryGetDouble(this SqliteDataReader reader, int index, out double result) { - var item = reader[index]; - if (item.IsDbNull()) + if (reader.IsDBNull(index)) { result = default; return false; } - result = item.ToDouble(); + result = reader.GetDouble(index); return true; } - public static Guid GetGuid(this IReadOnlyList<ResultSetValue> result, int index) + public static void TryBind(this SqliteCommand statement, string name, Guid value) { - return result[index].ReadGuidFromBlob(); + statement.TryBind(name, value, true); } - [Conditional("DEBUG")] - private static void CheckName(string name) + public static void TryBind(this SqliteCommand statement, string name, object? value, bool isBlob = false) { - throw new ArgumentException("Invalid param name: " + name, nameof(name)); - } - - public static void TryBind(this IStatement statement, string name, double value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) + var preparedValue = value ?? DBNull.Value; + if (statement.Parameters.Contains(name)) { - bindParam.Bind(value); + statement.Parameters[name].Value = preparedValue; } else { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, string value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - if (value is null) + // Blobs aren't always detected automatically + if (isBlob) { - bindParam.BindNull(); + statement.Parameters.Add(new SqliteParameter(name, SqliteType.Blob) { Value = value }); } else { - bindParam.Bind(value); + statement.Parameters.AddWithValue(name, preparedValue); } } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, bool value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.Bind(value); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, float value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.Bind(value); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, int value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.Bind(value); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, Guid value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - Span<byte> byteValue = stackalloc byte[16]; - value.TryWriteBytes(byteValue); - bindParam.Bind(byteValue); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, DateTime value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.Bind(value.ToDateTimeParamValue()); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, long value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.Bind(value); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, ReadOnlySpan<byte> value) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.Bind(value); - } - else - { - CheckName(name); - } - } - - public static void TryBindNull(this IStatement statement, string name) - { - if (statement.BindParameters.TryGetValue(name, out IBindParameter bindParam)) - { - bindParam.BindNull(); - } - else - { - CheckName(name); - } - } - - public static void TryBind(this IStatement statement, string name, DateTime? value) - { - if (value.HasValue) - { - TryBind(statement, name, value.Value); - } - else - { - TryBindNull(statement, name); - } - } - - public static void TryBind(this IStatement statement, string name, Guid? value) - { - if (value.HasValue) - { - TryBind(statement, name, value.Value); - } - else - { - TryBindNull(statement, name); - } - } - - public static void TryBind(this IStatement statement, string name, double? value) - { - if (value.HasValue) - { - TryBind(statement, name, value.Value); - } - else - { - TryBindNull(statement, name); - } } - public static void TryBind(this IStatement statement, string name, int? value) + public static void TryBindNull(this SqliteCommand statement, string name) { - if (value.HasValue) - { - TryBind(statement, name, value.Value); - } - else - { - TryBindNull(statement, name); - } + statement.TryBind(name, DBNull.Value); } - public static void TryBind(this IStatement statement, string name, float? value) + public static IEnumerable<SqliteDataReader> ExecuteQuery(this SqliteCommand command) { - if (value.HasValue) + using (var reader = command.ExecuteReader()) { - TryBind(statement, name, value.Value); - } - else - { - TryBindNull(statement, name); + while (reader.Read()) + { + yield return reader; + } } } - public static void TryBind(this IStatement statement, string name, bool? value) + public static int SelectScalarInt(this SqliteCommand command) { - if (value.HasValue) - { - TryBind(statement, name, value.Value); - } - else - { - TryBindNull(statement, name); - } + var result = command.ExecuteScalar(); + // Can't be null since the method is used to retrieve Count + return Convert.ToInt32(result!, CultureInfo.InvariantCulture); } - public static IEnumerable<IReadOnlyList<ResultSetValue>> ExecuteQuery(this IStatement statement) + public static SqliteCommand PrepareStatement(this SqliteConnection sqliteConnection, string sql) { - while (statement.MoveNext()) - { - yield return statement.Current; - } + var command = sqliteConnection.CreateCommand(); + command.CommandText = sql; + return command; } } } diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index d1fbea95a..e519364c2 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -3,7 +3,6 @@ #pragma warning disable CS1591 using System; -using System.Buffers.Text; using System.Collections.Generic; using System.Diagnostics; using System.Globalization; @@ -34,9 +33,9 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.Querying; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { @@ -436,128 +435,126 @@ namespace Emby.Server.Implementations.Data }; using (var connection = GetConnection()) - { - connection.RunQueries(queries); - - connection.RunInTransaction( - db => - { - var existingColumnNames = GetColumnNames(db, "AncestorIds"); - AddColumn(db, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); - - existingColumnNames = GetColumnNames(db, "TypedBaseItems"); - - AddColumn(db, "TypedBaseItems", "Path", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ChannelId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CustomRating", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Name", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "MediaType", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Overview", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ParentId", "GUID", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Genres", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SortName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "LockedFields", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Studios", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Audio", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Tags", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "UnratedType", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "TopParentId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CriticRating", "Float", existingColumnNames); - AddColumn(db, "TypedBaseItems", "CleanName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Album", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "LUFS", "Float", existingColumnNames); - AddColumn(db, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeasonName", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Tagline", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Images", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExtraIds", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExtraType", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Artists", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ExternalId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "ShowId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "OwnerId", "Text", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Width", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Height", "INT", existingColumnNames); - AddColumn(db, "TypedBaseItems", "Size", "BIGINT", existingColumnNames); - - existingColumnNames = GetColumnNames(db, "ItemValues"); - AddColumn(db, "ItemValues", "CleanValue", "Text", existingColumnNames); - - existingColumnNames = GetColumnNames(db, ChaptersTableName); - AddColumn(db, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames); - - existingColumnNames = GetColumnNames(db, "MediaStreams"); - AddColumn(db, "MediaStreams", "IsAvc", "BIT", existingColumnNames); - AddColumn(db, "MediaStreams", "TimeBase", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "Title", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "Comment", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "CodecTag", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "BitDepth", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "RefFrames", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames); - - AddColumn(db, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); - AddColumn(db, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); - - AddColumn(db, "MediaStreams", "DvVersionMajor", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "DvVersionMinor", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "DvProfile", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "DvLevel", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "RpuPresentFlag", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "ElPresentFlag", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "BlPresentFlag", "INT", existingColumnNames); - AddColumn(db, "MediaStreams", "DvBlSignalCompatibilityId", "INT", existingColumnNames); - - AddColumn(db, "MediaStreams", "IsHearingImpaired", "BIT", existingColumnNames); - }, - TransactionMode); - - connection.RunQueries(postQueries); + using (var transaction = connection.BeginTransaction()) + { + connection.Execute(string.Join(';', queries)); + + var existingColumnNames = GetColumnNames(connection, "AncestorIds"); + AddColumn(connection, "AncestorIds", "AncestorIdText", "Text", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, "TypedBaseItems"); + + AddColumn(connection, "TypedBaseItems", "Path", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "StartDate", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "EndDate", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ChannelId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsMovie", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CommunityRating", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CustomRating", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IndexNumber", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsLocked", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Name", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "OfficialRating", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "MediaType", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Overview", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ParentIndexNumber", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PremiereDate", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ProductionYear", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ParentId", "GUID", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Genres", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SortName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ForcedSortName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "RunTimeTicks", "BIGINT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateCreated", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateModified", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsSeries", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "EpisodeTitle", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsRepeat", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PreferredMetadataLanguage", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PreferredMetadataCountryCode", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateLastRefreshed", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateLastSaved", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsInMixedFolder", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "LockedFields", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Studios", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Audio", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExternalServiceId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Tags", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsFolder", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "InheritedParentalRatingValue", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "UnratedType", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "TopParentId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "TrailerTypes", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CriticRating", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "CleanName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PresentationUniqueKey", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "OriginalTitle", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "PrimaryVersionId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "DateLastMediaAdded", "DATETIME", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Album", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "LUFS", "Float", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "IsVirtualItem", "BIT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeriesName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "UserDataKey", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeasonName", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeasonId", "GUID", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeriesId", "GUID", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExternalSeriesId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Tagline", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ProviderIds", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Images", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ProductionLocations", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExtraIds", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "TotalBitrate", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExtraType", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Artists", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "AlbumArtists", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ExternalId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "SeriesPresentationUniqueKey", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "ShowId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "OwnerId", "Text", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Width", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Height", "INT", existingColumnNames); + AddColumn(connection, "TypedBaseItems", "Size", "BIGINT", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, "ItemValues"); + AddColumn(connection, "ItemValues", "CleanValue", "Text", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, ChaptersTableName); + AddColumn(connection, ChaptersTableName, "ImageDateModified", "DATETIME", existingColumnNames); + + existingColumnNames = GetColumnNames(connection, "MediaStreams"); + AddColumn(connection, "MediaStreams", "IsAvc", "BIT", existingColumnNames); + AddColumn(connection, "MediaStreams", "TimeBase", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "CodecTimeBase", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "Title", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "NalLengthSize", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "Comment", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "CodecTag", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "PixelFormat", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "BitDepth", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "RefFrames", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "KeyFrames", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "IsAnamorphic", "BIT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "ColorPrimaries", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "ColorSpace", "TEXT", existingColumnNames); + AddColumn(connection, "MediaStreams", "ColorTransfer", "TEXT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "DvVersionMajor", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvVersionMinor", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvProfile", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvLevel", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "RpuPresentFlag", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "ElPresentFlag", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "BlPresentFlag", "INT", existingColumnNames); + AddColumn(connection, "MediaStreams", "DvBlSignalCompatibilityId", "INT", existingColumnNames); + + AddColumn(connection, "MediaStreams", "IsHearingImpaired", "BIT", existingColumnNames); + + connection.Execute(string.Join(';', postQueries)); + + transaction.Commit(); } } @@ -567,21 +564,15 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - using (var saveImagesStatement = PrepareStatement(db, "Update TypedBaseItems set Images=@Images where guid=@Id")) - { - saveImagesStatement.TryBind("@Id", item.Id); - saveImagesStatement.TryBind("@Images", SerializeImages(item.ImageInfos)); + var images = SerializeImages(item.ImageInfos); + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + using var saveImagesStatement = PrepareStatement(connection, "Update TypedBaseItems set Images=@Images where guid=@Id"); + saveImagesStatement.TryBind("@Id", item.Id); + saveImagesStatement.TryBind("@Images", images); - saveImagesStatement.MoveNext(); - } - }, - TransactionMode); - } + saveImagesStatement.ExecuteNonQuery(); + transaction.Commit(); } /// <summary> @@ -617,18 +608,13 @@ namespace Emby.Server.Implementations.Data tuples[i] = (item, ancestorIds, topParent, userdataKey, inheritedTags); } - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - SaveItemsInTransaction(db, tuples); - }, - TransactionMode); - } + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + SaveItemsInTransaction(connection, tuples); + transaction.Commit(); } - private void SaveItemsInTransaction(IDatabaseConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples) + private void SaveItemsInTransaction(SqliteConnection db, IEnumerable<(BaseItem Item, List<Guid> AncestorIds, BaseItem TopParent, string UserDataKey, List<string> InheritedTags)> tuples) { using (var saveItemStatement = PrepareStatement(db, SaveItemCommandText)) using (var deleteAncestorsStatement = PrepareStatement(db, "delete from AncestorIds where ItemId=@ItemId")) @@ -638,7 +624,8 @@ namespace Emby.Server.Implementations.Data { if (requiresReset) { - saveItemStatement.Reset(); + saveItemStatement.Parameters.Clear(); + deleteAncestorsStatement.Parameters.Clear(); } var item = tuple.Item; @@ -676,7 +663,7 @@ namespace Emby.Server.Implementations.Data return _appHost.ExpandVirtualPath(path); } - private void SaveItem(BaseItem item, BaseItem topParent, string userDataKey, IStatement saveItemStatement) + private void SaveItem(BaseItem item, BaseItem topParent, string userDataKey, SqliteCommand saveItemStatement) { Type type = item.GetType(); @@ -685,7 +672,7 @@ namespace Emby.Server.Implementations.Data if (TypeRequiresDeserialization(type)) { - saveItemStatement.TryBind("@data", JsonSerializer.SerializeToUtf8Bytes(item, type, _jsonOptions)); + saveItemStatement.TryBind("@data", JsonSerializer.SerializeToUtf8Bytes(item, type, _jsonOptions), true); } else { @@ -1032,7 +1019,7 @@ namespace Emby.Server.Implementations.Data saveItemStatement.TryBind("@OwnerId", ownerId); } - saveItemStatement.MoveNext(); + saveItemStatement.ExecuteNonQuery(); } internal static string SerializeProviderIds(Dictionary<string, string> providerIds) @@ -1286,7 +1273,7 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, _retrieveItemColumnsSelectQuery)) { statement.TryBind("@guid", id); @@ -1304,96 +1291,35 @@ namespace Emby.Server.Implementations.Data { if (_config.Configuration.SkipDeserializationForBasicTypes) { - if (type == typeof(Channel)) + if (type == typeof(Channel) + || type == typeof(UserRootFolder)) { return false; } - - if (type == typeof(UserRootFolder)) - { - return false; - } - } - - if (type == typeof(Season)) - { - return false; - } - - if (type == typeof(MusicArtist)) - { - return false; - } - - if (type == typeof(Person)) - { - return false; - } - - if (type == typeof(MusicGenre)) - { - return false; - } - - if (type == typeof(Genre)) - { - return false; - } - - if (type == typeof(Studio)) - { - return false; - } - - if (type == typeof(PlaylistsFolder)) - { - return false; - } - - if (type == typeof(PhotoAlbum)) - { - return false; - } - - if (type == typeof(Year)) - { - return false; - } - - if (type == typeof(Book)) - { - return false; - } - - if (type == typeof(LiveTvProgram)) - { - return false; - } - - if (type == typeof(AudioBook)) - { - return false; } - if (type == typeof(Audio)) - { - return false; - } - - if (type == typeof(MusicAlbum)) - { - return false; - } - - return true; + return type != typeof(Season) + && type != typeof(MusicArtist) + && type != typeof(Person) + && type != typeof(MusicGenre) + && type != typeof(Genre) + && type != typeof(Studio) + && type != typeof(PlaylistsFolder) + && type != typeof(PhotoAlbum) + && type != typeof(Year) + && type != typeof(Book) + && type != typeof(LiveTvProgram) + && type != typeof(AudioBook) + && type != typeof(Audio) + && type != typeof(MusicAlbum); } - private BaseItem GetItem(IReadOnlyList<ResultSetValue> reader, InternalItemsQuery query) + private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query) { return GetItem(reader, query, HasProgramAttributes(query), HasEpisodeAttributes(query), HasServiceName(query), HasStartDate(query), HasTrailerTypes(query), HasArtistFields(query), HasSeriesFields(query)); } - private BaseItem GetItem(IReadOnlyList<ResultSetValue> reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) + private BaseItem GetItem(SqliteDataReader reader, InternalItemsQuery query, bool enableProgramAttributes, bool hasEpisodeAttributes, bool hasServiceName, bool queryHasStartDate, bool hasTrailerTypes, bool hasArtistFields, bool hasSeriesFields) { var typeString = reader.GetString(0); @@ -1410,7 +1336,7 @@ namespace Emby.Server.Implementations.Data { try { - item = JsonSerializer.Deserialize(reader[1].ToBlob(), type, _jsonOptions) as BaseItem; + item = JsonSerializer.Deserialize(reader.GetStream(1), type, _jsonOptions) as BaseItem; } catch (JsonException ex) { @@ -1451,17 +1377,9 @@ namespace Emby.Server.Implementations.Data item.EndDate = endDate; } - var channelId = reader[index]; - if (!channelId.IsDbNull()) + if (reader.TryGetGuid(index, out var guid)) { - if (!Utf8Parser.TryParse(channelId.ToBlob(), out Guid value, out _, standardFormat: 'N')) - { - var str = reader.GetString(index); - Logger.LogWarning("{ChannelId} isn't in the expected format", str); - value = new Guid(str); - } - - item.ChannelId = value; + item.ChannelId = guid; } index++; @@ -1977,7 +1895,7 @@ namespace Emby.Server.Implementations.Data CheckDisposed(); var chapters = new List<ChapterInfo>(); - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId order by ChapterIndex asc")) { statement.TryBind("@ItemId", item.Id); @@ -1996,7 +1914,7 @@ namespace Emby.Server.Implementations.Data { CheckDisposed(); - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, "select StartPositionTicks,Name,ImagePath,ImageDateModified from " + ChaptersTableName + " where ItemId = @ItemId and ChapterIndex=@ChapterIndex")) { statement.TryBind("@ItemId", item.Id); @@ -2017,7 +1935,7 @@ namespace Emby.Server.Implementations.Data /// <param name="reader">The reader.</param> /// <param name="item">The item.</param> /// <returns>ChapterInfo.</returns> - private ChapterInfo GetChapter(IReadOnlyList<ResultSetValue> reader, BaseItem item) + private ChapterInfo GetChapter(SqliteDataReader reader, BaseItem item) { var chapter = new ChapterInfo { @@ -2032,18 +1950,7 @@ namespace Emby.Server.Implementations.Data if (reader.TryGetString(2, out var imagePath)) { chapter.ImagePath = imagePath; - - if (!string.IsNullOrEmpty(chapter.ImagePath)) - { - try - { - chapter.ImageTag = _imageProcessor.GetImageCacheTag(item, chapter); - } - catch (Exception ex) - { - Logger.LogError(ex, "Failed to create image cache tag."); - } - } + chapter.ImageTag = _imageProcessor.GetImageCacheTag(item, chapter); } if (reader.TryReadDateTime(3, out var imageDateModified)) @@ -2070,23 +1977,18 @@ namespace Emby.Server.Implementations.Data ArgumentNullException.ThrowIfNull(chapters); - var idBlob = id.ToByteArray(); - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - // First delete chapters - db.Execute("delete from " + ChaptersTableName + " where ItemId=@ItemId", idBlob); + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + // First delete chapters + using var command = connection.PrepareStatement($"delete from {ChaptersTableName} where ItemId=@ItemId"); + command.TryBind("@ItemId", id); + command.ExecuteNonQuery(); - InsertChapters(idBlob, chapters, db); - }, - TransactionMode); - } + InsertChapters(id, chapters, connection); + transaction.Commit(); } - private void InsertChapters(byte[] idBlob, IReadOnlyList<ChapterInfo> chapters, IDatabaseConnection db) + private void InsertChapters(Guid idBlob, IReadOnlyList<ChapterInfo> chapters, SqliteConnection db) { var startIndex = 0; var limit = 100; @@ -2104,7 +2006,7 @@ namespace Emby.Server.Implementations.Data insertText.AppendFormat(CultureInfo.InvariantCulture, "(@ItemId, @ChapterIndex{0}, @StartPositionTicks{0}, @Name{0}, @ImagePath{0}, @ImageDateModified{0}),", i.ToString(CultureInfo.InvariantCulture)); } - insertText.Length -= 1; // Remove last , + insertText.Length -= 1; // Remove trailing comma using (var statement = PrepareStatement(db, insertText.ToString())) { @@ -2125,8 +2027,7 @@ namespace Emby.Server.Implementations.Data chapterIndex++; } - statement.Reset(); - statement.MoveNext(); + statement.ExecuteNonQuery(); } startIndex += limit; @@ -2451,7 +2352,9 @@ namespace Emby.Server.Implementations.Data if (query.SearchTerm.Length > 1) { builder.Append("+ ((CleanName like @SearchTermContains or (OriginalTitle not null and OriginalTitle like @SearchTermContains)) * 10)"); - builder.Append("+ ((Tags not null and Tags like @SearchTermContains) * 5)"); + builder.Append("+ (SELECT COUNT(1) * 1 from ItemValues where ItemId=Guid and CleanValue like @SearchTermContains)"); + builder.Append("+ (SELECT COUNT(1) * 2 from ItemValues where ItemId=Guid and CleanValue like @SearchTermStartsWith)"); + builder.Append("+ (SELECT COUNT(1) * 10 from ItemValues where ItemId=Guid and CleanValue like @SearchTermEquals)"); } builder.Append(") as SearchScore"); @@ -2460,7 +2363,7 @@ namespace Emby.Server.Implementations.Data } } - private void BindSearchParams(InternalItemsQuery query, IStatement statement) + private void BindSearchParams(InternalItemsQuery query, SqliteCommand statement) { var searchTerm = query.SearchTerm; @@ -2472,7 +2375,7 @@ namespace Emby.Server.Implementations.Data searchTerm = FixUnicodeChars(searchTerm); searchTerm = GetCleanValue(searchTerm); - var commandText = statement.SQL; + var commandText = statement.CommandText; if (commandText.Contains("@SearchTermStartsWith", StringComparison.OrdinalIgnoreCase)) { statement.TryBind("@SearchTermStartsWith", searchTerm + "%"); @@ -2482,9 +2385,14 @@ namespace Emby.Server.Implementations.Data { statement.TryBind("@SearchTermContains", "%" + searchTerm + "%"); } + + if (commandText.Contains("@SearchTermEquals", StringComparison.OrdinalIgnoreCase)) + { + statement.TryBind("@SearchTermEquals", searchTerm); + } } - private void BindSimilarParams(InternalItemsQuery query, IStatement statement) + private void BindSimilarParams(InternalItemsQuery query, SqliteCommand statement) { var item = query.SimilarTo; @@ -2493,7 +2401,7 @@ namespace Emby.Server.Implementations.Data return; } - var commandText = statement.SQL; + var commandText = statement.CommandText; if (commandText.Contains("@ItemOfficialRating", StringComparison.OrdinalIgnoreCase)) { @@ -2576,7 +2484,7 @@ namespace Emby.Server.Implementations.Data var commandText = commandTextBuilder.ToString(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, commandText)) { if (EnableJoinUserData(query)) @@ -2590,7 +2498,7 @@ namespace Emby.Server.Implementations.Data // Running this again will bind the params GetWhereClauses(query, statement); - return statement.ExecuteQuery().SelectScalarInt().First(); + return statement.SelectScalarInt(); } } @@ -2644,7 +2552,7 @@ namespace Emby.Server.Implementations.Data var commandText = commandTextBuilder.ToString(); var items = new List<BaseItem>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, commandText)) { if (EnableJoinUserData(query)) @@ -2852,69 +2760,65 @@ namespace Emby.Server.Implementations.Data var list = new List<BaseItem>(); var result = new QueryResult<BaseItem>(); - using (var connection = GetConnection(true)) + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + if (!isReturningZeroItems) { - connection.RunInTransaction( - db => + using (new QueryTimeLogger(Logger, itemQuery, "GetItems.ItemQuery")) + using (var statement = PrepareStatement(connection, itemQuery)) + { + if (EnableJoinUserData(query)) { - if (!isReturningZeroItems) + statement.TryBind("@UserId", query.User.InternalId); + } + + BindSimilarParams(query, statement); + BindSearchParams(query, statement); + + // Running this again will bind the params + GetWhereClauses(query, statement); + + var hasEpisodeAttributes = HasEpisodeAttributes(query); + var hasServiceName = HasServiceName(query); + var hasProgramAttributes = HasProgramAttributes(query); + var hasStartDate = HasStartDate(query); + var hasTrailerTypes = HasTrailerTypes(query); + var hasArtistFields = HasArtistFields(query); + var hasSeriesFields = HasSeriesFields(query); + + foreach (var row in statement.ExecuteQuery()) + { + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + if (item is not null) { - using (new QueryTimeLogger(Logger, itemQuery, "GetItems.ItemQuery")) - using (var statement = PrepareStatement(db, itemQuery)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - - // Running this again will bind the params - GetWhereClauses(query, statement); - - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasServiceName = HasServiceName(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); - - foreach (var row in statement.ExecuteQuery()) - { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); - if (item is not null) - { - list.Add(item); - } - } - } + list.Add(item); } + } + } + } - if (query.EnableTotalRecordCount) - { - using (new QueryTimeLogger(Logger, totalRecordCountQuery, "GetItems.TotalRecordCount")) - using (var statement = PrepareStatement(db, totalRecordCountQuery)) - { - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } + if (query.EnableTotalRecordCount) + { + using (new QueryTimeLogger(Logger, totalRecordCountQuery, "GetItems.TotalRecordCount")) + using (var statement = PrepareStatement(connection, totalRecordCountQuery)) + { + if (EnableJoinUserData(query)) + { + statement.TryBind("@UserId", query.User.InternalId); + } - BindSimilarParams(query, statement); - BindSearchParams(query, statement); + BindSimilarParams(query, statement); + BindSearchParams(query, statement); - // Running this again will bind the params - GetWhereClauses(query, statement); + // Running this again will bind the params + GetWhereClauses(query, statement); - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); - } - } - }, - ReadTransactionMode); + result.TotalRecordCount = statement.SelectScalarInt(); + } } + transaction.Commit(); + result.StartIndex = query.StartIndex ?? 0; result.Items = list; return result; @@ -3164,7 +3068,7 @@ namespace Emby.Server.Implementations.Data var commandText = commandTextBuilder.ToString(); var list = new List<Guid>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, commandText)) { if (EnableJoinUserData(query)) @@ -3180,7 +3084,7 @@ namespace Emby.Server.Implementations.Data foreach (var row in statement.ExecuteQuery()) { - list.Add(row[0].ReadGuidFromBlob()); + list.Add(row.GetGuid(0)); } } @@ -3216,7 +3120,7 @@ namespace Emby.Server.Implementations.Data } #nullable enable - private List<string> GetWhereClauses(InternalItemsQuery query, IStatement? statement) + private List<string> GetWhereClauses(InternalItemsQuery query, SqliteCommand? statement) { if (query.IsResumable ?? false) { @@ -3596,7 +3500,6 @@ namespace Emby.Server.Implementations.Data statement?.TryBind(paramName, "%" + trailerTypes[i] + "%"); } - // Remove last " OR " clauseBuilder.Length -= Or.Length; clauseBuilder.Append(')'); @@ -3637,14 +3540,9 @@ namespace Emby.Server.Implementations.Data .Append(paramName) .Append("))) OR "); - if (statement is not null) - { - query.PersonIds[i].TryWriteBytes(idBytes); - statement.TryBind(paramName, idBytes); - } + statement?.TryBind(paramName, query.PersonIds[i]); } - // Remove last " OR " clauseBuilder.Length -= Or.Length; clauseBuilder.Append(')'); @@ -3811,215 +3709,219 @@ namespace Emby.Server.Implementations.Data if (query.ArtistIds.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var artistId in query.ArtistIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.ArtistIds.Length; i++) { - var paramName = "@ArtistIds" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); - statement?.TryBind(paramName, artistId); - index++; + clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@ArtistIds") + .Append(i) + .Append(") and Type<=1)) OR "); + statement?.TryBind("@ArtistIds" + i, query.ArtistIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.AlbumArtistIds.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var artistId in query.AlbumArtistIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.AlbumArtistIds.Length; i++) { - var paramName = "@ArtistIds" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=1))"); - statement?.TryBind(paramName, artistId); - index++; + clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@ArtistIds") + .Append(i) + .Append(") and Type=1)) OR "); + statement?.TryBind("@ArtistIds" + i, query.AlbumArtistIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.ContributingArtistIds.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var artistId in query.ContributingArtistIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.ContributingArtistIds.Length; i++) { - var paramName = "@ArtistIds" + index; - clauses.Add("((select CleanName from TypedBaseItems where guid=" + paramName + ") in (select CleanValue from ItemValues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=" + paramName + ") not in (select CleanValue from ItemValues where ItemId=Guid and Type=1))"); - statement?.TryBind(paramName, artistId); - index++; + clauseBuilder.Append("((select CleanName from TypedBaseItems where guid=@ArtistIds") + .Append(i) + .Append(") in (select CleanValue from ItemValues where ItemId=Guid and Type=0) AND (select CleanName from TypedBaseItems where guid=@ArtistIds") + .Append(i) + .Append(") not in (select CleanValue from ItemValues where ItemId=Guid and Type=1)) OR "); + statement?.TryBind("@ArtistIds" + i, query.ContributingArtistIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.AlbumIds.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var albumId in query.AlbumIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.AlbumIds.Length; i++) { - var paramName = "@AlbumIds" + index; - clauses.Add("Album in (select Name from typedbaseitems where guid=" + paramName + ")"); - statement?.TryBind(paramName, albumId); - index++; + clauseBuilder.Append("Album in (select Name from typedbaseitems where guid=@AlbumIds") + .Append(i) + .Append(") OR "); + statement?.TryBind("@AlbumIds" + i, query.AlbumIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.ExcludeArtistIds.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var artistId in query.ExcludeArtistIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.ExcludeArtistIds.Length; i++) { - var paramName = "@ExcludeArtistId" + index; - clauses.Add("(guid not in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type<=1))"); - statement?.TryBind(paramName, artistId); - index++; + clauseBuilder.Append("(guid not in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@ExcludeArtistId") + .Append(i) + .Append(") and Type<=1)) OR "); + statement?.TryBind("@ExcludeArtistId" + i, query.ExcludeArtistIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.GenreIds.Count > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var genreId in query.GenreIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.GenreIds.Count; i++) { - var paramName = "@GenreId" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=2))"); - statement?.TryBind(paramName, genreId); - index++; + clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@GenreId") + .Append(i) + .Append(") and Type=2)) OR "); + statement?.TryBind("@GenreId" + i, query.GenreIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.Genres.Count > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var item in query.Genres) + clauseBuilder.Append('('); + for (var i = 0; i < query.Genres.Count; i++) { - clauses.Add("@Genre" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=2)"); - statement?.TryBind("@Genre" + index, GetCleanValue(item)); - index++; + clauseBuilder.Append("@Genre") + .Append(i) + .Append(" in (select CleanValue from ItemValues where ItemId=Guid and Type=2) OR "); + statement?.TryBind("@Genre" + i, GetCleanValue(query.Genres[i])); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (tags.Count > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var item in tags) + clauseBuilder.Append('('); + for (var i = 0; i < tags.Count; i++) { - clauses.Add("@Tag" + index + " in (select CleanValue from ItemValues where ItemId=Guid and Type=4)"); - statement?.TryBind("@Tag" + index, GetCleanValue(item)); - index++; + clauseBuilder.Append("@Tag") + .Append(i) + .Append(" in (select CleanValue from ItemValues where ItemId=Guid and Type=4) OR "); + statement?.TryBind("@Tag" + i, GetCleanValue(tags[i])); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (excludeTags.Count > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var item in excludeTags) + clauseBuilder.Append('('); + for (var i = 0; i < excludeTags.Count; i++) { - clauses.Add("@ExcludeTag" + index + " not in (select CleanValue from ItemValues where ItemId=Guid and Type=4)"); - statement?.TryBind("@ExcludeTag" + index, GetCleanValue(item)); - index++; + clauseBuilder.Append("@ExcludeTag") + .Append(i) + .Append(" not in (select CleanValue from ItemValues where ItemId=Guid and Type=4) OR "); + statement?.TryBind("@ExcludeTag" + i, GetCleanValue(excludeTags[i])); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.StudioIds.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var studioId in query.StudioIds) + clauseBuilder.Append('('); + for (var i = 0; i < query.StudioIds.Length; i++) { - var paramName = "@StudioId" + index; - clauses.Add("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=" + paramName + ") and Type=3))"); - statement?.TryBind(paramName, studioId); - index++; + clauseBuilder.Append("(guid in (select itemid from ItemValues where CleanValue = (select CleanName from TypedBaseItems where guid=@StudioId") + .Append(i) + .Append(") and Type=3)) OR "); + statement?.TryBind("@StudioId" + i, query.StudioIds[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.OfficialRatings.Length > 0) { - var clauses = new List<string>(); - var index = 0; - foreach (var item in query.OfficialRatings) + clauseBuilder.Append('('); + for (var i = 0; i < query.OfficialRatings.Length; i++) { - clauses.Add("OfficialRating=@OfficialRating" + index); - statement?.TryBind("@OfficialRating" + index, item); - index++; + clauseBuilder.Append("OfficialRating=@OfficialRating").Append(i).Append(Or); + statement?.TryBind("@OfficialRating" + i, query.OfficialRatings[i]); } - var clause = "(" + string.Join(" OR ", clauses) + ")"; - whereClauses.Add(clause); + clauseBuilder.Length -= Or.Length; + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } - var ratingClauseBuilder = new StringBuilder("("); + clauseBuilder.Append('('); if (query.HasParentalRating ?? false) { - ratingClauseBuilder.Append("InheritedParentalRatingValue not null"); + clauseBuilder.Append("InheritedParentalRatingValue not null"); if (query.MinParentalRating.HasValue) { - ratingClauseBuilder.Append(" AND InheritedParentalRatingValue >= @MinParentalRating"); + clauseBuilder.Append(" AND InheritedParentalRatingValue >= @MinParentalRating"); statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); } if (query.MaxParentalRating.HasValue) { - ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); + clauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); } } else if (query.BlockUnratedItems.Length > 0) { - var paramName = "@UnratedType"; - var index = 0; - string blockedUnratedItems = string.Join(',', query.BlockUnratedItems.Select(_ => paramName + index++)); - ratingClauseBuilder.Append("(InheritedParentalRatingValue is null AND UnratedType not in (" + blockedUnratedItems + "))"); + const string ParamName = "@UnratedType"; + clauseBuilder.Append("(InheritedParentalRatingValue is null AND UnratedType not in ("); - if (statement is not null) + for (int i = 0; i < query.BlockUnratedItems.Length; i++) { - for (var ind = 0; ind < query.BlockUnratedItems.Length; ind++) - { - statement.TryBind(paramName + ind, query.BlockUnratedItems[ind].ToString()); - } + clauseBuilder.Append(ParamName).Append(i).Append(','); + statement?.TryBind(ParamName + i, query.BlockUnratedItems[i].ToString()); } + // Remove trailing comma + clauseBuilder.Length--; + clauseBuilder.Append("))"); + if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) { - ratingClauseBuilder.Append(" OR ("); + clauseBuilder.Append(" OR ("); } if (query.MinParentalRating.HasValue) { - ratingClauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating"); + clauseBuilder.Append("InheritedParentalRatingValue >= @MinParentalRating"); statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); } @@ -4027,50 +3929,50 @@ namespace Emby.Server.Implementations.Data { if (query.MinParentalRating.HasValue) { - ratingClauseBuilder.Append(" AND "); + clauseBuilder.Append(" AND "); } - ratingClauseBuilder.Append("InheritedParentalRatingValue <= @MaxParentalRating"); + clauseBuilder.Append("InheritedParentalRatingValue <= @MaxParentalRating"); statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); } if (query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue) { - ratingClauseBuilder.Append(")"); + clauseBuilder.Append(')'); } if (!(query.MinParentalRating.HasValue || query.MaxParentalRating.HasValue)) { - ratingClauseBuilder.Append(" OR InheritedParentalRatingValue not null"); + clauseBuilder.Append(" OR InheritedParentalRatingValue not null"); } } else if (query.MinParentalRating.HasValue) { - ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR (InheritedParentalRatingValue >= @MinParentalRating"); + clauseBuilder.Append("InheritedParentalRatingValue is null OR (InheritedParentalRatingValue >= @MinParentalRating"); statement?.TryBind("@MinParentalRating", query.MinParentalRating.Value); if (query.MaxParentalRating.HasValue) { - ratingClauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); + clauseBuilder.Append(" AND InheritedParentalRatingValue <= @MaxParentalRating"); statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); } - ratingClauseBuilder.Append(")"); + clauseBuilder.Append(')'); } else if (query.MaxParentalRating.HasValue) { - ratingClauseBuilder.Append("InheritedParentalRatingValue is null OR InheritedParentalRatingValue <= @MaxParentalRating"); + clauseBuilder.Append("InheritedParentalRatingValue is null OR InheritedParentalRatingValue <= @MaxParentalRating"); statement?.TryBind("@MaxParentalRating", query.MaxParentalRating.Value); } else if (!query.HasParentalRating ?? false) { - ratingClauseBuilder.Append("InheritedParentalRatingValue is null"); + clauseBuilder.Append("InheritedParentalRatingValue is null"); } - var ratingClauseString = ratingClauseBuilder.ToString(); - if (!string.Equals(ratingClauseString, "(", StringComparison.OrdinalIgnoreCase)) + if (clauseBuilder.Length > 1) { - whereClauses.Add(ratingClauseString + ")"); + whereClauses.Add(clauseBuilder.Append(')').ToString()); + clauseBuilder.Length = 0; } if (query.HasOfficialRating.HasValue) @@ -4477,7 +4379,7 @@ namespace Emby.Server.Implementations.Data foreach (var videoType in query.VideoTypes) { - videoTypes.Add("data like '%\"VideoType\":\"" + videoType.ToString() + "\"%'"); + videoTypes.Add("data like '%\"VideoType\":\"" + videoType + "\"%'"); } whereClauses.Add("(" + string.Join(" OR ", videoTypes) + ")"); @@ -4557,7 +4459,6 @@ namespace Emby.Server.Implementations.Data return whereClauses; } -#nullable disable /// <summary> /// Formats a where clause for the specified provider. @@ -4574,6 +4475,7 @@ namespace Emby.Server.Implementations.Data provider); } +#nullable disable private List<string> GetItemByNameTypesInQuery(InternalItemsQuery query) { var list = new List<string>(); @@ -4653,44 +4555,28 @@ namespace Emby.Server.Implementations.Data return true; } - if (query.IncludeItemTypes.Contains(BaseItemKind.Episode) + return query.IncludeItemTypes.Contains(BaseItemKind.Episode) || query.IncludeItemTypes.Contains(BaseItemKind.Video) || query.IncludeItemTypes.Contains(BaseItemKind.Movie) || query.IncludeItemTypes.Contains(BaseItemKind.MusicVideo) || query.IncludeItemTypes.Contains(BaseItemKind.Series) - || query.IncludeItemTypes.Contains(BaseItemKind.Season)) - { - return true; - } - - return false; + || query.IncludeItemTypes.Contains(BaseItemKind.Season); } public void UpdateInheritedValues() { - string sql = string.Join( - ';', - new string[] - { - "delete from ItemValues where type = 6", - - "insert into ItemValues (ItemId, Type, Value, CleanValue) select ItemId, 6, Value, CleanValue from ItemValues where Type=4", - - @"insert into ItemValues (ItemId, Type, Value, CleanValue) select AncestorIds.itemid, 6, ItemValues.Value, ItemValues.CleanValue + const string Statements = """ +delete from ItemValues where type = 6; +insert into ItemValues (ItemId, Type, Value, CleanValue) select ItemId, 6, Value, CleanValue from ItemValues where Type=4; +insert into ItemValues (ItemId, Type, Value, CleanValue) select AncestorIds.itemid, 6, ItemValues.Value, ItemValues.CleanValue FROM AncestorIds LEFT JOIN ItemValues ON (AncestorIds.AncestorId = ItemValues.ItemId) -where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type = 4 " - }); - - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - connection.ExecuteAll(sql); - }, - TransactionMode); - } +where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type = 4; +"""; + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + connection.Execute(Statements); + transaction.Commit(); } public void DeleteItem(Guid id) @@ -4702,43 +4588,36 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - Span<byte> idBlob = stackalloc byte[16]; - id.TryWriteBytes(idBlob); + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + // Delete people + ExecuteWithSingleParam(connection, "delete from People where ItemId=@Id", id); - // Delete people - ExecuteWithSingleParam(db, "delete from People where ItemId=@Id", idBlob); + // Delete chapters + ExecuteWithSingleParam(connection, "delete from " + ChaptersTableName + " where ItemId=@Id", id); - // Delete chapters - ExecuteWithSingleParam(db, "delete from " + ChaptersTableName + " where ItemId=@Id", idBlob); + // Delete media streams + ExecuteWithSingleParam(connection, "delete from mediastreams where ItemId=@Id", id); - // Delete media streams - ExecuteWithSingleParam(db, "delete from mediastreams where ItemId=@Id", idBlob); + // Delete ancestors + ExecuteWithSingleParam(connection, "delete from AncestorIds where ItemId=@Id", id); - // Delete ancestors - ExecuteWithSingleParam(db, "delete from AncestorIds where ItemId=@Id", idBlob); + // Delete item values + ExecuteWithSingleParam(connection, "delete from ItemValues where ItemId=@Id", id); - // Delete item values - ExecuteWithSingleParam(db, "delete from ItemValues where ItemId=@Id", idBlob); + // Delete the item + ExecuteWithSingleParam(connection, "delete from TypedBaseItems where guid=@Id", id); - // Delete the item - ExecuteWithSingleParam(db, "delete from TypedBaseItems where guid=@Id", idBlob); - }, - TransactionMode); - } + transaction.Commit(); } - private void ExecuteWithSingleParam(IDatabaseConnection db, string query, ReadOnlySpan<byte> value) + private void ExecuteWithSingleParam(SqliteConnection db, string query, Guid value) { using (var statement = PrepareStatement(db, query)) { statement.TryBind("@Id", value); - statement.MoveNext(); + statement.ExecuteNonQuery(); } } @@ -4765,7 +4644,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type } var list = new List<string>(); - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params @@ -4786,25 +4665,25 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type CheckDisposed(); - var commandText = "select ItemId, Name, Role, PersonType, SortOrder from People p"; + StringBuilder commandText = new StringBuilder("select ItemId, Name, Role, PersonType, SortOrder from People p"); var whereClauses = GetPeopleWhereClauses(query, null); if (whereClauses.Count != 0) { - commandText += " where " + string.Join(" AND ", whereClauses); + commandText.Append(" where ").AppendJoin(" AND ", whereClauses); } - commandText += " order by ListOrder"; + commandText.Append(" order by ListOrder"); if (query.Limit > 0) { - commandText += " LIMIT " + query.Limit; + commandText.Append(" LIMIT ").Append(query.Limit); } var list = new List<PersonInfo>(); - using (var connection = GetConnection(true)) - using (var statement = PrepareStatement(connection, commandText)) + using (var connection = GetConnection()) + using (var statement = PrepareStatement(connection, commandText.ToString())) { // Run this again to bind the params GetPeopleWhereClauses(query, statement); @@ -4818,7 +4697,7 @@ where AncestorIdText not null and ItemValues.Value not null and ItemValues.Type return list; } - private List<string> GetPeopleWhereClauses(InternalPeopleQuery query, IStatement statement) + private List<string> GetPeopleWhereClauses(InternalPeopleQuery query, SqliteCommand statement) { var whereClauses = new List<string>(); @@ -4888,7 +4767,7 @@ AND Type = @InternalPersonType)"); return whereClauses; } - private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, IDatabaseConnection db, IStatement deleteAncestorsStatement) + private void UpdateAncestors(Guid itemId, List<Guid> ancestorIds, SqliteConnection db, SqliteCommand deleteAncestorsStatement) { if (itemId.Equals(default)) { @@ -4899,13 +4778,9 @@ AND Type = @InternalPersonType)"); CheckDisposed(); - Span<byte> itemIdBlob = stackalloc byte[16]; - itemId.TryWriteBytes(itemIdBlob); - // First delete - deleteAncestorsStatement.Reset(); - deleteAncestorsStatement.TryBind("@ItemId", itemIdBlob); - deleteAncestorsStatement.MoveNext(); + deleteAncestorsStatement.TryBind("@ItemId", itemId); + deleteAncestorsStatement.ExecuteNonQuery(); if (ancestorIds.Count == 0) { @@ -4922,26 +4797,24 @@ AND Type = @InternalPersonType)"); i.ToString(CultureInfo.InvariantCulture)); } - // Remove last , + // Remove trailing comma insertText.Length--; using (var statement = PrepareStatement(db, insertText.ToString())) { - statement.TryBind("@ItemId", itemIdBlob); + statement.TryBind("@ItemId", itemId); for (var i = 0; i < ancestorIds.Count; i++) { var index = i.ToString(CultureInfo.InvariantCulture); var ancestorId = ancestorIds[i]; - ancestorId.TryWriteBytes(itemIdBlob); - statement.TryBind("@AncestorId" + index, itemIdBlob); + statement.TryBind("@AncestorId" + index, ancestorId); statement.TryBind("@AncestorIdText" + index, ancestorId.ToString("N", CultureInfo.InvariantCulture)); } - statement.Reset(); - statement.MoveNext(); + statement.ExecuteNonQuery(); } } @@ -5049,7 +4922,7 @@ AND Type = @InternalPersonType)"); var list = new List<string>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, commandText)) { foreach (var row in statement.ExecuteQuery()) @@ -5249,77 +5122,75 @@ AND Type = @InternalPersonType)"); var list = new List<(BaseItem, ItemCounts)>(); var result = new QueryResult<(BaseItem, ItemCounts)>(); using (new QueryTimeLogger(Logger, commandText)) - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) + using (var transaction = connection.BeginTransaction(deferred: true)) { - connection.RunInTransaction( - db => + if (!isReturningZeroItems) + { + using (var statement = PrepareStatement(connection, commandText)) { - if (!isReturningZeroItems) + statement.TryBind("@SelectType", returnType); + if (EnableJoinUserData(query)) { - using (var statement = PrepareStatement(db, commandText)) - { - statement.TryBind("@SelectType", returnType); - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - if (typeSubQuery is not null) - { - GetWhereClauses(typeSubQuery, null); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - GetWhereClauses(innerQuery, statement); - GetWhereClauses(outerQuery, statement); - - var hasEpisodeAttributes = HasEpisodeAttributes(query); - var hasProgramAttributes = HasProgramAttributes(query); - var hasServiceName = HasServiceName(query); - var hasStartDate = HasStartDate(query); - var hasTrailerTypes = HasTrailerTypes(query); - var hasArtistFields = HasArtistFields(query); - var hasSeriesFields = HasSeriesFields(query); - - foreach (var row in statement.ExecuteQuery()) - { - var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); - if (item is not null) - { - var countStartColumn = columns.Count - 1; - - list.Add((item, GetItemCounts(row, countStartColumn, typesToCount))); - } - } - } + statement.TryBind("@UserId", query.User.InternalId); + } + + if (typeSubQuery is not null) + { + GetWhereClauses(typeSubQuery, null); } - if (query.EnableTotalRecordCount) + BindSimilarParams(query, statement); + BindSearchParams(query, statement); + GetWhereClauses(innerQuery, statement); + GetWhereClauses(outerQuery, statement); + + var hasEpisodeAttributes = HasEpisodeAttributes(query); + var hasProgramAttributes = HasProgramAttributes(query); + var hasServiceName = HasServiceName(query); + var hasStartDate = HasStartDate(query); + var hasTrailerTypes = HasTrailerTypes(query); + var hasArtistFields = HasArtistFields(query); + var hasSeriesFields = HasSeriesFields(query); + + foreach (var row in statement.ExecuteQuery()) { - using (var statement = PrepareStatement(db, countText)) + var item = GetItem(row, query, hasProgramAttributes, hasEpisodeAttributes, hasServiceName, hasStartDate, hasTrailerTypes, hasArtistFields, hasSeriesFields); + if (item is not null) { - statement.TryBind("@SelectType", returnType); - if (EnableJoinUserData(query)) - { - statement.TryBind("@UserId", query.User.InternalId); - } - - if (typeSubQuery is not null) - { - GetWhereClauses(typeSubQuery, null); - } - - BindSimilarParams(query, statement); - BindSearchParams(query, statement); - GetWhereClauses(innerQuery, statement); - GetWhereClauses(outerQuery, statement); - - result.TotalRecordCount = statement.ExecuteQuery().SelectScalarInt().First(); + var countStartColumn = columns.Count - 1; + + list.Add((item, GetItemCounts(row, countStartColumn, typesToCount))); } } - }, - ReadTransactionMode); + } + } + + if (query.EnableTotalRecordCount) + { + using (var statement = PrepareStatement(connection, countText)) + { + statement.TryBind("@SelectType", returnType); + if (EnableJoinUserData(query)) + { + statement.TryBind("@UserId", query.User.InternalId); + } + + if (typeSubQuery is not null) + { + GetWhereClauses(typeSubQuery, null); + } + + BindSimilarParams(query, statement); + BindSearchParams(query, statement); + GetWhereClauses(innerQuery, statement); + GetWhereClauses(outerQuery, statement); + + result.TotalRecordCount = statement.SelectScalarInt(); + } + } + + transaction.Commit(); } if (result.TotalRecordCount == 0) @@ -5333,7 +5204,7 @@ AND Type = @InternalPersonType)"); return result; } - private static ItemCounts GetItemCounts(IReadOnlyList<ResultSetValue> reader, int countStartColumn, BaseItemKind[] typesToCount) + private static ItemCounts GetItemCounts(SqliteDataReader reader, int countStartColumn, BaseItemKind[] typesToCount) { var counts = new ItemCounts(); @@ -5412,7 +5283,7 @@ AND Type = @InternalPersonType)"); return list; } - private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, IDatabaseConnection db) + private void UpdateItemValues(Guid itemId, List<(int MagicNumber, string Value)> values, SqliteConnection db) { if (itemId.Equals(default)) { @@ -5423,15 +5294,15 @@ AND Type = @InternalPersonType)"); CheckDisposed(); - var guidBlob = itemId.ToByteArray(); - // First delete - db.Execute("delete from ItemValues where ItemId=@Id", guidBlob); + using var command = db.PrepareStatement("delete from ItemValues where ItemId=@Id"); + command.TryBind("@Id", itemId); + command.ExecuteNonQuery(); - InsertItemValues(guidBlob, values, db); + InsertItemValues(itemId, values, db); } - private void InsertItemValues(byte[] idBlob, List<(int MagicNumber, string Value)> values, IDatabaseConnection db) + private void InsertItemValues(Guid id, List<(int MagicNumber, string Value)> values, SqliteConnection db) { const int Limit = 100; var startIndex = 0; @@ -5450,12 +5321,12 @@ AND Type = @InternalPersonType)"); i); } - // Remove last comma + // Remove trailing comma insertText.Length--; using (var statement = PrepareStatement(db, insertText.ToString())) { - statement.TryBind("@ItemId", idBlob); + statement.TryBind("@ItemId", id); for (var i = startIndex; i < endIndex; i++) { @@ -5476,8 +5347,7 @@ AND Type = @InternalPersonType)"); statement.TryBind("@CleanValue" + index, GetCleanValue(itemValue)); } - statement.Reset(); - statement.MoveNext(); + statement.ExecuteNonQuery(); } startIndex += Limit; @@ -5496,23 +5366,20 @@ AND Type = @InternalPersonType)"); CheckDisposed(); - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - var itemIdBlob = itemId.ToByteArray(); + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + // First delete chapters + using var command = connection.CreateCommand(); + command.CommandText = "delete from People where ItemId=@ItemId"; + command.TryBind("@ItemId", itemId); + command.ExecuteNonQuery(); - // First delete chapters - db.Execute("delete from People where ItemId=@ItemId", itemIdBlob); + InsertPeople(itemId, people, connection); - InsertPeople(itemIdBlob, people, db); - }, - TransactionMode); - } + transaction.Commit(); } - private void InsertPeople(byte[] idBlob, List<PersonInfo> people, IDatabaseConnection db) + private void InsertPeople(Guid id, List<PersonInfo> people, SqliteConnection db) { const int Limit = 100; var startIndex = 0; @@ -5531,12 +5398,12 @@ AND Type = @InternalPersonType)"); i.ToString(CultureInfo.InvariantCulture)); } - // Remove last comma + // Remove trailing comma insertText.Length--; using (var statement = PrepareStatement(db, insertText.ToString())) { - statement.TryBind("@ItemId", idBlob); + statement.TryBind("@ItemId", id); for (var i = startIndex; i < endIndex; i++) { @@ -5553,8 +5420,7 @@ AND Type = @InternalPersonType)"); listIndex++; } - statement.Reset(); - statement.MoveNext(); + statement.ExecuteNonQuery(); } startIndex += Limit; @@ -5562,7 +5428,7 @@ AND Type = @InternalPersonType)"); } } - private PersonInfo GetPerson(IReadOnlyList<ResultSetValue> reader) + private PersonInfo GetPerson(SqliteDataReader reader) { var item = new PersonInfo { @@ -5609,7 +5475,7 @@ AND Type = @InternalPersonType)"); cmdText += " order by StreamIndex ASC"; - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) { var list = new List<MediaStream>(); @@ -5650,23 +5516,19 @@ AND Type = @InternalPersonType)"); cancellationToken.ThrowIfCancellationRequested(); - using (var connection = GetConnection()) - { - connection.RunInTransaction( - db => - { - var itemIdBlob = id.ToByteArray(); + using var connection = GetConnection(); + using var transaction = connection.BeginTransaction(); + // Delete existing mediastreams + using var command = connection.PrepareStatement("delete from mediastreams where ItemId=@ItemId"); + command.TryBind("@ItemId", id); + command.ExecuteNonQuery(); - // Delete existing mediastreams - db.Execute("delete from mediastreams where ItemId=@ItemId", itemIdBlob); + InsertMediaStreams(id, streams, connection); - InsertMediaStreams(itemIdBlob, streams, db); - }, - TransactionMode); - } + transaction.Commit(); } - private void InsertMediaStreams(byte[] idBlob, IReadOnlyList<MediaStream> streams, IDatabaseConnection db) + private void InsertMediaStreams(Guid id, IReadOnlyList<MediaStream> streams, SqliteConnection db) { const int Limit = 10; var startIndex = 0; @@ -5698,7 +5560,7 @@ AND Type = @InternalPersonType)"); using (var statement = PrepareStatement(db, insertText.ToString())) { - statement.TryBind("@ItemId", idBlob); + statement.TryBind("@ItemId", id); for (var i = startIndex; i < endIndex; i++) { @@ -5734,6 +5596,7 @@ AND Type = @InternalPersonType)"); statement.TryBind("@PixelFormat" + index, stream.PixelFormat); statement.TryBind("@BitDepth" + index, stream.BitDepth); + statement.TryBind("@IsAnamorphic" + index, stream.IsAnamorphic); statement.TryBind("@IsExternal" + index, stream.IsExternal); statement.TryBind("@RefFrames" + index, stream.RefFrames); @@ -5762,8 +5625,7 @@ AND Type = @InternalPersonType)"); statement.TryBind("@IsHearingImpaired" + index, stream.IsHearingImpaired); } - statement.Reset(); - statement.MoveNext(); + statement.ExecuteNonQuery(); } startIndex += Limit; @@ -5776,15 +5638,14 @@ AND Type = @InternalPersonType)"); /// </summary> /// <param name="reader">The reader.</param> /// <returns>MediaStream.</returns> - private MediaStream GetMediaStream(IReadOnlyList<ResultSetValue> reader) + private MediaStream GetMediaStream(SqliteDataReader reader) { var item = new MediaStream { - Index = reader[1].ToInt() + Index = reader.GetInt32(1), + Type = Enum.Parse<MediaStreamType>(reader.GetString(2), true) }; - item.Type = Enum.Parse<MediaStreamType>(reader[2].ToString(), true); - if (reader.TryGetString(3, out var codec)) { item.Codec = codec; @@ -5971,7 +5832,7 @@ AND Type = @InternalPersonType)"); item.DvBlSignalCompatibilityId = dvBlSignalCompatibilityId; } - item.IsHearingImpaired = reader.GetBoolean(43); + item.IsHearingImpaired = reader.TryGetBoolean(43, out var result) && result; if (item.Type == MediaStreamType.Subtitle) { @@ -6001,10 +5862,10 @@ AND Type = @InternalPersonType)"); cmdText += " order by AttachmentIndex ASC"; var list = new List<MediaAttachment>(); - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) using (var statement = PrepareStatement(connection, cmdText)) { - statement.TryBind("@ItemId", query.ItemId.ToByteArray()); + statement.TryBind("@ItemId", query.ItemId); if (query.Index.HasValue) { @@ -6036,24 +5897,22 @@ AND Type = @InternalPersonType)"); cancellationToken.ThrowIfCancellationRequested(); using (var connection = GetConnection()) + using (var transaction = connection.BeginTransaction()) + using (var command = connection.PrepareStatement("delete from mediaattachments where ItemId=@ItemId")) { - connection.RunInTransaction( - db => - { - var itemIdBlob = id.ToByteArray(); + command.TryBind("@ItemId", id); + command.ExecuteNonQuery(); - db.Execute("delete from mediaattachments where ItemId=@ItemId", itemIdBlob); + InsertMediaAttachments(id, attachments, connection, cancellationToken); - InsertMediaAttachments(itemIdBlob, attachments, db, cancellationToken); - }, - TransactionMode); + transaction.Commit(); } } private void InsertMediaAttachments( - byte[] idBlob, + Guid id, IReadOnlyList<MediaAttachment> attachments, - IDatabaseConnection db, + SqliteConnection db, CancellationToken cancellationToken) { const int InsertAtOnce = 10; @@ -6065,14 +5924,13 @@ AND Type = @InternalPersonType)"); for (var i = startIndex; i < endIndex; i++) { - var index = i.ToString(CultureInfo.InvariantCulture); insertText.Append("(@ItemId, "); foreach (var column in _mediaAttachmentSaveColumns.Skip(1)) { insertText.Append('@') .Append(column) - .Append(index) + .Append(i) .Append(','); } @@ -6087,7 +5945,7 @@ AND Type = @InternalPersonType)"); using (var statement = PrepareStatement(db, insertText.ToString())) { - statement.TryBind("@ItemId", idBlob); + statement.TryBind("@ItemId", id); for (var i = startIndex; i < endIndex; i++) { @@ -6103,8 +5961,7 @@ AND Type = @InternalPersonType)"); statement.TryBind("@MIMEType" + index, attachment.MimeType); } - statement.Reset(); - statement.MoveNext(); + statement.ExecuteNonQuery(); } insertText.Length = _mediaAttachmentInsertPrefix.Length; @@ -6116,11 +5973,11 @@ AND Type = @InternalPersonType)"); /// </summary> /// <param name="reader">The reader.</param> /// <returns>MediaAttachment.</returns> - private MediaAttachment GetMediaAttachment(IReadOnlyList<ResultSetValue> reader) + private MediaAttachment GetMediaAttachment(SqliteDataReader reader) { var item = new MediaAttachment { - Index = reader[1].ToInt() + Index = reader.GetInt32(1) }; if (reader.TryGetString(2, out var codec)) diff --git a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs index a1e217ad1..a5edcc58c 100644 --- a/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteUserDataRepository.cs @@ -11,8 +11,8 @@ using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Persistence; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Emby.Server.Implementations.Data { @@ -44,48 +44,48 @@ namespace Emby.Server.Implementations.Data var userDataTableExists = TableExists(connection, "userdata"); var users = userDatasTableExists ? null : _userManager.Users; + using var transaction = connection.BeginTransaction(); + connection.Execute(string.Join( + ';', + "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)", + "drop index if exists idx_userdata", + "drop index if exists idx_userdata1", + "drop index if exists idx_userdata2", + "drop index if exists userdataindex1", + "drop index if exists userdataindex", + "drop index if exists userdataindex3", + "drop index if exists userdataindex4", + "create unique index if not exists UserDatasIndex1 on UserDatas (key, userId)", + "create index if not exists UserDatasIndex2 on UserDatas (key, userId, played)", + "create index if not exists UserDatasIndex3 on UserDatas (key, userId, playbackPositionTicks)", + "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)")); + + if (!userDataTableExists) + { + transaction.Commit(); + return; + } - connection.RunInTransaction( - db => - { - db.ExecuteAll(string.Join(';', new[] - { - "create table if not exists UserDatas (key nvarchar not null, userId INT not null, rating float null, played bit not null, playCount int not null, isFavorite bit not null, playbackPositionTicks bigint not null, lastPlayedDate datetime null, AudioStreamIndex INT, SubtitleStreamIndex INT)", - - "drop index if exists idx_userdata", - "drop index if exists idx_userdata1", - "drop index if exists idx_userdata2", - "drop index if exists userdataindex1", - "drop index if exists userdataindex", - "drop index if exists userdataindex3", - "drop index if exists userdataindex4", - "create unique index if not exists UserDatasIndex1 on UserDatas (key, userId)", - "create index if not exists UserDatasIndex2 on UserDatas (key, userId, played)", - "create index if not exists UserDatasIndex3 on UserDatas (key, userId, playbackPositionTicks)", - "create index if not exists UserDatasIndex4 on UserDatas (key, userId, isFavorite)" - })); - - if (userDataTableExists) - { - var existingColumnNames = GetColumnNames(db, "userdata"); - - AddColumn(db, "userdata", "InternalUserId", "int", existingColumnNames); - AddColumn(db, "userdata", "AudioStreamIndex", "int", existingColumnNames); - AddColumn(db, "userdata", "SubtitleStreamIndex", "int", existingColumnNames); - - if (!userDatasTableExists) - { - ImportUserIds(db, users); - - db.ExecuteAll("INSERT INTO UserDatas (key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex) SELECT key, InternalUserId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex from userdata where InternalUserId not null"); - } - } - }, - TransactionMode); + var existingColumnNames = GetColumnNames(connection, "userdata"); + + AddColumn(connection, "userdata", "InternalUserId", "int", existingColumnNames); + AddColumn(connection, "userdata", "AudioStreamIndex", "int", existingColumnNames); + AddColumn(connection, "userdata", "SubtitleStreamIndex", "int", existingColumnNames); + + if (userDatasTableExists) + { + return; + } + + ImportUserIds(connection, users); + + connection.Execute("INSERT INTO UserDatas (key, userId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex) SELECT key, InternalUserId, rating, played, playCount, isFavorite, playbackPositionTicks, lastPlayedDate, AudioStreamIndex, SubtitleStreamIndex from userdata where InternalUserId not null"); + + transaction.Commit(); } } - private void ImportUserIds(IDatabaseConnection db, IEnumerable<User> users) + private void ImportUserIds(SqliteConnection db, IEnumerable<User> users) { var userIdsWithUserData = GetAllUserIdsWithUserData(db); @@ -101,13 +101,12 @@ namespace Emby.Server.Implementations.Data statement.TryBind("@UserId", user.Id); statement.TryBind("@InternalUserId", user.InternalId); - statement.MoveNext(); - statement.Reset(); + statement.ExecuteNonQuery(); } } } - private List<Guid> GetAllUserIdsWithUserData(IDatabaseConnection db) + private List<Guid> GetAllUserIdsWithUserData(SqliteConnection db) { var list = new List<Guid>(); @@ -117,7 +116,7 @@ namespace Emby.Server.Implementations.Data { try { - list.Add(row[0].ReadGuidFromBlob()); + list.Add(row.GetGuid(0)); } catch (Exception ex) { @@ -169,17 +168,14 @@ namespace Emby.Server.Implementations.Data cancellationToken.ThrowIfCancellationRequested(); using (var connection = GetConnection()) + using (var transaction = connection.BeginTransaction()) { - connection.RunInTransaction( - db => - { - SaveUserData(db, internalUserId, key, userData); - }, - TransactionMode); + SaveUserData(connection, internalUserId, key, userData); + transaction.Commit(); } } - private static void SaveUserData(IDatabaseConnection db, long internalUserId, string key, UserItemData userData) + private static void SaveUserData(SqliteConnection db, long internalUserId, string key, UserItemData userData) { using (var statement = db.PrepareStatement("replace into UserDatas (key, userId, rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex) values (@key, @userId, @rating,@played,@playCount,@isFavorite,@playbackPositionTicks,@lastPlayedDate,@AudioStreamIndex,@SubtitleStreamIndex)")) { @@ -227,7 +223,7 @@ namespace Emby.Server.Implementations.Data statement.TryBindNull("@SubtitleStreamIndex"); } - statement.MoveNext(); + statement.ExecuteNonQuery(); } } @@ -239,16 +235,14 @@ namespace Emby.Server.Implementations.Data cancellationToken.ThrowIfCancellationRequested(); using (var connection = GetConnection()) + using (var transaction = connection.BeginTransaction()) { - connection.RunInTransaction( - db => - { - foreach (var userItemData in userDataList) - { - SaveUserData(db, internalUserId, userItemData.Key, userItemData); - } - }, - TransactionMode); + foreach (var userItemData in userDataList) + { + SaveUserData(connection, internalUserId, userItemData.Key, userItemData); + } + + transaction.Commit(); } } @@ -272,7 +266,7 @@ namespace Emby.Server.Implementations.Data ArgumentException.ThrowIfNullOrEmpty(key); - using (var connection = GetConnection(true)) + using (var connection = GetConnection()) { using (var statement = connection.PrepareStatement("select key,userid,rating,played,playCount,isFavorite,playbackPositionTicks,lastPlayedDate,AudioStreamIndex,SubtitleStreamIndex from UserDatas where key =@Key and userId=@UserId")) { @@ -336,7 +330,7 @@ namespace Emby.Server.Implementations.Data /// </summary> /// <param name="reader">The list of result set values.</param> /// <returns>The user item data.</returns> - private UserItemData ReadRow(IReadOnlyList<ResultSetValue> reader) + private UserItemData ReadRow(SqliteDataReader reader) { var userData = new UserItemData(); @@ -348,10 +342,10 @@ namespace Emby.Server.Implementations.Data userData.Rating = rating; } - userData.Played = reader[3].ToBool(); - userData.PlayCount = reader[4].ToInt(); - userData.IsFavorite = reader[5].ToBool(); - userData.PlaybackPositionTicks = reader[6].ToInt64(); + userData.Played = reader.GetBoolean(3); + userData.PlayCount = reader.GetInt32(4); + userData.IsFavorite = reader.GetBoolean(5); + userData.PlaybackPositionTicks = reader.GetInt64(6); if (reader.TryReadDateTime(7, out var lastPlayedDate)) { diff --git a/Emby.Server.Implementations/Dto/DtoService.cs b/Emby.Server.Implementations/Dto/DtoService.cs index be361c4d1..44b97e8b8 100644 --- a/Emby.Server.Implementations/Dto/DtoService.cs +++ b/Emby.Server.Implementations/Dto/DtoService.cs @@ -907,10 +907,11 @@ namespace Emby.Server.Implementations.Dto dto.IsPlaceHolder = supportsPlaceHolders.IsPlaceHolder; } + dto.LUFS = item.LUFS; + // Add audio info if (item is Audio audio) { - dto.LUFS = audio.LUFS; dto.Album = audio.Album; if (audio.ExtraType.HasValue) { diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index b8655c760..80263c139 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -24,6 +24,7 @@ <ItemGroup> <PackageReference Include="DiscUtils.Udf" /> <PackageReference Include="Jellyfin.XmlTv" /> + <PackageReference Include="Microsoft.Data.Sqlite" /> <PackageReference Include="Microsoft.Extensions.DependencyInjection" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" /> @@ -31,7 +32,6 @@ <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" /> <PackageReference Include="Mono.Nat" /> <PackageReference Include="prometheus-net.DotNetRuntime" /> - <PackageReference Include="SQLitePCL.pretty.netstandard" /> <PackageReference Include="DotNet.Glob" /> </ItemGroup> @@ -43,8 +43,6 @@ <TargetFramework>net7.0</TargetFramework> <GenerateAssemblyInfo>false</GenerateAssemblyInfo> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> - <NoWarn>AD0001</NoWarn> </PropertyGroup> <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> diff --git a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs index 06e57ad12..d6da597b8 100644 --- a/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs +++ b/Emby.Server.Implementations/EntryPoints/ExternalPortForwarding.cs @@ -57,7 +57,7 @@ namespace Emby.Server.Implementations.EntryPoints return new StringBuilder(32) .Append(config.EnableUPnP).Append(Separator) - .Append(config.PublicPort).Append(Separator) + .Append(config.PublicHttpPort).Append(Separator) .Append(config.PublicHttpsPort).Append(Separator) .Append(_appHost.HttpPort).Append(Separator) .Append(_appHost.HttpsPort).Append(Separator) @@ -146,7 +146,7 @@ namespace Emby.Server.Implementations.EntryPoints private IEnumerable<Task> CreatePortMaps(INatDevice device) { var config = _config.GetNetworkConfiguration(); - yield return CreatePortMap(device, _appHost.HttpPort, config.PublicPort); + yield return CreatePortMap(device, _appHost.HttpPort, config.PublicHttpPort); if (_appHost.ListenWithHttps) { diff --git a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs index e45baedd7..7e4994f1a 100644 --- a/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs +++ b/Emby.Server.Implementations/EntryPoints/UdpServerEntryPoint.cs @@ -1,10 +1,15 @@ using System; +using System.Collections.Generic; +using System.Linq; +using System.Net; using System.Net.Sockets; using System.Threading; using System.Threading.Tasks; using Emby.Server.Implementations.Udp; using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Extensions; using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Net; using MediaBrowser.Controller; using MediaBrowser.Controller.Plugins; using Microsoft.Extensions.Configuration; @@ -13,7 +18,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.EntryPoints { /// <summary> - /// Class UdpServerEntryPoint. + /// Class responsible for registering all UDP broadcast endpoints and their handlers. /// </summary> public sealed class UdpServerEntryPoint : IServerEntryPoint { @@ -29,13 +34,14 @@ namespace Emby.Server.Implementations.EntryPoints private readonly IServerApplicationHost _appHost; private readonly IConfiguration _config; private readonly IConfigurationManager _configurationManager; + private readonly INetworkManager _networkManager; /// <summary> /// The UDP server. /// </summary> - private UdpServer? _udpServer; - private CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); - private bool _disposed = false; + private readonly List<UdpServer> _udpServers; + private readonly CancellationTokenSource _cancellationTokenSource = new CancellationTokenSource(); + private bool _disposed; /// <summary> /// Initializes a new instance of the <see cref="UdpServerEntryPoint" /> class. @@ -44,16 +50,20 @@ namespace Emby.Server.Implementations.EntryPoints /// <param name="appHost">Instance of the <see cref="IServerApplicationHost"/> interface.</param> /// <param name="configuration">Instance of the <see cref="IConfiguration"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> + /// <param name="networkManager">Instance of the <see cref="INetworkManager"/> interface.</param> public UdpServerEntryPoint( ILogger<UdpServerEntryPoint> logger, IServerApplicationHost appHost, IConfiguration configuration, - IConfigurationManager configurationManager) + IConfigurationManager configurationManager, + INetworkManager networkManager) { _logger = logger; _appHost = appHost; _config = configuration; _configurationManager = configurationManager; + _networkManager = networkManager; + _udpServers = new List<UdpServer>(); } /// <inheritdoc /> @@ -68,8 +78,43 @@ namespace Emby.Server.Implementations.EntryPoints try { - _udpServer = new UdpServer(_logger, _appHost, _config, PortNumber); - _udpServer.Start(_cancellationTokenSource.Token); + // Linux needs to bind to the broadcast addresses to get broadcast traffic + // Windows receives broadcast fine when binding to just the interface, it is unable to bind to broadcast addresses + if (OperatingSystem.IsLinux()) + { + // Add global broadcast listener + var server = new UdpServer(_logger, _appHost, _config, IPAddress.Broadcast, PortNumber); + server.Start(_cancellationTokenSource.Token); + _udpServers.Add(server); + + // Add bind address specific broadcast listeners + // IPv6 is currently unsupported + var validInterfaces = _networkManager.GetInternalBindAddresses().Where(i => i.AddressFamily == AddressFamily.InterNetwork); + foreach (var intf in validInterfaces) + { + var broadcastAddress = NetworkExtensions.GetBroadcastAddress(intf.Subnet); + _logger.LogDebug("Binding UDP server to {Address} on port {PortNumber}", broadcastAddress, PortNumber); + + server = new UdpServer(_logger, _appHost, _config, broadcastAddress, PortNumber); + server.Start(_cancellationTokenSource.Token); + _udpServers.Add(server); + } + } + else + { + // Add bind address specific broadcast listeners + // IPv6 is currently unsupported + var validInterfaces = _networkManager.GetInternalBindAddresses().Where(i => i.AddressFamily == AddressFamily.InterNetwork); + foreach (var intf in validInterfaces) + { + var intfAddress = intf.Address; + _logger.LogDebug("Binding UDP server to {Address} on port {PortNumber}", intfAddress, PortNumber); + + var server = new UdpServer(_logger, _appHost, _config, intfAddress, PortNumber); + server.Start(_cancellationTokenSource.Token); + _udpServers.Add(server); + } + } } catch (SocketException ex) { @@ -83,7 +128,7 @@ namespace Emby.Server.Implementations.EntryPoints { if (_disposed) { - throw new ObjectDisposedException(this.GetType().Name); + throw new ObjectDisposedException(GetType().Name); } } @@ -97,9 +142,12 @@ namespace Emby.Server.Implementations.EntryPoints _cancellationTokenSource.Cancel(); _cancellationTokenSource.Dispose(); - _udpServer?.Dispose(); - _udpServer = null; + foreach (var server in _udpServers) + { + server.Dispose(); + } + _udpServers.Clear(); _disposed = true; } } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs index b1a99853a..f83da566b 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketConnection.cs @@ -9,7 +9,8 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Extensions.Json; using MediaBrowser.Controller.Net; -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; +using MediaBrowser.Controller.Net.WebSocketMessages.Outbound; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; @@ -42,14 +43,17 @@ namespace Emby.Server.Implementations.HttpServer /// </summary> /// <param name="logger">The logger.</param> /// <param name="socket">The socket.</param> + /// <param name="authorizationInfo">The authorization information.</param> /// <param name="remoteEndPoint">The remote end point.</param> public WebSocketConnection( ILogger<WebSocketConnection> logger, WebSocket socket, + AuthorizationInfo authorizationInfo, IPAddress? remoteEndPoint) { _logger = logger; _socket = socket; + AuthorizationInfo = authorizationInfo; RemoteEndPoint = remoteEndPoint; _jsonOptions = JsonDefaults.Options; @@ -59,47 +63,40 @@ namespace Emby.Server.Implementations.HttpServer /// <inheritdoc /> public event EventHandler<EventArgs>? Closed; - /// <summary> - /// Gets the remote end point. - /// </summary> + /// <inheritdoc /> + public AuthorizationInfo AuthorizationInfo { get; } + + /// <inheritdoc /> public IPAddress? RemoteEndPoint { get; } - /// <summary> - /// Gets or sets the receive action. - /// </summary> - /// <value>The receive action.</value> + /// <inheritdoc /> public Func<WebSocketMessageInfo, Task>? OnReceive { get; set; } - /// <summary> - /// Gets the last activity date. - /// </summary> - /// <value>The last activity date.</value> + /// <inheritdoc /> public DateTime LastActivityDate { get; private set; } /// <inheritdoc /> public DateTime LastKeepAliveDate { get; set; } - /// <summary> - /// Gets the state. - /// </summary> - /// <value>The state.</value> + /// <inheritdoc /> public WebSocketState State => _socket.State; - /// <summary> - /// Sends a message asynchronously. - /// </summary> - /// <typeparam name="T">The type of the message.</typeparam> - /// <param name="message">The message.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken) + /// <inheritdoc /> + public Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken) + { + var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); + return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); + } + + /// <inheritdoc /> + public Task SendAsync<T>(OutboundWebSocketMessage<T> message, CancellationToken cancellationToken) { var json = JsonSerializer.SerializeToUtf8Bytes(message, _jsonOptions); return _socket.SendAsync(json, WebSocketMessageType.Text, true, cancellationToken); } /// <inheritdoc /> - public async Task ProcessAsync(CancellationToken cancellationToken = default) + public async Task ReceiveAsync(CancellationToken cancellationToken = default) { var pipe = new Pipe(); var writer = pipe.Writer; @@ -171,7 +168,7 @@ namespace Emby.Server.Implementations.HttpServer return; } - WebSocketMessage<object>? stub; + InboundWebSocketMessage<object>? stub; long bytesConsumed; try { @@ -212,10 +209,10 @@ namespace Emby.Server.Implementations.HttpServer } } - internal WebSocketMessage<object>? DeserializeWebSocketMessage(ReadOnlySequence<byte> bytes, out long bytesConsumed) + internal InboundWebSocketMessage<object>? DeserializeWebSocketMessage(ReadOnlySequence<byte> bytes, out long bytesConsumed) { var jsonReader = new Utf8JsonReader(bytes); - var ret = JsonSerializer.Deserialize<WebSocketMessage<object>>(ref jsonReader, _jsonOptions); + var ret = JsonSerializer.Deserialize<InboundWebSocketMessage<object>>(ref jsonReader, _jsonOptions); bytesConsumed = jsonReader.BytesConsumed; return ret; } @@ -224,11 +221,7 @@ namespace Emby.Server.Implementations.HttpServer { LastKeepAliveDate = DateTime.UtcNow; return SendAsync( - new WebSocketMessage<string> - { - MessageId = Guid.NewGuid(), - MessageType = SessionMessageType.KeepAlive - }, + new OutboundKeepAliveMessage(), CancellationToken.None); } diff --git a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs index 4f7d1c40a..52f14b0b1 100644 --- a/Emby.Server.Implementations/HttpServer/WebSocketManager.cs +++ b/Emby.Server.Implementations/HttpServer/WebSocketManager.cs @@ -51,7 +51,8 @@ namespace Emby.Server.Implementations.HttpServer using var connection = new WebSocketConnection( _loggerFactory.CreateLogger<WebSocketConnection>(), webSocket, - context.GetNormalizedRemoteIp()) + authorizationInfo, + context.GetNormalizedRemoteIP()) { OnReceive = ProcessWebSocketMessageReceived }; @@ -64,7 +65,7 @@ namespace Emby.Server.Implementations.HttpServer await Task.WhenAll(tasks).ConfigureAwait(false); - await connection.ProcessAsync().ConfigureAwait(false); + await connection.ReceiveAsync().ConfigureAwait(false); _logger.LogInformation("WS {IP} closed", context.Connection.RemoteIpAddress); } catch (Exception ex) // Otherwise ASP.Net will ignore the exception diff --git a/Emby.Server.Implementations/IO/FileRefresher.cs b/Emby.Server.Implementations/IO/FileRefresher.cs index 0ad81b653..e75cab64c 100644 --- a/Emby.Server.Implementations/IO/FileRefresher.cs +++ b/Emby.Server.Implementations/IO/FileRefresher.cs @@ -85,7 +85,7 @@ namespace Emby.Server.Implementations.IO } } - public void ResetPath(string path, string affectedFile) + public void ResetPath(string path, string? affectedFile) { lock (_timerLock) { @@ -148,13 +148,6 @@ namespace Emby.Server.Implementations.IO { item.ChangedExternally(); } - catch (IOException ex) - { - // For now swallow and log. - // Research item: If an IOException occurs, the item may be in a disconnected state (media unavailable) - // Should we remove it from it's parent? - _logger.LogError(ex, "Error refreshing {Name}", item.Name); - } catch (Exception ex) { _logger.LogError(ex, "Error refreshing {Name}", item.Name); @@ -217,7 +210,6 @@ namespace Emby.Server.Implementations.IO DisposeTimer(); _disposed = true; - GC.SuppressFinalize(this); } } } diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index f67a02be8..dde38906f 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -160,7 +158,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - private void OnLibraryManagerItemRemoved(object sender, ItemChangeEventArgs e) + private void OnLibraryManagerItemRemoved(object? sender, ItemChangeEventArgs e) { if (e.Parent is AggregateFolder) { @@ -173,7 +171,7 @@ namespace Emby.Server.Implementations.IO /// </summary> /// <param name="sender">The source of the event.</param> /// <param name="e">The <see cref="ItemChangeEventArgs"/> instance containing the event data.</param> - private void OnLibraryManagerItemAdded(object sender, ItemChangeEventArgs e) + private void OnLibraryManagerItemAdded(object? sender, ItemChangeEventArgs e) { if (e.Parent is AggregateFolder) { @@ -189,19 +187,28 @@ namespace Emby.Server.Implementations.IO /// <param name="path">The path.</param> /// <returns><c>true</c> if [contains parent folder] [the specified LST]; otherwise, <c>false</c>.</returns> /// <exception cref="ArgumentNullException"><paramref name="path"/> is <c>null</c>.</exception> - private static bool ContainsParentFolder(IEnumerable<string> lst, string path) + private static bool ContainsParentFolder(IReadOnlyList<string> lst, ReadOnlySpan<char> path) { - ArgumentException.ThrowIfNullOrEmpty(path); + if (path.IsEmpty) + { + throw new ArgumentException("Path can't be empty", nameof(path)); + } path = path.TrimEnd(Path.DirectorySeparatorChar); - return lst.Any(str => + foreach (var str in lst) { // this should be a little quicker than examining each actual parent folder... - var compare = str.TrimEnd(Path.DirectorySeparatorChar); + var compare = str.AsSpan().TrimEnd(Path.DirectorySeparatorChar); - return path.Equals(compare, StringComparison.OrdinalIgnoreCase) || (path.StartsWith(compare, StringComparison.OrdinalIgnoreCase) && path[compare.Length] == Path.DirectorySeparatorChar); - }); + if (path.Equals(compare, StringComparison.OrdinalIgnoreCase) + || (path.StartsWith(compare, StringComparison.OrdinalIgnoreCase) && path[compare.Length] == Path.DirectorySeparatorChar)) + { + return true; + } + } + + return false; } /// <summary> @@ -349,21 +356,19 @@ namespace Emby.Server.Implementations.IO { ArgumentException.ThrowIfNullOrEmpty(path); - var monitorPath = !IgnorePatterns.ShouldIgnore(path); + if (IgnorePatterns.ShouldIgnore(path)) + { + return; + } // Ignore certain files, If the parent of an ignored path has a change event, ignore that too - if (_tempIgnoredPaths.Keys.Any(i => + foreach (var i in _tempIgnoredPaths.Keys) { - if (_fileSystem.AreEqual(i, path)) - { - _logger.LogDebug("Ignoring change to {Path}", path); - return true; - } - - if (_fileSystem.ContainsSubPath(i, path)) + if (_fileSystem.AreEqual(i, path) + || _fileSystem.ContainsSubPath(i, path)) { _logger.LogDebug("Ignoring change to {Path}", path); - return true; + return; } // Go up a level @@ -371,20 +376,11 @@ namespace Emby.Server.Implementations.IO if (!string.IsNullOrEmpty(parent) && _fileSystem.AreEqual(parent, path)) { _logger.LogDebug("Ignoring change to {Path}", path); - return true; + return; } - - return false; - })) - { - monitorPath = false; } - if (monitorPath) - { - // Avoid implicitly captured closure - CreateRefresher(path); - } + CreateRefresher(path); } private void CreateRefresher(string path) @@ -417,7 +413,8 @@ namespace Emby.Server.Implementations.IO } // They are siblings. Rebase the refresher to the parent folder. - if (string.Equals(parentPath, Path.GetDirectoryName(refresher.Path), StringComparison.Ordinal)) + if (parentPath is not null + && Path.GetDirectoryName(refresher.Path.AsSpan()).Equals(parentPath, StringComparison.Ordinal)) { refresher.ResetPath(parentPath, path); return; @@ -430,8 +427,13 @@ namespace Emby.Server.Implementations.IO } } - private void OnNewRefresherCompleted(object sender, EventArgs e) + private void OnNewRefresherCompleted(object? sender, EventArgs e) { + if (sender is null) + { + return; + } + var refresher = (FileRefresher)sender; DisposeRefresher(refresher); } diff --git a/Emby.Server.Implementations/IO/ManagedFileSystem.cs b/Emby.Server.Implementations/IO/ManagedFileSystem.cs index 1fffdfbfa..c380d67db 100644 --- a/Emby.Server.Implementations/IO/ManagedFileSystem.cs +++ b/Emby.Server.Implementations/IO/ManagedFileSystem.cs @@ -15,29 +15,34 @@ namespace Emby.Server.Implementations.IO /// </summary> public class ManagedFileSystem : IFileSystem { - private readonly ILogger<ManagedFileSystem> _logger; + private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); + private static readonly char[] _invalidPathCharacters = + { + '\"', '<', '>', '|', '\0', + (char)1, (char)2, (char)3, (char)4, (char)5, (char)6, (char)7, (char)8, (char)9, (char)10, + (char)11, (char)12, (char)13, (char)14, (char)15, (char)16, (char)17, (char)18, (char)19, (char)20, + (char)21, (char)22, (char)23, (char)24, (char)25, (char)26, (char)27, (char)28, (char)29, (char)30, + (char)31, ':', '*', '?', '\\', '/' + }; - private readonly List<IShortcutHandler> _shortcutHandlers = new List<IShortcutHandler>(); + private readonly ILogger<ManagedFileSystem> _logger; + private readonly List<IShortcutHandler> _shortcutHandlers; private readonly string _tempPath; - private static readonly bool _isEnvironmentCaseInsensitive = OperatingSystem.IsWindows(); /// <summary> /// Initializes a new instance of the <see cref="ManagedFileSystem"/> class. /// </summary> /// <param name="logger">The <see cref="ILogger"/> instance to use.</param> /// <param name="applicationPaths">The <see cref="IApplicationPaths"/> instance to use.</param> + /// <param name="shortcutHandlers">the <see cref="IShortcutHandler"/>'s to use.</param> public ManagedFileSystem( ILogger<ManagedFileSystem> logger, - IApplicationPaths applicationPaths) + IApplicationPaths applicationPaths, + IEnumerable<IShortcutHandler> shortcutHandlers) { _logger = logger; _tempPath = applicationPaths.TempDirectory; - } - - /// <inheritdoc /> - public virtual void AddShortcutHandler(IShortcutHandler handler) - { - _shortcutHandlers.Add(handler); + _shortcutHandlers = shortcutHandlers.ToList(); } /// <summary> @@ -86,7 +91,7 @@ namespace Emby.Server.Implementations.IO } // unc path - if (filePath.StartsWith("\\\\", StringComparison.Ordinal)) + if (filePath.StartsWith(@"\\", StringComparison.Ordinal)) { return filePath; } @@ -98,15 +103,17 @@ namespace Emby.Server.Implementations.IO return filePath; } + var filePathSpan = filePath.AsSpan(); + // relative path if (firstChar == '\\') { - filePath = filePath.Substring(1); + filePathSpan = filePathSpan.Slice(1); } try { - return Path.GetFullPath(Path.Combine(folderPath, filePath)); + return Path.GetFullPath(Path.Join(folderPath, filePathSpan)); } catch (ArgumentException) { @@ -275,8 +282,7 @@ namespace Emby.Server.Implementations.IO /// <exception cref="ArgumentNullException">The filename is null.</exception> public string GetValidFilename(string filename) { - var invalid = Path.GetInvalidFileNameChars(); - var first = filename.IndexOfAny(invalid); + var first = filename.IndexOfAny(_invalidPathCharacters); if (first == -1) { // Fast path for clean strings @@ -285,7 +291,7 @@ namespace Emby.Server.Implementations.IO return string.Create( filename.Length, - (filename, invalid, first), + (filename, _invalidPathCharacters, first), (chars, state) => { state.filename.AsSpan().CopyTo(chars); @@ -293,7 +299,7 @@ namespace Emby.Server.Implementations.IO chars[state.first++] = ' '; var len = chars.Length; - foreach (var c in state.invalid) + foreach (var c in state._invalidPathCharacters) { for (int i = state.first; i < len; i++) { @@ -479,24 +485,10 @@ namespace Emby.Server.Implementations.IO } /// <inheritdoc /> - public virtual string NormalizePath(string path) - { - ArgumentException.ThrowIfNullOrEmpty(path); - - if (path.EndsWith(":\\", StringComparison.OrdinalIgnoreCase)) - { - return path; - } - - return Path.TrimEndingDirectorySeparator(path); - } - - /// <inheritdoc /> public virtual bool AreEqual(string path1, string path2) { - return string.Equals( - NormalizePath(path1), - NormalizePath(path2), + return Path.TrimEndingDirectorySeparator(path1).Equals( + Path.TrimEndingDirectorySeparator(path2), _isEnvironmentCaseInsensitive ? StringComparison.OrdinalIgnoreCase : StringComparison.Ordinal); } diff --git a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs index c2aab3879..5776c7a7c 100644 --- a/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs +++ b/Emby.Server.Implementations/IO/MbLinkShortcutHandler.cs @@ -8,24 +8,17 @@ namespace Emby.Server.Implementations.IO { public class MbLinkShortcutHandler : IShortcutHandler { - private readonly IFileSystem _fileSystem; - - public MbLinkShortcutHandler(IFileSystem fileSystem) - { - _fileSystem = fileSystem; - } - public string Extension => ".mblink"; public string? Resolve(string shortcutPath) { ArgumentException.ThrowIfNullOrEmpty(shortcutPath); - if (string.Equals(Path.GetExtension(shortcutPath), ".mblink", StringComparison.OrdinalIgnoreCase)) + if (Path.GetExtension(shortcutPath.AsSpan()).Equals(".mblink", StringComparison.OrdinalIgnoreCase)) { var path = File.ReadAllText(shortcutPath); - return _fileSystem.NormalizePath(path); + return Path.TrimEndingDirectorySeparator(path); } return null; diff --git a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs index 84c21931c..539d4a63a 100644 --- a/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs +++ b/Emby.Server.Implementations/Images/BaseFolderImageProvider.cs @@ -31,6 +31,7 @@ namespace Emby.Server.Implementations.Images return _libraryManager.GetItemList(new InternalItemsQuery { Parent = item, + Recursive = true, DtoOptions = new DtoOptions(true), ImageTypes = new ImageType[] { ImageType.Primary }, OrderBy = new (string, SortOrder)[] diff --git a/Emby.Server.Implementations/Library/IgnorePatterns.cs b/Emby.Server.Implementations/Library/IgnorePatterns.cs index 5384c04b3..cf6fc1845 100644 --- a/Emby.Server.Implementations/Library/IgnorePatterns.cs +++ b/Emby.Server.Implementations/Library/IgnorePatterns.cs @@ -89,6 +89,10 @@ namespace Emby.Server.Implementations.Library // bts sync files "**/*.bts", "**/*.sync", + + // zfs + "**/.zfs/**", + "**/.zfs" }; private static readonly GlobOptions _globOptions = new GlobOptions diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index ea45bf0ba..4f0983564 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -3,6 +3,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.Globalization; using System.IO; @@ -45,7 +46,6 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.Library; using MediaBrowser.Model.Querying; using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; using EpisodeInfo = Emby.Naming.TV.EpisodeInfo; @@ -63,7 +63,7 @@ namespace Emby.Server.Implementations.Library private const string ShortcutFileExtension = ".mblink"; private readonly ILogger<LibraryManager> _logger; - private readonly IMemoryCache _memoryCache; + private readonly ConcurrentDictionary<Guid, BaseItem> _cache; private readonly ITaskManager _taskManager; private readonly IUserManager _userManager; private readonly IUserDataManager _userDataRepository; @@ -111,7 +111,6 @@ namespace Emby.Server.Implementations.Library /// <param name="mediaEncoder">The media encoder.</param> /// <param name="itemRepository">The item repository.</param> /// <param name="imageProcessor">The image processor.</param> - /// <param name="memoryCache">The memory cache.</param> /// <param name="namingOptions">The naming options.</param> /// <param name="directoryService">The directory service.</param> public LibraryManager( @@ -128,7 +127,6 @@ namespace Emby.Server.Implementations.Library IMediaEncoder mediaEncoder, IItemRepository itemRepository, IImageProcessor imageProcessor, - IMemoryCache memoryCache, NamingOptions namingOptions, IDirectoryService directoryService) { @@ -145,7 +143,7 @@ namespace Emby.Server.Implementations.Library _mediaEncoder = mediaEncoder; _itemRepository = itemRepository; _imageProcessor = imageProcessor; - _memoryCache = memoryCache; + _cache = new ConcurrentDictionary<Guid, BaseItem>(); _namingOptions = namingOptions; _extraResolver = new ExtraResolver(loggerFactory.CreateLogger<ExtraResolver>(), namingOptions, directoryService); @@ -300,7 +298,7 @@ namespace Emby.Server.Implementations.Library } } - _memoryCache.Set(item.Id, item); + _cache[item.Id] = item; } public void DeleteItem(BaseItem item, DeleteOptions options) @@ -359,7 +357,7 @@ namespace Emby.Server.Implementations.Library var children = item.IsFolder ? ((Folder)item).GetRecursiveChildren(false) - : Enumerable.Empty<BaseItem>(); + : Array.Empty<BaseItem>(); foreach (var metadataPath in GetMetadataPaths(item, children)) { @@ -441,7 +439,7 @@ namespace Emby.Server.Implementations.Library _itemRepository.DeleteItem(child.Id); } - _memoryCache.Remove(item.Id); + _cache.TryRemove(item.Id, out _); ReportItemRemoved(item, parent); } @@ -609,7 +607,7 @@ namespace Emby.Server.Implementations.Library var originalList = paths.ToList(); var list = originalList.Where(i => i.IsDirectory) - .Select(i => _fileSystem.NormalizePath(i.FullName)) + .Select(i => Path.TrimEndingDirectorySeparator(i.FullName)) .Distinct(StringComparer.OrdinalIgnoreCase) .ToList(); @@ -840,19 +838,12 @@ namespace Emby.Server.Implementations.Library { var path = Person.GetPath(name); var id = GetItemByNameId<Person>(path); - if (GetItemById(id) is not Person item) + if (GetItemById(id) is Person item) { - item = new Person - { - Name = name, - Id = id, - DateCreated = DateTime.UtcNow, - DateModified = DateTime.UtcNow, - Path = path - }; + return item; } - return item; + return null; } /// <summary> @@ -1163,7 +1154,7 @@ namespace Emby.Server.Implementations.Library Name = Path.GetFileName(dir), Locations = _fileSystem.GetFilePaths(dir, false) - .Where(i => string.Equals(ShortcutFileExtension, Path.GetExtension(i), StringComparison.OrdinalIgnoreCase)) + .Where(i => Path.GetExtension(i.AsSpan()).Equals(ShortcutFileExtension, StringComparison.OrdinalIgnoreCase)) .Select(i => { try @@ -1233,7 +1224,7 @@ namespace Emby.Server.Implementations.Library throw new ArgumentException("Guid can't be empty", nameof(id)); } - if (_memoryCache.TryGetValue(id, out BaseItem item)) + if (_cache.TryGetValue(id, out BaseItem item)) { return item; } @@ -2069,7 +2060,9 @@ namespace Emby.Server.Implementations.Library .Find(folder => folder is CollectionFolder) as CollectionFolder; } - return collectionFolder is null ? new LibraryOptions() : collectionFolder.GetLibraryOptions(); + return collectionFolder is null + ? new LibraryOptions() + : collectionFolder.GetLibraryOptions(); } public string GetContentType(BaseItem item) @@ -2857,7 +2850,7 @@ namespace Emby.Server.Implementations.Library { var path = Path.Combine(virtualFolderPath, collectionType.ToString().ToLowerInvariant() + ".collection"); - File.WriteAllBytes(path, Array.Empty<byte>()); + await File.WriteAllBytesAsync(path, Array.Empty<byte>()).ConfigureAwait(false); } CollectionFolder.SaveLibraryOptions(virtualFolderPath, options); @@ -2899,9 +2892,18 @@ namespace Emby.Server.Implementations.Library var saveEntity = false; var personEntity = GetPerson(person.Name); - // if PresentationUniqueKey is empty it's likely a new item. - if (string.IsNullOrEmpty(personEntity.PresentationUniqueKey)) + if (personEntity is null) { + var path = Person.GetPath(person.Name); + personEntity = new Person() + { + Name = person.Name, + Id = GetItemByNameId<Person>(path), + DateCreated = DateTime.UtcNow, + DateModified = DateTime.UtcNow, + Path = path + }; + personEntity.PresentationUniqueKey = personEntity.CreatePresentationUniqueKey(); saveEntity = true; } @@ -3134,7 +3136,7 @@ namespace Emby.Server.Implementations.Library } var shortcut = _fileSystem.GetFilePaths(virtualFolderPath, true) - .Where(i => string.Equals(ShortcutFileExtension, Path.GetExtension(i), StringComparison.OrdinalIgnoreCase)) + .Where(i => Path.GetExtension(i.AsSpan()).Equals(ShortcutFileExtension, StringComparison.OrdinalIgnoreCase)) .FirstOrDefault(f => _appHost.ExpandVirtualPath(_fileSystem.ResolveShortcut(f)).Equals(mediaPath, StringComparison.OrdinalIgnoreCase)); if (!string.IsNullOrEmpty(shortcut)) diff --git a/Emby.Server.Implementations/Library/LiveStreamHelper.cs b/Emby.Server.Implementations/Library/LiveStreamHelper.cs index 936a08da8..59d705ace 100644 --- a/Emby.Server.Implementations/Library/LiveStreamHelper.cs +++ b/Emby.Server.Implementations/Library/LiveStreamHelper.cs @@ -48,15 +48,20 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(cacheKey)) { + FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); try { - await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); // _logger.LogDebug("Found cached media info"); } - catch + catch (Exception ex) { + _logger.LogError(ex, "Error deserializing mediainfo cache"); + } + finally + { + await jsonStream.DisposeAsync().ConfigureAwait(false); } } @@ -84,10 +89,13 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath is not null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - await using FileStream createStream = AsyncFile.OpenWrite(cacheFilePath); - await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); + FileStream createStream = AsyncFile.OpenWrite(cacheFilePath); + await using (createStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); + } - // _logger.LogDebug("Saved media info to {0}", cacheFilePath); + _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 c9a26a30f..91469dba9 100644 --- a/Emby.Server.Implementations/Library/MediaSourceManager.cs +++ b/Emby.Server.Implementations/Library/MediaSourceManager.cs @@ -625,17 +625,19 @@ namespace Emby.Server.Implementations.Library if (!string.IsNullOrEmpty(cacheKey)) { + FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); try { - await using FileStream jsonStream = AsyncFile.OpenRead(cacheFilePath); mediaInfo = await JsonSerializer.DeserializeAsync<MediaInfo>(jsonStream, _jsonOptions, cancellationToken).ConfigureAwait(false); - - // _logger.LogDebug("Found cached media info"); } catch (Exception ex) { _logger.LogDebug(ex, "_jsonSerializer.DeserializeFromFile threw an exception."); } + finally + { + await jsonStream.DisposeAsync().ConfigureAwait(false); + } } if (mediaInfo is null) @@ -664,8 +666,11 @@ namespace Emby.Server.Implementations.Library if (cacheFilePath is not null) { Directory.CreateDirectory(Path.GetDirectoryName(cacheFilePath)); - await using FileStream createStream = File.Create(cacheFilePath); - await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); + FileStream createStream = File.Create(cacheFilePath); + await using (createStream.ConfigureAwait(false)) + { + await JsonSerializer.SerializeAsync(createStream, mediaInfo, _jsonOptions, cancellationToken).ConfigureAwait(false); + } // _logger.LogDebug("Saved media info to {0}", cacheFilePath); } diff --git a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs index a74f82475..862f144e6 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Audio/AudioResolver.cs @@ -94,9 +94,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio if (AudioFileParser.IsAudioFile(args.Path, _namingOptions)) { - var extension = Path.GetExtension(args.Path); + var extension = Path.GetExtension(args.Path.AsSpan()); - if (string.Equals(extension, ".cue", StringComparison.OrdinalIgnoreCase)) + if (extension.Equals(".cue", StringComparison.OrdinalIgnoreCase)) { // if audio file exists of same name, return null return null; @@ -128,7 +128,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Audio if (item is not null) { - item.IsShortcut = string.Equals(extension, ".strm", StringComparison.OrdinalIgnoreCase); + item.IsShortcut = extension.Equals(".strm", StringComparison.OrdinalIgnoreCase); item.IsInMixedFolder = true; } diff --git a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs index 381796d0e..779cfd5be 100644 --- a/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/BaseVideoResolver.cs @@ -263,7 +263,7 @@ namespace Emby.Server.Implementations.Library.Resolvers return false; } - return directoryService.GetFilePaths(fullPath).Any(i => string.Equals(Path.GetExtension(i), ".vob", StringComparison.OrdinalIgnoreCase)); + return directoryService.GetFilePaths(fullPath).Any(i => Path.GetExtension(i.AsSpan()).Equals(".vob", StringComparison.OrdinalIgnoreCase)); } /// <summary> diff --git a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs index 042422c6f..73861ff59 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Books/BookResolver.cs @@ -32,9 +32,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Books return GetBook(args); } - var extension = Path.GetExtension(args.Path); + var extension = Path.GetExtension(args.Path.AsSpan()); - if (extension is not null && _validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) + if (_validExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { // It's a book return new Book @@ -51,12 +51,11 @@ 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.AsSpan()); return _validExtensions.Contains( fileExtension, - StringComparer.OrdinalIgnoreCase); + StringComparison.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/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index ea980b992..0b65bf921 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -24,7 +24,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <summary> /// Class MovieResolver. /// </summary> - public class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver + public partial class MovieResolver : BaseVideoResolver<Video>, IMultiItemResolver { private readonly IImageProcessor _imageProcessor; @@ -56,6 +56,9 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies /// <value>The priority.</value> public override ResolverPriority Priority => ResolverPriority.Fourth; + [GeneratedRegex(@"\bsample\b", RegexOptions.IgnoreCase)] + private static partial Regex IsIgnoredRegex(); + /// <inheritdoc /> public MultiItemResolverResult ResolveMultiple( Folder parent, @@ -261,7 +264,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies { leftOver.Add(child); } - else if (!IsIgnored(child.Name)) + else if (!IsIgnoredRegex().IsMatch(child.Name)) { files.Add(child); } @@ -314,9 +317,6 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies return result; } - private static bool IsIgnored(ReadOnlySpan<char> filename) - => Regex.IsMatch(filename, @"\bsample\b", RegexOptions.IgnoreCase | RegexOptions.Compiled); - private static bool ContainsFile(IReadOnlyList<VideoInfo> result, FileSystemMetadata file) { for (var i = 0; i < result.Count; i++) diff --git a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs index 9026160ff..c860391fc 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PhotoResolver.cs @@ -1,7 +1,4 @@ -#nullable disable - using System; -using System.Collections.Generic; using System.IO; using System.Linq; using Emby.Naming.Common; @@ -25,7 +22,7 @@ namespace Emby.Server.Implementations.Library.Resolvers private readonly NamingOptions _namingOptions; private readonly IDirectoryService _directoryService; - private static readonly HashSet<string> _ignoreFiles = new HashSet<string>(StringComparer.OrdinalIgnoreCase) + private static readonly string[] _ignoreFiles = new[] { "folder", "thumb", @@ -56,7 +53,7 @@ namespace Emby.Server.Implementations.Library.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>Trailer.</returns> - protected override Photo Resolve(ItemResolveArgs args) + protected override Photo? Resolve(ItemResolveArgs args) { if (!args.IsDirectory) { @@ -68,10 +65,11 @@ namespace Emby.Server.Implementations.Library.Resolvers { if (IsImageFile(args.Path, _imageProcessor)) { - var filename = Path.GetFileNameWithoutExtension(args.Path); + var filename = Path.GetFileNameWithoutExtension(args.Path.AsSpan()); // Make sure the image doesn't belong to a video file - var files = _directoryService.GetFiles(Path.GetDirectoryName(args.Path)); + var files = _directoryService.GetFiles(Path.GetDirectoryName(args.Path) + ?? throw new InvalidOperationException("Path can't be a root directory.")); foreach (var file in files) { @@ -92,32 +90,32 @@ namespace Emby.Server.Implementations.Library.Resolvers return null; } - internal static bool IsOwnedByMedia(NamingOptions namingOptions, string file, string imageFilename) + internal static bool IsOwnedByMedia(NamingOptions namingOptions, string file, ReadOnlySpan<char> imageFilename) { return VideoResolver.IsVideoFile(file, namingOptions) && IsOwnedByResolvedMedia(file, imageFilename); } - internal static bool IsOwnedByResolvedMedia(string file, string imageFilename) + internal static bool IsOwnedByResolvedMedia(ReadOnlySpan<char> file, ReadOnlySpan<char> imageFilename) => imageFilename.StartsWith(Path.GetFileNameWithoutExtension(file), StringComparison.OrdinalIgnoreCase); internal static bool IsImageFile(string path, IImageProcessor imageProcessor) { ArgumentNullException.ThrowIfNull(path); - var filename = Path.GetFileNameWithoutExtension(path); - - if (_ignoreFiles.Contains(filename)) + var extension = Path.GetExtension(path.AsSpan()).TrimStart('.'); + if (!imageProcessor.SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return false; } - if (_ignoreFiles.Any(i => filename.IndexOf(i, StringComparison.OrdinalIgnoreCase) != -1)) + var filename = Path.GetFileNameWithoutExtension(path); + + if (_ignoreFiles.Any(i => filename.StartsWith(i, StringComparison.OrdinalIgnoreCase))) { return false; } - string extension = Path.GetExtension(path).TrimStart('.'); - return imageProcessor.SupportedInputFormats.Contains(extension, StringComparison.OrdinalIgnoreCase); + return true; } } } diff --git a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs index e9538a5c9..858c5b281 100644 --- a/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/TV/SeasonResolver.cs @@ -62,7 +62,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.TV var resolver = new Naming.TV.EpisodeResolver(namingOptions); var folderName = System.IO.Path.GetFileName(path); - var testPath = "\\\\test\\" + folderName; + var testPath = @"\\test\" + folderName; var episodeInfo = resolver.Resolve(testPath, true); diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index b9d0f170a..74b62ca3f 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -1851,7 +1851,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - await using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (stream.ConfigureAwait(false)) { var settings = new XmlWriterSettings { @@ -1860,7 +1861,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV Async = true }; - await using (var writer = XmlWriter.Create(stream, settings)) + var writer = XmlWriter.Create(stream, settings); + await using (writer.ConfigureAwait(false)) { await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); await writer.WriteStartElementAsync(null, "tvshow", null).ConfigureAwait(false); @@ -1914,7 +1916,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV return; } - await using (var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None)) + var stream = new FileStream(nfoPath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (stream.ConfigureAwait(false)) { var settings = new XmlWriterSettings { @@ -1927,7 +1930,8 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV var isSeriesEpisode = timer.IsProgramSeries; - await using (var writer = XmlWriter.Create(stream, settings)) + var writer = XmlWriter.Create(stream, settings); + await using (writer.ConfigureAwait(false)) { await writer.WriteStartDocumentAsync(true).ConfigureAwait(false); @@ -1965,7 +1969,7 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV } else { - await writer.WriteStartElementAsync(null, "movie", null); + await writer.WriteStartElementAsync(null, "movie", null).ConfigureAwait(false); if (!string.IsNullOrWhiteSpace(item.Name)) { diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 7645c6c52..6b0520ad0 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -106,8 +106,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings options.Content = JsonContent.Create(requestList, options: _jsonOptions); options.Headers.TryAddWithoutValidation("token", token); using var response = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - await using var responseStream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var dailySchedules = await JsonSerializer.DeserializeAsync<IReadOnlyList<DayDto>>(responseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var dailySchedules = await response.Content.ReadFromJsonAsync<IReadOnlyList<DayDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); if (dailySchedules is null) { return Array.Empty<ProgramInfo>(); @@ -122,8 +121,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings programRequestOptions.Content = JsonContent.Create(programIds, options: _jsonOptions); using var innerResponse = await Send(programRequestOptions, true, info, cancellationToken).ConfigureAwait(false); - await using var innerResponseStream = await innerResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var programDetails = await JsonSerializer.DeserializeAsync<IReadOnlyList<ProgramDetailsDto>>(innerResponseStream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var programDetails = await innerResponse.Content.ReadFromJsonAsync<IReadOnlyList<ProgramDetailsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); if (programDetails is null) { return Array.Empty<ProgramInfo>(); @@ -482,8 +480,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { 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<IReadOnlyList<ShowImagesDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); + return await innerResponse2.Content.ReadFromJsonAsync<IReadOnlyList<ShowImagesDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); } catch (Exception ex) { @@ -510,10 +507,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings try { using var httpResponse = await Send(options, false, info, cancellationToken).ConfigureAwait(false); - await using var response = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - - var root = await JsonSerializer.DeserializeAsync<IReadOnlyList<HeadendsDto>>(response, _jsonOptions, cancellationToken).ConfigureAwait(false); - + var root = await httpResponse.Content.ReadFromJsonAsync<IReadOnlyList<HeadendsDto>>(_jsonOptions, cancellationToken).ConfigureAwait(false); if (root is not null) { foreach (HeadendsDto headend in root) @@ -649,8 +643,7 @@ 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<TokenDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await response.Content.ReadFromJsonAsync<TokenDto>(_jsonOptions, cancellationToken).ConfigureAwait(false); if (string.Equals(root?.Message, "OK", StringComparison.Ordinal)) { _logger.LogInformation("Authenticated with Schedules Direct token: {Token}", root.Token); @@ -691,10 +684,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings { using var httpResponse = await Send(options, false, null, cancellationToken).ConfigureAwait(false); httpResponse.EnsureSuccessStatusCode(); - await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var response = httpResponse.Content; - var root = await JsonSerializer.DeserializeAsync<LineupsDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - + var root = await httpResponse.Content.ReadFromJsonAsync<LineupsDto>(_jsonOptions, cancellationToken).ConfigureAwait(false); return root?.Lineups.Any(i => string.Equals(info.ListingsId, i.Lineup, StringComparison.OrdinalIgnoreCase)) ?? false; } catch (HttpRequestException ex) @@ -748,8 +738,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings options.Headers.TryAddWithoutValidation("token", token); using var httpResponse = await Send(options, true, info, cancellationToken).ConfigureAwait(false); - await using var stream = await httpResponse.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var root = await JsonSerializer.DeserializeAsync<ChannelDto>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); + var root = await httpResponse.Content.ReadFromJsonAsync<ChannelDto>(_jsonOptions, cancellationToken).ConfigureAwait(false); if (root is null) { return new List<ChannelInfo>(); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs index 7b6c8b80a..ff25ee585 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/BaseTunerHost.cs @@ -3,6 +3,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -16,21 +17,20 @@ using MediaBrowser.Controller.LiveTv; using MediaBrowser.Model.Dto; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts { public abstract class BaseTunerHost { - private readonly IMemoryCache _memoryCache; + private readonly ConcurrentDictionary<string, List<ChannelInfo>> _cache; - protected BaseTunerHost(IServerConfigurationManager config, ILogger<BaseTunerHost> logger, IFileSystem fileSystem, IMemoryCache memoryCache) + protected BaseTunerHost(IServerConfigurationManager config, ILogger<BaseTunerHost> logger, IFileSystem fileSystem) { Config = config; Logger = logger; - _memoryCache = memoryCache; FileSystem = fileSystem; + _cache = new ConcurrentDictionary<string, List<ChannelInfo>>(); } protected IServerConfigurationManager Config { get; } @@ -51,7 +51,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var key = tuner.Id; - if (enableCache && !string.IsNullOrEmpty(key) && _memoryCache.TryGetValue(key, out List<ChannelInfo> cache)) + if (enableCache && !string.IsNullOrEmpty(key) && _cache.TryGetValue(key, out List<ChannelInfo> cache)) { return cache; } @@ -61,7 +61,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts if (!string.IsNullOrEmpty(key) && list.Count > 0) { - _memoryCache.Set(key, list); + _cache[key] = list; } return list; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs index 98bbc1540..8cd0c4ffb 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunHost.cs @@ -9,6 +9,7 @@ using System.IO; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Text.Json; using System.Threading; using System.Threading.Tasks; @@ -27,7 +28,6 @@ using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; using MediaBrowser.Model.Net; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun @@ -50,9 +50,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun IHttpClientFactory httpClientFactory, IServerApplicationHost appHost, ISocketFactory socketFactory, - IStreamHelper streamHelper, - IMemoryCache memoryCache) - : base(config, logger, fileSystem, memoryCache) + IStreamHelper streamHelper) + : base(config, logger, fileSystem) { _httpClientFactory = httpClientFactory; _appHost = appHost; @@ -77,13 +76,10 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun var model = await GetModelInfo(info, false, cancellationToken).ConfigureAwait(false); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(model.LineupURL ?? model.BaseURL + "/lineup.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var lineup = await JsonSerializer.DeserializeAsync<List<Channels>>(stream, _jsonOptions, cancellationToken) - .ConfigureAwait(false) ?? new List<Channels>(); - + var lineup = await response.Content.ReadFromJsonAsync<IEnumerable<Channels>>(_jsonOptions, cancellationToken).ConfigureAwait(false) ?? Enumerable.Empty<Channels>(); if (info.ImportFavoritesOnly) { - lineup = lineup.Where(i => i.Favorite).ToList(); + lineup = lineup.Where(i => i.Favorite); } return lineup.Where(i => !i.DRM).ToList(); @@ -130,9 +126,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun .GetAsync(GetApiUrl(info) + "/discover.json", HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); response.EnsureSuccessStatusCode(); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - var discoverResponse = await JsonSerializer.DeserializeAsync<DiscoverResponse>(stream, _jsonOptions, cancellationToken) - .ConfigureAwait(false); + var discoverResponse = await response.Content.ReadFromJsonAsync<DiscoverResponse>(_jsonOptions, cancellationToken).ConfigureAwait(false); if (!string.IsNullOrEmpty(cacheKey)) { @@ -176,34 +170,37 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun using var response = await _httpClientFactory.CreateClient(NamedClient.Default) .GetAsync(string.Format(CultureInfo.InvariantCulture, "{0}/tuners.html", GetApiUrl(info)), HttpCompletionOption.ResponseHeadersRead, cancellationToken) .ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); var tuners = new List<LiveTvTunerInfo>(); - await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false)) + var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); + await using (stream.ConfigureAwait(false)) { - string stripedLine = StripXML(line); - if (stripedLine.Contains("Channel", StringComparison.Ordinal)) + using var sr = new StreamReader(stream, System.Text.Encoding.UTF8); + await foreach (var line in sr.ReadAllLinesAsync().ConfigureAwait(false)) { - LiveTvTunerStatus status; - var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); - var name = stripedLine.Substring(0, index - 1); - var currentChannel = stripedLine.Substring(index + 7); - if (string.Equals(currentChannel, "none", StringComparison.Ordinal)) + string stripedLine = StripXML(line); + if (stripedLine.Contains("Channel", StringComparison.Ordinal)) { - status = LiveTvTunerStatus.LiveTv; - } - else - { - status = LiveTvTunerStatus.Available; - } + LiveTvTunerStatus status; + var index = stripedLine.IndexOf("Channel", StringComparison.OrdinalIgnoreCase); + var name = stripedLine.Substring(0, index - 1); + var currentChannel = stripedLine.Substring(index + 7); + if (string.Equals(currentChannel, "none", StringComparison.Ordinal)) + { + status = LiveTvTunerStatus.LiveTv; + } + else + { + status = LiveTvTunerStatus.Available; + } - tuners.Add(new LiveTvTunerInfo - { - Name = name, - SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, - ProgramName = currentChannel, - Status = status - }); + tuners.Add(new LiveTvTunerInfo + { + Name = name, + SourceType = string.IsNullOrWhiteSpace(model.ModelNumber) ? Name : model.ModelNumber, + ProgramName = currentChannel, + Status = status + }); + } } } @@ -661,18 +658,18 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun // Need a way to set the Receive timeout on the socket otherwise this might never timeout? try { - await udpClient.SendToAsync(discBytes, 0, discBytes.Length, new IPEndPoint(IPAddress.Parse("255.255.255.255"), 65001), cancellationToken).ConfigureAwait(false); + await udpClient.SendToAsync(discBytes, new IPEndPoint(IPAddress.Broadcast, 65001), cancellationToken).ConfigureAwait(false); var receiveBuffer = new byte[8192]; while (!cancellationToken.IsCancellationRequested) { - var response = await udpClient.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, cancellationToken).ConfigureAwait(false); - var deviceIp = response.RemoteEndPoint.Address.ToString(); + var response = await udpClient.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.Any, 0), cancellationToken).ConfigureAwait(false); + var deviceIP = ((IPEndPoint)response.RemoteEndPoint).Address.ToString(); - // check to make sure we have enough bytes received to be a valid message and make sure the 2nd byte is the discover reply byte - if (response.ReceivedBytes > 13 && response.Buffer[1] == 3) + // Check to make sure we have enough bytes received to be a valid message and make sure the 2nd byte is the discover reply byte + if (response.ReceivedBytes > 13 && receiveBuffer[1] == 3) { - var deviceAddress = "http://" + deviceIp; + var deviceAddress = "http://" + deviceIP; var info = await TryGetTunerHostInfo(deviceAddress, cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index 7bc209d6b..68383a554 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -44,14 +44,12 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun StopStreaming(socket).GetAwaiter().GetResult(); } } - - GC.SuppressFinalize(this); } - public async Task<bool> CheckTunerAvailability(IPAddress remoteIp, int tuner, CancellationToken cancellationToken) + public async Task<bool> CheckTunerAvailability(IPAddress remoteIP, int tuner, CancellationToken cancellationToken) { using var client = new TcpClient(); - await client.ConnectAsync(remoteIp, HdHomeRunPort, cancellationToken).ConfigureAwait(false); + await client.ConnectAsync(remoteIP, HdHomeRunPort, cancellationToken).ConfigureAwait(false); using var stream = client.GetStream(); return await CheckTunerAvailability(stream, tuner, cancellationToken).ConfigureAwait(false); @@ -75,9 +73,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - public async Task StartStreaming(IPAddress remoteIp, IPAddress localIp, int localPort, IHdHomerunChannelCommands commands, int numTuners, CancellationToken cancellationToken) + public async Task StartStreaming(IPAddress remoteIP, IPAddress localIP, int localPort, IHdHomerunChannelCommands commands, int numTuners, CancellationToken cancellationToken) { - _remoteEndPoint = new IPEndPoint(remoteIp, HdHomeRunPort); + _remoteEndPoint = new IPEndPoint(remoteIP, HdHomeRunPort); _tcpClient = new TcpClient(); await _tcpClient.ConnectAsync(_remoteEndPoint, cancellationToken).ConfigureAwait(false); @@ -125,7 +123,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } - var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIp, localPort); + var targetValue = string.Format(CultureInfo.InvariantCulture, "rtp://{0}:{1}", localIP, localPort); var targetMsgLen = WriteSetMessage(buffer, i, "target", targetValue, lockKeyValue); await stream.WriteAsync(buffer.AsMemory(0, targetMsgLen), cancellationToken).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs index 3450f971f..654474e97 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/LegacyHdHomerunChannelCommands.cs @@ -5,7 +5,7 @@ using System.Text.RegularExpressions; namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun { - public class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands + public partial class LegacyHdHomerunChannelCommands : IHdHomerunChannelCommands { private string? _channel; private string? _program; @@ -13,7 +13,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun public LegacyHdHomerunChannelCommands(string url) { // parse url for channel and program - var match = Regex.Match(url, @"\/ch([0-9]+)-?([0-9]*)"); + var match = ChannelAndProgramRegex().Match(url); if (match.Success) { _channel = match.Groups[1].Value; @@ -21,6 +21,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun } } + [GeneratedRegex(@"\/ch([0-9]+)-?([0-9]*)")] + private static partial Regex ChannelAndProgramRegex(); + public IEnumerable<(string CommandName, string CommandValue)> GetCommands() { if (!string.IsNullOrEmpty(_channel)) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs index acf3964c8..db5e81df5 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3UTunerHost.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Net.Http; using System.Threading; @@ -22,7 +21,6 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; using MediaBrowser.Model.LiveTv; using MediaBrowser.Model.MediaInfo; -using Microsoft.Extensions.Caching.Memory; using Microsoft.Extensions.Logging; using Microsoft.Net.Http.Headers; @@ -54,9 +52,8 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts IHttpClientFactory httpClientFactory, IServerApplicationHost appHost, INetworkManager networkManager, - IStreamHelper streamHelper, - IMemoryCache memoryCache) - : base(config, logger, fileSystem, memoryCache) + IStreamHelper streamHelper) + : base(config, logger, fileSystem) { _httpClientFactory = httpClientFactory; _appHost = appHost; diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs index b41816230..341782d9d 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/M3uParser.cs @@ -20,7 +20,7 @@ using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.LiveTv.TunerHosts { - public class M3uParser + public partial class M3uParser { private const string ExtInfPrefix = "#EXTINF:"; @@ -33,6 +33,9 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts _httpClientFactory = httpClientFactory; } + [GeneratedRegex(@"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex KeyValueRegex(); + public async Task<List<ChannelInfo>> Parse(TunerHostInfo info, string channelIdPrefix, CancellationToken cancellationToken) { // Read the file and display it line by line. @@ -91,14 +94,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts else if (!string.IsNullOrWhiteSpace(extInf) && !trimmedLine.StartsWith('#')) { var channel = GetChannelnfo(extInf, tunerHostId, trimmedLine); - if (string.IsNullOrWhiteSpace(channel.Id)) - { - channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); - } - else - { - channel.Id = channelIdPrefix + channel.Id.GetMD5().ToString("N", CultureInfo.InvariantCulture); - } + channel.Id = channelIdPrefix + trimmedLine.GetMD5().ToString("N", CultureInfo.InvariantCulture); channel.Path = trimmedLine; channels.Add(channel); @@ -311,7 +307,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts { var dict = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - var matches = Regex.Matches(line, @"([a-z0-9\-_]+)=\""([^""]+)\""", RegexOptions.IgnoreCase); + var matches = KeyValueRegex().Matches(line); remaining = line; @@ -320,7 +316,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts var key = match.Groups[1].Value; var value = match.Groups[2].Value; - dict[match.Groups[1].Value] = match.Groups[2].Value; + dict[key] = value; remaining = remaining.Replace(key + "=\"" + value + "\"", string.Empty, StringComparison.OrdinalIgnoreCase); } diff --git a/Emby.Server.Implementations/Localization/Core/as.json b/Emby.Server.Implementations/Localization/Core/as.json index 0967ef424..7c7dd26e9 100644 --- a/Emby.Server.Implementations/Localization/Core/as.json +++ b/Emby.Server.Implementations/Localization/Core/as.json @@ -1 +1,43 @@ -{} +{ + "Albums": "এলবাম", + "Application": "আবেদন", + "AppDeviceValues": "এপ্: {0}, ডিভাইচ: {1}", + "Artists": "শিল্পী", + "Channels": "চেনেলস", + "Default": "ডিফল্ট", + "AuthenticationSucceededWithUserName": "{0} সফলভাবে প্রমাণিত", + "Books": "পুস্তক", + "Movies": "চলচ্চিত্ৰ", + "CameraImageUploadedFrom": "একটি নতুন ক্যামেরা চিত্র আপলোড করা হয়েছে {0}", + "Collections": "সংগ্রহ", + "HeaderFavoriteShows": "প্রিয় শোসমূহ", + "Latest": "শেহতীয়া", + "MessageApplicationUpdated": "জেলিফিন চাইভাৰ আপডেট কৰা হৈছে", + "MixedContent": "মিশ্ৰিত সমগ্ৰতা", + "NewVersionIsAvailable": "ডাউনলোড কৰিবলৈ জেলিফিন চাইভাৰৰ এটা নতুন সংস্কৰণ উপলব্ধ আছে.", + "NotificationOptionCameraImageUploaded": "কেমেৰাৰ চিত্ৰ আপল'ড কৰা হ'ল", + "External": "বাহ্যিক", + "Favorites": "পছন্দসই", + "Folders": "ফোল্ডাৰ", + "Forced": "বলপূর্বক", + "Genres": "শ্রেণী", + "HeaderAlbumArtists": "অ্যালবাম শিল্পী", + "HeaderContinueWatching": "দেখা চালিয়ে যান", + "FailedLoginAttemptWithUserName": "লগইন ব্যর্থ চেষ্টা কৰা হৈছে থেকে {0}", + "HeaderFavoriteAlbums": "প্রিয় অ্যালবামসমূহ", + "HeaderFavoriteArtists": "প্রিয় শিল্পীসমূহ", + "HeaderFavoriteEpisodes": "প্রিয় পর্বসমূহ", + "HeaderFavoriteSongs": "প্ৰিয় গীত", + "HeaderLiveTV": "প্ৰতিবেদন টিভি", + "HeaderNextUp": "পৰৱৰ্তী অংশ", + "HeaderRecordingGroups": "অলংকৰণ গোষ্ঠীসমূহ", + "HearingImpaired": "শ্ৰবণ অক্ষম", + "HomeVideos": "ঘৰৰ ভিডিঅ'সমূহ", + "Inherit": "উত্তপ্ত কৰা", + "MessageServerConfigurationUpdated": "চাইভাৰ কনফিগাৰেশ্যন আপডেট কৰা হৈছে", + "NotificationOptionApplicationUpdateAvailable": "অ্যাপ্লিকেশ্যন আপডেট উপলব্ধ", + "NotificationOptionApplicationUpdateInstalled": "অ্যাপ্লিকেশ্যন আপডেট ইনষ্টল কৰা হ'ল", + "NotificationOptionAudioPlayback": "অডিঅ' প্লেবেক আৰম্ভ হ'ল", + "NotificationOptionAudioPlaybackStopped": "অডিঅ' প্লেবেক আঁতৰ হ'ল", + "NotificationOptionInstallationFailed": "ইনষ্টলেশ্যন ব্যৰ্থতা" +} diff --git a/Emby.Server.Implementations/Localization/Core/chr.json b/Emby.Server.Implementations/Localization/Core/chr.json new file mode 100644 index 000000000..85d1f4c88 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/chr.json @@ -0,0 +1,52 @@ +{ + "ChapterNameValue": "Didanedi {0}", + "HeaderAlbumArtists": "Didanidanolisgisgi", + "HeaderFavoriteAlbums": "Dvganidi didanidisgisgi", + "HeaderLiveTV": "Anigadi didanidisgosgi", + "HeaderRecordingGroups": "Didanisquodiisgisgi", + "HomeVideos": "Diganadi dinagadisgisgi", + "Inherit": "Anigwe", + "MessageApplicationUpdatedTo": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe anigadi {0}", + "MixedContent": "Ganinidi dininoladisgisgi", + "Movies": "Anidvnisgisgi", + "MusicVideos": "Danodisgisgi didanidisgosgi", + "NotificationOptionAudioPlayback": "Didanidigwe diganuyisgisgi anigadi", + "NotificationOptionInstallationFailed": "Diudvdi anadvnatisgisgi", + "NotificationOptionPluginUninstalled": "Ditsigvhnidv anawvdisgisgi", + "Albums": "Anigawidaniyv", + "Application": "Didanvyi", + "Artists": "Dinidaniyi", + "AuthenticationSucceededWithUserName": "{0} Sesoquonisdi nagadani", + "Books": "Didanedi", + "CameraImageUploadedFrom": "Anigawidaniyv nasgi didagwalanvyi {0}", + "Channels": "Diganadasgi", + "Collections": "Diganadisgi", + "Default": "Dinadi", + "DeviceOfflineWithName": "{0} Aniyvolehvi nasgi", + "External": "Amohdi", + "Favorites": "Nvdayelvdisgi", + "Folders": "Didanididisgi", + "Forced": "Ganedi", + "Genres": "Diganadisgi", + "HeaderContinueWatching": "Uwoditsu asdanidisgisgi", + "HeaderFavoriteArtists": "Dvganidi dinidanolisgisgi", + "HeaderFavoriteEpisodes": "Dvganidi didanidilisgadisgisgi", + "HeaderFavoriteShows": "Dvganidi didanididanolisgisgi)", + "HeaderFavoriteSongs": "Dvganidi danodisgisgi", + "HeaderNextUp": "Anidvli uwodoli", + "HearingImpaired": "Anitsunidi talunidisgisgi", + "ItemAddedWithName": "{0} Dinigwe anididanidisgi", + "Latest": "Uwodoli", + "MessageApplicationUpdated": "Tsenigwidinonvhi Jellyfin Server tsadanidigwe", + "MessageServerConfigurationUpdated": "Sedanidvdi anigadi diganidinonvhi", + "Music": "Danodisgisgi", + "NameSeasonUnknown": "Tsunita anidvdisgi", + "NewVersionIsAvailable": "Danodigwe anigadi Jellyfin Server tsadanidigwe adisdi uwodvdi diganidinonvhi.", + "NotificationOptionApplicationUpdateAvailable": "Disisdi tsadanidigwe udvdi", + "NotificationOptionApplicationUpdateInstalled": "Disisdi tsadanidigwe digawvdi", + "NotificationOptionAudioPlaybackStopped": "Didanidigwe diganuyisgisgi digawvdi", + "NotificationOptionCameraImageUploaded": "Asdayi adininisgisgi diganuyisgisgi", + "NotificationOptionNewLibraryContent": "Danodisgisgi anigadi digawvdi", + "NotificationOptionPluginError": "Ditsigvhnidv anadvnatisgisgi", + "NotificationOptionPluginInstalled": "Ditsigvhnidv digawvdi" +} diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 08db5a30e..f33ea2fc9 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -22,7 +22,7 @@ "HeaderFavoriteEpisodes": "Oblíbené epizody", "HeaderFavoriteShows": "Oblíbené seriály", "HeaderFavoriteSongs": "Oblíbená hudba", - "HeaderLiveTV": "Televize", + "HeaderLiveTV": "Živý přenos", "HeaderNextUp": "Další díly", "HeaderRecordingGroups": "Skupiny nahrávek", "HomeVideos": "Domácí videa", diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 1b6eecdcf..837172a5b 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -15,13 +15,13 @@ "Favorites": "Favoritter", "Folders": "Mapper", "Genres": "Genrer", - "HeaderAlbumArtists": "Albums kunstnere", + "HeaderAlbumArtists": "Albumkunstnere", "HeaderContinueWatching": "Fortsæt afspilning", - "HeaderFavoriteAlbums": "Favorit albummer", - "HeaderFavoriteArtists": "Favorit kunstnere", - "HeaderFavoriteEpisodes": "Favorit afsnit", - "HeaderFavoriteShows": "Favorit serier", - "HeaderFavoriteSongs": "Favorit sange", + "HeaderFavoriteAlbums": "Favoritalbummer", + "HeaderFavoriteArtists": "Favoritkunstnere", + "HeaderFavoriteEpisodes": "Yndlingsafsnit", + "HeaderFavoriteShows": "Yndlingsserier", + "HeaderFavoriteSongs": "Yndlingssange", "HeaderLiveTV": "Live-TV", "HeaderNextUp": "Næste", "HeaderRecordingGroups": "Optagelsesgrupper", @@ -34,8 +34,8 @@ "Latest": "Seneste", "MessageApplicationUpdated": "Jellyfin Server er blevet opdateret", "MessageApplicationUpdatedTo": "Jellyfin Server er blevet opdateret til {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Server konfiguration sektion {0} er blevet opdateret", - "MessageServerConfigurationUpdated": "Server konfigurationen er blevet opdateret", + "MessageNamedServerConfigurationUpdatedWithValue": "Serverkonfiguration sektion {0} er blevet opdateret", + "MessageServerConfigurationUpdated": "Serverkonfigurationen er blevet opdateret", "MixedContent": "Blandet indhold", "Movies": "Film", "Music": "Musik", @@ -51,7 +51,7 @@ "NotificationOptionCameraImageUploaded": "Kamerabillede uploadet", "NotificationOptionInstallationFailed": "Installationen mislykkedes", "NotificationOptionNewLibraryContent": "Nyt indhold tilføjet", - "NotificationOptionPluginError": "Plugin fejl", + "NotificationOptionPluginError": "Plugin-fejl", "NotificationOptionPluginInstalled": "Plugin blev installeret", "NotificationOptionPluginUninstalled": "Plugin blev afinstalleret", "NotificationOptionPluginUpdateInstalled": "Opdatering til plugin blev installeret", @@ -92,26 +92,26 @@ "ValueHasBeenAddedToLibrary": "{0} er blevet tilføjet til dit mediebibliotek", "ValueSpecialEpisodeName": "Special - {0}", "VersionNumber": "Version {0}", - "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata konfigurationen.", + "TaskDownloadMissingSubtitlesDescription": "Søger på internettet efter manglende undertekster baseret på metadata-konfigurationen.", "TaskDownloadMissingSubtitles": "Hent manglende undertekster", "TaskUpdatePluginsDescription": "Henter og installerer opdateringer for plugins, som er indstillet til at blive opdateret automatisk.", "TaskUpdatePlugins": "Opdater Plugins", - "TaskCleanLogsDescription": "Sletter log filer som er mere end {0} dage gamle.", - "TaskCleanLogs": "Ryd Log mappe", - "TaskRefreshLibraryDescription": "Scanner dit medie bibliotek for nye filer og opdateret metadata.", - "TaskRefreshLibrary": "Scan Medie Bibliotek", - "TaskCleanCacheDescription": "Sletter cache filer som systemet ikke længere bruger.", - "TaskCleanCache": "Ryd Cache mappe", - "TasksChannelsCategory": "Internet Kanaler", + "TaskCleanLogsDescription": "Sletter log-filer som er mere end {0} dage gamle.", + "TaskCleanLogs": "Ryd Log-mappe", + "TaskRefreshLibraryDescription": "Scanner dit mediebibliotek for nye filer og opdateret metadata.", + "TaskRefreshLibrary": "Scan Mediebibliotek", + "TaskCleanCacheDescription": "Sletter cache-filer som systemet ikke længere bruger.", + "TaskCleanCache": "Ryd Cache-mappe", + "TasksChannelsCategory": "Internetkanaler", "TasksApplicationCategory": "Applikation", "TasksLibraryCategory": "Bibliotek", "TasksMaintenanceCategory": "Vedligeholdelse", - "TaskRefreshChapterImages": "Udtræk kapitel billeder", - "TaskRefreshChapterImagesDescription": "Lav miniaturebilleder for videoer der har kapitler.", - "TaskRefreshChannelsDescription": "Opdater internet kanal information.", + "TaskRefreshChapterImages": "Udtræk kapitelbilleder", + "TaskRefreshChapterImagesDescription": "Laver miniaturebilleder for videoer, der har kapitler.", + "TaskRefreshChannelsDescription": "Opdaterer information for internetkanal.", "TaskRefreshChannels": "Opdater Kanaler", - "TaskCleanTranscodeDescription": "Fjern transcode filer som er mere end 1 dag gammel.", - "TaskCleanTranscode": "Tøm Transcode mappen", + "TaskCleanTranscodeDescription": "Fjerner transcode-filer, som er mere end 1 dag gammel.", + "TaskCleanTranscode": "Tøm Transcode-mappen", "TaskRefreshPeople": "Opdater Personer", "TaskRefreshPeopleDescription": "Opdaterer metadata for skuespillere og instruktører i dit mediebibliotek.", "TaskCleanActivityLogDescription": "Sletter linjer i aktivitetsloggen ældre end den konfigurerede alder.", @@ -121,8 +121,8 @@ "Default": "Standard", "TaskOptimizeDatabaseDescription": "Komprimerer databasen og frigør plads. Denne handling køres efter at have scannet mediebiblioteket, eller efter at have lavet ændringer til databasen, for at højne ydeevnen.", "TaskOptimizeDatabase": "Optimér database", - "TaskKeyframeExtractorDescription": "Udtrækker billeder fra videofiler for at lave mere præcise HLS playlister. Denne opgave kan tage lang tid.", - "TaskKeyframeExtractor": "Nøglebillede udtræk", + "TaskKeyframeExtractorDescription": "Udtrækker billeder fra videofiler for at lave mere præcise HLS-playlister. Denne opgave kan tage lang tid.", + "TaskKeyframeExtractor": "Udtræk af nøglebillede", "External": "Ekstern", "HearingImpaired": "Hørehæmmet" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index f5636a0af..4c56f789d 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -3,9 +3,9 @@ "AppDeviceValues": "Aplicación: {0}, Dispositivo: {1}", "Application": "Aplicación", "Artists": "Artistas", - "AuthenticationSucceededWithUserName": "{0} identificado correctamente", + "AuthenticationSucceededWithUserName": "{0} autenticado correctamente", "Books": "Libros", - "CameraImageUploadedFrom": "Se ha subido una nueva imagen de cámara desde {0}", + "CameraImageUploadedFrom": "Se ha subido una nueva imagen por cámara desde {0}", "Channels": "Canales", "ChapterNameValue": "Capítulo {0}", "Collections": "Colecciones", diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index 8672cfb9f..08344abeb 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -74,16 +74,16 @@ "Shows": "Sarjat", "ServerNameNeedsToBeRestarted": "\"{0}\" on käynnistettävä uudelleen", "ProviderValue": "Lähde: {0}", - "Plugin": "Laajennus", + "Plugin": "Lisäosa", "NotificationOptionVideoPlaybackStopped": "Videon toisto lopetettu", "NotificationOptionVideoPlayback": "Videon toisto aloitettu", "NotificationOptionUserLockedOut": "Käyttäjä on lukittu", "NotificationOptionTaskFailed": "Ajoitettu tehtävä epäonnistui", "NotificationOptionServerRestartRequired": "Tarvitaan palvelimen uudelleenkäynnistys", - "NotificationOptionPluginUpdateInstalled": "Laajennus on päivitetty", - "NotificationOptionPluginUninstalled": "Laajennus on poistettu", - "NotificationOptionPluginInstalled": "Laajennus on asennettu", - "NotificationOptionPluginError": "Laajennuksen virhe", + "NotificationOptionPluginUpdateInstalled": "Lisäosa päivitettiin", + "NotificationOptionPluginUninstalled": "Lisäosa poistettiin", + "NotificationOptionPluginInstalled": "Lisäosa asennettiin", + "NotificationOptionPluginError": "Lisäosan virhe", "NotificationOptionNewLibraryContent": "Sisältöä on lisätty", "NotificationOptionInstallationFailed": "Asennus epäonnistui", "NotificationOptionCameraImageUploaded": "Kameran kuva on tallennettu", @@ -98,8 +98,8 @@ "TaskRefreshChannels": "Päivitä kanavat", "TaskCleanTranscodeDescription": "Poistaa päivää vanhemmat transkoodaustiedostot.", "TaskCleanTranscode": "Puhdista transkoodauskansio", - "TaskUpdatePluginsDescription": "Lataa ja asentaa päivitykset laajennuksille, jotka on määritetty päivittymään automaattisesti.", - "TaskUpdatePlugins": "Päivitä laajennukset", + "TaskUpdatePluginsDescription": "Lataa ja asentaa päivitykset lisäosille, jotka on määritetty päivittymään automaattisesti.", + "TaskUpdatePlugins": "Päivitä lisäosat", "TaskRefreshPeopleDescription": "Päivittää mediakirjaston näyttelijöiden ja ohjaajien metatiedot.", "TaskRefreshPeople": "Päivitä henkilöt", "TaskCleanLogsDescription": "Poistaa {0} päivää vanhemmat lokitiedostot.", diff --git a/Emby.Server.Implementations/Localization/Core/fo.json b/Emby.Server.Implementations/Localization/Core/fo.json new file mode 100644 index 000000000..40aa5f71a --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/fo.json @@ -0,0 +1,18 @@ +{ + "Artists": "Listafólk", + "Collections": "Søvn", + "Default": "Sjálvgildi", + "DeviceOfflineWithName": "{0} hevur slitið sambandið", + "External": "Ytri", + "Genres": "Greinar", + "Albums": "Album", + "AppDeviceValues": "App: {0}, Eind: {1}", + "Application": "Nýtsluskipan", + "Books": "Bøkur", + "Channels": "Rásir", + "ChapterNameValue": "Kapittul {0}", + "DeviceOnlineWithName": "{0} er sambundið", + "Favorites": "Yndis", + "Folders": "Mappur", + "Forced": "Kravt" +} diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 4877bcd7a..a2b429dcd 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -105,8 +105,8 @@ "TaskRefreshPeople": "Actualiser les acteurs", "TaskCleanLogsDescription": "Supprime les journaux de plus de {0} jours.", "TaskCleanLogs": "Nettoyer le répertoire des journaux", - "TaskRefreshLibraryDescription": "Scanne votre médiathèque pour trouver les nouveaux fichiers et actualise les métadonnées.", - "TaskRefreshLibrary": "Scanner la médiathèque", + "TaskRefreshLibraryDescription": "Analyser sa médiathèque pour trouver les nouveaux fichiers et actualiser les métadonnées.", + "TaskRefreshLibrary": "Analyser la médiathèque", "TaskRefreshChapterImagesDescription": "Crée des vignettes pour les vidéos ayant des chapitres.", "TaskRefreshChapterImages": "Extraire les images de chapitre", "TaskCleanCacheDescription": "Supprime les fichiers de cache dont le système n'a plus besoin.", diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index 694a3d688..68e9fe833 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -5,18 +5,18 @@ "Artists": "אומנים", "AuthenticationSucceededWithUserName": "{0} אומת בהצלחה", "Books": "ספרים", - "CameraImageUploadedFrom": "תמונת מצלמה חדשה הועלתה מ {0}", + "CameraImageUploadedFrom": "תמונת מצלמה חדשה הועלתה מתוך {0}", "Channels": "ערוצים", "ChapterNameValue": "פרק {0}", "Collections": "אוספים", "DeviceOfflineWithName": "{0} התנתק", "DeviceOnlineWithName": "{0} מחובר", - "FailedLoginAttemptWithUserName": "ניסיון כניסה שגוי מ{0}", + "FailedLoginAttemptWithUserName": "ניסיון כניסה שגוי דרך {0}", "Favorites": "מועדפים", "Folders": "תיקיות", - "Genres": "ז'אנרים", + "Genres": "ז׳אנרים", "HeaderAlbumArtists": "אמני האלבום", - "HeaderContinueWatching": "המשך לצפות", + "HeaderContinueWatching": "להמשיך לצפות", "HeaderFavoriteAlbums": "אלבומים מועדפים", "HeaderFavoriteArtists": "אמנים מועדפים", "HeaderFavoriteEpisodes": "פרקים מועדפים", @@ -27,14 +27,14 @@ "HeaderRecordingGroups": "קבוצות הקלטה", "HomeVideos": "סרטונים בייתים", "Inherit": "הורש", - "ItemAddedWithName": "{0} הוסף לספרייה", + "ItemAddedWithName": "{0} נוסף לספרייה", "ItemRemovedWithName": "{0} נמחק מהספרייה", "LabelIpAddressValue": "Ip כתובת: {0}", "LabelRunningTimeValue": "משך צפייה: {0}", "Latest": "אחרון", "MessageApplicationUpdated": "שרת הJellyfin עודכן", - "MessageApplicationUpdatedTo": "שרת הJellyfin עודכן לגרסא {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "הגדרת השרת {0} שונתה", + "MessageApplicationUpdatedTo": "שרת ה־Jellyfin עודכן לגרסה {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "סעיף הגדרת השרת {0} עודכן", "MessageServerConfigurationUpdated": "תצורת השרת עודכנה", "MixedContent": "תוכן מעורב", "Movies": "סרטים", @@ -50,7 +50,7 @@ "NotificationOptionAudioPlaybackStopped": "ניגון שמע הופסק", "NotificationOptionCameraImageUploaded": "תמונת מצלמה הועלתה", "NotificationOptionInstallationFailed": "התקנה נכשלה", - "NotificationOptionNewLibraryContent": "תוכן חדש הוסף", + "NotificationOptionNewLibraryContent": "תוכן חדש נוסף", "NotificationOptionPluginError": "כשלון בתוסף", "NotificationOptionPluginInstalled": "התוסף הותקן", "NotificationOptionPluginUninstalled": "התוסף הוסר", @@ -61,41 +61,41 @@ "NotificationOptionVideoPlayback": "ניגון וידאו החל", "NotificationOptionVideoPlaybackStopped": "ניגון וידאו הופסק", "Photos": "תמונות", - "Playlists": "רשימות הפעלה", - "Plugin": "Plugin", + "Playlists": "רשימות נגינה", + "Plugin": "תוסף", "PluginInstalledWithName": "{0} הותקן", "PluginUninstalledWithName": "{0} הוסר", "PluginUpdatedWithName": "{0} עודכן", - "ProviderValue": "Provider: {0}", + "ProviderValue": "ספק: {0}", "ScheduledTaskFailedWithName": "{0} נכשל", "ScheduledTaskStartedWithName": "{0} החל", "ServerNameNeedsToBeRestarted": "{0} דורש הפעלה מחדש", "Shows": "סדרות", "Songs": "שירים", - "StartupEmbyServerIsLoading": "שרת Jellyfin בהליכי טעינה. אנא נסה שנית בעוד זמן קצר.", + "StartupEmbyServerIsLoading": "שרת Jellyfin בהליכי טעינה. נא לנסות שנית בהקדם.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "הורדת כתוביות נכשלה מ-{0} עבור {1}", - "Sync": "סנכרן", - "System": "System", + "SubtitleDownloadFailureFromForItem": "הורדת כתוביות מ־{0} עבור {1} נכשלה", + "Sync": "סנכרון", + "System": "מערכת", "TvShows": "סדרות טלוויזיה", - "User": "User", + "User": "משתמש", "UserCreatedWithName": "המשתמש {0} נוצר", "UserDeletedWithName": "המשתמש {0} הוסר", "UserDownloadingItemWithValues": "{0} מוריד את {1}", "UserLockedOutWithName": "המשתמש {0} ננעל", - "UserOfflineFromDevice": "{0} התנתק מ-{1}", - "UserOnlineFromDevice": "{0} מחובר מ-{1}", + "UserOfflineFromDevice": "{0} התנתק מ־{1}", + "UserOnlineFromDevice": "{0} מחובר מ־{1}", "UserPasswordChangedWithName": "הסיסמה שונתה עבור המשתמש {0}", "UserPolicyUpdatedWithName": "מדיניות המשתמש {0} עודכנה", "UserStartedPlayingItemWithValues": "{0} מנגן את {1} על {2}", "UserStoppedPlayingItemWithValues": "{0} סיים לנגן את {1} על {2}", "ValueHasBeenAddedToLibrary": "{0} התווסף לספריית המדיה שלך", "ValueSpecialEpisodeName": "מיוחד- {0}", - "VersionNumber": "Version {0}", + "VersionNumber": "גרסה {0}", "TaskRefreshLibrary": "סרוק ספריית מדיה", "TaskRefreshChapterImages": "חלץ תמונות פרקים", "TaskCleanCacheDescription": "מחק קבצי מטמון שלא בשימוש המערכת.", - "TaskCleanCache": "נקה תיקיית מטמון", + "TaskCleanCache": "ניקוי תיקיית מטמון", "TasksApplicationCategory": "יישום", "TasksLibraryCategory": "ספרייה", "TasksMaintenanceCategory": "תחזוקה", @@ -103,7 +103,7 @@ "TaskRefreshPeopleDescription": "מעדכן מטא נתונים עבור שחקנים ובמאים בספריית המדיה שלך.", "TaskRefreshPeople": "רענן אנשים", "TaskCleanLogsDescription": "מוחק קבצי יומן בני יותר מ- {0} ימים.", - "TaskCleanLogs": "נקה תיקיית יומן", + "TaskCleanLogs": "ניקוי תיקיית יומן", "TaskRefreshLibraryDescription": "סורק את ספריית המדיה שלך אחר קבצים חדשים ומרענן מטא נתונים.", "TaskRefreshChapterImagesDescription": "יוצר תמונות ממוזערות לסרטונים שיש להם פרקים.", "TasksChannelsCategory": "ערוצי אינטרנט", diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index 62d48cebd..5a4a02d80 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -1,11 +1,11 @@ { "Albums": "Albumok", - "AppDeviceValues": "Program: {0}, Eszköz: {1}", + "AppDeviceValues": "Program: {0}, eszköz: {1}", "Application": "Alkalmazás", "Artists": "Előadók", - "AuthenticationSucceededWithUserName": "{0} sikeresen azonosítva", + "AuthenticationSucceededWithUserName": "{0} sikeresen hitelesítve", "Books": "Könyvek", - "CameraImageUploadedFrom": "Új kamerakép került feltöltésre innen: {0}", + "CameraImageUploadedFrom": "Új kamerakép feltöltve innen: {0}", "Channels": "Csatornák", "ChapterNameValue": "{0}. jelenet", "Collections": "Gyűjtemények", @@ -15,13 +15,13 @@ "Favorites": "Kedvencek", "Folders": "Könyvtárak", "Genres": "Műfajok", - "HeaderAlbumArtists": "Album előadó(k)", + "HeaderAlbumArtists": "Albumelőadók", "HeaderContinueWatching": "Megtekintés folytatása", "HeaderFavoriteAlbums": "Kedvenc albumok", "HeaderFavoriteArtists": "Kedvenc előadók", "HeaderFavoriteEpisodes": "Kedvenc epizódok", "HeaderFavoriteShows": "Kedvenc sorozatok", - "HeaderFavoriteSongs": "Kedvenc dalok", + "HeaderFavoriteSongs": "Kedvenc számok", "HeaderLiveTV": "Élő TV", "HeaderNextUp": "Következik", "HeaderRecordingGroups": "Felvételi csoportok", @@ -29,37 +29,37 @@ "Inherit": "Örökölt", "ItemAddedWithName": "{0} hozzáadva a könyvtárhoz", "ItemRemovedWithName": "{0} eltávolítva a könyvtárból", - "LabelIpAddressValue": "IP cím: {0}", - "LabelRunningTimeValue": "Futási idő: {0}", + "LabelIpAddressValue": "IP-cím: {0}", + "LabelRunningTimeValue": "Lejátszási idő: {0}", "Latest": "Legújabb", - "MessageApplicationUpdated": "Jellyfin Szerver frissítve", - "MessageApplicationUpdatedTo": "Jellyfin Szerver frissítve lett a következőre: {0}", - "MessageNamedServerConfigurationUpdatedWithValue": "Szerver konfigurációs rész frissítve: {0}", - "MessageServerConfigurationUpdated": "Szerver konfiguráció frissítve", + "MessageApplicationUpdated": "A Jellyfin kiszolgáló frissítve", + "MessageApplicationUpdatedTo": "A Jellyfin kiszolgáló frissítve lett a következőre: {0}", + "MessageNamedServerConfigurationUpdatedWithValue": "A kiszolgálókonfigurációs rész frissítve: {0}", + "MessageServerConfigurationUpdated": "Kiszolgálókonfiguráció frissítve", "MixedContent": "Vegyes tartalom", "Movies": "Filmek", - "Music": "Zene", + "Music": "Zenék", "MusicVideos": "Zenei videóklippek", "NameInstallFailed": "{0} sikertelen telepítés", "NameSeasonNumber": "{0}. évad", "NameSeasonUnknown": "Ismeretlen évad", - "NewVersionIsAvailable": "Letölthető a Jellyfin Szerver új verziója.", + "NewVersionIsAvailable": "Letölthető a Jellyfin kiszolgáló új verziója.", "NotificationOptionApplicationUpdateAvailable": "Frissítés érhető el az alkalmazáshoz", "NotificationOptionApplicationUpdateInstalled": "Alkalmazásfrissítés telepítve", - "NotificationOptionAudioPlayback": "Audió lejátszás elkezdve", - "NotificationOptionAudioPlaybackStopped": "Audió lejátszás leállítva", - "NotificationOptionCameraImageUploaded": "Kamera kép feltöltve", - "NotificationOptionInstallationFailed": "Telepítés sikertelen", + "NotificationOptionAudioPlayback": "Hanglejátszás elkezdve", + "NotificationOptionAudioPlaybackStopped": "Hanglejátszás leállítva", + "NotificationOptionCameraImageUploaded": "Kamerakép feltöltve", + "NotificationOptionInstallationFailed": "Telepítési hiba", "NotificationOptionNewLibraryContent": "Új tartalom hozzáadva", - "NotificationOptionPluginError": "Bővítmény hiba", + "NotificationOptionPluginError": "Bővítményhiba", "NotificationOptionPluginInstalled": "Bővítmény telepítve", "NotificationOptionPluginUninstalled": "Bővítmény eltávolítva", - "NotificationOptionPluginUpdateInstalled": "Bővítmény frissítés telepítve", - "NotificationOptionServerRestartRequired": "Szerver újraindítás szükséges", + "NotificationOptionPluginUpdateInstalled": "Bővítményfrissítés telepítve", + "NotificationOptionServerRestartRequired": "A kiszolgáló újraindítása szükséges", "NotificationOptionTaskFailed": "Ütemezett feladat hiba", "NotificationOptionUserLockedOut": "Felhasználó tiltva", - "NotificationOptionVideoPlayback": "Videó lejátszás elkezdve", - "NotificationOptionVideoPlaybackStopped": "Videó lejátszás leállítva", + "NotificationOptionVideoPlayback": "Videólejátszás elkezdve", + "NotificationOptionVideoPlaybackStopped": "Videólejátszás leállítva", "Photos": "Fényképek", "Playlists": "Lejátszási listák", "Plugin": "Bővítmény", @@ -69,47 +69,47 @@ "ProviderValue": "Szolgáltató: {0}", "ScheduledTaskFailedWithName": "{0} sikertelen", "ScheduledTaskStartedWithName": "{0} elkezdve", - "ServerNameNeedsToBeRestarted": "{0}-t újra kell indítani", + "ServerNameNeedsToBeRestarted": "A(z) {0} újraindítása szükséges", "Shows": "Sorozatok", - "Songs": "Dalok", - "StartupEmbyServerIsLoading": "A Jellyfin Szerver betöltődik. Kérlek, próbáld újra hamarosan.", + "Songs": "Számok", + "StartupEmbyServerIsLoading": "A Jellyfin kiszolgáló betöltődik. Próbálja újra hamarosan.", "SubtitleDownloadFailureForItem": "Subtitles failed to download for {0}", - "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0} ehhez: {1}", - "Sync": "Szinkronizál", + "SubtitleDownloadFailureFromForItem": "Nem sikerült a felirat letöltése innen: {0}, ehhez: {1}", + "Sync": "Szinkronizálás", "System": "Rendszer", "TvShows": "TV műsorok", "User": "Felhasználó", "UserCreatedWithName": "{0} felhasználó létrehozva", "UserDeletedWithName": "{0} felhasználó törölve", - "UserDownloadingItemWithValues": "{0} letölti {1}", + "UserDownloadingItemWithValues": "{0} letölti: {1}", "UserLockedOutWithName": "{0} felhasználó zárolva van", "UserOfflineFromDevice": "{0} kijelentkezett innen: {1}", "UserOnlineFromDevice": "{0} online innen: {1}", - "UserPasswordChangedWithName": "Jelszó megváltozott a következő felhasználó számára: {0}", - "UserPolicyUpdatedWithName": "A felhasználói házirend frissítve lett neki: {0}", - "UserStartedPlayingItemWithValues": "{0} elkezdte játszani a következőt: {1} itt: {2}", - "UserStoppedPlayingItemWithValues": "{0} befejezte {1} lejátászását itt: {2}", + "UserPasswordChangedWithName": "{0} jelszava megváltozott", + "UserPolicyUpdatedWithName": "{0} felhasználói házirendje frissült", + "UserStartedPlayingItemWithValues": "{0} elkezdte lejátszani a következőt: {1}, itt: {2}", + "UserStoppedPlayingItemWithValues": "{0} befejezte a következő lejátszását: {1}, itt: {2}", "ValueHasBeenAddedToLibrary": "{0} hozzáadva a médiatárhoz", - "ValueSpecialEpisodeName": "Special - {0}", + "ValueSpecialEpisodeName": "Különkiadás – {0}", "VersionNumber": "Verzió: {0}", "TaskCleanTranscode": "Átkódolási könyvtár ürítése", "TaskUpdatePluginsDescription": "Letölti és telepíti a frissítéseket azokhoz a bővítményekhez, amelyeknél az automatikus frissítés engedélyezve van.", "TaskUpdatePlugins": "Bővítmények frissítése", - "TaskRefreshPeopleDescription": "Frissíti a szereplők és a stábok metaadatait a könyvtáradban.", + "TaskRefreshPeopleDescription": "Frissíti a szereplők és a stábok metaadatait a médiatárban.", "TaskRefreshPeople": "Személyek frissítése", "TaskCleanLogsDescription": "Törli azokat a naplófájlokat, amelyek {0} napnál régebbiek.", "TaskCleanLogs": "Naplózási könyvtár ürítése", - "TaskRefreshLibraryDescription": "Átvizsgálja a könyvtáraidat új fájlokért és frissíti a metaadatokat.", - "TaskRefreshLibrary": "Média könyvtár beolvasása", - "TaskRefreshChapterImagesDescription": "Miniatűröket generál olyan videókhoz, amely tartalmaz fejezeteket.", - "TaskRefreshChapterImages": "Fejezetek képeinek generálása", + "TaskRefreshLibraryDescription": "Átvizsgálja a médiatárat új fájlokat keresve, és frissíti a metaadatokat.", + "TaskRefreshLibrary": "Médiatár átvizsgálása", + "TaskRefreshChapterImagesDescription": "Miniatűröket hoz létre az olyan videókhoz, amely tartalmaz fejezeteket.", + "TaskRefreshChapterImages": "Fejezetképek kinyerése", "TaskCleanCacheDescription": "Törli azokat a gyorsítótárazott fájlokat, amikre a rendszernek már nincs szüksége.", "TaskCleanCache": "Gyorsítótár könyvtárának ürítése", "TasksChannelsCategory": "Internetes csatornák", "TasksApplicationCategory": "Alkalmazás", "TasksLibraryCategory": "Könyvtár", "TasksMaintenanceCategory": "Karbantartás", - "TaskDownloadMissingSubtitlesDescription": "A metaadat konfiguráció alapján ellenőrzi és letölti a hiányzó feliratokat az internetről.", + "TaskDownloadMissingSubtitlesDescription": "A metaadat-konfiguráció alapján ellenőrzi és letölti a hiányzó feliratokat az internetről.", "TaskDownloadMissingSubtitles": "Hiányzó feliratok letöltése", "TaskRefreshChannelsDescription": "Frissíti az internetes csatornák adatait.", "TaskRefreshChannels": "Csatornák frissítése", @@ -121,8 +121,8 @@ "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", - "TaskKeyframeExtractor": "Kulcskockák kibontása", - "TaskKeyframeExtractorDescription": "Kulcskockákat bont ki a videofájlokból, hogy pontosabb HLS lejátszási listákat hozzon létre. Ez a feladat hosszú ideig tarthat.", + "TaskKeyframeExtractor": "Kulcsképkockák kibontása", + "TaskKeyframeExtractorDescription": "Kibontja a kulcsképkockákat a videófájlokból, hogy pontosabb HLS lejátszási listákat hozzon létre. Ez a feladat hosszú ideig tarthat.", "External": "Külső", "HearingImpaired": "Hallássérült" } diff --git a/Emby.Server.Implementations/Localization/Core/is.json b/Emby.Server.Implementations/Localization/Core/is.json index a40f49506..0f1f0b3d2 100644 --- a/Emby.Server.Implementations/Localization/Core/is.json +++ b/Emby.Server.Implementations/Localization/Core/is.json @@ -13,8 +13,8 @@ "HeaderFavoriteArtists": "Uppáhalds Listamenn", "HeaderFavoriteAlbums": "Uppáhalds Plötur", "HeaderContinueWatching": "Halda áfram að horfa", - "HeaderAlbumArtists": "Höfundur plötu", - "Genres": "Tegundir", + "HeaderAlbumArtists": "Listamaður á umslagi", + "Genres": "Stefnur", "Folders": "Möppur", "Favorites": "Uppáhalds", "FailedLoginAttemptWithUserName": "{0} reyndi að auðkenna sig", @@ -22,32 +22,32 @@ "DeviceOfflineWithName": "{0} hefur aftengst", "Collections": "Söfn", "ChapterNameValue": "Kafli {0}", - "Channels": "Stöðvar", - "CameraImageUploadedFrom": "Ný ljósmynd frá myndavél hefur verið hlaðið upp frá {0}", + "Channels": "Rásir", + "CameraImageUploadedFrom": "{0} hefur hlaðið upp nýrri ljósmynd úr myndavél sinni", "Books": "Bækur", - "AuthenticationSucceededWithUserName": "{0} auðkenning tókst", - "Artists": "Listamaður", + "AuthenticationSucceededWithUserName": "Auðkenning fyrir {0} tókst", + "Artists": "Listamenn", "Application": "Forrit", "AppDeviceValues": "Snjallforrit: {0}, Tæki: {1}", "Albums": "Plötur", - "Plugin": "Viðbót", - "Photos": "Myndir", - "NotificationOptionVideoPlaybackStopped": "Myndbandafspilun stöðvuð", - "NotificationOptionVideoPlayback": "Myndbandafspilun hafin", + "Plugin": "Viðbótarvirkni", + "Photos": "Ljósmyndir", + "NotificationOptionVideoPlaybackStopped": "Myndbandsafspilun stöðvuð", + "NotificationOptionVideoPlayback": "Myndbandsafspilun hafin", "NotificationOptionUserLockedOut": "Notandi læstur úti", - "NotificationOptionServerRestartRequired": "Endurræsing þjóns er nauðsynileg", - "NotificationOptionPluginUpdateInstalled": "Viðbótar uppfærsla uppsett", - "NotificationOptionPluginUninstalled": "Viðbót fjarlægð", - "NotificationOptionPluginInstalled": "Viðbót sett upp", + "NotificationOptionServerRestartRequired": "Endurræsing þjóns er nauðsynleg", + "NotificationOptionPluginUpdateInstalled": "Uppfærslu á viðbótarvirkni lokið", + "NotificationOptionPluginUninstalled": "Viðbótarvirkni fjarlægð", + "NotificationOptionPluginInstalled": "Viðbótarvirkni sett upp", "NotificationOptionPluginError": "Bilun í viðbót", "NotificationOptionInstallationFailed": "Uppsetning tókst ekki", - "NotificationOptionCameraImageUploaded": "Myndavélarmynd hlaðið upp", + "NotificationOptionCameraImageUploaded": "Ljósmynd hlaðið upp", "NotificationOptionAudioPlaybackStopped": "Hljóðafspilun stöðvuð", "NotificationOptionAudioPlayback": "Hljóðafspilun hafin", "NotificationOptionApplicationUpdateInstalled": "Uppfærsla uppsett", "NotificationOptionApplicationUpdateAvailable": "Uppfærsla í boði", - "NameSeasonUnknown": "Sería óþekkt", - "NameSeasonNumber": "Sería {0}", + "NameSeasonUnknown": "Þáttaröð óþekkt", + "NameSeasonNumber": "Þáttaröð {0}", "MixedContent": "Blandað efni", "MessageServerConfigurationUpdated": "Stillingar þjóns hafa verið uppfærðar", "MessageApplicationUpdatedTo": "Jellyfin þjónn hefur verið uppfærður í {0}", @@ -57,24 +57,24 @@ "User": "Notandi", "System": "Kerfi", "NotificationOptionNewLibraryContent": "Nýju efni bætt við", - "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er fáanleg til niðurhals.", + "NewVersionIsAvailable": "Ný útgáfa af Jellyfin þjón er tilbúin til niðurhals.", "NameInstallFailed": "{0} uppsetning mistókst", "MusicVideos": "Tónlistarmyndbönd", "Music": "Tónlist", "Movies": "Kvikmyndir", "UserDeletedWithName": "Notanda {0} hefur verið eytt", "UserCreatedWithName": "Notandi {0} hefur verið stofnaður", - "TvShows": "Þættir", + "TvShows": "Sjónvarpsþættir", "Sync": "Samstilla", "Songs": "Lög", - "ServerNameNeedsToBeRestarted": "{0} þarf að endurræsa", + "ServerNameNeedsToBeRestarted": "{0} þarf að vera endurræstur", "ScheduledTaskStartedWithName": "{0} hafin", "ScheduledTaskFailedWithName": "{0} mistókst", "PluginUpdatedWithName": "{0} var uppfært", "PluginUninstalledWithName": "{0} var fjarlægt", "PluginInstalledWithName": "{0} var sett upp", "NotificationOptionTaskFailed": "Tímasett verkefni mistókst", - "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að hlaðast. Vinsamlega prufaðu aftur fljótlega.", + "StartupEmbyServerIsLoading": "Jellyfin netþjónnin er að ræsa sig upp. Vinsamlegast reyndu aftur fljótlega.", "VersionNumber": "Útgáfa {0}", "ValueHasBeenAddedToLibrary": "{0} hefur verið bætt við í gagnasafnið þitt", "UserStoppedPlayingItemWithValues": "{0} hefur lokið spilunar af {1} á {2}", @@ -83,14 +83,14 @@ "UserPasswordChangedWithName": "Lykilorði fyrir notandann {0} hefur verið breytt", "UserOnlineFromDevice": "{0} hefur verið virkur síðan {1}", "UserOfflineFromDevice": "{0} hefur aftengst frá {1}", - "UserLockedOutWithName": "Notanda {0} hefur verið heflaður aðgangur", - "UserDownloadingItemWithValues": "{0} Hleður niður {1}", + "UserLockedOutWithName": "Notandi {0} hefur verið læstur úti", + "UserDownloadingItemWithValues": "{0} hleður niður {1}", "SubtitleDownloadFailureFromForItem": "Tókst ekki að hala niður skjátextum frá {0} til {1}", - "ProviderValue": "Veitandi: {0}", + "ProviderValue": "Efnisveita: {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Stilling {0} hefur verið uppfærð á netþjón", - "ValueSpecialEpisodeName": "Sérstakt - {0}", - "Shows": "Sýningar", - "Playlists": "Spilunarlisti", + "ValueSpecialEpisodeName": "Sérstaktur - {0}", + "Shows": "Þættir", + "Playlists": "Efnisskrár", "TaskRefreshChannelsDescription": "Endurhlaða upplýsingum netrása.", "TaskRefreshChannels": "Endurhlaða Rásir", "TaskCleanTranscodeDescription": "Eyða umkóðuðum skrám sem eru meira en einum degi eldri.", @@ -116,5 +116,12 @@ "TaskCleanLogsDescription": "Eyðir færslu skrám sem eru meira en {0} gömul.", "TaskCleanLogs": "Hreinsa færslu skrá", "TaskDownloadMissingSubtitlesDescription": "Leitar á netinu að texta sem vantar miðað við uppsetningu lýsigagna.", - "HearingImpaired": "Heyrnarskertur" + "HearingImpaired": "Heyrnarskertur", + "TaskOptimizeDatabaseDescription": "Þjappar gagnagrunni og bætir við lausu diskaplássi. Að keyra þessa aðgerð eftir skönnun safnsins, eða eftir einhverjar breytingar sem fela í sér gagnagrunnsbreytingar, gætu aukið hraðvirkni.", + "TaskKeyframeExtractor": "Lykilrammaplokkari", + "TaskKeyframeExtractorDescription": "Plokkar lykilramma úr myndbandsskrám til að búa til nákvæmari HLS uppskiptingarlista. Þetta verk getur tekið langan tíma.", + "TaskRefreshChapterImages": "Plokka kafla-myndir", + "TaskCleanActivityLogDescription": "Eyðir virkniskráningarfærslum sem hafa náð settum hámarksaldri.", + "Forced": "Þvingað", + "External": "Útvær" } diff --git a/Emby.Server.Implementations/Localization/Core/kn.json b/Emby.Server.Implementations/Localization/Core/kn.json index 3c8c38ed4..5e2b3756b 100644 --- a/Emby.Server.Implementations/Localization/Core/kn.json +++ b/Emby.Server.Implementations/Localization/Core/kn.json @@ -3,5 +3,125 @@ "TaskOptimizeDatabase": "ಡೇಟಾಬೇಸ್ ಅನ್ನು ಆಪ್ಟಿಮೈಜ್ ಮಾಡಿ", "TaskOptimizeDatabaseDescription": "ಡೇಟಾಬೇಸ್ ಅನ್ನು ಕಾಂಪ್ಯಾಕ್ಟ್ ಮಾಡುತ್ತದೆ ಮತ್ತು ಮುಕ್ತ ಜಾಗವನ್ನು ಮೊಟಕುಗೊಳಿಸುತ್ತದೆ. ಲೈಬ್ರರಿಯನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡಿದ ನಂತರ ಈ ಕಾರ್ಯವನ್ನು ನಡೆಸುವುದು ಅಥವಾ ಡೇಟಾಬೇಸ್ ಮಾರ್ಪಾಡುಗಳನ್ನು ಸೂಚಿಸುವ ಇತರ ಬದಲಾವಣೆಗಳನ್ನು ಮಾಡುವುದರಿಂದ ಕಾರ್ಯಕ್ಷಮತೆಯನ್ನು ಸುಧಾರಿಸಬಹುದು.", "TaskKeyframeExtractor": "ಕೀಫ್ರೇಮ್ ಎಕ್ಸ್ಟ್ರಾಕ್ಟರ್", - "TaskKeyframeExtractorDescription": "ಹೆಚ್ಚು ನಿಖರವಾದ HLS ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ರಚಿಸಲು ವೀಡಿಯೊ ಫೈಲ್ಗಳಿಂದ ಕೀಫ್ರೇಮ್ಗಳನ್ನು ಹೊರತೆಗೆಯುತ್ತದೆ. ಈ ಕಾರ್ಯವು ದೀರ್ಘಕಾಲದವರೆಗೆ ನಡೆಯಬಹುದು." + "TaskKeyframeExtractorDescription": "ಹೆಚ್ಚು ನಿಖರವಾದ HLS ಪ್ಲೇಪಟ್ಟಿಗಳನ್ನು ರಚಿಸಲು ವೀಡಿಯೊ ಫೈಲ್ಗಳಿಂದ ಕೀಫ್ರೇಮ್ಗಳನ್ನು ಹೊರತೆಗೆಯುತ್ತದೆ. ಈ ಕಾರ್ಯವು ದೀರ್ಘಕಾಲದವರೆಗೆ ನಡೆಯಬಹುದು.", + "ValueHasBeenAddedToLibrary": "{0} ಅನ್ನು ನಿಮ್ಮ ಮಾಧ್ಯಮ ಲೈಬ್ರರಿಗೆ ಸೇರಿಸಲಾಗಿದೆ", + "ValueSpecialEpisodeName": "ವಿಶೇಷ - {0}", + "TasksLibraryCategory": "ಸಮೊಹ", + "TasksApplicationCategory": "ಅಪ್ಲಿಕೇಶನ್", + "TasksChannelsCategory": "ಇಂಟರ್ನೆಟ್ ಚಾನೆಲ್ಗಳು", + "TaskCleanCache": "ಕ್ಲೀನ್ ಕ್ಯಾಶ ಡೈರೆಕ್ಟರಿ", + "TaskCleanCacheDescription": "ಸಿಸ್ಟಮ್ಗೆ ಇನ್ನು ಮುಂದೆ ಅಗತ್ಯವಿಲ್ಲದ ಸಂಗ್ರಹ ಫೈಲ್ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", + "TaskRefreshLibrary": "ಸ್ಕ್ಯಾನ್ ಮೀಡಿಯಾ ಲೈಬ್ರರಿ", + "UserOfflineFromDevice": "{1} ನಿಂದ {0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", + "Albums": "ಸಂಪುಟ", + "Application": "ಅಪ್ಲಿಕೇಶನ್", + "AppDeviceValues": "ಅಪ್ಲಿಕೇಶನ್: {0}, ಸಾಧನ: {1}", + "Artists": "ಕಲಾವಿದರು", + "AuthenticationSucceededWithUserName": "{0} ಯಶಸ್ವಿಯಾಗಿ ದೃಢೀಕರಿಸಲಾಗಿದೆ", + "Books": "ಪುಸ್ತಕಗಳು", + "ChapterNameValue": "ಅಧ್ಯಾಯ {0}", + "Collections": "ಸಂಗ್ರಹಣೆಗಳು", + "Default": "ಪೂರ್ವನಿಯೋಜಿತ", + "DeviceOfflineWithName": "{0} ಸಂಪರ್ಕ ಕಡಿತಗೊಂಡಿದೆ", + "DeviceOnlineWithName": "{0} ಸಂಪರ್ಕಗೊಂಡಿದೆ", + "External": "ಹೊರಗಿನ", + "FailedLoginAttemptWithUserName": "{0} ರಿಂದ ವಿಫಲ ಲಾಗಿನ್ ಪ್ರಯತ್ನ", + "Favorites": "ಮೆಚ್ಚಿನವುಗಳು", + "Folders": "ಫೋಲ್ಡರ್ಗಳು", + "Forced": "ಬಲವಂತವಾಗಿ", + "Genres": "ಪ್ರಕಾರಗಳು", + "HeaderContinueWatching": "ನೋಡುವುದನ್ನು ಮುಂದುವರಿಸಿ", + "HeaderFavoriteAlbums": "ಮೆಚ್ಚಿನ ಸಂಪುಟಗಳು", + "HeaderFavoriteArtists": "ಮೆಚ್ಚಿನ ಕಲಾವಿದರು", + "HeaderFavoriteShows": "ಮೆಚ್ಚಿನ ಪ್ರದರ್ಶನಗಳು", + "HeaderFavoriteSongs": "ಮೆಚ್ಚಿನ ಹಾಡುಗಳು", + "HeaderLiveTV": "ನೇರ ದೂರದರ್ಶನ", + "HeaderNextUp": "ಮುಂದೆ", + "HeaderRecordingGroups": "ರೆಕಾರ್ಡಿಂಗ್ ಗುಂಪುಗಳು", + "MessageApplicationUpdated": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", + "CameraImageUploadedFrom": "ಹೊಸ ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು {0} ನಿಂದ ಅಪ್ಲೋಡ್ ಮಾಡಲಾಗಿದೆ", + "Channels": "ಮೂಲಗಳು", + "HeaderAlbumArtists": "ಸಂಪುಟ ಕಲಾವಿದರು", + "HeaderFavoriteEpisodes": "ಮೆಚ್ಚಿನ ಸಂಚಿಕೆಗಳು", + "HearingImpaired": "ಮೂಗ", + "ItemAddedWithName": "{0} ಅನ್ನು ಸಂಕಲನಕ್ಕೆ ಸೇರಿಸಲಾಗಿದೆ", + "MessageApplicationUpdatedTo": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಅನ್ನು {0} ಗೆ ನವೀಕರಿಸಲಾಗಿದೆ", + "MessageNamedServerConfigurationUpdatedWithValue": "ಸರ್ವರ್ ಕಾನ್ಫಿಗರೇಶನ್ ವಿಭಾಗ {0} ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", + "NewVersionIsAvailable": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ನ ಹೊಸ ಆವೃತ್ತಿಯು ಡೌನ್ಲೋಡ್ಗೆ ಲಭ್ಯವಿದೆ.", + "NotificationOptionAudioPlayback": "ಆಡಿಯೋ ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಾರಂಭವಾಗಿದೆ", + "NotificationOptionCameraImageUploaded": "ಕ್ಯಾಮರಾ ಚಿತ್ರವನ್ನು ಅಪ್ಲೋಡ್ ಮಾಡಲಾಗಿದೆ", + "NotificationOptionPluginUninstalled": "ಪ್ಲಗಿನ್ ಅನ್ಇನ್ಸ್ಟಾಲ್ ಮಾಡಲಾಗಿದೆ", + "NotificationOptionUserLockedOut": "ಬಳಕೆದಾರರು ಲಾಕ್ ಔಟ್ ಆಗಿದ್ದಾರೆ", + "NotificationOptionVideoPlaybackStopped": "ವೀಡಿಯೊ ಪ್ಲೇಬ್ಯಾಕ್ ನಿಲ್ಲಿಸಲಾಗಿದೆ", + "PluginUninstalledWithName": "{0} ಅನ್ನು ಅನ್ಇನ್ಸ್ಟಾಲ್ ಮಾಡಲಾಗಿದೆ", + "ScheduledTaskFailedWithName": "{0} ವಿಫಲವಾಗಿದೆ", + "ScheduledTaskStartedWithName": "{0} ಪ್ರಾರಂಭವಾಯಿತು", + "ServerNameNeedsToBeRestarted": "{0} ಅನ್ನು ಮರುಪ್ರಾರಂಭಿಸಬೇಕಾಗಿದೆ", + "UserCreatedWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ರಚಿಸಲಾಗಿದೆ", + "UserLockedOutWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ಲಾಕ್ ಮಾಡಲಾಗಿದೆ", + "UserOnlineFromDevice": "{1} ನಿಂದ {0} ಆನ್ಲೈನ್ನಲ್ಲಿದೆ", + "UserPasswordChangedWithName": "{0} ಬಳಕೆದಾರರಿಗಾಗಿ ಪಾಸ್ವರ್ಡ್ ಅನ್ನು ಬದಲಾಯಿಸಲಾಗಿದೆ", + "UserPolicyUpdatedWithName": "ಬಳಕೆದಾರರ ನೀತಿಯನ್ನು {0} ಗೆ ನವೀಕರಿಸಲಾಗಿದೆ", + "UserStartedPlayingItemWithValues": "{2} ರಂದು {0} ಆಡುತ್ತಿದೆ {1}", + "UserStoppedPlayingItemWithValues": "{0} ಅವರು {1} ಅನ್ನು {2} ನಲ್ಲಿ ಆಡುವುದನ್ನು ಮುಗಿಸಿದ್ದಾರೆ", + "VersionNumber": "ಆವೃತ್ತಿ {0}", + "TasksMaintenanceCategory": "ನಿರ್ವಹಣೆ", + "TaskCleanActivityLog": "ಕ್ಲೀನ್ ಚಟುವಟಿಕೆ ಲಾಗ್", + "TaskCleanActivityLogDescription": "ಕಾನ್ಫಿಗರ್ ಮಾಡಿದ ವಯಸ್ಸಿಗಿಂತ ಹಳೆಯದಾದ ಚಟುವಟಿಕೆ ಲಾಗ್ ನಮೂದುಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", + "TaskRefreshChapterImages": "ಅಧ್ಯಾಯ ಚಿತ್ರಗಳನ್ನು ಹೊರತೆಗೆಯಿರಿ", + "TaskRefreshChapterImagesDescription": "ಅಧ್ಯಾಯಗಳನ್ನು ಹೊಂದಿರುವ ವೀಡಿಯೊಗಳಿಗಾಗಿ ಥಂಬ್ನೇಲ್ಗಳನ್ನು ರಚಿಸುತ್ತದೆ.", + "TaskRefreshLibraryDescription": "ಹೊಸ ಫೈಲ್ಗಳಿಗಾಗಿ ನಿಮ್ಮ ಮೀಡಿಯಾ ಲೈಬ್ರರಿಯನ್ನು ಸ್ಕ್ಯಾನ್ ಮಾಡುತ್ತದೆ ಮತ್ತು ಮೆಟಾಡೇಟಾವನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ.", + "TaskCleanLogsDescription": "{0} ದಿನಗಳಿಗಿಂತ ಹಳೆಯದಾದ ಲಾಗ್ ಫೈಲ್ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", + "TaskUpdatePluginsDescription": "ಸ್ವಯಂಚಾಲಿತವಾಗಿ ನವೀಕರಿಸಲು ಕಾನ್ಫಿಗರ್ ಮಾಡಲಾದ ಪ್ಲಗಿನ್ಗಳಿಗಾಗಿ ನವೀಕರಣಗಳನ್ನು ಡೌನ್ಲೋಡ್ ಮಾಡುತ್ತದೆ ಮತ್ತು ಸ್ಥಾಪಿಸುತ್ತದೆ.", + "TaskCleanTranscodeDescription": "ಒಂದು ದಿನಕ್ಕಿಂತ ಹಳೆಯದಾದ ಟ್ರಾನ್ಸ್ಕೋಡ್ ಫೈಲ್ಗಳನ್ನು ಅಳಿಸುತ್ತದೆ.", + "TaskDownloadMissingSubtitles": "ಕಾಣೆಯಾದ ಉಪಶೀರ್ಷಿಕೆಗಳನ್ನು ಡೌನ್ಲೋಡ್ ಮಾಡಿ", + "Shows": "ಧಾರವಾಹಿಗಳು", + "Songs": "ಹಾಡುಗಳು", + "StartupEmbyServerIsLoading": "ಜೆಲ್ಲಿಫಿನ್ ಸರ್ವರ್ ಲೋಡ್ ಆಗುತ್ತಿದೆ. ದಯವಿಟ್ಟು ಸ್ವಲ್ಪ ಸಮಯದ ನಂತರ ಮತ್ತೆ ಪ್ರಯತ್ನಿಸಿ.", + "UserDeletedWithName": "ಬಳಕೆದಾರ {0} ಅನ್ನು ಅಳಿಸಲಾಗಿದೆ", + "UserDownloadingItemWithValues": "{0} ಡೌನ್ಲೋಡ್ ಆಗುತ್ತಿದೆ {1}", + "SubtitleDownloadFailureFromForItem": "ಉಪಶೀರ್ಷಿಕೆಗಳು {0} ನಿಂದ {1} ಗಾಗಿ ಡೌನ್ಲೋಡ್ ಮಾಡಲು ವಿಫಲವಾಗಿವೆ", + "Sync": "ಹೊಂದಿಕೆ", + "System": "ವ್ಯವಸ್ಥೆ", + "TvShows": "ದೂರದರ್ಶನ ಕಾರ್ಯಕ್ರಮಗಳು", + "Undefined": "ವ್ಯಾಖ್ಯಾನಿಸಲಾಗಿಲ್ಲ", + "User": "ಬಳಕೆದಾರ", + "HomeVideos": "ಮುಖಪುಟ ವೀಡಿಯೊಗಳು", + "Inherit": "ಪಾರಂಪರ್ಯವಾಗಿ", + "ItemRemovedWithName": "{0} ಅನ್ನು ಸಂಕಲನದಿಂದ ತೆಗೆದುಹಾಕಲಾಗಿದೆ", + "LabelIpAddressValue": "IP ವಿಳಾಸ: {0}", + "LabelRunningTimeValue": "ಅವಧಿ: {0}", + "Latest": "ಹೊಸದಾದ", + "MessageServerConfigurationUpdated": "ಸರ್ವರ್ ಕಾನ್ಫಿಗರೇಶನ್ ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", + "MixedContent": "ಮಿಶ್ರ ವಿಷಯ", + "Movies": "ಚಲನಚಿತ್ರಗಳು", + "Music": "ಸಂಗೀತ", + "MusicVideos": "ಸಂಗೀತ ವೀಡಿಯೊಗಳು", + "NameInstallFailed": "{0} ಸ್ಥಾಪನೆ ವಿಫಲವಾಗಿದೆ", + "NameSeasonNumber": "ಸೀಸನ್ {0}", + "NameSeasonUnknown": "ಸೀಸನ್ ತಿಳಿದಿಲ್ಲ", + "NotificationOptionApplicationUpdateAvailable": "ಅಪ್ಲಿಕೇಶನ್ ನವೀಕರಣ ಲಭ್ಯವಿದೆ", + "NotificationOptionApplicationUpdateInstalled": "ಅಪ್ಲಿಕೇಶನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", + "NotificationOptionAudioPlaybackStopped": "ಆಡಿಯೋ ಪ್ಲೇಬ್ಯಾಕ್ ನಿಲ್ಲಿಸಲಾಗಿದೆ", + "NotificationOptionInstallationFailed": "ಸ್ಥಾಪನ ವೈಫಲ್ಯ", + "NotificationOptionNewLibraryContent": "ಹೊಸ ವಿಷಯವನ್ನು ಒಳಗೊಂಡಿದೆ", + "NotificationOptionPluginError": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", + "NotificationOptionPluginInstalled": "ಪ್ಲಗಿನ್ ವೈಫಲ್ಯ", + "NotificationOptionPluginUpdateInstalled": "ಪ್ಲಗಿನ್ ನವೀಕರಣವನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", + "NotificationOptionServerRestartRequired": "ಸರ್ವರ್ ಮರುಪ್ರಾರಂಭದ ಅಗತ್ಯವಿದೆ", + "NotificationOptionTaskFailed": "ನಿಗದಿತ ಕಾರ್ಯ ವೈಫಲ್ಯ", + "NotificationOptionVideoPlayback": "ವೀಡಿಯೊ ಪ್ಲೇಬ್ಯಾಕ್ ಪ್ರಾರಂಭವಾಗಿದೆ", + "Photos": "ಚಿತ್ರಗಳು", + "Playlists": "ಪ್ಲೇಪಟ್ಟಿಗಳು", + "Plugin": "ಪ್ಲಗಿನ್", + "PluginInstalledWithName": "{0} ಅನ್ನು ಸ್ಥಾಪಿಸಲಾಗಿದೆ", + "PluginUpdatedWithName": "{0} ಅನ್ನು ನವೀಕರಿಸಲಾಗಿದೆ", + "ProviderValue": "ಒದಗಿಸುವವರು: {0}", + "TaskCleanLogs": "ಕ್ಲೀನ್ ಲಾಗ್ ಡೈರೆಕ್ಟರಿ", + "TaskRefreshPeople": "ಜನರನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ", + "TaskRefreshPeopleDescription": "ನಿಮ್ಮ ಮಾಧ್ಯಮ ಲೈಬ್ರರಿಯಲ್ಲಿ ನಟರು ಮತ್ತು ನಿರ್ದೇಶಕರಿಗಾಗಿ ಮೆಟಾಡೇಟಾವನ್ನು ನವೀಕರಿಸಿ.", + "TaskUpdatePlugins": "ಪ್ಲಗಿನ್ಗಳನ್ನು ನವೀಕರಿಸಿ", + "TaskCleanTranscode": "ಟ್ರಾನ್ಸ್ಕೋಡ್ ಡೈರೆಕ್ಟರಿಯನ್ನು ಸ್ವಚ್ಛಗೊಳಿಸಿ", + "TaskRefreshChannels": "ಚಾನಲ್ಗಳನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡಿ", + "TaskRefreshChannelsDescription": "ಇಂಟರ್ನೆಟ್ ಚಾನಲ್ ಮಾಹಿತಿಯನ್ನು ರಿಫ್ರೆಶ್ ಮಾಡುತ್ತದೆ." } diff --git a/Emby.Server.Implementations/Localization/Core/lv.json b/Emby.Server.Implementations/Localization/Core/lv.json index f7b24412a..83a000014 100644 --- a/Emby.Server.Implementations/Localization/Core/lv.json +++ b/Emby.Server.Implementations/Localization/Core/lv.json @@ -1,7 +1,7 @@ { "ServerNameNeedsToBeRestarted": "{0} ir vajadzīgs restarts", "NotificationOptionTaskFailed": "Plānota uzdevuma kļūme", - "HeaderRecordingGroups": "Ierakstu Grupas", + "HeaderRecordingGroups": "Ierakstu grupas", "UserPolicyUpdatedWithName": "Lietotāju politika atjaunota priekš {0}", "SubtitleDownloadFailureFromForItem": "Subtitru lejupielāde no {0} priekš {1} neizdevās", "NotificationOptionVideoPlaybackStopped": "Video atskaņošana apturēta", @@ -14,7 +14,7 @@ "Photos": "Attēli", "NotificationOptionUserLockedOut": "Lietotājs bloķēts", "LabelRunningTimeValue": "Garums: {0}", - "Inherit": "Mantot", + "Inherit": "Pārmantot", "AppDeviceValues": "Lietotne: {0}, Ierīce: {1}", "VersionNumber": "Versija {0}", "ValueHasBeenAddedToLibrary": "{0} ir ticis pievienots jūsu multvides bibliotēkai", @@ -28,7 +28,7 @@ "UserDeletedWithName": "Lietotājs {0} ir izdzēsts", "UserCreatedWithName": "Lietotājs {0} ir ticis izveidots", "User": "Lietotājs", - "TvShows": "TV Raidījumi", + "TvShows": "TV raidījumi", "Sync": "Sinhronizācija", "System": "Sistēma", "StartupEmbyServerIsLoading": "Jellyfin Serveris lādējas. Lūdzu mēģiniet vēlreiz pēc brīža.", @@ -38,11 +38,11 @@ "PluginUninstalledWithName": "{0} tika noņemts", "PluginInstalledWithName": "{0} tika uzstādīts", "Plugin": "Paplašinājums", - "Playlists": "Atskaņošanas Saraksti", + "Playlists": "Atskaņošanas saraksti", "MixedContent": "Jaukts saturs", - "HomeVideos": "Mājas Video", + "HomeVideos": "Mājas video", "HeaderNextUp": "Nākamais", - "ChapterNameValue": "Nodaļa {0}", + "ChapterNameValue": "{0}. nodaļa", "Application": "Lietotne", "NotificationOptionServerRestartRequired": "Vajadzīgs servera restarts", "NotificationOptionPluginUpdateInstalled": "Paplašinājuma atjauninājums uzstādīts", @@ -56,14 +56,14 @@ "NotificationOptionApplicationUpdateInstalled": "Lietotnes atjauninājums uzstādīts", "NotificationOptionApplicationUpdateAvailable": "Lietotnes atjauninājums pieejams", "NewVersionIsAvailable": "Lejupielādei ir pieejama jauna Jellyfin Server versija.", - "NameSeasonUnknown": "Nezināma Sezona", - "NameSeasonNumber": "Sezona {0}", + "NameSeasonUnknown": "Nezināma sezona", + "NameSeasonNumber": "{0}. sezona", "NameInstallFailed": "{0} instalācija neizdevās", "MusicVideos": "Mūzikas video", "Music": "Mūzika", "Movies": "Filmas", "MessageServerConfigurationUpdated": "Servera konfigurācija ir tikusi atjaunota", - "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} ir tikusi atjaunota", + "MessageNamedServerConfigurationUpdatedWithValue": "Servera konfigurācijas sadaļa {0} tika atjaunota", "MessageApplicationUpdatedTo": "Jellyfin Server ir ticis atjaunots uz {0}", "MessageApplicationUpdated": "Jellyfin Server ir ticis atjaunots", "Latest": "Jaunākais", @@ -71,57 +71,57 @@ "ItemRemovedWithName": "{0} tika noņemts no bibliotēkas", "ItemAddedWithName": "{0} tika pievienots bibliotēkai", "HeaderLiveTV": "Tiešraides TV", - "HeaderContinueWatching": "Turpināt Skatīšanos", - "HeaderAlbumArtists": "Albumu Izpildītāji", + "HeaderContinueWatching": "Turpināt skatīšanos", + "HeaderAlbumArtists": "Albumu izpildītāji", "Genres": "Žanri", "Folders": "Mapes", - "Favorites": "Favorīti", - "FailedLoginAttemptWithUserName": "Neizdevies pieslēgšanās mēģinājums no {0}", - "DeviceOnlineWithName": "{0} ir pievienojies", - "DeviceOfflineWithName": "{0} ir atvienojies", + "Favorites": "Izlase", + "FailedLoginAttemptWithUserName": "Neizdevies ieiešanas mēģinājums no {0}", + "DeviceOnlineWithName": "Savienojums ar {0} ir izveidots", + "DeviceOfflineWithName": "Savienojums ar {0} ir pārtraukts", "Collections": "Kolekcijas", "Channels": "Kanāli", - "CameraImageUploadedFrom": "Jauns kameras attēls ir ticis augšupielādēts no {0}", + "CameraImageUploadedFrom": "Jauns kameras attēls tika augšupielādēts no {0}", "Books": "Grāmatas", "Artists": "Izpildītāji", "Albums": "Albumi", "ProviderValue": "Provider: {0}", - "HeaderFavoriteSongs": "Dziesmu Favorīti", - "HeaderFavoriteShows": "Raidījumu Favorīti", - "HeaderFavoriteEpisodes": "Episožu Favorīti", - "HeaderFavoriteArtists": "Izpildītāju Favorīti", - "HeaderFavoriteAlbums": "Albumu Favorīti", - "TaskCleanCacheDescription": "Nodzēš keša datnes, kas vairs nav sistēmai vajadzīgas.", - "TaskRefreshChapterImages": "Izvilkt Nodaļu Attēlus", + "HeaderFavoriteSongs": "Dziesmu izlase", + "HeaderFavoriteShows": "Raidījumu izlase", + "HeaderFavoriteEpisodes": "Sēriju izlase", + "HeaderFavoriteArtists": "Izpildītāju izlase", + "HeaderFavoriteAlbums": "Albumu izlase", + "TaskCleanCacheDescription": "Nodzēš kešatmiņas datnes, kas vairs nav sistēmai vajadzīgas.", + "TaskRefreshChapterImages": "Izvilkt nodaļu attēlus", "TasksApplicationCategory": "Lietotne", "TasksLibraryCategory": "Bibliotēka", "TaskDownloadMissingSubtitlesDescription": "Internetā meklē trūkstošus subtitrus balstoties uz metadatu uzstādījumiem.", - "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošus subtitrus", + "TaskDownloadMissingSubtitles": "Lejupielādēt trūkstošos subtitrus", "TaskRefreshChannelsDescription": "Atjauno interneta kanālu informāciju.", - "TaskRefreshChannels": "Atjaunot Kanālus", - "TaskCleanTranscodeDescription": "Izdzēš trans-kodēšanas datnes, kas ir vecākas par vienu dienu.", - "TaskCleanTranscode": "Iztīrīt Trans-kodēšanas Mapi", + "TaskRefreshChannels": "Atjaunot kanālus", + "TaskCleanTranscodeDescription": "Izdzēš transkodēšanas datnes, kas ir senākas par vienu dienu.", + "TaskCleanTranscode": "Iztīrīt transkodēšanas mapi", "TaskUpdatePluginsDescription": "Lejupielādē un uzstāda atjauninājumus paplašinājumiem, kam ir uzstādīta automātiskā atjaunināšana.", - "TaskUpdatePlugins": "Atjaunot Paplašinājumus", + "TaskUpdatePlugins": "Atjaunot paplašinājumus", "TaskRefreshPeopleDescription": "Atjauno metadatus aktieriem un direktoriem jūsu multivides bibliotēkā.", - "TaskRefreshPeople": "Atjaunot Cilvēkus", - "TaskCleanLogsDescription": "Nodzēš log datnes, kas ir vairāk par {0} dienām vecas.", - "TaskCleanLogs": "Iztīrīt Logdatņu Mapi", + "TaskRefreshPeople": "Atjaunot cilvēkus", + "TaskCleanLogsDescription": "Nodzēš logdatnes, kas ir senākas par {0} dienām.", + "TaskCleanLogs": "Iztīrīt logdatņu mapi", "TaskRefreshLibraryDescription": "Skenē jūsu multivides bibliotēku, lai atrastu jaunas datnes, un atsvaidzina metadatus.", - "TaskRefreshLibrary": "Skenēt Multivides Bibliotēku", + "TaskRefreshLibrary": "Skenēt multivides bibliotēku", "TaskRefreshChapterImagesDescription": "Izveido sīktēlus priekš video ar sadaļām.", - "TaskCleanCache": "Iztīrīt Kešošanas Mapi", - "TasksChannelsCategory": "Interneta Kanāli", + "TaskCleanCache": "Iztīrīt kešatmiņas mapi", + "TasksChannelsCategory": "Interneta kanāli", "TasksMaintenanceCategory": "Apkope", - "Forced": "Piespiests", + "Forced": "Piespiedu", "TaskCleanActivityLogDescription": "Nodzēš darbību žurnāla ierakstus, kuri ir vecāki par doto vecumu.", - "TaskCleanActivityLog": "Notīrīt Darbību Žurnālu", + "TaskCleanActivityLog": "Notīrīt darbību žurnālu", "Undefined": "Nenoteikts", "Default": "Noklusējuma", - "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.", + "TaskOptimizeDatabaseDescription": "Saspiež datubāzi un atbrīvo atmiņu. Šī uzdevuma 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", "External": "Ārējais", "HearingImpaired": "Ar dzirdes traucējumiem", - "TaskKeyframeExtractor": "Atslēgkadru Ekstraktors", + "TaskKeyframeExtractor": "Atslēgkadru ekstraktors", "TaskKeyframeExtractorDescription": "Ekstraktē atslēgkadrus no video failiem lai izveidotu precīzākus HLS atskaņošanas sarakstus. Šis process var būt ilgs." } diff --git a/Emby.Server.Implementations/Localization/Core/ml.json b/Emby.Server.Implementations/Localization/Core/ml.json index 0620fbcdb..0b50fa529 100644 --- a/Emby.Server.Implementations/Localization/Core/ml.json +++ b/Emby.Server.Implementations/Localization/Core/ml.json @@ -121,5 +121,7 @@ "TaskOptimizeDatabaseDescription": "ഡാറ്റാബേസ് ചുരുക്കുകയും സ്വതന്ത്ര ഇടം വെട്ടിച്ചുരുക്കുകയും ചെയ്യുന്നു. ലൈബ്രറി സ്കാൻ ചെയ്തതിനുശേഷം അല്ലെങ്കിൽ ഡാറ്റാബേസ് പരിഷ്ക്കരണങ്ങളെ സൂചിപ്പിക്കുന്ന മറ്റ് മാറ്റങ്ങൾ ചെയ്തതിന് ശേഷം ഈ ടാസ്ക് പ്രവർത്തിപ്പിക്കുന്നത് പ്രകടനം മെച്ചപ്പെടുത്തും.", "TaskOptimizeDatabase": "ഡാറ്റാബേസ് ഒപ്റ്റിമൈസ് ചെയ്യുക", "HearingImpaired": "കേൾവി തകരാറുകൾ", - "External": "പുറമേയുള്ള" + "External": "പുറമേയുള്ള", + "TaskKeyframeExtractorDescription": "കൂടുതൽ കൃത്യമായ HLS പ്ലേലിസ്റ്റുകൾ സൃഷ്ടിക്കുന്നതിന് വീഡിയോ ഫയലുകളിൽ നിന്ന് കീഫ്രെയിമുകൾ എക്സ്ട്രാക്റ്റ് ചെയ്യുന്നു. ഈ പ്രവർത്തനം പൂർത്തിയാവാൻ കുറച്ചധികം സമയം എടുത്തേക്കാം.", + "TaskKeyframeExtractor": "കീഫ്രെയിം എക്സ്ട്രാക്റ്റർ" } diff --git a/Emby.Server.Implementations/Localization/Core/ms.json b/Emby.Server.Implementations/Localization/Core/ms.json index b2293e4b6..a07222975 100644 --- a/Emby.Server.Implementations/Localization/Core/ms.json +++ b/Emby.Server.Implementations/Localization/Core/ms.json @@ -1,5 +1,5 @@ { - "Albums": "Album-album", + "Albums": "Album", "AppDeviceValues": "Apl: {0}, Peranti: {1}", "Application": "Aplikasi", "Artists": "Artis-artis", diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 4eb00d289..ac7b92de6 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -1,9 +1,9 @@ { "Albums": "Albums", "AppDeviceValues": "App: {0}, Apparaat: {1}", - "Application": "Toepassing", + "Application": "Applicatie", "Artists": "Artiesten", - "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", + "AuthenticationSucceededWithUserName": "{0} succesvol geauthenticeerd", "Books": "Boeken", "CameraImageUploadedFrom": "Nieuwe camera-afbeelding toegevoegd vanaf {0}", "Channels": "Kanalen", diff --git a/Emby.Server.Implementations/Localization/Core/pr.json b/Emby.Server.Implementations/Localization/Core/pr.json index 87800a2fe..26dc5ce82 100644 --- a/Emby.Server.Implementations/Localization/Core/pr.json +++ b/Emby.Server.Implementations/Localization/Core/pr.json @@ -24,5 +24,13 @@ "TaskDownloadMissingSubtitlesDescription": "Scours the seven seas o' the internet for subtitles that be missin' based on the captain's map o' metadata.", "HeaderAlbumArtists": "Buccaneers o' the musical arts", "HeaderFavoriteAlbums": "Beloved booty o' musical adventures", - "HeaderFavoriteArtists": "Treasured scallywags o' the creative seas" + "HeaderFavoriteArtists": "Treasured scallywags o' the creative seas", + "Channels": "Channels", + "Forced": "Pressed", + "External": "Outboard", + "HeaderFavoriteEpisodes": "Treasured Tales", + "HeaderFavoriteShows": "Treasured Tales", + "ChapterNameValue": "Piece {0}", + "HeaderFavoriteSongs": "Treasured Chimes", + "HeaderNextUp": "Incoming" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index 421513341..fa6c753b6 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -31,13 +31,13 @@ "ItemRemovedWithName": "{0} - изъято из медиатеки", "LabelIpAddressValue": "IP-адрес: {0}", "LabelRunningTimeValue": "Длительность: {0}", - "Latest": "Новое", + "Latest": "Последние добавленные", "MessageApplicationUpdated": "Jellyfin Server был обновлён", "MessageApplicationUpdatedTo": "Jellyfin Server был обновлён до {0}", "MessageNamedServerConfigurationUpdatedWithValue": "Конфигурация сервера (раздел {0}) была обновлена", "MessageServerConfigurationUpdated": "Конфигурация сервера была обновлена", "MixedContent": "Смешанное содержание", - "Movies": "Кино", + "Movies": "Фильмы", "Music": "Музыка", "MusicVideos": "Муз. видео", "NameInstallFailed": "Установка {0} неудачна", @@ -77,7 +77,7 @@ "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", "Sync": "Синхронизация", "System": "Система", - "TvShows": "ТВ", + "TvShows": "Телесериалы", "User": "Пользователь", "UserCreatedWithName": "Пользователь {0} был создан", "UserDeletedWithName": "Пользователь {0} был удалён", diff --git a/Emby.Server.Implementations/Localization/Core/si.json b/Emby.Server.Implementations/Localization/Core/si.json new file mode 100644 index 000000000..0967ef424 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/si.json @@ -0,0 +1 @@ +{} diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 858cc40dd..c231d76fe 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -124,5 +124,5 @@ "TaskKeyframeExtractorDescription": "Extrahuje kľúčové snímky z video súborov na vytvorenie presnejších HLS playlistov. Táto úloha môže trvať dlhšiu dobu.", "TaskKeyframeExtractor": "Extraktor kľúčových snímkov", "External": "Externé", - "HearingImpaired": "Sluchovo Postihnutý" + "HearingImpaired": "Sluchovo postihnutí" } diff --git a/Emby.Server.Implementations/Localization/Core/sl-SI.json b/Emby.Server.Implementations/Localization/Core/sl-SI.json index 4c23f71ef..1944e072c 100644 --- a/Emby.Server.Implementations/Localization/Core/sl-SI.json +++ b/Emby.Server.Implementations/Localization/Core/sl-SI.json @@ -11,7 +11,7 @@ "Collections": "Zbirke", "DeviceOfflineWithName": "{0} je prekinil povezavo", "DeviceOnlineWithName": "{0} je povezan", - "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave iz {0}", + "FailedLoginAttemptWithUserName": "Neuspešen poskus prijave z {0}", "Favorites": "Priljubljeno", "Folders": "Mape", "Genres": "Zvrsti", diff --git a/Emby.Server.Implementations/Localization/Core/ta.json b/Emby.Server.Implementations/Localization/Core/ta.json index dfce6bd25..770624a8d 100644 --- a/Emby.Server.Implementations/Localization/Core/ta.json +++ b/Emby.Server.Implementations/Localization/Core/ta.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "தரவுத்தளத்தை மேம்படுத்தவும்", "TaskKeyframeExtractorDescription": "மிகவும் துல்லியமான HLS பிளேலிஸ்ட்களை உருவாக்க வீடியோ கோப்புகளிலிருந்து கீஃப்ரேம்களைப் பிரித்தெடுக்கிறது. இந்த பணி நீண்ட காலமாக இருக்கலாம்.", "TaskKeyframeExtractor": "கீஃப்ரேம் எக்ஸ்ட்ராக்டர்", - "External": "வெளி" + "External": "வெளி", + "HearingImpaired": "செவித்திறன் குறைபாடுடையவர்" } diff --git a/Emby.Server.Implementations/Localization/Core/th.json b/Emby.Server.Implementations/Localization/Core/th.json index 1a4fef64e..3cdf743d5 100644 --- a/Emby.Server.Implementations/Localization/Core/th.json +++ b/Emby.Server.Implementations/Localization/Core/th.json @@ -121,5 +121,7 @@ "TaskOptimizeDatabase": "ปรับปรุงประสิทธิภาพฐานข้อมูล", "TaskOptimizeDatabaseDescription": "ลดขนาดการจัดเก็บฐานข้อมูล ใช้งานคำสั่งนี้หลังจากสแกนไลบรารีหรือหลังจากการเปลี่ยนแปลงฐานข้อมูล อาจจะทำให้ระบบทำงานเร็วขึ้น", "External": "ภายนอก", - "HearingImpaired": "บกพร่องทางการได้ยิน" + "HearingImpaired": "บกพร่องทางการได้ยิน", + "TaskKeyframeExtractor": "ตัวแยกคีย์เฟรม", + "TaskKeyframeExtractorDescription": "แยกคีย์เฟรมจากไฟล์วีดีโอเพื่อสร้างรายการ HLS ให้ถูกต้อง. กระบวนการนี้อาจใช้ระยะเวลานาน" } diff --git a/Emby.Server.Implementations/Localization/Core/tr.json b/Emby.Server.Implementations/Localization/Core/tr.json index 9a140f871..3ce928859 100644 --- a/Emby.Server.Implementations/Localization/Core/tr.json +++ b/Emby.Server.Implementations/Localization/Core/tr.json @@ -3,19 +3,19 @@ "AppDeviceValues": "Uygulama: {0}, Aygıt: {1}", "Application": "Uygulama", "Artists": "Sanatçılar", - "AuthenticationSucceededWithUserName": "{0} kimlik başarıyla doğrulandı", + "AuthenticationSucceededWithUserName": "{0} kimliği başarıyla doğrulandı", "Books": "Kitaplar", "CameraImageUploadedFrom": "{0} 'den yeni bir kamera resmi yüklendi", "Channels": "Kanallar", - "ChapterNameValue": "Bölüm {0}", + "ChapterNameValue": "{0}. Bölüm", "Collections": "Koleksiyonlar", "DeviceOfflineWithName": "{0} bağlantısı kesildi", "DeviceOnlineWithName": "{0} bağlı", - "FailedLoginAttemptWithUserName": "{0} adresinden giriş denemesi başarısız oldu", + "FailedLoginAttemptWithUserName": "{0} kullanıcısının giriş denemesi başarısız oldu", "Favorites": "Favoriler", "Folders": "Klasörler", "Genres": "Türler", - "HeaderAlbumArtists": "Albüm Sanatçıları", + "HeaderAlbumArtists": "Albüm sanatçıları", "HeaderContinueWatching": "İzlemeye Devam Et", "HeaderFavoriteAlbums": "Favori Albümler", "HeaderFavoriteArtists": "Favori Sanatçılar", @@ -25,7 +25,7 @@ "HeaderLiveTV": "Canlı TV", "HeaderNextUp": "Gelecek Hafta", "HeaderRecordingGroups": "Kayıt Grupları", - "HomeVideos": "Ana sayfa videoları", + "HomeVideos": "Ana Sayfa Videoları", "Inherit": "Devral", "ItemAddedWithName": "{0} kütüphaneye eklendi", "ItemRemovedWithName": "{0} kütüphaneden silindi", @@ -34,14 +34,14 @@ "Latest": "En son", "MessageApplicationUpdated": "Jellyfin Sunucusu güncellendi", "MessageApplicationUpdatedTo": "Jellyfin Sunucusu {0} sürümüne güncellendi", - "MessageNamedServerConfigurationUpdatedWithValue": "Sunucu ayar kısmı {0} güncellendi", - "MessageServerConfigurationUpdated": "Sunucu ayarları güncellendi", + "MessageNamedServerConfigurationUpdatedWithValue": "Sunucu yapılandırma bölümü {0} güncellendi", + "MessageServerConfigurationUpdated": "Sunucu yapılandırması güncellendi", "MixedContent": "Karışık içerik", "Movies": "Filmler", "Music": "Müzik", - "MusicVideos": "Müzik videoları", + "MusicVideos": "Müzik Videoları", "NameInstallFailed": "{0} kurulumu başarısız", - "NameSeasonNumber": "Sezon {0}", + "NameSeasonNumber": "{0}. Sezon", "NameSeasonUnknown": "Bilinmeyen Sezon", "NewVersionIsAvailable": "Jellyfin Sunucusunun yeni bir sürümü indirmek için hazır.", "NotificationOptionApplicationUpdateAvailable": "Uygulama güncellemesi mevcut", @@ -55,9 +55,9 @@ "NotificationOptionPluginInstalled": "Eklenti yüklendi", "NotificationOptionPluginUninstalled": "Eklenti kaldırıldı", "NotificationOptionPluginUpdateInstalled": "Eklenti güncellemesi yüklendi", - "NotificationOptionServerRestartRequired": "Sunucu yeniden başlatma gerekli", + "NotificationOptionServerRestartRequired": "Sunucunun yeniden başlatılması gerekiyor", "NotificationOptionTaskFailed": "Zamanlanmış görev hatası", - "NotificationOptionUserLockedOut": "Kullanıcı kitlendi", + "NotificationOptionUserLockedOut": "Kullanıcı kilitlendi", "NotificationOptionVideoPlayback": "Video oynatma başladı", "NotificationOptionVideoPlaybackStopped": "Video oynatma durduruldu", "Photos": "Fotoğraflar", @@ -74,36 +74,36 @@ "Songs": "Şarkılar", "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", + "SubtitleDownloadFailureFromForItem": "{1} için alt yazılar {0} sağlayıcısından indirilemedi", "Sync": "Eşzamanlama", "System": "Sistem", "TvShows": "Diziler", "User": "Kullanıcı", "UserCreatedWithName": "{0} kullanıcısı oluşturuldu", - "UserDeletedWithName": "Kullanıcı {0} silindi", - "UserDownloadingItemWithValues": "{0} indiriliyor {1}", - "UserLockedOutWithName": "Kullanıcı {0} kitlendi", - "UserOfflineFromDevice": "{0}, {1} ile bağlantısı kesildi", - "UserOnlineFromDevice": "{0}, {1} çevrimiçi", - "UserPasswordChangedWithName": "{0} kullanıcısı için şifre değiştirildi", - "UserPolicyUpdatedWithName": "Kullanıcı politikası {0} için güncellendi", + "UserDeletedWithName": "{0} kullanıcısı silindi", + "UserDownloadingItemWithValues": "{0} {1} medyasını indiriyor", + "UserLockedOutWithName": "{0} adlı kullanıcı kilitlendi", + "UserOfflineFromDevice": "{0} kullanıcısının {1} ile bağlantısı kesildi", + "UserOnlineFromDevice": "{0} kullanıcısı {1} ile çevrimiçi", + "UserPasswordChangedWithName": "{0} kullanıcısının parolası değiştirildi", + "UserPolicyUpdatedWithName": "{0} için kullanıcı politikası güncellendi", "UserStartedPlayingItemWithValues": "{0}, {2} cihazında {1} izliyor", "UserStoppedPlayingItemWithValues": "{0}, {2} cihazında {1} izlemeyi bitirdi", "ValueHasBeenAddedToLibrary": "Medya kütüphanenize {0} eklendi", "ValueSpecialEpisodeName": "Özel - {0}", "VersionNumber": "Sürüm {0}", - "TaskCleanCache": "Geçici dosya klasörünü temizle", - "TasksChannelsCategory": "İnternet kanalları", + "TaskCleanCache": "Geçici Dosya Klasörünü Temizle", + "TasksChannelsCategory": "İnternet Kanalları", "TasksApplicationCategory": "Uygulama", "TasksLibraryCategory": "Kütüphane", "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.", + "TaskDownloadMissingSubtitlesDescription": "Meta veri yapılandırmasına dayalı olarak eksik altyazılar için internette arama yapar.", "TaskDownloadMissingSubtitles": "Eksik altyazıları indir", "TaskRefreshChannelsDescription": "Internet kanal bilgilerini yenile.", "TaskRefreshChannels": "Kanalları Yenile", - "TaskCleanTranscodeDescription": "Bir günden daha eski dönüştürme dosyalarını siler.", - "TaskCleanTranscode": "Dönüşüm Dizinini Temizle", + "TaskCleanTranscodeDescription": "Bir günden daha eski kod dönüştürme dosyalarını siler.", + "TaskCleanTranscode": "Kod Dönüştürme Dizinini Temizle", "TaskUpdatePluginsDescription": "Otomatik güncellenmeye ayarlanmış eklentilerin güncellemelerini indirir ve kurar.", "TaskUpdatePlugins": "Eklentileri Güncelle", "TaskRefreshPeople": "Kullanıcıları Yenile", diff --git a/Emby.Server.Implementations/Localization/Core/zu.json b/Emby.Server.Implementations/Localization/Core/zu.json index b5f4b920f..aa056d449 100644 --- a/Emby.Server.Implementations/Localization/Core/zu.json +++ b/Emby.Server.Implementations/Localization/Core/zu.json @@ -25,5 +25,14 @@ "Channels": "Amashaneli", "Books": "Izincwadi", "Artists": "Abadlali", - "Albums": "Ama-albhamu" + "Albums": "Ama-albhamu", + "CameraImageUploadedFrom": "Kulandelayo lwesithonjana sekhamera selithunyelwe kusuka ku {0}", + "HeaderFavoriteArtists": "Abasethi Abathandekayo", + "HeaderFavoriteEpisodes": "Izilimi Ezithandekayo", + "HeaderFavoriteShows": "Izisho Ezithandekayo", + "External": "Kwezifungo", + "FailedLoginAttemptWithUserName": "Ukushayiswa kwesithombe sokungena okungekho {0}", + "HeaderContinueWatching": "Buyela Ukubona", + "HeaderFavoriteAlbums": "Izimpahla Ezithandwayo", + "HeaderAlbumArtists": "Abasethi wenkulumo" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 96f435399..16776b6bd 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -71,25 +71,28 @@ namespace Emby.Server.Implementations.Localization string countryCode = resource.Substring(RatingsPath.Length, 2); var dict = new Dictionary<string, ParentalRating>(StringComparer.OrdinalIgnoreCase); - 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)) + var stream = _assembly.GetManifestResourceStream(resource); + await using (stream!.ConfigureAwait(false)) // shouldn't be null here, we just got the resource path from Assembly.GetManifestResourceNames() { - if (string.IsNullOrWhiteSpace(line)) + using var reader = new StreamReader(stream!); + await foreach (var line in reader.ReadAllLinesAsync().ConfigureAwait(false)) { - 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)); - } - else - { - _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); + 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)); + } + else + { + _logger.LogWarning("Malformed line in ratings file for country {CountryCode}", countryCode); + } } } diff --git a/Emby.Server.Implementations/Localization/Ratings/au.csv b/Emby.Server.Implementations/Localization/Ratings/au.csv index 4ab808ae9..688125917 100644 --- a/Emby.Server.Implementations/Localization/Ratings/au.csv +++ b/Emby.Server.Implementations/Localization/Ratings/au.csv @@ -4,10 +4,14 @@ G,0 M,15 MA,15 MA15+,15 +MA 15+,15 PG,16 16+,16 R,18 R18+,18 -X18+,18 +R 18+,18 18+,18 +X18+,1000 +X 18+,1000 X,1000 +RC,1001 diff --git a/Emby.Server.Implementations/Localization/Ratings/de.csv b/Emby.Server.Implementations/Localization/Ratings/de.csv index d633a5dab..f6181575e 100644 --- a/Emby.Server.Implementations/Localization/Ratings/de.csv +++ b/Emby.Server.Implementations/Localization/Ratings/de.csv @@ -1,12 +1,17 @@ Educational,0 Infoprogramm,0 FSK-0,0 +FSK 0,0 0,0 FSK-6,6 +FSK 6,6 6,6 FSK-12,12 +FSK 12,12 12,12 FSK-16,16 +FSK 16,16 16,16 FSK-18,18 +FSK 18,18 18,18 diff --git a/Emby.Server.Implementations/Localization/Ratings/es.csv b/Emby.Server.Implementations/Localization/Ratings/es.csv index 0bc1d3f7d..619e948d8 100644 --- a/Emby.Server.Implementations/Localization/Ratings/es.csv +++ b/Emby.Server.Implementations/Localization/Ratings/es.csv @@ -3,6 +3,7 @@ A/fig,0 A/i,0 A/fig/i,0 APTA,0 +ERI,0 TP,0 0+,0 6+,6 diff --git a/Emby.Server.Implementations/Localization/Ratings/fr.csv b/Emby.Server.Implementations/Localization/Ratings/fr.csv index 774a70589..139ea376b 100644 --- a/Emby.Server.Implementations/Localization/Ratings/fr.csv +++ b/Emby.Server.Implementations/Localization/Ratings/fr.csv @@ -1,5 +1,6 @@ Public Averti,0 Tous Publics,0 +TP,0 U,0 0+,0 6+,6 diff --git a/Emby.Server.Implementations/Localization/Ratings/sk.csv b/Emby.Server.Implementations/Localization/Ratings/sk.csv new file mode 100644 index 000000000..dbafd8efa --- /dev/null +++ b/Emby.Server.Implementations/Localization/Ratings/sk.csv @@ -0,0 +1,6 @@ +NR,0
+U,0
+7,7
+12,12
+15,15
+18,18
diff --git a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs index 7732e32d0..896f47923 100644 --- a/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs +++ b/Emby.Server.Implementations/MediaEncoder/EncodingManager.cs @@ -222,7 +222,7 @@ namespace Emby.Server.Implementations.MediaEncoder { var deadImages = images .Except(chapters.Select(i => i.ImagePath).Where(i => !string.IsNullOrEmpty(i)), StringComparer.OrdinalIgnoreCase) - .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i), StringComparison.OrdinalIgnoreCase)) + .Where(i => BaseItem.SupportedImageExtensions.Contains(Path.GetExtension(i.AsSpan()), StringComparison.OrdinalIgnoreCase)) .ToList(); foreach (var image in deadImages) diff --git a/Emby.Server.Implementations/Net/SocketFactory.cs b/Emby.Server.Implementations/Net/SocketFactory.cs index 303875df5..2bcd5eab2 100644 --- a/Emby.Server.Implementations/Net/SocketFactory.cs +++ b/Emby.Server.Implementations/Net/SocketFactory.cs @@ -1,69 +1,76 @@ -#pragma warning disable CS1591 - using System; +using System.Linq; using System.Net; +using System.Net.NetworkInformation; using System.Net.Sockets; using MediaBrowser.Model.Net; namespace Emby.Server.Implementations.Net { + /// <summary> + /// Factory class to create different kinds of sockets. + /// </summary> public class SocketFactory : ISocketFactory { /// <inheritdoc /> - public ISocket CreateUdpBroadcastSocket(int localPort) + public Socket CreateUdpBroadcastSocket(int localPort) { if (localPort < 0) { throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); try { - retVal.EnableBroadcast = true; - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1); + socket.EnableBroadcast = true; + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, 1); + socket.Bind(new IPEndPoint(IPAddress.Any, localPort)); - return new UdpSocket(retVal, localPort, IPAddress.Any); + return socket; } catch { - retVal?.Dispose(); + socket.Dispose(); throw; } } /// <inheritdoc /> - public ISocket CreateSsdpUdpSocket(IPAddress localIp, int localPort) + public Socket CreateSsdpUdpSocket(IPData bindInterface, int localPort) { + var interfaceAddress = bindInterface.Address; + ArgumentNullException.ThrowIfNull(interfaceAddress); + if (localPort < 0) { throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); try { - retVal.EnableBroadcast = true; - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, 4); + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.Bind(new IPEndPoint(interfaceAddress, localPort)); - retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(IPAddress.Parse("239.255.255.250"), localIp)); - return new UdpSocket(retVal, localPort, localIp); + return socket; } catch { - retVal?.Dispose(); + socket.Dispose(); throw; } } /// <inheritdoc /> - public ISocket CreateUdpMulticastSocket(IPAddress ipAddress, int multicastTimeToLive, int localPort) + public Socket CreateUdpMulticastSocket(IPAddress multicastAddress, IPData bindInterface, int multicastTimeToLive, int localPort) { - ArgumentNullException.ThrowIfNull(ipAddress); + var bindIPAddress = bindInterface.Address; + ArgumentNullException.ThrowIfNull(multicastAddress); + ArgumentNullException.ThrowIfNull(bindIPAddress); if (multicastTimeToLive <= 0) { @@ -75,36 +82,35 @@ namespace Emby.Server.Implementations.Net throw new ArgumentException("localPort cannot be less than zero.", nameof(localPort)); } - var retVal = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); - - retVal.ExclusiveAddressUse = false; - - try - { - // seeing occasional exceptions thrown on qnap - // System.Net.Sockets.SocketException (0x80004005): Protocol not available - retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); - } - catch (SocketException) - { - } + var socket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); try { - retVal.EnableBroadcast = true; - // retVal.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.Broadcast, true); - retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, multicastTimeToLive); - - var localIp = IPAddress.Any; - - retVal.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(ipAddress, localIp)); - retVal.MulticastLoopback = true; - - return new UdpSocket(retVal, localPort, localIp); + socket.MulticastLoopback = false; + socket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.PacketInformation, true); + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.MulticastTimeToLive, multicastTimeToLive); + + if (OperatingSystem.IsLinux() || OperatingSystem.IsMacOS()) + { + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastAddress)); + socket.Bind(new IPEndPoint(multicastAddress, localPort)); + } + else + { + // Only create socket if interface supports multicast + var interfaceIndex = bindInterface.Index; + var interfaceIndexSwapped = IPAddress.HostToNetworkOrder(interfaceIndex); + + socket.SetSocketOption(SocketOptionLevel.IP, SocketOptionName.AddMembership, new MulticastOption(multicastAddress, interfaceIndex)); + socket.Bind(new IPEndPoint(bindIPAddress, localPort)); + } + + return socket; } catch { - retVal?.Dispose(); + socket.Dispose(); throw; } diff --git a/Emby.Server.Implementations/Net/UdpSocket.cs b/Emby.Server.Implementations/Net/UdpSocket.cs deleted file mode 100644 index 577b79283..000000000 --- a/Emby.Server.Implementations/Net/UdpSocket.cs +++ /dev/null @@ -1,267 +0,0 @@ -#nullable disable - -#pragma warning disable CS1591 - -using System; -using System.Net; -using System.Net.Sockets; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Model.Net; - -namespace Emby.Server.Implementations.Net -{ - // THIS IS A LINKED FILE - SHARED AMONGST MULTIPLE PLATFORMS - // Be careful to check any changes compile and work for all platform projects it is shared in. - - public sealed class UdpSocket : ISocket, IDisposable - { - private readonly int _localPort; - - private readonly SocketAsyncEventArgs _receiveSocketAsyncEventArgs = new SocketAsyncEventArgs() - { - SocketFlags = SocketFlags.None - }; - - private readonly SocketAsyncEventArgs _sendSocketAsyncEventArgs = new SocketAsyncEventArgs() - { - SocketFlags = SocketFlags.None - }; - - private Socket _socket; - private bool _disposed = false; - private TaskCompletionSource<SocketReceiveResult> _currentReceiveTaskCompletionSource; - private TaskCompletionSource<int> _currentSendTaskCompletionSource; - - public UdpSocket(Socket socket, int localPort, IPAddress ip) - { - ArgumentNullException.ThrowIfNull(socket); - - _socket = socket; - _localPort = localPort; - LocalIPAddress = ip; - - _socket.Bind(new IPEndPoint(ip, _localPort)); - - InitReceiveSocketAsyncEventArgs(); - } - - public UdpSocket(Socket socket, IPEndPoint endPoint) - { - ArgumentNullException.ThrowIfNull(socket); - - _socket = socket; - _socket.Connect(endPoint); - - InitReceiveSocketAsyncEventArgs(); - } - - public Socket Socket => _socket; - - public IPAddress LocalIPAddress { get; } - - private void InitReceiveSocketAsyncEventArgs() - { - var receiveBuffer = new byte[8192]; - _receiveSocketAsyncEventArgs.SetBuffer(receiveBuffer, 0, receiveBuffer.Length); - _receiveSocketAsyncEventArgs.Completed += OnReceiveSocketAsyncEventArgsCompleted; - - var sendBuffer = new byte[8192]; - _sendSocketAsyncEventArgs.SetBuffer(sendBuffer, 0, sendBuffer.Length); - _sendSocketAsyncEventArgs.Completed += OnSendSocketAsyncEventArgsCompleted; - } - - private void OnReceiveSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e) - { - var tcs = _currentReceiveTaskCompletionSource; - if (tcs is not null) - { - _currentReceiveTaskCompletionSource = null; - - if (e.SocketError == SocketError.Success) - { - tcs.TrySetResult(new SocketReceiveResult - { - Buffer = e.Buffer, - ReceivedBytes = e.BytesTransferred, - RemoteEndPoint = e.RemoteEndPoint as IPEndPoint, - LocalIPAddress = LocalIPAddress - }); - } - else - { - tcs.TrySetException(new SocketException((int)e.SocketError)); - } - } - } - - private void OnSendSocketAsyncEventArgsCompleted(object sender, SocketAsyncEventArgs e) - { - var tcs = _currentSendTaskCompletionSource; - if (tcs is not null) - { - _currentSendTaskCompletionSource = null; - - if (e.SocketError == SocketError.Success) - { - tcs.TrySetResult(e.BytesTransferred); - } - else - { - tcs.TrySetException(new SocketException((int)e.SocketError)); - } - } - } - - public IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback) - { - ThrowIfDisposed(); - - EndPoint receivedFromEndPoint = new IPEndPoint(IPAddress.Any, 0); - - return _socket.BeginReceiveFrom(buffer, offset, count, SocketFlags.None, ref receivedFromEndPoint, callback, buffer); - } - - public int Receive(byte[] buffer, int offset, int count) - { - ThrowIfDisposed(); - - return _socket.Receive(buffer, 0, buffer.Length, SocketFlags.None); - } - - public SocketReceiveResult EndReceive(IAsyncResult result) - { - ThrowIfDisposed(); - - var sender = new IPEndPoint(IPAddress.Any, 0); - var remoteEndPoint = (EndPoint)sender; - - var receivedBytes = _socket.EndReceiveFrom(result, ref remoteEndPoint); - - var buffer = (byte[])result.AsyncState; - - return new SocketReceiveResult - { - ReceivedBytes = receivedBytes, - RemoteEndPoint = (IPEndPoint)remoteEndPoint, - Buffer = buffer, - LocalIPAddress = LocalIPAddress - }; - } - - public Task<SocketReceiveResult> ReceiveAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken) - { - ThrowIfDisposed(); - - var taskCompletion = new TaskCompletionSource<SocketReceiveResult>(TaskCreationOptions.RunContinuationsAsynchronously); - bool isResultSet = false; - - Action<IAsyncResult> callback = callbackResult => - { - try - { - if (!isResultSet) - { - isResultSet = true; - taskCompletion.TrySetResult(EndReceive(callbackResult)); - } - } - catch (Exception ex) - { - taskCompletion.TrySetException(ex); - } - }; - - var result = BeginReceive(buffer, offset, count, new AsyncCallback(callback)); - - if (result.CompletedSynchronously) - { - callback(result); - return taskCompletion.Task; - } - - cancellationToken.Register(() => taskCompletion.TrySetCanceled()); - - return taskCompletion.Task; - } - - public Task SendToAsync(byte[] buffer, int offset, int bytes, IPEndPoint endPoint, CancellationToken cancellationToken) - { - ThrowIfDisposed(); - - var taskCompletion = new TaskCompletionSource<int>(TaskCreationOptions.RunContinuationsAsynchronously); - bool isResultSet = false; - - Action<IAsyncResult> callback = callbackResult => - { - try - { - if (!isResultSet) - { - isResultSet = true; - taskCompletion.TrySetResult(EndSendTo(callbackResult)); - } - } - catch (Exception ex) - { - taskCompletion.TrySetException(ex); - } - }; - - var result = BeginSendTo(buffer, offset, bytes, endPoint, new AsyncCallback(callback), null); - - if (result.CompletedSynchronously) - { - callback(result); - return taskCompletion.Task; - } - - cancellationToken.Register(() => taskCompletion.TrySetCanceled()); - - return taskCompletion.Task; - } - - public IAsyncResult BeginSendTo(byte[] buffer, int offset, int size, IPEndPoint endPoint, AsyncCallback callback, object state) - { - ThrowIfDisposed(); - - return _socket.BeginSendTo(buffer, offset, size, SocketFlags.None, endPoint, callback, state); - } - - public int EndSendTo(IAsyncResult result) - { - ThrowIfDisposed(); - - return _socket.EndSendTo(result); - } - - private void ThrowIfDisposed() - { - if (_disposed) - { - throw new ObjectDisposedException(nameof(UdpSocket)); - } - } - - /// <inheritdoc /> - public void Dispose() - { - if (_disposed) - { - return; - } - - _socket?.Dispose(); - _receiveSocketAsyncEventArgs.Dispose(); - _sendSocketAsyncEventArgs.Dispose(); - _currentReceiveTaskCompletionSource?.TrySetCanceled(); - _currentSendTaskCompletionSource?.TrySetCanceled(); - - _socket = null; - _currentReceiveTaskCompletionSource = null; - _currentSendTaskCompletionSource = null; - - _disposed = true; - } - } -} diff --git a/Emby.Server.Implementations/Playlists/PlaylistManager.cs b/Emby.Server.Implementations/Playlists/PlaylistManager.cs index 702f8d45b..649c49924 100644 --- a/Emby.Server.Implementations/Playlists/PlaylistManager.cs +++ b/Emby.Server.Implementations/Playlists/PlaylistManager.cs @@ -327,9 +327,9 @@ namespace Emby.Server.Implementations.Playlists // this is probably best done as a metadata provider // saving a file over itself will require some work to prevent this from happening when not needed var playlistPath = item.Path; - var extension = Path.GetExtension(playlistPath); + var extension = Path.GetExtension(playlistPath.AsSpan()); - if (string.Equals(".wpl", extension, StringComparison.OrdinalIgnoreCase)) + if (extension.Equals(".wpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new WplPlaylist(); foreach (var child in item.GetLinkedChildren()) @@ -362,8 +362,7 @@ namespace Emby.Server.Implementations.Playlists string text = new WplContent().ToText(playlist); File.WriteAllText(playlistPath, text); } - - if (string.Equals(".zpl", extension, StringComparison.OrdinalIgnoreCase)) + else if (extension.Equals(".zpl", StringComparison.OrdinalIgnoreCase)) { var playlist = new ZplPlaylist(); foreach (var child in item.GetLinkedChildren()) @@ -396,8 +395,7 @@ namespace Emby.Server.Implementations.Playlists string text = new ZplContent().ToText(playlist); File.WriteAllText(playlistPath, text); } - - if (string.Equals(".m3u", extension, StringComparison.OrdinalIgnoreCase)) + else if (extension.Equals(".m3u", StringComparison.OrdinalIgnoreCase)) { var playlist = new M3uPlaylist { @@ -428,8 +426,7 @@ namespace Emby.Server.Implementations.Playlists string text = new M3uContent().ToText(playlist); File.WriteAllText(playlistPath, text); } - - if (string.Equals(".m3u8", extension, StringComparison.OrdinalIgnoreCase)) + else if (extension.Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { var playlist = new M3uPlaylist(); playlist.IsExtended = true; @@ -458,8 +455,7 @@ namespace Emby.Server.Implementations.Playlists string text = new M3uContent().ToText(playlist); File.WriteAllText(playlistPath, text); } - - if (string.Equals(".pls", extension, StringComparison.OrdinalIgnoreCase)) + else if (extension.Equals(".pls", StringComparison.OrdinalIgnoreCase)) { var playlist = new PlsPlaylist(); foreach (var child in item.GetLinkedChildren()) @@ -518,6 +514,11 @@ namespace Emby.Server.Implementations.Playlists return relativePath; } + public Folder GetPlaylistsFolder() + { + return GetPlaylistsFolder(Guid.Empty); + } + public Folder GetPlaylistsFolder(Guid userId) { const string TypeName = "PlaylistsFolder"; diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index 48584ae0c..20793ee39 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.Data; using System.Globalization; using System.IO; using System.Linq; @@ -11,7 +10,6 @@ using System.Text; using System.Text.Json; using System.Threading.Tasks; using Emby.Server.Implementations.Library; -using Jellyfin.Extensions; using Jellyfin.Extensions.Json; using Jellyfin.Extensions.Json.Converters; using MediaBrowser.Common; @@ -30,7 +28,7 @@ namespace Emby.Server.Implementations.Plugins /// <summary> /// Defines the <see cref="PluginManager" />. /// </summary> - public class PluginManager : IPluginManager + public sealed class PluginManager : IPluginManager, IDisposable { private const string MetafileName = "meta.json"; @@ -191,15 +189,6 @@ namespace Emby.Server.Implementations.Plugins } } - /// <inheritdoc /> - public void UnloadAssemblies() - { - foreach (var assemblyLoadContext in _assemblyLoadContexts) - { - assemblyLoadContext.Unload(); - } - } - /// <summary> /// Creates all the plugin instances. /// </summary> @@ -397,11 +386,11 @@ namespace Emby.Server.Implementations.Plugins var url = new Uri(packageInfo.ImageUrl); imagePath = Path.Join(path, url.Segments[^1]); - await using var fileStream = AsyncFile.OpenWrite(imagePath); - + var fileStream = AsyncFile.OpenWrite(imagePath); + Stream? downloadStream = null; try { - await using var downloadStream = await HttpClientFactory + downloadStream = await HttpClientFactory .CreateClient(NamedClient.Default) .GetStreamAsync(url) .ConfigureAwait(false); @@ -413,6 +402,14 @@ namespace Emby.Server.Implementations.Plugins _logger.LogError(ex, "Failed to download image to path {Path} on disk.", imagePath); imagePath = string.Empty; } + finally + { + await fileStream.DisposeAsync().ConfigureAwait(false); + if (downloadStream is not null) + { + await downloadStream.DisposeAsync().ConfigureAwait(false); + } + } } var manifest = new PluginManifest @@ -432,7 +429,7 @@ namespace Emby.Server.Implementations.Plugins ImagePath = imagePath }; - if (!await ReconcileManifest(manifest, path)) + if (!await ReconcileManifest(manifest, path).ConfigureAwait(false)) { // An error occurred during reconciliation and saving could be undesirable. return false; @@ -441,6 +438,15 @@ namespace Emby.Server.Implementations.Plugins return SaveManifest(manifest, path); } + /// <inheritdoc /> + public void Dispose() + { + foreach (var assemblyLoadContext in _assemblyLoadContexts) + { + assemblyLoadContext.Unload(); + } + } + /// <summary> /// Reconciles the manifest against any properties that exist locally in a pre-packaged meta.json found at the path. /// If no file is found, no reconciliation occurs. @@ -460,7 +466,7 @@ namespace Emby.Server.Implementations.Plugins } using var metaStream = File.OpenRead(metafile); - var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions); + var localManifest = await JsonSerializer.DeserializeAsync<PluginManifest>(metaStream, _jsonOptions).ConfigureAwait(false); localManifest ??= new PluginManifest(); if (!Equals(localManifest.Id, manifest.Id)) @@ -677,7 +683,7 @@ namespace Emby.Server.Implementations.Plugins } catch (JsonException ex) { - _logger.LogError(ex, "Error deserializing {Json}.", Encoding.UTF8.GetString(data!)); + _logger.LogError(ex, "Error deserializing {Json}.", Encoding.UTF8.GetString(data)); } if (manifest is not null) diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 6ad6c4cbd..5d15c3a21 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -115,7 +115,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { try { - previouslyFailedImages = File.ReadAllText(failHistoryPath) + previouslyFailedImages = (await File.ReadAllTextAsync(failHistoryPath, cancellationToken).ConfigureAwait(false)) .Split('|', StringSplitOptions.RemoveEmptyEntries) .ToList(); } @@ -156,7 +156,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } string text = string.Join('|', previouslyFailedImages); - File.WriteAllText(failHistoryPath, text); + await File.WriteAllTextAsync(failHistoryPath, text, cancellationToken).ConfigureAwait(false); } numComplete++; diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs new file mode 100644 index 000000000..acd4bf905 --- /dev/null +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionAndPlaylistPathsTask.cs @@ -0,0 +1,146 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller.Collections; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Playlists; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Model.Globalization; +using MediaBrowser.Model.IO; +using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; + +namespace Emby.Server.Implementations.ScheduledTasks.Tasks; + +/// <summary> +/// Deletes path references from collections and playlists that no longer exists. +/// </summary> +public class CleanupCollectionAndPlaylistPathsTask : IScheduledTask +{ + private readonly ILocalizationManager _localization; + private readonly ICollectionManager _collectionManager; + private readonly IPlaylistManager _playlistManager; + private readonly ILogger<CleanupCollectionAndPlaylistPathsTask> _logger; + private readonly IProviderManager _providerManager; + private readonly IFileSystem _fileSystem; + + /// <summary> + /// Initializes a new instance of the <see cref="CleanupCollectionAndPlaylistPathsTask"/> class. + /// </summary> + /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> + /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param> + /// <param name="playlistManager">Instance of the <see cref="IPlaylistManager"/> interface.</param> + /// <param name="logger">The logger.</param> + /// <param name="providerManager">The provider manager.</param> + /// <param name="fileSystem">The filesystem.</param> + public CleanupCollectionAndPlaylistPathsTask( + ILocalizationManager localization, + ICollectionManager collectionManager, + IPlaylistManager playlistManager, + ILogger<CleanupCollectionAndPlaylistPathsTask> logger, + IProviderManager providerManager, + IFileSystem fileSystem) + { + _localization = localization; + _collectionManager = collectionManager; + _playlistManager = playlistManager; + _logger = logger; + _providerManager = providerManager; + _fileSystem = fileSystem; + } + + /// <inheritdoc /> + public string Name => _localization.GetLocalizedString("TaskCleanCollectionsAndPlaylists"); + + /// <inheritdoc /> + public string Key => "CleanCollectionsAndPlaylists"; + + /// <inheritdoc /> + public string Description => _localization.GetLocalizedString("TaskCleanCollectionsAndPlaylistsDescription"); + + /// <inheritdoc /> + public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); + + /// <inheritdoc /> + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + { + var collectionsFolder = await _collectionManager.GetCollectionsFolder(false).ConfigureAwait(false); + if (collectionsFolder is null) + { + _logger.LogDebug("There is no collections folder to be found"); + } + else + { + var collections = collectionsFolder.Children.OfType<BoxSet>().ToArray(); + _logger.LogDebug("Found {CollectionLength} boxsets", collections.Length); + + for (var index = 0; index < collections.Length; index++) + { + var collection = collections[index]; + _logger.LogDebug("Checking boxset {CollectionName}", collection.Name); + + CleanupLinkedChildren(collection, cancellationToken); + progress.Report(50D / collections.Length * (index + 1)); + } + } + + var playlistsFolder = _playlistManager.GetPlaylistsFolder(); + if (playlistsFolder is null) + { + _logger.LogDebug("There is no playlists folder to be found"); + return; + } + + var playlists = playlistsFolder.Children.OfType<Playlist>().ToArray(); + _logger.LogDebug("Found {PlaylistLength} playlists", playlists.Length); + + for (var index = 0; index < playlists.Length; index++) + { + var playlist = playlists[index]; + _logger.LogDebug("Checking playlist {PlaylistName}", playlist.Name); + + CleanupLinkedChildren(playlist, cancellationToken); + progress.Report(50D / playlists.Length * (index + 1)); + } + } + + private void CleanupLinkedChildren<T>(T folder, CancellationToken cancellationToken) + where T : Folder + { + List<LinkedChild>? itemsToRemove = null; + foreach (var linkedChild in folder.LinkedChildren) + { + if (!File.Exists(folder.Path)) + { + _logger.LogInformation("Item in {FolderName} cannot be found at {ItemPath}", folder.Name, linkedChild.Path); + (itemsToRemove ??= new List<LinkedChild>()).Add(linkedChild); + } + } + + if (itemsToRemove is not null) + { + _logger.LogDebug("Updating {FolderName}", folder.Name); + folder.LinkedChildren = folder.LinkedChildren.Except(itemsToRemove).ToArray(); + folder.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken); + + _providerManager.QueueRefresh( + folder.Id, + new MetadataRefreshOptions(new DirectoryService(_fileSystem)) + { + ForceSave = true + }, + RefreshPriority.High); + } + } + + /// <inheritdoc /> + public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() + { + return new[] { new TaskTriggerInfo() { Type = TaskTriggerInfo.TriggerStartup } }; + } +} diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionPathsTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionPathsTask.cs deleted file mode 100644 index f78fc6f97..000000000 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/CleanupCollectionPathsTask.cs +++ /dev/null @@ -1,119 +0,0 @@ -using System; -using System.Collections.Generic; -using System.IO; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using MediaBrowser.Controller.Collections; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Entities.Movies; -using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Providers; -using MediaBrowser.Model.Globalization; -using MediaBrowser.Model.IO; -using MediaBrowser.Model.Tasks; -using Microsoft.Extensions.Logging; - -namespace Emby.Server.Implementations.ScheduledTasks.Tasks; - -/// <summary> -/// Deletes Path references from collections that no longer exists. -/// </summary> -public class CleanupCollectionPathsTask : IScheduledTask -{ - private readonly ILocalizationManager _localization; - private readonly ICollectionManager _collectionManager; - private readonly ILogger<CleanupCollectionPathsTask> _logger; - private readonly IProviderManager _providerManager; - private readonly IFileSystem _fileSystem; - - /// <summary> - /// Initializes a new instance of the <see cref="CleanupCollectionPathsTask"/> class. - /// </summary> - /// <param name="localization">Instance of the <see cref="ILocalizationManager"/> interface.</param> - /// <param name="collectionManager">Instance of the <see cref="ICollectionManager"/> interface.</param> - /// <param name="logger">The logger.</param> - /// <param name="providerManager">The provider manager.</param> - /// <param name="fileSystem">The filesystem.</param> - public CleanupCollectionPathsTask( - ILocalizationManager localization, - ICollectionManager collectionManager, - ILogger<CleanupCollectionPathsTask> logger, - IProviderManager providerManager, - IFileSystem fileSystem) - { - _localization = localization; - _collectionManager = collectionManager; - _logger = logger; - _providerManager = providerManager; - _fileSystem = fileSystem; - } - - /// <inheritdoc /> - public string Name => _localization.GetLocalizedString("TaskCleanCollections"); - - /// <inheritdoc /> - public string Key => "CleanCollections"; - - /// <inheritdoc /> - public string Description => _localization.GetLocalizedString("TaskCleanCollectionsDescription"); - - /// <inheritdoc /> - public string Category => _localization.GetLocalizedString("TasksMaintenanceCategory"); - - /// <inheritdoc /> - public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) - { - var collectionsFolder = await _collectionManager.GetCollectionsFolder(false).ConfigureAwait(false); - if (collectionsFolder is null) - { - _logger.LogDebug("There is no collection folder to be found"); - return; - } - - var collections = collectionsFolder.Children.OfType<BoxSet>().ToArray(); - _logger.LogDebug("Found {CollectionLength} Boxsets", collections.Length); - - var itemsToRemove = new List<LinkedChild>(); - for (var index = 0; index < collections.Length; index++) - { - var collection = collections[index]; - _logger.LogDebug("Check Boxset {CollectionName}", collection.Name); - - foreach (var collectionLinkedChild in collection.LinkedChildren) - { - if (!File.Exists(collectionLinkedChild.Path)) - { - _logger.LogInformation("Item in boxset {CollectionName} cannot be found at {ItemPath}", collection.Name, collectionLinkedChild.Path); - itemsToRemove.Add(collectionLinkedChild); - } - } - - if (itemsToRemove.Count != 0) - { - _logger.LogDebug("Update Boxset {CollectionName}", collection.Name); - collection.LinkedChildren = collection.LinkedChildren.Except(itemsToRemove).ToArray(); - await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken) - .ConfigureAwait(false); - - _providerManager.QueueRefresh( - collection.Id, - new MetadataRefreshOptions(new DirectoryService(_fileSystem)) - { - ForceSave = true - }, - RefreshPriority.High); - - itemsToRemove.Clear(); - } - - progress.Report(100D / collections.Length * (index + 1)); - } - } - - /// <inheritdoc /> - public IEnumerable<TaskTriggerInfo> GetDefaultTriggers() - { - return new[] { new TaskTriggerInfo() { Type = TaskTriggerInfo.TriggerStartup } }; - } -} diff --git a/Emby.Server.Implementations/Session/SessionManager.cs b/Emby.Server.Implementations/Session/SessionManager.cs index 5f6dc93fb..e935f7e5e 100644 --- a/Emby.Server.Implementations/Session/SessionManager.cs +++ b/Emby.Server.Implementations/Session/SessionManager.cs @@ -24,6 +24,7 @@ using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Dto; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; @@ -35,6 +36,7 @@ using MediaBrowser.Model.Querying; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Hosting; using Microsoft.Extensions.Logging; using Episode = MediaBrowser.Controller.Entities.TV.Episode; @@ -43,7 +45,7 @@ namespace Emby.Server.Implementations.Session /// <summary> /// Class SessionManager. /// </summary> - public class SessionManager : ISessionManager, IDisposable + public sealed class SessionManager : ISessionManager, IAsyncDisposable { private readonly IUserDataManager _userDataManager; private readonly ILogger<SessionManager> _logger; @@ -56,11 +58,9 @@ namespace Emby.Server.Implementations.Session private readonly IMediaSourceManager _mediaSourceManager; private readonly IServerApplicationHost _appHost; private readonly IDeviceManager _deviceManager; - - /// <summary> - /// The active connections. - /// </summary> - private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections = new(StringComparer.OrdinalIgnoreCase); + private readonly CancellationTokenRegistration _shutdownCallback; + private readonly ConcurrentDictionary<string, SessionInfo> _activeConnections + = new(StringComparer.OrdinalIgnoreCase); private Timer _idleTimer; @@ -78,7 +78,8 @@ namespace Emby.Server.Implementations.Session IImageProcessor imageProcessor, IServerApplicationHost appHost, IDeviceManager deviceManager, - IMediaSourceManager mediaSourceManager) + IMediaSourceManager mediaSourceManager, + IHostApplicationLifetime hostApplicationLifetime) { _logger = logger; _eventManager = eventManager; @@ -91,6 +92,7 @@ namespace Emby.Server.Implementations.Session _appHost = appHost; _deviceManager = deviceManager; _mediaSourceManager = mediaSourceManager; + _shutdownCallback = hostApplicationLifetime.ApplicationStopping.Register(OnApplicationStopping); _deviceManager.DeviceOptionsUpdated += OnDeviceManagerDeviceOptionsUpdated; } @@ -150,36 +152,6 @@ namespace Emby.Server.Implementations.Session } } - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and optionally managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - _idleTimer?.Dispose(); - } - - _idleTimer = null; - - _deviceManager.DeviceOptionsUpdated -= OnDeviceManagerDeviceOptionsUpdated; - - _disposed = true; - } - private void CheckDisposed() { if (_disposed) @@ -979,28 +951,28 @@ namespace Emby.Server.Implementations.Session private bool OnPlaybackStopped(User user, BaseItem item, long? positionTicks, bool playbackFailed) { - bool playedToCompletion = false; - - if (!playbackFailed) + if (playbackFailed) { - var data = _userDataManager.GetUserData(user, item); - - if (positionTicks.HasValue) - { - playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value); - } - else - { - // If the client isn't able to report this, then we'll just have to make an assumption - data.PlayCount++; - data.Played = item.SupportsPlayedStatus; - data.PlaybackPositionTicks = 0; - playedToCompletion = true; - } + return false; + } - _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); + var data = _userDataManager.GetUserData(user, item); + bool playedToCompletion; + if (positionTicks.HasValue) + { + playedToCompletion = _userDataManager.UpdatePlayState(item, data, positionTicks.Value); + } + else + { + // If the client isn't able to report this, then we'll just have to make an assumption + data.PlayCount++; + data.Played = item.SupportsPlayedStatus; + data.PlaybackPositionTicks = 0; + playedToCompletion = true; } + _userDataManager.SaveUserData(user, item, data, UserDataSaveReason.PlaybackFinished, CancellationToken.None); + return playedToCompletion; } @@ -1330,32 +1302,6 @@ namespace Emby.Server.Implementations.Session } /// <summary> - /// Sends the server shutdown notification. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public Task SendServerShutdownNotification(CancellationToken cancellationToken) - { - CheckDisposed(); - - return SendMessageToSessions(Sessions, SessionMessageType.ServerShuttingDown, string.Empty, cancellationToken); - } - - /// <summary> - /// Sends the server restart notification. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - public Task SendServerRestartNotification(CancellationToken cancellationToken) - { - CheckDisposed(); - - _logger.LogDebug("Beginning SendServerRestartNotification"); - - return SendMessageToSessions(Sessions, SessionMessageType.ServerRestarting, string.Empty, cancellationToken); - } - - /// <summary> /// Adds the additional user. /// </summary> /// <param name="sessionId">The session identifier.</param> @@ -1462,7 +1408,7 @@ namespace Emby.Server.Implementations.Session if (user is null) { - await _eventManager.PublishAsync(new GenericEventArgs<AuthenticationRequest>(request)).ConfigureAwait(false); + await _eventManager.PublishAsync(new AuthenticationRequestEventArgs(request)).ConfigureAwait(false); throw new AuthenticationException("Invalid username or password entered."); } @@ -1498,7 +1444,7 @@ namespace Emby.Server.Implementations.Session ServerId = _appHost.SystemId }; - await _eventManager.PublishAsync(new GenericEventArgs<AuthenticationResult>(returnResult)).ConfigureAwait(false); + await _eventManager.PublishAsync(new AuthenticationResultEventArgs(returnResult)).ConfigureAwait(false); return returnResult; } @@ -1508,35 +1454,20 @@ namespace Emby.Server.Implementations.Session new DeviceQuery { DeviceId = deviceId, - UserId = user.Id, - Limit = 1 - }).ConfigureAwait(false)).Items.FirstOrDefault(); - - var allExistingForDevice = (await _deviceManager.GetDevices( - new DeviceQuery - { - DeviceId = deviceId + UserId = user.Id }).ConfigureAwait(false)).Items; - foreach (var auth in allExistingForDevice) + foreach (var auth in existing) { - if (existing is null || !string.Equals(auth.AccessToken, existing.AccessToken, StringComparison.Ordinal)) + try { - try - { - await Logout(auth).ConfigureAwait(false); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error while logging out."); - } + // Logout any existing sessions for the user on this device + await Logout(auth).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error while logging out existing session."); } - } - - if (existing is not null) - { - _logger.LogInformation("Reissuing access token: {Token}", existing.AccessToken); - return existing.AccessToken; } _logger.LogInformation("Creating new access token for user {0}", user.Id); @@ -1847,5 +1778,53 @@ namespace Emby.Server.Implementations.Session return SendMessageToSessions(sessions, name, data, cancellationToken); } + + /// <inheritdoc /> + public async ValueTask DisposeAsync() + { + if (_disposed) + { + return; + } + + foreach (var session in _activeConnections.Values) + { + await session.DisposeAsync().ConfigureAwait(false); + } + + if (_idleTimer is not null) + { + await _idleTimer.DisposeAsync().ConfigureAwait(false); + _idleTimer = null; + } + + await _shutdownCallback.DisposeAsync().ConfigureAwait(false); + + _deviceManager.DeviceOptionsUpdated -= OnDeviceManagerDeviceOptionsUpdated; + _disposed = true; + } + + private async void OnApplicationStopping() + { + _logger.LogInformation("Sending shutdown notifications"); + try + { + var messageType = _appHost.ShouldRestart ? SessionMessageType.ServerRestarting : SessionMessageType.ServerShuttingDown; + + await SendMessageToSessions(Sessions, messageType, string.Empty, CancellationToken.None).ConfigureAwait(false); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error sending server shutdown notifications"); + } + + // Close open websockets to allow Kestrel to shut down cleanly + foreach (var session in _activeConnections.Values) + { + await session.DisposeAsync().ConfigureAwait(false); + } + + _activeConnections.Clear(); + } } } diff --git a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs index 4e427b1a4..b3c93a904 100644 --- a/Emby.Server.Implementations/Session/SessionWebSocketListener.cs +++ b/Emby.Server.Implementations/Session/SessionWebSocketListener.cs @@ -6,9 +6,8 @@ using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Extensions; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Net.WebSocketMessages.Outbound; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Net; -using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -308,11 +307,7 @@ namespace Emby.Server.Implementations.Session private Task SendForceKeepAlive(IWebSocketConnection webSocket) { return webSocket.SendAsync( - new WebSocketMessage<int> - { - MessageType = SessionMessageType.ForceKeepAlive, - Data = WebSocketLostTimeout - }, + new ForceKeepAliveMessage(WebSocketLostTimeout), CancellationToken.None); } diff --git a/Emby.Server.Implementations/Session/WebSocketController.cs b/Emby.Server.Implementations/Session/WebSocketController.cs index cdc736950..cf8e0fb00 100644 --- a/Emby.Server.Implementations/Session/WebSocketController.cs +++ b/Emby.Server.Implementations/Session/WebSocketController.cs @@ -7,8 +7,8 @@ using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; using MediaBrowser.Controller.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Controller.Session; -using MediaBrowser.Model.Net; using MediaBrowser.Model.Session; using Microsoft.Extensions.Logging; @@ -77,7 +77,7 @@ namespace Emby.Server.Implementations.Session } return socket.SendAsync( - new WebSocketMessage<T> + new OutboundWebSocketMessage<T> { Data = data, MessageType = name, diff --git a/Emby.Server.Implementations/SystemManager.cs b/Emby.Server.Implementations/SystemManager.cs new file mode 100644 index 000000000..2c477218f --- /dev/null +++ b/Emby.Server.Implementations/SystemManager.cs @@ -0,0 +1,104 @@ +using System; +using System.Linq; +using System.Threading.Tasks; +using MediaBrowser.Common.Configuration; +using MediaBrowser.Common.Updates; +using MediaBrowser.Controller; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.System; +using Microsoft.AspNetCore.Http; +using Microsoft.Extensions.Hosting; + +namespace Emby.Server.Implementations; + +/// <inheritdoc /> +public class SystemManager : ISystemManager +{ + private readonly IHostApplicationLifetime _applicationLifetime; + private readonly IServerApplicationHost _applicationHost; + private readonly IServerApplicationPaths _applicationPaths; + private readonly IServerConfigurationManager _configurationManager; + private readonly IStartupOptions _startupOptions; + private readonly IInstallationManager _installationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="SystemManager"/> class. + /// </summary> + /// <param name="applicationLifetime">Instance of <see cref="IHostApplicationLifetime"/>.</param> + /// <param name="applicationHost">Instance of <see cref="IServerApplicationHost"/>.</param> + /// <param name="applicationPaths">Instance of <see cref="IServerApplicationPaths"/>.</param> + /// <param name="configurationManager">Instance of <see cref="IServerConfigurationManager"/>.</param> + /// <param name="startupOptions">Instance of <see cref="IStartupOptions"/>.</param> + /// <param name="installationManager">Instance of <see cref="IInstallationManager"/>.</param> + public SystemManager( + IHostApplicationLifetime applicationLifetime, + IServerApplicationHost applicationHost, + IServerApplicationPaths applicationPaths, + IServerConfigurationManager configurationManager, + IStartupOptions startupOptions, + IInstallationManager installationManager) + { + _applicationLifetime = applicationLifetime; + _applicationHost = applicationHost; + _applicationPaths = applicationPaths; + _configurationManager = configurationManager; + _startupOptions = startupOptions; + _installationManager = installationManager; + } + + /// <inheritdoc /> + public SystemInfo GetSystemInfo(HttpRequest request) + { + return new SystemInfo + { + HasPendingRestart = _applicationHost.HasPendingRestart, + IsShuttingDown = _applicationLifetime.ApplicationStopping.IsCancellationRequested, + Version = _applicationHost.ApplicationVersionString, + WebSocketPortNumber = _applicationHost.HttpPort, + CompletedInstallations = _installationManager.CompletedInstallations.ToArray(), + Id = _applicationHost.SystemId, + ProgramDataPath = _applicationPaths.ProgramDataPath, + WebPath = _applicationPaths.WebPath, + LogPath = _applicationPaths.LogDirectoryPath, + ItemsByNamePath = _applicationPaths.InternalMetadataPath, + InternalMetadataPath = _applicationPaths.InternalMetadataPath, + CachePath = _applicationPaths.CachePath, + TranscodingTempPath = _configurationManager.GetTranscodePath(), + ServerName = _applicationHost.FriendlyName, + LocalAddress = _applicationHost.GetSmartApiUrl(request), + SupportsLibraryMonitor = true, + PackageName = _startupOptions.PackageName, + CastReceiverApplications = _configurationManager.Configuration.CastReceiverApplications + }; + } + + /// <inheritdoc /> + public PublicSystemInfo GetPublicSystemInfo(HttpRequest request) + { + return new PublicSystemInfo + { + Version = _applicationHost.ApplicationVersionString, + ProductName = _applicationHost.Name, + Id = _applicationHost.SystemId, + ServerName = _applicationHost.FriendlyName, + LocalAddress = _applicationHost.GetSmartApiUrl(request), + StartupWizardCompleted = _configurationManager.CommonConfiguration.IsStartupWizardCompleted + }; + } + + /// <inheritdoc /> + public void Restart() => ShutdownInternal(true); + + /// <inheritdoc /> + public void Shutdown() => ShutdownInternal(false); + + private void ShutdownInternal(bool restart) + { + Task.Run(async () => + { + await Task.Delay(100).ConfigureAwait(false); + _applicationHost.ShouldRestart = restart; + _applicationLifetime.StopApplication(); + }); + } +} diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index f0e173f0b..ef890aeb4 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -135,13 +135,13 @@ namespace Emby.Server.Implementations.TV private IEnumerable<Episode> GetNextUpEpisodes(NextUpQuery request, User user, IReadOnlyList<string> seriesKeys, DtoOptions dtoOptions) { - var allNextUp = seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, false)); + var allNextUp = seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, request.EnableResumable, false)); if (request.EnableRewatching) { - allNextUp = allNextUp.Concat( - seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, true))) - .OrderByDescending(i => i.LastWatchedDate); + allNextUp = allNextUp + .Concat(seriesKeys.Select(i => GetNextUp(i, user, dtoOptions, false, true))) + .OrderByDescending(i => i.LastWatchedDate); } // If viewing all next up for all series, remove first episodes @@ -183,7 +183,7 @@ namespace Emby.Server.Implementations.TV /// Gets the next up. /// </summary> /// <returns>Task{Episode}.</returns> - private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool rewatching) + private (DateTime LastWatchedDate, Func<Episode?> GetEpisodeFunction) GetNextUp(string seriesKey, User user, DtoOptions dtoOptions, bool includeResumable, bool includePlayed) { var lastQuery = new InternalItemsQuery(user) { @@ -200,8 +200,8 @@ namespace Emby.Server.Implementations.TV } }; - // If rewatching is enabled, sort first by date played and then by season and episode numbers - lastQuery.OrderBy = rewatching + // If including played results, sort first by date played and then by season and episode numbers + lastQuery.OrderBy = includePlayed ? new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) } : new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }; @@ -216,7 +216,7 @@ namespace Emby.Server.Implementations.TV IncludeItemTypes = new[] { BaseItemKind.Episode }, OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Ascending), (ItemSortBy.IndexNumber, SortOrder.Ascending) }, Limit = 1, - IsPlayed = rewatching, + IsPlayed = includePlayed, IsVirtualItem = false, ParentIndexNumberNotEquals = 0, DtoOptions = dtoOptions @@ -240,7 +240,7 @@ namespace Emby.Server.Implementations.TV SeriesPresentationUniqueKey = seriesKey, ParentIndexNumber = 0, IncludeItemTypes = new[] { BaseItemKind.Episode }, - IsPlayed = rewatching, + IsPlayed = includePlayed, IsVirtualItem = false, DtoOptions = dtoOptions }) @@ -269,7 +269,7 @@ namespace Emby.Server.Implementations.TV nextEpisode = sortedConsideredEpisodes.FirstOrDefault(); } - if (nextEpisode is not null) + if (nextEpisode is not null && !includeResumable) { var userData = _userDataManager.GetUserData(user, nextEpisode); diff --git a/Emby.Server.Implementations/Udp/UdpServer.cs b/Emby.Server.Implementations/Udp/UdpServer.cs index 937e792f5..2d806c146 100644 --- a/Emby.Server.Implementations/Udp/UdpServer.cs +++ b/Emby.Server.Implementations/Udp/UdpServer.cs @@ -27,9 +27,9 @@ namespace Emby.Server.Implementations.Udp private readonly byte[] _receiveBuffer = new byte[8192]; - private Socket _udpSocket; - private IPEndPoint _endpoint; - private bool _disposed = false; + private readonly Socket _udpSocket; + private readonly IPEndPoint _endpoint; + private bool _disposed; /// <summary> /// Initializes a new instance of the <see cref="UdpServer" /> class. @@ -37,20 +37,25 @@ namespace Emby.Server.Implementations.Udp /// <param name="logger">The logger.</param> /// <param name="appHost">The application host.</param> /// <param name="configuration">The configuration manager.</param> + /// <param name="bindAddress"> The bind address.</param> /// <param name="port">The port.</param> public UdpServer( ILogger logger, IServerApplicationHost appHost, IConfiguration configuration, + IPAddress bindAddress, int port) { _logger = logger; _appHost = appHost; _config = configuration; - _endpoint = new IPEndPoint(IPAddress.Any, port); + _endpoint = new IPEndPoint(bindAddress, port); - _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp); + _udpSocket = new Socket(AddressFamily.InterNetwork, SocketType.Dgram, ProtocolType.Udp) + { + MulticastLoopback = false, + }; _udpSocket.SetSocketOption(SocketOptionLevel.Socket, SocketOptionName.ReuseAddress, true); } @@ -72,6 +77,7 @@ namespace Emby.Server.Implementations.Udp try { + _logger.LogDebug("Sending AutoDiscovery response"); await _udpSocket.SendToAsync(JsonSerializer.SerializeToUtf8Bytes(response), SocketFlags.None, endpoint, cancellationToken).ConfigureAwait(false); } catch (SocketException ex) @@ -97,7 +103,8 @@ namespace Emby.Server.Implementations.Udp { try { - var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, SocketFlags.None, _endpoint, cancellationToken).ConfigureAwait(false); + var endpoint = (EndPoint)new IPEndPoint(IPAddress.Any, 0); + var result = await _udpSocket.ReceiveFromAsync(_receiveBuffer, endpoint, cancellationToken).ConfigureAwait(false); var text = Encoding.UTF8.GetString(_receiveBuffer, 0, result.ReceivedBytes); if (text.Contains("who is JellyfinServer?", StringComparison.OrdinalIgnoreCase)) { @@ -110,7 +117,7 @@ namespace Emby.Server.Implementations.Udp } catch (OperationCanceledException) { - // Don't throw + _logger.LogDebug("Broadcast socket operation cancelled"); } } } @@ -123,9 +130,8 @@ namespace Emby.Server.Implementations.Udp return; } - _udpSocket?.Dispose(); - - GC.SuppressFinalize(this); + _udpSocket.Dispose(); + _disposed = true; } } } diff --git a/Emby.Server.Implementations/Updates/InstallationManager.cs b/Emby.Server.Implementations/Updates/InstallationManager.cs index 6c198b6f9..c717744b1 100644 --- a/Emby.Server.Implementations/Updates/InstallationManager.cs +++ b/Emby.Server.Implementations/Updates/InstallationManager.cs @@ -504,8 +504,7 @@ namespace Emby.Server.Implementations.Updates private async Task PerformPackageInstallation(InstallationInfo package, PluginStatus status, CancellationToken cancellationToken) { - var extension = Path.GetExtension(package.SourceUrl); - if (!string.Equals(extension, ".zip", StringComparison.OrdinalIgnoreCase)) + if (!Path.GetExtension(package.SourceUrl.AsSpan()).Equals(".zip", StringComparison.OrdinalIgnoreCase)) { _logger.LogError("Only zip packages are supported. {SourceUrl} is not a zip archive.", package.SourceUrl); return; @@ -521,10 +520,9 @@ namespace Emby.Server.Implementations.Updates // CA5351: Do Not Use Broken Cryptographic Algorithms #pragma warning disable CA5351 - using var md5 = MD5.Create(); cancellationToken.ThrowIfCancellationRequested(); - var hash = Convert.ToHexString(md5.ComputeHash(stream)); + var hash = Convert.ToHexString(await MD5.HashDataAsync(stream, cancellationToken).ConfigureAwait(false)); if (!string.Equals(package.Checksum, hash, StringComparison.OrdinalIgnoreCase)) { _logger.LogError( @@ -557,7 +555,7 @@ namespace Emby.Server.Implementations.Updates reader.ExtractToDirectory(targetDir, true); // Ensure we create one or populate existing ones with missing data. - await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status); + await _pluginManager.PopulateManifest(package.PackageInfo, package.Version, targetDir, status).ConfigureAwait(false); _pluginManager.ImportPluginFrom(targetDir); } diff --git a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs index 741b88ea9..3c1401ded 100644 --- a/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs +++ b/Jellyfin.Api/Auth/AnonymousLanAccessPolicy/AnonymousLanAccessHandler.cs @@ -30,7 +30,7 @@ namespace Jellyfin.Api.Auth.AnonymousLanAccessPolicy /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, AnonymousLanAccessRequirement requirement) { - var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp(); + var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIP(); // Loopback will be on LAN, so we can accept null. if (ip is null || _networkManager.IsInLocalNetwork(ip)) diff --git a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs index de271ab64..cf3cb6905 100644 --- a/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs +++ b/Jellyfin.Api/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandler.cs @@ -54,7 +54,7 @@ namespace Jellyfin.Api.Auth.DefaultAuthorizationPolicy } var isInLocalNetwork = _httpContextAccessor.HttpContext is not null - && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIp()); + && _networkManager.IsInLocalNetwork(_httpContextAccessor.HttpContext.GetNormalizedRemoteIP()); var user = _userManager.GetUserById(userId); if (user is null) { diff --git a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs index 6ed6fc90b..557b7d3aa 100644 --- a/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs +++ b/Jellyfin.Api/Auth/LocalAccessOrRequiresElevationPolicy/LocalAccessOrRequiresElevationHandler.cs @@ -31,7 +31,7 @@ namespace Jellyfin.Api.Auth.LocalAccessOrRequiresElevationPolicy /// <inheritdoc /> protected override Task HandleRequirementAsync(AuthorizationHandlerContext context, LocalAccessOrRequiresElevationRequirement requirement) { - var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIp(); + var ip = _httpContextAccessor.HttpContext?.GetNormalizedRemoteIP(); // Loopback will be on LAN, so we can accept null. if (ip is null || _networkManager.IsInLocalNetwork(ip)) diff --git a/Jellyfin.Api/Controllers/DlnaServerController.cs b/Jellyfin.Api/Controllers/DlnaServerController.cs index 95b296fae..42576934b 100644 --- a/Jellyfin.Api/Controllers/DlnaServerController.cs +++ b/Jellyfin.Api/Controllers/DlnaServerController.cs @@ -5,7 +5,6 @@ using System.IO; using System.Net.Mime; using System.Threading.Tasks; using Emby.Dlna; -using Emby.Dlna.Main; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Controller.Dlna; @@ -33,12 +32,19 @@ public class DlnaServerController : BaseJellyfinApiController /// Initializes a new instance of the <see cref="DlnaServerController"/> class. /// </summary> /// <param name="dlnaManager">Instance of the <see cref="IDlnaManager"/> interface.</param> - public DlnaServerController(IDlnaManager dlnaManager) + /// <param name="contentDirectory">Instance of the <see cref="IContentDirectory"/> interface.</param> + /// <param name="connectionManager">Instance of the <see cref="IConnectionManager"/> interface.</param> + /// <param name="mediaReceiverRegistrar">Instance of the <see cref="IMediaReceiverRegistrar"/> interface.</param> + public DlnaServerController( + IDlnaManager dlnaManager, + IContentDirectory contentDirectory, + IConnectionManager connectionManager, + IMediaReceiverRegistrar mediaReceiverRegistrar) { _dlnaManager = dlnaManager; - _contentDirectory = DlnaEntryPoint.Current.ContentDirectory; - _connectionManager = DlnaEntryPoint.Current.ConnectionManager; - _mediaReceiverRegistrar = DlnaEntryPoint.Current.MediaReceiverRegistrar; + _contentDirectory = contentDirectory; + _connectionManager = connectionManager; + _mediaReceiverRegistrar = mediaReceiverRegistrar; } /// <summary> diff --git a/Jellyfin.Api/Controllers/DynamicHlsController.cs b/Jellyfin.Api/Controllers/DynamicHlsController.cs index 4b89738a1..38953dc21 100644 --- a/Jellyfin.Api/Controllers/DynamicHlsController.cs +++ b/Jellyfin.Api/Controllers/DynamicHlsController.cs @@ -45,6 +45,8 @@ public class DynamicHlsController : BaseJellyfinApiController private const string DefaultEventEncoderPreset = "superfast"; private const TranscodingJobType TranscodingJobType = MediaBrowser.Controller.MediaEncoding.TranscodingJobType.Hls; + private readonly Version _minFFmpegFlacInMp4 = new Version(6, 0); + private readonly ILibraryManager _libraryManager; private readonly IUserManager _userManager; private readonly IDlnaManager _dlnaManager; @@ -1654,7 +1656,7 @@ public class DynamicHlsController : BaseJellyfinApiController _encodingHelper.GetInputArgument(state, _encodingOptions, segmentContainer), threads, mapArgs, - GetVideoArguments(state, startNumber, isEventPlaylist), + GetVideoArguments(state, startNumber, isEventPlaylist, segmentContainer), GetAudioArguments(state), maxMuxingQueueSize, state.SegmentLength.ToString(CultureInfo.InvariantCulture), @@ -1706,19 +1708,33 @@ public class DynamicHlsController : BaseJellyfinApiController } var audioCodec = _encodingHelper.GetAudioEncoder(state); + var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); + + // opus, dts, truehd and flac (in FFmpeg 5 and older) are experimental in mp4 muxer + var strictArgs = string.Empty; + var actualOutputAudioCodec = state.ActualOutputAudioCodec; + if (string.Equals(actualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) + || string.Equals(actualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase) + || (string.Equals(actualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) + && _mediaEncoder.EncoderVersion < _minFFmpegFlacInMp4)) + { + strictArgs = " -strict -2"; + } if (!state.IsOutputVideo) { + var audioTranscodeParams = string.Empty; + + // -vn to drop any video streams + audioTranscodeParams += "-vn"; + if (EncodingHelper.IsCopyCodec(audioCodec)) { - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); - - return "-acodec copy -strict -2" + bitStreamArgs; + return audioTranscodeParams + " -acodec copy" + bitStreamArgs + strictArgs; } - var audioTranscodeParams = string.Empty; - - audioTranscodeParams += "-acodec " + audioCodec; + audioTranscodeParams += " -acodec " + audioCodec + bitStreamArgs + strictArgs; var audioBitrate = state.OutputAudioBitrate; var audioChannels = state.OutputAudioChannels; @@ -1746,25 +1762,12 @@ public class DynamicHlsController : BaseJellyfinApiController audioTranscodeParams += " -ar " + state.OutputAudioSampleRate.Value.ToString(CultureInfo.InvariantCulture); } - audioTranscodeParams += " -vn"; return audioTranscodeParams; } - // dts, flac, opus and truehd are experimental in mp4 muxer - var strictArgs = string.Empty; - var actualOutputAudioCodec = state.ActualOutputAudioCodec; - if (string.Equals(actualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase) - || string.Equals(actualOutputAudioCodec, "opus", StringComparison.OrdinalIgnoreCase) - || string.Equals(actualOutputAudioCodec, "dts", StringComparison.OrdinalIgnoreCase) - || string.Equals(actualOutputAudioCodec, "truehd", StringComparison.OrdinalIgnoreCase)) - { - strictArgs = " -strict -2"; - } - if (EncodingHelper.IsCopyCodec(audioCodec)) { var videoCodec = _encodingHelper.GetVideoEncoder(state, _encodingOptions); - var bitStreamArgs = EncodingHelper.GetAudioBitStreamArguments(state, state.Request.SegmentContainer, state.MediaSource.Container); var copyArgs = "-codec:a:0 copy" + bitStreamArgs + strictArgs; if (EncodingHelper.IsCopyCodec(videoCodec) && state.EnableBreakOnNonKeyFrames(videoCodec)) @@ -1775,7 +1778,7 @@ public class DynamicHlsController : BaseJellyfinApiController return copyArgs; } - var args = "-codec:a:0 " + audioCodec + strictArgs; + var args = "-codec:a:0 " + audioCodec + bitStreamArgs + strictArgs; var channels = state.OutputAudioChannels; @@ -1819,8 +1822,9 @@ public class DynamicHlsController : BaseJellyfinApiController /// <param name="state">The <see cref="StreamState"/>.</param> /// <param name="startNumber">The first number in the hls sequence.</param> /// <param name="isEventPlaylist">Whether the playlist is EVENT or VOD.</param> + /// <param name="segmentContainer">The segment container.</param> /// <returns>The command line arguments for video transcoding.</returns> - private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist) + private string GetVideoArguments(StreamState state, int startNumber, bool isEventPlaylist, string segmentContainer) { if (state.VideoStream is null) { @@ -1912,7 +1916,7 @@ public class DynamicHlsController : BaseJellyfinApiController } // TODO why was this not enabled for VOD? - if (isEventPlaylist) + if (isEventPlaylist && string.Equals(segmentContainer, "ts", StringComparison.OrdinalIgnoreCase)) { args += " -flags -global_header"; } @@ -2045,9 +2049,9 @@ public class DynamicHlsController : BaseJellyfinApiController return null; } - var playlistFilename = Path.GetFileNameWithoutExtension(playlist); + var playlistFilename = Path.GetFileNameWithoutExtension(playlist.AsSpan()); - var indexString = Path.GetFileNameWithoutExtension(file.Name).Substring(playlistFilename.Length); + var indexString = Path.GetFileNameWithoutExtension(file.Name.AsSpan()).Slice(playlistFilename.Length); return int.Parse(indexString, NumberStyles.Integer, CultureInfo.InvariantCulture); } diff --git a/Jellyfin.Api/Controllers/HlsSegmentController.cs b/Jellyfin.Api/Controllers/HlsSegmentController.cs index d7cec865e..6eedfd8c7 100644 --- a/Jellyfin.Api/Controllers/HlsSegmentController.cs +++ b/Jellyfin.Api/Controllers/HlsSegmentController.cs @@ -59,7 +59,7 @@ public class HlsSegmentController : BaseJellyfinApiController public ActionResult GetHlsAudioSegmentLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string segmentId) { // TODO: Deprecate with new iOS app - var file = segmentId + Path.GetExtension(Request.Path); + var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); var transcodePath = _serverConfigurationManager.GetTranscodePath(); file = Path.GetFullPath(Path.Combine(transcodePath, file)); var fileDir = Path.GetDirectoryName(file); @@ -85,11 +85,12 @@ public class HlsSegmentController : BaseJellyfinApiController [SuppressMessage("Microsoft.Performance", "CA1801:ReviewUnusedParameters", MessageId = "itemId", Justification = "Required for ServiceStack")] public ActionResult GetHlsPlaylistLegacy([FromRoute, Required] string itemId, [FromRoute, Required] string playlistId) { - var file = playlistId + Path.GetExtension(Request.Path); + var file = string.Concat(playlistId, Path.GetExtension(Request.Path.Value.AsSpan())); var transcodePath = _serverConfigurationManager.GetTranscodePath(); file = Path.GetFullPath(Path.Combine(transcodePath, file)); var fileDir = Path.GetDirectoryName(file); - if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) || Path.GetExtension(file) != ".m3u8") + if (string.IsNullOrEmpty(fileDir) || !fileDir.StartsWith(transcodePath, StringComparison.InvariantCulture) + || Path.GetExtension(file.AsSpan()).Equals(".m3u8", StringComparison.OrdinalIgnoreCase)) { return BadRequest("Invalid segment."); } @@ -138,7 +139,7 @@ public class HlsSegmentController : BaseJellyfinApiController [FromRoute, Required] string segmentId, [FromRoute, Required] string segmentContainer) { - var file = segmentId + Path.GetExtension(Request.Path); + var file = string.Concat(segmentId, Path.GetExtension(Request.Path.Value.AsSpan())); var transcodeFolderPath = _serverConfigurationManager.GetTranscodePath(); file = Path.GetFullPath(Path.Combine(transcodeFolderPath, file)); diff --git a/Jellyfin.Api/Controllers/ImageController.cs b/Jellyfin.Api/Controllers/ImageController.cs index 3c5f18af5..7b10ea170 100644 --- a/Jellyfin.Api/Controllers/ImageController.cs +++ b/Jellyfin.Api/Controllers/ImageController.cs @@ -7,6 +7,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Mime; +using System.Security.Cryptography; using System.Threading; using System.Threading.Tasks; using Jellyfin.Api.Attributes; @@ -78,6 +79,9 @@ public class ImageController : BaseJellyfinApiController _appPaths = appPaths; } + private static Stream GetFromBase64Stream(Stream inputStream) + => new CryptoStream(inputStream, new FromBase64Transform(), CryptoStreamMode.Read); + /// <summary> /// Sets the user image. /// </summary> @@ -116,8 +120,8 @@ public class ImageController : BaseJellyfinApiController return BadRequest("Incorrect ContentType."); } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) + var stream = GetFromBase64Stream(Request.Body); + await using (stream.ConfigureAwait(false)) { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); @@ -130,7 +134,7 @@ public class ImageController : BaseJellyfinApiController user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); await _providerManager - .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .SaveImage(stream, mimeType, user.ProfileImage.Path) .ConfigureAwait(false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false); @@ -176,8 +180,8 @@ public class ImageController : BaseJellyfinApiController return BadRequest("Incorrect ContentType."); } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) + var stream = GetFromBase64Stream(Request.Body); + await using (stream.ConfigureAwait(false)) { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); @@ -190,7 +194,7 @@ public class ImageController : BaseJellyfinApiController user.ProfileImage = new Data.Entities.ImageInfo(Path.Combine(userDataPath, "profile" + extension)); await _providerManager - .SaveImage(memoryStream, mimeType, user.ProfileImage.Path) + .SaveImage(stream, mimeType, user.ProfileImage.Path) .ConfigureAwait(false); await _userManager.UpdateUserAsync(user).ConfigureAwait(false); @@ -372,12 +376,12 @@ public class ImageController : BaseJellyfinApiController return BadRequest("Incorrect ContentType."); } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) + var stream = GetFromBase64Stream(Request.Body); + await using (stream.ConfigureAwait(false)) { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await _providerManager.SaveImage(item, stream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); return NoContent(); @@ -416,12 +420,12 @@ public class ImageController : BaseJellyfinApiController return BadRequest("Incorrect ContentType."); } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) + var stream = GetFromBase64Stream(Request.Body); + await using (stream.ConfigureAwait(false)) { // Handle image/png; charset=utf-8 var mimeType = Request.ContentType?.Split(';').FirstOrDefault(); - await _providerManager.SaveImage(item, memoryStream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); + await _providerManager.SaveImage(item, stream, mimeType, imageType, null, CancellationToken.None).ConfigureAwait(false); await item.UpdateToRepositoryAsync(ItemUpdateType.ImageUpdate, CancellationToken.None).ConfigureAwait(false); return NoContent(); @@ -1792,8 +1796,8 @@ public class ImageController : BaseJellyfinApiController return BadRequest("Incorrect ContentType."); } - var memoryStream = await GetMemoryStream(Request.Body).ConfigureAwait(false); - await using (memoryStream.ConfigureAwait(false)) + var stream = GetFromBase64Stream(Request.Body); + await using (stream.ConfigureAwait(false)) { var filePath = Path.Combine(_appPaths.DataPath, "splashscreen-upload" + extension); var brandingOptions = _serverConfigurationManager.GetConfiguration<BrandingOptions>("branding"); @@ -1803,7 +1807,7 @@ public class ImageController : BaseJellyfinApiController var fs = new FileStream(filePath, FileMode.Create, FileAccess.Write, FileShare.None, IODefaults.FileStreamBufferSize, FileOptions.Asynchronous); await using (fs.ConfigureAwait(false)) { - await memoryStream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); + await stream.CopyToAsync(fs, CancellationToken.None).ConfigureAwait(false); } return NoContent(); @@ -1833,15 +1837,6 @@ public class ImageController : BaseJellyfinApiController return NoContent(); } - private static async Task<MemoryStream> GetMemoryStream(Stream inputStream) - { - using var reader = new StreamReader(inputStream); - var text = await reader.ReadToEndAsync().ConfigureAwait(false); - - var bytes = Convert.FromBase64String(text); - return new MemoryStream(bytes, 0, bytes.Length, false, true); - } - private ImageInfo? GetImageInfo(BaseItem item, ItemImageInfo info, int? imageIndex) { int? width = null; diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index 46c0a8d52..21941ff94 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -294,8 +294,8 @@ public class LibraryController : BaseJellyfinApiController return new AllThemeMediaResult { - ThemeSongsResult = themeSongs?.Value, - ThemeVideosResult = themeVideos?.Value, + ThemeSongsResult = themeSongs.Value, + ThemeVideosResult = themeVideos.Value, SoundtrackSongsResult = new ThemeMediaResult() }; } @@ -490,7 +490,7 @@ public class LibraryController : BaseJellyfinApiController baseItemDtos.Add(_dtoService.GetBaseItemDto(parent, dtoOptions, user)); - parent = parent?.GetParent(); + parent = parent.GetParent(); } return baseItemDtos; diff --git a/Jellyfin.Api/Controllers/LiveTvController.cs b/Jellyfin.Api/Controllers/LiveTvController.cs index 267ba4afb..649397d68 100644 --- a/Jellyfin.Api/Controllers/LiveTvController.cs +++ b/Jellyfin.Api/Controllers/LiveTvController.cs @@ -23,7 +23,6 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.LiveTv; -using MediaBrowser.Controller.Session; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Entities; using MediaBrowser.Model.LiveTv; @@ -48,7 +47,6 @@ public class LiveTvController : BaseJellyfinApiController private readonly IMediaSourceManager _mediaSourceManager; private readonly IConfigurationManager _configurationManager; private readonly TranscodingJobHelper _transcodingJobHelper; - private readonly ISessionManager _sessionManager; /// <summary> /// Initializes a new instance of the <see cref="LiveTvController"/> class. @@ -61,7 +59,6 @@ public class LiveTvController : BaseJellyfinApiController /// <param name="mediaSourceManager">Instance of the <see cref="IMediaSourceManager"/> interface.</param> /// <param name="configurationManager">Instance of the <see cref="IConfigurationManager"/> interface.</param> /// <param name="transcodingJobHelper">Instance of the <see cref="TranscodingJobHelper"/> class.</param> - /// <param name="sessionManager">Instance of the <see cref="ISessionManager"/> interface.</param> public LiveTvController( ILiveTvManager liveTvManager, IUserManager userManager, @@ -70,8 +67,7 @@ public class LiveTvController : BaseJellyfinApiController IDtoService dtoService, IMediaSourceManager mediaSourceManager, IConfigurationManager configurationManager, - TranscodingJobHelper transcodingJobHelper, - ISessionManager sessionManager) + TranscodingJobHelper transcodingJobHelper) { _liveTvManager = liveTvManager; _userManager = userManager; @@ -81,7 +77,6 @@ public class LiveTvController : BaseJellyfinApiController _mediaSourceManager = mediaSourceManager; _configurationManager = configurationManager; _transcodingJobHelper = transcodingJobHelper; - _sessionManager = sessionManager; } /// <summary> diff --git a/Jellyfin.Api/Controllers/MediaInfoController.cs b/Jellyfin.Api/Controllers/MediaInfoController.cs index da24616ff..bea545cfd 100644 --- a/Jellyfin.Api/Controllers/MediaInfoController.cs +++ b/Jellyfin.Api/Controllers/MediaInfoController.cs @@ -184,7 +184,7 @@ public class MediaInfoController : BaseJellyfinApiController enableTranscoding.Value, allowVideoStreamCopy.Value, allowAudioStreamCopy.Value, - Request.HttpContext.GetNormalizedRemoteIp()); + Request.HttpContext.GetNormalizedRemoteIP()); } _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); diff --git a/Jellyfin.Api/Controllers/SubtitleController.cs b/Jellyfin.Api/Controllers/SubtitleController.cs index b3e9d6297..fb89e9610 100644 --- a/Jellyfin.Api/Controllers/SubtitleController.cs +++ b/Jellyfin.Api/Controllers/SubtitleController.cs @@ -6,6 +6,7 @@ using System.Globalization; using System.IO; using System.Linq; using System.Net.Mime; +using System.Security.Cryptography; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -90,7 +91,7 @@ public class SubtitleController : BaseJellyfinApiController [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status204NoContent)] [ProducesResponseType(StatusCodes.Status404NotFound)] - public ActionResult<Task> DeleteSubtitle( + public async Task<ActionResult> DeleteSubtitle( [FromRoute, Required] Guid itemId, [FromRoute, Required] int index) { @@ -101,7 +102,7 @@ public class SubtitleController : BaseJellyfinApiController return NotFound(); } - _subtitleManager.DeleteSubtitles(item, index); + await _subtitleManager.DeleteSubtitles(item, index).ConfigureAwait(false); return NoContent(); } @@ -405,9 +406,8 @@ public class SubtitleController : BaseJellyfinApiController [FromBody, Required] UploadSubtitleDto body) { var video = (Video)_libraryManager.GetItemById(itemId); - var data = Convert.FromBase64String(body.Data); - var memoryStream = new MemoryStream(data, 0, data.Length, false, true); - await using (memoryStream.ConfigureAwait(false)) + var stream = new CryptoStream(Request.Body, new FromBase64Transform(), CryptoStreamMode.Read); + await using (stream.ConfigureAwait(false)) { await _subtitleManager.UploadSubtitle( video, @@ -416,7 +416,8 @@ public class SubtitleController : BaseJellyfinApiController Format = body.Format, Language = body.Language, IsForced = body.IsForced, - Stream = memoryStream + IsHearingImpaired = body.IsHearingImpaired, + Stream = stream }).ConfigureAwait(false); _providerManager.QueueRefresh(video.Id, new MetadataRefreshOptions(new DirectoryService(_fileSystem)), RefreshPriority.High); diff --git a/Jellyfin.Api/Controllers/SystemController.cs b/Jellyfin.Api/Controllers/SystemController.cs index 9ed69f420..11095a97f 100644 --- a/Jellyfin.Api/Controllers/SystemController.cs +++ b/Jellyfin.Api/Controllers/SystemController.cs @@ -4,14 +4,12 @@ using System.ComponentModel.DataAnnotations; using System.IO; using System.Linq; using System.Net.Mime; -using System.Threading.Tasks; using Jellyfin.Api.Attributes; using Jellyfin.Api.Constants; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller; -using MediaBrowser.Controller.Configuration; using MediaBrowser.Model.IO; using MediaBrowser.Model.Net; using MediaBrowser.Model.System; @@ -27,32 +25,36 @@ namespace Jellyfin.Api.Controllers; /// </summary> public class SystemController : BaseJellyfinApiController { + private readonly ILogger<SystemController> _logger; private readonly IServerApplicationHost _appHost; private readonly IApplicationPaths _appPaths; private readonly IFileSystem _fileSystem; - private readonly INetworkManager _network; - private readonly ILogger<SystemController> _logger; + private readonly INetworkManager _networkManager; + private readonly ISystemManager _systemManager; /// <summary> /// Initializes a new instance of the <see cref="SystemController"/> class. /// </summary> - /// <param name="serverConfigurationManager">Instance of <see cref="IServerConfigurationManager"/> interface.</param> + /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> + /// <param name="appPaths">Instance of <see cref="IServerApplicationPaths"/> interface.</param> /// <param name="appHost">Instance of <see cref="IServerApplicationHost"/> interface.</param> /// <param name="fileSystem">Instance of <see cref="IFileSystem"/> interface.</param> - /// <param name="network">Instance of <see cref="INetworkManager"/> interface.</param> - /// <param name="logger">Instance of <see cref="ILogger{SystemController}"/> interface.</param> + /// <param name="networkManager">Instance of <see cref="INetworkManager"/> interface.</param> + /// <param name="systemManager">Instance of <see cref="ISystemManager"/> interface.</param> public SystemController( - IServerConfigurationManager serverConfigurationManager, + ILogger<SystemController> logger, IServerApplicationHost appHost, + IServerApplicationPaths appPaths, IFileSystem fileSystem, - INetworkManager network, - ILogger<SystemController> logger) + INetworkManager networkManager, + ISystemManager systemManager) { - _appPaths = serverConfigurationManager.ApplicationPaths; + _logger = logger; _appHost = appHost; + _appPaths = appPaths; _fileSystem = fileSystem; - _network = network; - _logger = logger; + _networkManager = networkManager; + _systemManager = systemManager; } /// <summary> @@ -66,9 +68,7 @@ public class SystemController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] [ProducesResponseType(StatusCodes.Status403Forbidden)] public ActionResult<SystemInfo> GetSystemInfo() - { - return _appHost.GetSystemInfo(Request); - } + => _systemManager.GetSystemInfo(Request); /// <summary> /// Gets public information about the server. @@ -78,9 +78,7 @@ public class SystemController : BaseJellyfinApiController [HttpGet("Info/Public")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<PublicSystemInfo> GetPublicSystemInfo() - { - return _appHost.GetPublicSystemInfo(Request); - } + => _systemManager.GetPublicSystemInfo(Request); /// <summary> /// Pings the system. @@ -91,9 +89,7 @@ public class SystemController : BaseJellyfinApiController [HttpPost("Ping", Name = "PostPingSystem")] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<string> PingSystem() - { - return _appHost.Name; - } + => _appHost.Name; /// <summary> /// Restarts the application. @@ -107,11 +103,7 @@ public class SystemController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status403Forbidden)] public ActionResult RestartApplication() { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - _appHost.Restart(); - }); + _systemManager.Restart(); return NoContent(); } @@ -127,11 +119,7 @@ public class SystemController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status403Forbidden)] public ActionResult ShutdownApplication() { - Task.Run(async () => - { - await Task.Delay(100).ConfigureAwait(false); - await _appHost.Shutdown().ConfigureAwait(false); - }); + _systemManager.Shutdown(); return NoContent(); } @@ -189,7 +177,7 @@ public class SystemController : BaseJellyfinApiController return new EndPointInfo { IsLocal = HttpContext.IsLocal(), - IsInNetwork = _network.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp()) + IsInNetwork = _networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP()) }; } @@ -227,7 +215,7 @@ public class SystemController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<IEnumerable<WakeOnLanInfo>> GetWakeOnLanInfo() { - var result = _network.GetMacAddresses() + var result = _networkManager.GetMacAddresses() .Select(i => new WakeOnLanInfo(i)); return Ok(result); } diff --git a/Jellyfin.Api/Controllers/TvShowsController.cs b/Jellyfin.Api/Controllers/TvShowsController.cs index 7d23281f2..bdbbd1e0d 100644 --- a/Jellyfin.Api/Controllers/TvShowsController.cs +++ b/Jellyfin.Api/Controllers/TvShowsController.cs @@ -68,7 +68,8 @@ public class TvShowsController : BaseJellyfinApiController /// <param name="nextUpDateCutoff">Optional. Starting date of shows to show in Next Up section.</param> /// <param name="enableTotalRecordCount">Whether to enable the total records count. Defaults to true.</param> /// <param name="disableFirstEpisode">Whether to disable sending the first episode in a series as next up.</param> - /// <param name="enableRewatching">Whether to include watched episode in next up results.</param> + /// <param name="enableResumable">Whether to include resumable episodes in next up results.</param> + /// <param name="enableRewatching">Whether to include watched episodes in next up results.</param> /// <returns>A <see cref="QueryResult{BaseItemDto}"/> with the next up episodes.</returns> [HttpGet("NextUp")] [ProducesResponseType(StatusCodes.Status200OK)] @@ -86,6 +87,7 @@ public class TvShowsController : BaseJellyfinApiController [FromQuery] DateTime? nextUpDateCutoff, [FromQuery] bool enableTotalRecordCount = true, [FromQuery] bool disableFirstEpisode = false, + [FromQuery] bool enableResumable = true, [FromQuery] bool enableRewatching = false) { userId = RequestHelpers.GetUserId(User, userId); @@ -104,6 +106,7 @@ public class TvShowsController : BaseJellyfinApiController EnableTotalRecordCount = enableTotalRecordCount, DisableFirstEpisode = disableFirstEpisode, NextUpDateCutoff = nextUpDateCutoff ?? DateTime.MinValue, + EnableResumable = enableResumable, EnableRewatching = enableRewatching }, options); diff --git a/Jellyfin.Api/Controllers/UniversalAudioController.cs b/Jellyfin.Api/Controllers/UniversalAudioController.cs index 2e9035d24..7177a0440 100644 --- a/Jellyfin.Api/Controllers/UniversalAudioController.cs +++ b/Jellyfin.Api/Controllers/UniversalAudioController.cs @@ -138,7 +138,7 @@ public class UniversalAudioController : BaseJellyfinApiController true, true, true, - Request.HttpContext.GetNormalizedRemoteIp()); + Request.HttpContext.GetNormalizedRemoteIP()); } _mediaInfoHelper.SortMediaSources(info, maxStreamingBitrate); diff --git a/Jellyfin.Api/Controllers/UserController.cs b/Jellyfin.Api/Controllers/UserController.cs index 530bd9603..1be40111d 100644 --- a/Jellyfin.Api/Controllers/UserController.cs +++ b/Jellyfin.Api/Controllers/UserController.cs @@ -134,7 +134,7 @@ public class UserController : BaseJellyfinApiController return NotFound("User not found"); } - var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIp().ToString()); + var result = _userManager.GetUserDto(user, HttpContext.GetNormalizedRemoteIP().ToString()); return result; } @@ -217,7 +217,7 @@ public class UserController : BaseJellyfinApiController DeviceId = auth.DeviceId, DeviceName = auth.Device, Password = request.Pw, - RemoteEndPoint = HttpContext.GetNormalizedRemoteIp().ToString(), + RemoteEndPoint = HttpContext.GetNormalizedRemoteIP().ToString(), Username = request.Username }).ConfigureAwait(false); @@ -226,7 +226,7 @@ public class UserController : BaseJellyfinApiController catch (SecurityException e) { // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIP()}] {e.Message}", e); } } @@ -248,7 +248,7 @@ public class UserController : BaseJellyfinApiController catch (SecurityException e) { // rethrow adding IP address to message - throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIp()}] {e.Message}", e); + throw new SecurityException($"[{HttpContext.GetNormalizedRemoteIP()}] {e.Message}", e); } } @@ -294,7 +294,7 @@ public class UserController : BaseJellyfinApiController user.Username, request.CurrentPw ?? string.Empty, request.CurrentPw ?? string.Empty, - HttpContext.GetNormalizedRemoteIp().ToString(), + HttpContext.GetNormalizedRemoteIP().ToString(), false).ConfigureAwait(false); if (success is null) @@ -475,7 +475,7 @@ public class UserController : BaseJellyfinApiController await _userManager.ChangePassword(newUser, request.Password).ConfigureAwait(false); } - var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIp().ToString()); + var result = _userManager.GetUserDto(newUser, HttpContext.GetNormalizedRemoteIP().ToString()); return result; } @@ -490,11 +490,11 @@ public class UserController : BaseJellyfinApiController [ProducesResponseType(StatusCodes.Status200OK)] public async Task<ActionResult<ForgotPasswordResult>> ForgotPassword([FromBody, Required] ForgotPasswordDto forgotPasswordRequest) { - var ip = HttpContext.GetNormalizedRemoteIp(); + var ip = HttpContext.GetNormalizedRemoteIP(); var isLocal = HttpContext.IsLocal() || _networkManager.IsInLocalNetwork(ip); - if (isLocal) + if (!isLocal) { _logger.LogWarning("Password reset process initiated from outside the local network with IP: {IP}", ip); } @@ -571,7 +571,7 @@ public class UserController : BaseJellyfinApiController if (filterByNetwork) { - if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIp())) + if (!_networkManager.IsInLocalNetwork(HttpContext.GetNormalizedRemoteIP())) { users = users.Where(i => i.HasPermission(PermissionKind.EnableRemoteAccess)); } @@ -579,7 +579,7 @@ public class UserController : BaseJellyfinApiController var result = users .OrderBy(u => u.Username) - .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIp().ToString())); + .Select(i => _userManager.GetUserDto(i, HttpContext.GetNormalizedRemoteIP().ToString())); return result; } diff --git a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs index 9b0b65b10..24082fcff 100644 --- a/Jellyfin.Api/Helpers/DynamicHlsHelper.cs +++ b/Jellyfin.Api/Helpers/DynamicHlsHelper.cs @@ -206,13 +206,6 @@ public class DynamicHlsHelper if (state.VideoStream is not null && state.VideoRequest is not null) { - // Provide a workaround for the case issue between flac and fLaC. - var flacWaPlaylist = ApplyFlacCaseWorkaround(state, basicPlaylist.ToString()); - if (!string.IsNullOrEmpty(flacWaPlaylist)) - { - builder.Append(flacWaPlaylist); - } - var encodingOptions = _serverConfigurationManager.GetEncodingOptions(); // Provide SDR HEVC entrance for backward compatibility. @@ -242,14 +235,7 @@ public class DynamicHlsHelper } var sdrTotalBitrate = sdrOutputAudioBitrate + sdrOutputVideoBitrate; - var sdrPlaylist = AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); - - // Provide a workaround for the case issue between flac and fLaC. - flacWaPlaylist = ApplyFlacCaseWorkaround(state, sdrPlaylist.ToString()); - if (!string.IsNullOrEmpty(flacWaPlaylist)) - { - builder.Append(flacWaPlaylist); - } + AppendPlaylist(builder, state, sdrVideoUrl, sdrTotalBitrate, subtitleGroup); // Restore the video codec state.OutputVideoCodec = "copy"; @@ -280,17 +266,10 @@ public class DynamicHlsHelper state.VideoStream.Level = originalLevel; var newPlaylist = ReplacePlaylistCodecsField(basicPlaylist, playlistCodecsField, newPlaylistCodecsField); builder.Append(newPlaylist); - - // Provide a workaround for the case issue between flac and fLaC. - flacWaPlaylist = ApplyFlacCaseWorkaround(state, newPlaylist); - if (!string.IsNullOrEmpty(flacWaPlaylist)) - { - builder.Append(flacWaPlaylist); - } } } - if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIp())) + if (EnableAdaptiveBitrateStreaming(state, isLiveStream, enableAdaptiveBitrateStreaming, _httpContextAccessor.HttpContext.GetNormalizedRemoteIP())) { var requestedVideoBitrate = state.VideoRequest is null ? 0 : state.VideoRequest.VideoBitRate ?? 0; @@ -741,7 +720,7 @@ public class DynamicHlsHelper // Currently we only transcode to 8 bits AV1 int bitDepth = 8; if (EncodingHelper.IsCopyCodec(state.OutputVideoCodec) - && state.VideoStream != null + && state.VideoStream is not null && state.VideoStream.BitDepth.HasValue) { bitDepth = state.VideoStream.BitDepth.Value; @@ -815,16 +794,4 @@ public class DynamicHlsHelper newValue.ToString(), StringComparison.Ordinal); } - - private string ApplyFlacCaseWorkaround(StreamState state, string srcPlaylist) - { - if (!string.Equals(state.ActualOutputAudioCodec, "flac", StringComparison.OrdinalIgnoreCase)) - { - return string.Empty; - } - - var newPlaylist = srcPlaylist.Replace(",flac\"", ",fLaC\"", StringComparison.Ordinal); - - return newPlaylist.Contains(",fLaC\"", StringComparison.Ordinal) ? newPlaylist : string.Empty; - } } diff --git a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs index 9a141a16d..5eec1b0ca 100644 --- a/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs +++ b/Jellyfin.Api/Helpers/HlsCodecStringHelpers.cs @@ -5,7 +5,9 @@ using System.Text; namespace Jellyfin.Api.Helpers; /// <summary> -/// Hls Codec string helpers. +/// Helpers to generate HLS codec strings according to +/// <a href="https://datatracker.ietf.org/doc/html/rfc6381#section-3.3">RFC 6381 section 3.3</a> +/// and the <a href="https://mp4ra.org">MP4 Registration Authority</a>. /// </summary> public static class HlsCodecStringHelpers { @@ -27,7 +29,7 @@ public static class HlsCodecStringHelpers /// <summary> /// Codec name for FLAC. /// </summary> - public const string FLAC = "flac"; + public const string FLAC = "fLaC"; /// <summary> /// Codec name for ALAC. @@ -37,7 +39,7 @@ public static class HlsCodecStringHelpers /// <summary> /// Codec name for OPUS. /// </summary> - public const string OPUS = "opus"; + public const string OPUS = "Opus"; /// <summary> /// Gets a MP3 codec string. diff --git a/Jellyfin.Api/Helpers/MediaInfoHelper.cs b/Jellyfin.Api/Helpers/MediaInfoHelper.cs index 5910d8073..a36028cfe 100644 --- a/Jellyfin.Api/Helpers/MediaInfoHelper.cs +++ b/Jellyfin.Api/Helpers/MediaInfoHelper.cs @@ -421,7 +421,7 @@ public class MediaInfoHelper true, true, true, - httpContext.GetNormalizedRemoteIp()); + httpContext.GetNormalizedRemoteIP()); } else { @@ -487,7 +487,7 @@ public class MediaInfoHelper { var isInLocalNetwork = _networkManager.IsInLocalNetwork(ipAddress); - _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIp: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); + _logger.LogInformation("RemoteClientBitrateLimit: {0}, RemoteIP: {1}, IsInLocalNetwork: {2}", remoteClientMaxBitrate, ipAddress, isInLocalNetwork); if (!isInLocalNetwork) { maxBitrate = Math.Min(maxBitrate ?? remoteClientMaxBitrate, remoteClientMaxBitrate); diff --git a/Jellyfin.Api/Helpers/RequestHelpers.cs b/Jellyfin.Api/Helpers/RequestHelpers.cs index 57098edba..bc12ca388 100644 --- a/Jellyfin.Api/Helpers/RequestHelpers.cs +++ b/Jellyfin.Api/Helpers/RequestHelpers.cs @@ -125,7 +125,7 @@ public static class RequestHelpers httpContext.User.GetVersion(), httpContext.User.GetDeviceId(), httpContext.User.GetDevice(), - httpContext.GetNormalizedRemoteIp().ToString(), + httpContext.GetNormalizedRemoteIP().ToString(), user).ConfigureAwait(false); if (session is null) diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 782cd6568..11f6bcf6b 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -191,6 +191,11 @@ public static class StreamingHelpers state.OutputAudioBitrate = encodingHelper.GetAudioBitrateParam(streamingRequest.AudioBitRate, streamingRequest.AudioCodec, state.AudioStream, state.OutputAudioChannels) ?? 0; } + if (outputAudioCodec.StartsWith("pcm_", StringComparison.Ordinal)) + { + containerInternal = ".pcm"; + } + state.OutputAudioCodec = outputAudioCodec; state.OutputContainer = (containerInternal ?? string.Empty).TrimStart('.'); state.OutputAudioChannels = encodingHelper.GetNumAudioChannelsParam(state, state.AudioStream, state.OutputAudioCodec); @@ -243,7 +248,7 @@ public static class StreamingHelpers ? GetOutputFileExtension(state, mediaSource) : ("." + state.OutputContainer); - state.OutputFilePath = GetOutputFilePath(state, ext!, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); + state.OutputFilePath = GetOutputFilePath(state, ext, serverConfigurationManager, streamingRequest.DeviceId, streamingRequest.PlaySessionId); return state; } @@ -416,10 +421,9 @@ public static class StreamingHelpers /// <param name="state">The state.</param> /// <param name="mediaSource">The mediaSource.</param> /// <returns>System.String.</returns> - private static string? GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource) + private static string GetOutputFileExtension(StreamState state, MediaSourceInfo? mediaSource) { var ext = Path.GetExtension(state.RequestedUrl); - if (!string.IsNullOrEmpty(ext)) { return ext; @@ -458,10 +462,9 @@ public static class StreamingHelpers return ".asf"; } } - - // Try to infer based on the desired audio codec - if (!state.IsVideoRequest) + else { + // Try to infer based on the desired audio codec var audioCodec = state.Request.AudioCodec; if (string.Equals("aac", audioCodec, StringComparison.OrdinalIgnoreCase)) @@ -492,7 +495,7 @@ public static class StreamingHelpers return '.' + (idx == -1 ? mediaSource.Container : mediaSource.Container[..idx]).Trim(); } - return null; + throw new InvalidOperationException("Failed to find an appropriate file extension"); } /// <summary> @@ -509,7 +512,7 @@ public static class StreamingHelpers var data = $"{state.MediaPath}-{state.UserAgent}-{deviceId!}-{playSessionId!}"; var filename = data.GetMD5().ToString("N", CultureInfo.InvariantCulture); - var ext = outputFileExtension?.ToLowerInvariant(); + var ext = outputFileExtension.ToLowerInvariant(); var folder = serverConfigurationManager.GetTranscodePath(); return Path.Combine(folder, filename + ext); diff --git a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs index cee8e0f9b..c16a586d6 100644 --- a/Jellyfin.Api/Helpers/TranscodingJobHelper.cs +++ b/Jellyfin.Api/Helpers/TranscodingJobHelper.cs @@ -538,7 +538,7 @@ public class TranscodingJobHelper : IDisposable await _attachmentExtractor.ExtractAllAttachments(state.MediaPath, state.MediaSource, attachmentPath, cancellationTokenSource.Token).ConfigureAwait(false); } - if (state.SubtitleStream.IsExternal && string.Equals(Path.GetExtension(state.SubtitleStream.Path), ".mks", StringComparison.OrdinalIgnoreCase)) + if (state.SubtitleStream.IsExternal && Path.GetExtension(state.SubtitleStream.Path.AsSpan()).Equals(".mks", StringComparison.OrdinalIgnoreCase)) { string subtitlePath = state.SubtitleStream.Path; string subtitlePathArgument = string.Format(CultureInfo.InvariantCulture, "file:\"{0}\"", subtitlePath.Replace("\"", "\\\"", StringComparison.Ordinal)); @@ -620,7 +620,7 @@ public class TranscodingJobHelper : IDisposable state.TranscodingJob = transcodingJob; // Important - don't await the log task or we won't be able to kill FFmpeg when the user stops playback - _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError.BaseStream, logStream); + _ = new JobLogger(_logger).StartStreamingLog(state, process.StandardError, logStream); // Wait for the file to exist before proceeding var ffmpegTargetFile = state.WaitForPath ?? outputPath; diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 6a0a4706b..7ac231885 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -8,8 +8,6 @@ <PropertyGroup> <TargetFramework>net7.0</TargetFramework> <GenerateDocumentationFile>true</GenerateDocumentationFile> - <!-- https://github.com/microsoft/ApplicationInsights-dotnet/issues/2047 --> - <NoWarn>AD0001</NoWarn> </PropertyGroup> <ItemGroup> diff --git a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs index 060c14f89..acbb4877d 100644 --- a/Jellyfin.Api/Middleware/ExceptionMiddleware.cs +++ b/Jellyfin.Api/Middleware/ExceptionMiddleware.cs @@ -122,17 +122,17 @@ public class ExceptionMiddleware private static int GetStatusCode(Exception ex) { - switch (ex) + return ex switch { - case ArgumentException _: return StatusCodes.Status400BadRequest; - case AuthenticationException _: return StatusCodes.Status401Unauthorized; - case SecurityException _: return StatusCodes.Status403Forbidden; - case DirectoryNotFoundException _: - case FileNotFoundException _: - case ResourceNotFoundException _: return StatusCodes.Status404NotFound; - case MethodNotAllowedException _: return StatusCodes.Status405MethodNotAllowed; - default: return StatusCodes.Status500InternalServerError; - } + ArgumentException => StatusCodes.Status400BadRequest, + AuthenticationException => StatusCodes.Status401Unauthorized, + SecurityException => StatusCodes.Status403Forbidden, + DirectoryNotFoundException => StatusCodes.Status404NotFound, + FileNotFoundException => StatusCodes.Status404NotFound, + ResourceNotFoundException => StatusCodes.Status404NotFound, + MethodNotAllowedException => StatusCodes.Status405MethodNotAllowed, + _ => StatusCodes.Status500InternalServerError + }; } private string NormalizeExceptionMessage(string msg) diff --git a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs index f45b6b5c0..27bcd5570 100644 --- a/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs +++ b/Jellyfin.Api/Middleware/IpBasedAccessValidationMiddleware.cs @@ -9,15 +9,15 @@ namespace Jellyfin.Api.Middleware; /// <summary> /// Validates the IP of requests coming from local networks wrt. remote access. /// </summary> -public class IpBasedAccessValidationMiddleware +public class IPBasedAccessValidationMiddleware { private readonly RequestDelegate _next; /// <summary> - /// Initializes a new instance of the <see cref="IpBasedAccessValidationMiddleware"/> class. + /// Initializes a new instance of the <see cref="IPBasedAccessValidationMiddleware"/> class. /// </summary> /// <param name="next">The next delegate in the pipeline.</param> - public IpBasedAccessValidationMiddleware(RequestDelegate next) + public IPBasedAccessValidationMiddleware(RequestDelegate next) { _next = next; } @@ -37,9 +37,9 @@ public class IpBasedAccessValidationMiddleware return; } - var remoteIp = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; + var remoteIP = httpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback; - if (!networkManager.HasRemoteAccess(remoteIp)) + if (!networkManager.HasRemoteAccess(remoteIP)) { return; } diff --git a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs index 9c2194faf..94de30d1b 100644 --- a/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs +++ b/Jellyfin.Api/Middleware/LanFilteringMiddleware.cs @@ -38,7 +38,7 @@ public class LanFilteringMiddleware return; } - var host = httpContext.GetNormalizedRemoteIp(); + var host = httpContext.GetNormalizedRemoteIP(); if (!networkManager.IsInLocalNetwork(host)) { return; diff --git a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs index db3917743..279ea70d8 100644 --- a/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs +++ b/Jellyfin.Api/Middleware/ResponseTimeMiddleware.cs @@ -51,9 +51,9 @@ public class ResponseTimeMiddleware if (enableWarning && responseTimeMs > warningThreshold && _logger.IsEnabled(LogLevel.Debug)) { _logger.LogDebug( - "Slow HTTP Response from {Url} to {RemoteIp} in {Elapsed:g} with Status Code {StatusCode}", + "Slow HTTP Response from {Url} to {RemoteIP} in {Elapsed:g} with Status Code {StatusCode}", context.Request.GetDisplayUrl(), - context.GetNormalizedRemoteIp(), + context.GetNormalizedRemoteIP(), responseTime, context.Response.StatusCode); } diff --git a/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs index 8bf626035..acf3645fd 100644 --- a/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs +++ b/Jellyfin.Api/Middleware/RobotsRedirectionMiddleware.cs @@ -33,8 +33,7 @@ public class RobotsRedirectionMiddleware /// <returns>The async task.</returns> public async Task Invoke(HttpContext httpContext) { - var localPath = httpContext.Request.Path.ToString(); - if (string.Equals(localPath, "/robots.txt", StringComparison.OrdinalIgnoreCase)) + if (httpContext.Request.Path.Equals("/robots.txt", StringComparison.OrdinalIgnoreCase)) { _logger.LogDebug("Redirecting robots.txt request to web/robots.txt"); httpContext.Response.Redirect("web/robots.txt"); diff --git a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs index a34fd01d5..3e3604b2a 100644 --- a/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/CommaDelimitedArrayModelBinder.cs @@ -77,7 +77,7 @@ public class CommaDelimitedArrayModelBinder : IModelBinder var typedValueIndex = 0; for (var i = 0; i < parsedValues.Length; i++) { - if (parsedValues[i] != null) + if (parsedValues[i] is not null) { typedValues.SetValue(parsedValues[i], typedValueIndex); typedValueIndex++; diff --git a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs index cb9a82955..ae9f0a8cd 100644 --- a/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs +++ b/Jellyfin.Api/ModelBinders/PipeDelimitedArrayModelBinder.cs @@ -77,7 +77,7 @@ public class PipeDelimitedArrayModelBinder : IModelBinder var typedValueIndex = 0; for (var i = 0; i < parsedValues.Length; i++) { - if (parsedValues[i] != null) + if (parsedValues[i] is not null) { typedValues.SetValue(parsedValues[i], typedValueIndex); typedValueIndex++; diff --git a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs index 3c903ea6b..2c45e704b 100644 --- a/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs +++ b/Jellyfin.Api/Models/SubtitleDtos/UploadSubtitleDto.cs @@ -26,6 +26,12 @@ public class UploadSubtitleDto public bool IsForced { get; set; } /// <summary> + /// Gets or sets a value indicating whether the subtitle is for hearing impaired. + /// </summary> + [Required] + public bool IsHearingImpaired { get; set; } + + /// <summary> /// Gets or sets the subtitle data. /// </summary> [Required] diff --git a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs index 4a5e0ecd4..5b90d65d8 100644 --- a/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/ActivityLogWebSocketListener.cs @@ -1,6 +1,8 @@ using System; using System.Threading.Tasks; +using Jellyfin.Data.Enums; using Jellyfin.Data.Events; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Session; @@ -9,7 +11,7 @@ using Microsoft.Extensions.Logging; namespace Jellyfin.Api.WebSocketListeners; /// <summary> -/// Class SessionInfoWebSocketListener. +/// Class ActivityLogWebSocketListener. /// </summary> public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<ActivityLogEntry[], WebSocketListenerState> { @@ -56,6 +58,20 @@ public class ActivityLogWebSocketListener : BasePeriodicWebSocketListener<Activi base.Dispose(dispose); } + /// <summary> + /// Starts sending messages over an activity log web socket. + /// </summary> + /// <param name="message">The message.</param> + protected override void Start(WebSocketMessageInfo message) + { + if (!message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) + { + throw new AuthenticationException("Only admin users can retrieve the activity log."); + } + + base.Start(message); + } + private async void OnEntryCreated(object? sender, GenericEventArgs<ActivityLogEntry> e) { await SendData(true).ConfigureAwait(false); diff --git a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs index 0d8bf205c..b403ff46d 100644 --- a/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs +++ b/Jellyfin.Api/WebSocketListeners/SessionInfoWebSocketListener.cs @@ -1,5 +1,7 @@ using System.Collections.Generic; using System.Threading.Tasks; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Session; @@ -66,6 +68,20 @@ public class SessionInfoWebSocketListener : BasePeriodicWebSocketListener<IEnume base.Dispose(dispose); } + /// <summary> + /// Starts sending messages over a session info web socket. + /// </summary> + /// <param name="message">The message.</param> + protected override void Start(WebSocketMessageInfo message) + { + if (!message.Connection.AuthorizationInfo.User.HasPermission(PermissionKind.IsAdministrator)) + { + throw new AuthenticationException("Only admin users can subscribe to session information."); + } + + base.Start(message); + } + private async void OnSessionManagerSessionActivity(object? sender, SessionEventArgs e) { await SendData(false).ConfigureAwait(false); diff --git a/Jellyfin.Data/Entities/User.cs b/Jellyfin.Data/Entities/User.cs index 58ddaaf83..5c3e0338d 100644 --- a/Jellyfin.Data/Entities/User.cs +++ b/Jellyfin.Data/Entities/User.cs @@ -288,6 +288,12 @@ namespace Jellyfin.Data.Entities /// </summary> public SyncPlayUserAccessType SyncPlayAccess { get; set; } + /// <summary> + /// Gets or sets the cast receiver id. + /// </summary> + [StringLength(32)] + public string? CastReceiverId { get; set; } + /// <inheritdoc /> [ConcurrencyCheck] public uint RowVersion { get; private set; } diff --git a/Jellyfin.Data/Enums/PersonKind.cs b/Jellyfin.Data/Enums/PersonKind.cs index 10a805666..29308789a 100644 --- a/Jellyfin.Data/Enums/PersonKind.cs +++ b/Jellyfin.Data/Enums/PersonKind.cs @@ -94,4 +94,40 @@ public enum PersonKind /// A person who was the illustrator. /// </summary> Illustrator, + + /// <summary> + /// A person responsible for drawing the art. + /// </summary> + Penciller, + + /// <summary> + /// A person responsible for inking the pencil art. + /// </summary> + Inker, + + /// <summary> + /// A person responsible for applying color to drawings. + /// </summary> + Colorist, + + /// <summary> + /// A person responsible for drawing text and speech bubbles. + /// </summary> + Letterer, + + /// <summary> + /// A person responsible for drawing the cover art. + /// </summary> + CoverArtist, + + /// <summary> + /// A person contributing to a resource by revising or elucidating the content, e.g., adding an introduction, notes, or other critical matter. + /// An editor may also prepare a resource for production, publication, or distribution. + /// </summary> + Editor, + + /// <summary> + /// A person who renders a text from one language into another. + /// </summary> + Translator } diff --git a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs index 361dbc814..90ebcd390 100644 --- a/Jellyfin.Networking/Configuration/NetworkConfiguration.cs +++ b/Jellyfin.Networking/Configuration/NetworkConfiguration.cs @@ -10,33 +10,18 @@ namespace Jellyfin.Networking.Configuration public class NetworkConfiguration { /// <summary> - /// The default value for <see cref="HttpServerPortNumber"/>. + /// The default value for <see cref="InternalHttpPort"/>. /// </summary> public const int DefaultHttpPort = 8096; /// <summary> - /// The default value for <see cref="PublicHttpsPort"/> and <see cref="HttpsPortNumber"/>. + /// The default value for <see cref="PublicHttpsPort"/> and <see cref="InternalHttpsPort"/>. /// </summary> public const int DefaultHttpsPort = 8920; private string _baseUrl = string.Empty; /// <summary> - /// Gets or sets a value indicating whether the server should force connections over HTTPS. - /// </summary> - public bool RequireHttps { get; set; } - - /// <summary> - /// Gets or sets the filesystem path of an X.509 certificate to use for SSL. - /// </summary> - public string CertificatePath { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>. - /// </summary> - public string CertificatePassword { get; set; } = string.Empty; - - /// <summary> /// Gets or sets a value used to specify the URL prefix that your Jellyfin instance can be accessed at. /// </summary> public string BaseUrl @@ -70,24 +55,6 @@ namespace Jellyfin.Networking.Configuration } /// <summary> - /// Gets or sets the public HTTPS port. - /// </summary> - /// <value>The public HTTPS port.</value> - public int PublicHttpsPort { get; set; } = DefaultHttpsPort; - - /// <summary> - /// Gets or sets the HTTP server port number. - /// </summary> - /// <value>The HTTP server port number.</value> - public int HttpServerPortNumber { get; set; } = DefaultHttpPort; - - /// <summary> - /// Gets or sets the HTTPS server port number. - /// </summary> - /// <value>The HTTPS server port number.</value> - public int HttpsPortNumber { get; set; } = DefaultHttpsPort; - - /// <summary> /// Gets or sets a value indicating whether to use HTTPS. /// </summary> /// <remarks> @@ -97,100 +64,104 @@ namespace Jellyfin.Networking.Configuration public bool EnableHttps { get; set; } /// <summary> - /// Gets or sets the public mapped port. + /// Gets or sets a value indicating whether the server should force connections over HTTPS. /// </summary> - /// <value>The public mapped port.</value> - public int PublicPort { get; set; } = DefaultHttpPort; + public bool RequireHttps { get; set; } /// <summary> - /// Gets or sets a value indicating whether the http port should be mapped as part of UPnP automatic port forwarding. + /// Gets or sets the filesystem path of an X.509 certificate to use for SSL. /// </summary> - public bool UPnPCreateHttpPortMap { get; set; } + public string CertificatePath { get; set; } = string.Empty; /// <summary> - /// Gets or sets the UDPPortRange. + /// Gets or sets the password required to access the X.509 certificate data in the file specified by <see cref="CertificatePath"/>. /// </summary> - public string UDPPortRange { get; set; } = string.Empty; + public string CertificatePassword { get; set; } = string.Empty; /// <summary> - /// Gets or sets a value indicating whether gets or sets IPV6 capability. + /// Gets or sets the internal HTTP server port. /// </summary> - public bool EnableIPV6 { get; set; } + /// <value>The HTTP server port.</value> + public int InternalHttpPort { get; set; } = DefaultHttpPort; /// <summary> - /// Gets or sets a value indicating whether gets or sets IPV4 capability. + /// Gets or sets the internal HTTPS server port. /// </summary> - public bool EnableIPV4 { get; set; } = true; + /// <value>The HTTPS server port.</value> + public int InternalHttpsPort { get; set; } = DefaultHttpsPort; /// <summary> - /// Gets or sets a value indicating whether detailed SSDP logs are sent to the console/log. - /// "Emby.Dlna": "Debug" must be set in logging.default.json for this property to have any effect. + /// Gets or sets the public HTTP port. /// </summary> - public bool EnableSSDPTracing { get; set; } + /// <value>The public HTTP port.</value> + public int PublicHttpPort { get; set; } = DefaultHttpPort; /// <summary> - /// Gets or sets the SSDPTracingFilter - /// Gets or sets a value indicating whether an IP address is to be used to filter the detailed ssdp logs that are being sent to the console/log. - /// If the setting "Emby.Dlna": "Debug" msut be set in logging.default.json for this property to work. + /// Gets or sets the public HTTPS port. /// </summary> - public string SSDPTracingFilter { get; set; } = string.Empty; + /// <value>The public HTTPS port.</value> + public int PublicHttpsPort { get; set; } = DefaultHttpsPort; /// <summary> - /// Gets or sets the number of times SSDP UDP messages are sent. + /// Gets or sets a value indicating whether Autodiscovery is enabled. /// </summary> - public int UDPSendCount { get; set; } = 2; + public bool AutoDiscovery { get; set; } = true; /// <summary> - /// Gets or sets the delay between each groups of SSDP messages (in ms). + /// Gets or sets a value indicating whether to enable automatic port forwarding. /// </summary> - public int UDPSendDelay { get; set; } = 100; + public bool EnableUPnP { get; set; } /// <summary> - /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be Ignore for the purposes of binding. + /// Gets or sets a value indicating whether IPv6 is enabled. /// </summary> - public bool IgnoreVirtualInterfaces { get; set; } = true; + public bool EnableIPv4 { get; set; } = true; /// <summary> - /// Gets or sets a value indicating the interfaces that should be ignored. The list can be comma separated. <seealso cref="IgnoreVirtualInterfaces"/>. + /// Gets or sets a value indicating whether IPv6 is enabled. /// </summary> - public string VirtualInterfaceNames { get; set; } = "vEthernet*"; + public bool EnableIPv6 { get; set; } /// <summary> - /// Gets or sets the time (in seconds) between the pings of SSDP gateway monitor. + /// Gets or sets a value indicating whether access from outside of the LAN is permitted. /// </summary> - public int GatewayMonitorPeriod { get; set; } = 60; + public bool EnableRemoteAccess { get; set; } = true; /// <summary> - /// Gets a value indicating whether multi-socket binding is available. + /// Gets or sets the subnets that are deemed to make up the LAN. /// </summary> - public bool EnableMultiSocketBinding { get; } = true; + public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); /// <summary> - /// Gets or sets a value indicating whether all IPv6 interfaces should be treated as on the internal network. - /// Depending on the address range implemented ULA ranges might not be used. + /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used. /// </summary> - public bool TrustAllIP6Interfaces { get; set; } + public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); /// <summary> - /// Gets or sets the ports that HDHomerun uses. + /// Gets or sets the known proxies. /// </summary> - public string HDHomerunPortRange { get; set; } = string.Empty; + public string[] KnownProxies { get; set; } = Array.Empty<string>(); /// <summary> - /// Gets or sets the PublishedServerUriBySubnet - /// Gets or sets PublishedServerUri to advertise for specific subnets. + /// Gets or sets a value indicating whether address names that match <see cref="VirtualInterfaceNames"/> should be ignored for the purposes of binding. /// </summary> - public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); + public bool IgnoreVirtualInterfaces { get; set; } = true; /// <summary> - /// Gets or sets a value indicating whether Autodiscovery tracing is enabled. + /// Gets or sets a value indicating the interface name prefixes that should be ignored. The list can be comma separated and values are case-insensitive. <seealso cref="IgnoreVirtualInterfaces"/>. /// </summary> - public bool AutoDiscoveryTracing { get; set; } + public string[] VirtualInterfaceNames { get; set; } = new string[] { "veth" }; /// <summary> - /// Gets or sets a value indicating whether Autodiscovery is enabled. + /// Gets or sets a value indicating whether the published server uri is based on information in HTTP requests. /// </summary> - public bool AutoDiscovery { get; set; } = true; + public bool EnablePublishedServerUriByRequest { get; set; } = false; + + /// <summary> + /// Gets or sets the PublishedServerUriBySubnet + /// Gets or sets PublishedServerUri to advertise for specific subnets. + /// </summary> + public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); /// <summary> /// Gets or sets the filter for remote IP connectivity. Used in conjunction with <seealso cref="IsRemoteIPFilterBlacklist"/>. @@ -201,35 +172,5 @@ namespace Jellyfin.Networking.Configuration /// Gets or sets a value indicating whether <seealso cref="RemoteIPFilter"/> contains a blacklist or a whitelist. Default is a whitelist. /// </summary> public bool IsRemoteIPFilterBlacklist { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether to enable automatic port forwarding. - /// </summary> - public bool EnableUPnP { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether access outside of the LAN is permitted. - /// </summary> - public bool EnableRemoteAccess { get; set; } = true; - - /// <summary> - /// Gets or sets the subnets that are deemed to make up the LAN. - /// </summary> - public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets the interface addresses which Jellyfin will bind to. If empty, all interfaces will be used. - /// </summary> - public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets the known proxies. If the proxy is a network, it's added to the KnownNetworks. - /// </summary> - public string[] KnownProxies { get; set; } = Array.Empty<string>(); - - /// <summary> - /// Gets or sets a value indicating whether the published server uri is based on information in HTTP requests. - /// </summary> - public bool EnablePublishedServerUriByRequest { get; set; } = false; } } diff --git a/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs index 8cbe398b0..3ba6bb8fc 100644 --- a/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs +++ b/Jellyfin.Networking/Configuration/NetworkConfigurationExtensions.cs @@ -14,7 +14,7 @@ namespace Jellyfin.Networking.Configuration /// <returns>The <see cref="NetworkConfiguration"/>.</returns> public static NetworkConfiguration GetNetworkConfiguration(this IConfigurationManager config) { - return config.GetConfiguration<NetworkConfiguration>("network"); + return config.GetConfiguration<NetworkConfiguration>(NetworkConfigurationStore.StoreKey); } } } diff --git a/Jellyfin.Networking/Constants/Network.cs b/Jellyfin.Networking/Constants/Network.cs new file mode 100644 index 000000000..7fadc74bb --- /dev/null +++ b/Jellyfin.Networking/Constants/Network.cs @@ -0,0 +1,75 @@ +using System.Net; +using Microsoft.AspNetCore.HttpOverrides; + +namespace Jellyfin.Networking.Constants; + +/// <summary> +/// Networking constants. +/// </summary> +public static class Network +{ + /// <summary> + /// IPv4 mask bytes. + /// </summary> + public const int IPv4MaskBytes = 4; + + /// <summary> + /// IPv6 mask bytes. + /// </summary> + public const int IPv6MaskBytes = 16; + + /// <summary> + /// Minimum IPv4 prefix size. + /// </summary> + public const int MinimumIPv4PrefixSize = 32; + + /// <summary> + /// Minimum IPv6 prefix size. + /// </summary> + public const int MinimumIPv6PrefixSize = 128; + + /// <summary> + /// Whole IPv4 address space. + /// </summary> + public static readonly IPNetwork IPv4Any = new IPNetwork(IPAddress.Any, 0); + + /// <summary> + /// Whole IPv6 address space. + /// </summary> + public static readonly IPNetwork IPv6Any = new IPNetwork(IPAddress.IPv6Any, 0); + + /// <summary> + /// IPv4 Loopback as defined in RFC 5735. + /// </summary> + public static readonly IPNetwork IPv4RFC5735Loopback = new IPNetwork(IPAddress.Loopback, 8); + + /// <summary> + /// IPv4 private class A as defined in RFC 1918. + /// </summary> + public static readonly IPNetwork IPv4RFC1918PrivateClassA = new IPNetwork(IPAddress.Parse("10.0.0.0"), 8); + + /// <summary> + /// IPv4 private class B as defined in RFC 1918. + /// </summary> + public static readonly IPNetwork IPv4RFC1918PrivateClassB = new IPNetwork(IPAddress.Parse("172.16.0.0"), 12); + + /// <summary> + /// IPv4 private class C as defined in RFC 1918. + /// </summary> + public static readonly IPNetwork IPv4RFC1918PrivateClassC = new IPNetwork(IPAddress.Parse("192.168.0.0"), 16); + + /// <summary> + /// IPv6 loopback as defined in RFC 4291. + /// </summary> + public static readonly IPNetwork IPv6RFC4291Loopback = new IPNetwork(IPAddress.IPv6Loopback, 128); + + /// <summary> + /// IPv6 site local as defined in RFC 4291. + /// </summary> + public static readonly IPNetwork IPv6RFC4291SiteLocal = new IPNetwork(IPAddress.Parse("fe80::"), 10); + + /// <summary> + /// IPv6 unique local as defined in RFC 4193. + /// </summary> + public static readonly IPNetwork IPv6RFC4193UniqueLocal = new IPNetwork(IPAddress.Parse("fc00::"), 7); +} diff --git a/Jellyfin.Networking/Extensions/NetworkExtensions.cs b/Jellyfin.Networking/Extensions/NetworkExtensions.cs new file mode 100644 index 000000000..a1e1140f1 --- /dev/null +++ b/Jellyfin.Networking/Extensions/NetworkExtensions.cs @@ -0,0 +1,346 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Net; +using System.Net.Sockets; +using System.Text.RegularExpressions; +using Jellyfin.Extensions; +using Jellyfin.Networking.Constants; +using Microsoft.AspNetCore.HttpOverrides; + +namespace Jellyfin.Networking.Extensions; + +/// <summary> +/// Defines the <see cref="NetworkExtensions" />. +/// </summary> +public static partial class NetworkExtensions +{ + // Use regular expression as CheckHostName isn't RFC5892 compliant. + // Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation + [GeneratedRegex(@"(?im)^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)(:(\d){1,5}){0,1}$", RegexOptions.IgnoreCase, "en-US")] + private static partial Regex FqdnGeneratedRegex(); + + /// <summary> + /// Returns true if the IPAddress contains an IP6 Local link address. + /// </summary> + /// <param name="address">IPAddress object to check.</param> + /// <returns>True if it is a local link address.</returns> + /// <remarks> + /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress + /// it appears that the IPAddress.IsIPv6LinkLocal is out of date. + /// </remarks> + public static bool IsIPv6LinkLocal(IPAddress address) + { + ArgumentNullException.ThrowIfNull(address); + + if (address.IsIPv4MappedToIPv6) + { + address = address.MapToIPv4(); + } + + if (address.AddressFamily != AddressFamily.InterNetworkV6) + { + return false; + } + + // GetAddressBytes + Span<byte> octet = stackalloc byte[16]; + address.TryWriteBytes(octet, out _); + uint word = (uint)(octet[0] << 8) + octet[1]; + + return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link. + } + + /// <summary> + /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only. + /// </summary> + /// <param name="cidr">Subnet mask in CIDR notation.</param> + /// <param name="family">IPv4 or IPv6 family.</param> + /// <returns>String value of the subnet mask in dotted decimal notation.</returns> + public static IPAddress CidrToMask(byte cidr, AddressFamily family) + { + uint addr = 0xFFFFFFFF << ((family == AddressFamily.InterNetwork ? Network.MinimumIPv4PrefixSize : Network.MinimumIPv6PrefixSize) - cidr); + addr = ((addr & 0xff000000) >> 24) + | ((addr & 0x00ff0000) >> 8) + | ((addr & 0x0000ff00) << 8) + | ((addr & 0x000000ff) << 24); + return new IPAddress(addr); + } + + /// <summary> + /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only. + /// </summary> + /// <param name="cidr">Subnet mask in CIDR notation.</param> + /// <param name="family">IPv4 or IPv6 family.</param> + /// <returns>String value of the subnet mask in dotted decimal notation.</returns> + public static IPAddress CidrToMask(int cidr, AddressFamily family) + { + uint addr = 0xFFFFFFFF << ((family == AddressFamily.InterNetwork ? Network.MinimumIPv4PrefixSize : Network.MinimumIPv6PrefixSize) - cidr); + addr = ((addr & 0xff000000) >> 24) + | ((addr & 0x00ff0000) >> 8) + | ((addr & 0x0000ff00) << 8) + | ((addr & 0x000000ff) << 24); + return new IPAddress(addr); + } + + /// <summary> + /// Convert a subnet mask to a CIDR. IPv4 only. + /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask. + /// </summary> + /// <param name="mask">Subnet mask.</param> + /// <returns>Byte CIDR representing the mask.</returns> + public static byte MaskToCidr(IPAddress mask) + { + ArgumentNullException.ThrowIfNull(mask); + + byte cidrnet = 0; + if (mask.Equals(IPAddress.Any)) + { + return cidrnet; + } + + // GetAddressBytes + Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? Network.IPv4MaskBytes : Network.IPv6MaskBytes]; + if (!mask.TryWriteBytes(bytes, out var bytesWritten)) + { + Console.WriteLine("Unable to write address bytes, only ${bytesWritten} bytes written."); + } + + var zeroed = false; + for (var i = 0; i < bytes.Length; i++) + { + for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1) + { + if (zeroed) + { + // Invalid netmask. + return (byte)~cidrnet; + } + + if ((v & 0x80) == 0) + { + zeroed = true; + } + else + { + cidrnet++; + } + } + } + + return cidrnet; + } + + /// <summary> + /// Converts an IPAddress into a string. + /// IPv6 addresses are returned in [ ], with their scope removed. + /// </summary> + /// <param name="address">Address to convert.</param> + /// <returns>URI safe conversion of the address.</returns> + public static string FormatIPString(IPAddress? address) + { + if (address is null) + { + return string.Empty; + } + + var str = address.ToString(); + if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + int i = str.IndexOf('%', StringComparison.Ordinal); + if (i != -1) + { + str = str.Substring(0, i); + } + + return $"[{str}]"; + } + + return str; + } + + /// <summary> + /// Try parsing an array of strings into <see cref="IPNetwork"/> objects, respecting exclusions. + /// Elements without a subnet mask will be represented as <see cref="IPNetwork"/> with a single IP. + /// </summary> + /// <param name="values">Input string array to be parsed.</param> + /// <param name="result">Collection of <see cref="IPNetwork"/>.</param> + /// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param> + /// <returns><c>True</c> if parsing was successful.</returns> + public static bool TryParseToSubnets(string[] values, [NotNullWhen(true)] out IReadOnlyList<IPNetwork>? result, bool negated = false) + { + if (values is null || values.Length == 0) + { + result = null; + return false; + } + + var tmpResult = new List<IPNetwork>(); + for (int a = 0; a < values.Length; a++) + { + if (TryParseToSubnet(values[a], out var innerResult, negated)) + { + tmpResult.Add(innerResult); + } + } + + result = tmpResult; + return tmpResult.Count > 0; + } + + /// <summary> + /// Try parsing a string into an <see cref="IPNetwork"/>, respecting exclusions. + /// Inputs without a subnet mask will be represented as <see cref="IPNetwork"/> with a single IP. + /// </summary> + /// <param name="value">Input string to be parsed.</param> + /// <param name="result">An <see cref="IPNetwork"/>.</param> + /// <param name="negated">Boolean signaling if negated or not negated values should be parsed.</param> + /// <returns><c>True</c> if parsing was successful.</returns> + public static bool TryParseToSubnet(ReadOnlySpan<char> value, [NotNullWhen(true)] out IPNetwork? result, bool negated = false) + { + var splitString = value.Trim().Split('/'); + if (splitString.MoveNext()) + { + var ipBlock = splitString.Current; + var address = IPAddress.None; + if (negated && ipBlock.StartsWith("!") && IPAddress.TryParse(ipBlock[1..], out var tmpAddress)) + { + address = tmpAddress; + } + else if (!negated && IPAddress.TryParse(ipBlock, out tmpAddress)) + { + address = tmpAddress; + } + + if (address != IPAddress.None) + { + if (splitString.MoveNext()) + { + var subnetBlock = splitString.Current; + if (int.TryParse(subnetBlock, out var netmask)) + { + result = new IPNetwork(address, netmask); + return true; + } + else if (IPAddress.TryParse(subnetBlock, out var netmaskAddress)) + { + result = new IPNetwork(address, NetworkExtensions.MaskToCidr(netmaskAddress)); + return true; + } + } + else if (address.AddressFamily == AddressFamily.InterNetwork) + { + result = address.Equals(IPAddress.Any) ? Network.IPv4Any : new IPNetwork(address, Network.MinimumIPv4PrefixSize); + return true; + } + else if (address.AddressFamily == AddressFamily.InterNetworkV6) + { + result = address.Equals(IPAddress.IPv6Any) ? Network.IPv6Any : new IPNetwork(address, Network.MinimumIPv6PrefixSize); + return true; + } + } + } + + result = null; + return false; + } + + /// <summary> + /// Attempts to parse a host span. + /// </summary> + /// <param name="host">Host name to parse.</param> + /// <param name="addresses">Object representing the span, if it has successfully been parsed.</param> + /// <param name="isIPv4Enabled"><c>true</c> if IPv4 is enabled.</param> + /// <param name="isIPv6Enabled"><c>true</c> if IPv6 is enabled.</param> + /// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns> + public static bool TryParseHost(ReadOnlySpan<char> host, [NotNullWhen(true)] out IPAddress[]? addresses, bool isIPv4Enabled = true, bool isIPv6Enabled = false) + { + host = host.Trim(); + if (host.IsEmpty) + { + addresses = null; + return false; + } + + // See if it's an IPv6 with port address e.g. [::1] or [::1]:120. + if (host[0] == '[') + { + int i = host.IndexOf(']'); + if (i != -1) + { + return TryParseHost(host[1..(i - 1)], out addresses); + } + + addresses = Array.Empty<IPAddress>(); + return false; + } + + var hosts = new List<string>(); + foreach (var splitSpan in host.Split(':')) + { + hosts.Add(splitSpan.ToString()); + } + + if (hosts.Count <= 2) + { + var firstPart = hosts[0]; + + // Is hostname or hostname:port + if (FqdnGeneratedRegex().IsMatch(firstPart)) + { + try + { + // .NET automatically filters only supported returned addresses based on OS support. + addresses = Dns.GetHostAddresses(firstPart); + return true; + } + catch (SocketException) + { + // Ignore socket errors, as the result value will just be an empty array. + } + } + + // Is an IPv4 or IPv4:port + if (IPAddress.TryParse(firstPart.AsSpan().LeftPart('/'), out var address)) + { + if (((address.AddressFamily == AddressFamily.InterNetwork) && (!isIPv4Enabled && isIPv6Enabled)) + || ((address.AddressFamily == AddressFamily.InterNetworkV6) && (isIPv4Enabled && !isIPv6Enabled))) + { + addresses = Array.Empty<IPAddress>(); + return false; + } + + addresses = new[] { address }; + + // Host name is an IPv4 address, so fake resolve. + return true; + } + } + else if (hosts.Count > 0 && hosts.Count <= 9) // 8 octets + port + { + if (IPAddress.TryParse(host.LeftPart('/'), out var address)) + { + addresses = new[] { address }; + return true; + } + } + + addresses = Array.Empty<IPAddress>(); + return false; + } + + /// <summary> + /// Gets the broadcast address for a <see cref="IPNetwork"/>. + /// </summary> + /// <param name="network">The <see cref="IPNetwork"/>.</param> + /// <returns>The broadcast address.</returns> + public static IPAddress GetBroadcastAddress(IPNetwork network) + { + var addressBytes = network.Prefix.GetAddressBytes(); + uint ipAddress = BitConverter.ToUInt32(addressBytes, 0); + uint ipMaskV4 = BitConverter.ToUInt32(CidrToMask(network.PrefixLength, AddressFamily.InterNetwork).GetAddressBytes(), 0); + uint broadCastIPAddress = ipAddress | ~ipMaskV4; + + return new IPAddress(BitConverter.GetBytes(broadCastIPAddress)); + } +} diff --git a/Jellyfin.Networking/Manager/NetworkManager.cs b/Jellyfin.Networking/Manager/NetworkManager.cs index afb053820..9c59500d7 100644 --- a/Jellyfin.Networking/Manager/NetworkManager.cs +++ b/Jellyfin.Networking/Manager/NetworkManager.cs @@ -1,93 +1,76 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; using System.Diagnostics.CodeAnalysis; using System.Globalization; using System.Linq; using System.Net; using System.Net.NetworkInformation; using System.Net.Sockets; -using System.Threading.Tasks; +using System.Threading; using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Constants; +using Jellyfin.Networking.Extensions; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Net; +using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; +using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; +using static MediaBrowser.Controller.Extensions.ConfigurationExtensions; namespace Jellyfin.Networking.Manager { /// <summary> /// Class to take care of network interface management. - /// Note: The normal collection methods and properties will not work with Collection{IPObject}. <see cref="MediaBrowser.Common.Net.NetworkExtensions"/>. /// </summary> public class NetworkManager : INetworkManager, IDisposable { /// <summary> - /// Contains the description of the interface along with its index. - /// </summary> - private readonly Dictionary<string, int> _interfaceNames; - - /// <summary> /// Threading lock for network properties. /// </summary> - private readonly object _intLock = new object(); - - /// <summary> - /// List of all interface addresses and masks. - /// </summary> - private readonly Collection<IPObject> _interfaceAddresses; - - /// <summary> - /// List of all interface MAC addresses. - /// </summary> - private readonly List<PhysicalAddress> _macAddresses; + private readonly object _initLock; private readonly ILogger<NetworkManager> _logger; private readonly IConfigurationManager _configurationManager; - private readonly object _eventFireLock; + private readonly IConfiguration _startupConfig; - /// <summary> - /// Holds the bind address overrides. - /// </summary> - private readonly Dictionary<IPNetAddress, string> _publishedServerUrls; + private readonly object _networkEventLock; /// <summary> - /// Used to stop "event-racing conditions". + /// Holds the published server URLs and the IPs to use them on. /// </summary> - private bool _eventfire; + private IReadOnlyList<PublishedServerUriOverride> _publishedServerUrls; - /// <summary> - /// Unfiltered user defined LAN subnets. (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>) - /// or internal interface network subnets if undefined by user. - /// </summary> - private Collection<IPObject> _lanSubnets; + private IReadOnlyList<IPNetwork> _remoteAddressFilter; /// <summary> - /// User defined list of subnets to excluded from the LAN. + /// Used to stop "event-racing conditions". /// </summary> - private Collection<IPObject> _excludedSubnets; + private bool _eventfire; /// <summary> - /// List of interface addresses to bind the WS. + /// List of all interface MAC addresses. /// </summary> - private Collection<IPObject> _bindAddresses; + private IReadOnlyList<PhysicalAddress> _macAddresses; /// <summary> - /// List of interface addresses to exclude from bind. + /// Dictionary containing interface addresses and their subnets. /// </summary> - private Collection<IPObject> _bindExclusions; + private IReadOnlyList<IPData> _interfaces; /// <summary> - /// Caches list of all internal filtered interface addresses and masks. + /// Unfiltered user defined LAN subnets (<see cref="NetworkConfiguration.LocalNetworkSubnets"/>) + /// or internal interface network subnets if undefined by user. /// </summary> - private Collection<IPObject> _internalInterfaces; + private IReadOnlyList<IPNetwork> _lanSubnets; /// <summary> - /// Flag set when no custom LAN has been defined in the configuration. + /// User defined list of subnets to excluded from the LAN. /// </summary> - private bool _usingPrivateAddresses; + private IReadOnlyList<IPNetwork> _excludedSubnets; /// <summary> /// True if this object is disposed. @@ -97,19 +80,24 @@ namespace Jellyfin.Networking.Manager /// <summary> /// Initializes a new instance of the <see cref="NetworkManager"/> class. /// </summary> - /// <param name="configurationManager">IServerConfigurationManager instance.</param> + /// <param name="configurationManager">The <see cref="IConfigurationManager"/> instance.</param> + /// <param name="startupConfig">The <see cref="IConfiguration"/> instance holding startup parameters.</param> /// <param name="logger">Logger to use for messages.</param> #pragma warning disable CS8618 // Non-nullable field is uninitialized. : Values are set in UpdateSettings function. Compiler doesn't yet recognise this. - public NetworkManager(IConfigurationManager configurationManager, ILogger<NetworkManager> logger) + public NetworkManager(IConfigurationManager configurationManager, IConfiguration startupConfig, ILogger<NetworkManager> logger) { - _logger = logger ?? throw new ArgumentNullException(nameof(logger)); - _configurationManager = configurationManager ?? throw new ArgumentNullException(nameof(configurationManager)); - - _interfaceAddresses = new Collection<IPObject>(); + ArgumentNullException.ThrowIfNull(logger); + ArgumentNullException.ThrowIfNull(configurationManager); + + _logger = logger; + _configurationManager = configurationManager; + _startupConfig = startupConfig; + _initLock = new(); + _interfaces = new List<IPData>(); _macAddresses = new List<PhysicalAddress>(); - _interfaceNames = new Dictionary<string, int>(); - _publishedServerUrls = new Dictionary<IPNetAddress, string>(); - _eventFireLock = new object(); + _publishedServerUrls = new List<PublishedServerUriOverride>(); + _networkEventLock = new object(); + _remoteAddressFilter = new List<IPNetwork>(); UpdateSettings(_configurationManager.GetNetworkConfiguration()); @@ -131,46 +119,24 @@ namespace Jellyfin.Networking.Manager public static string MockNetworkSettings { get; set; } = string.Empty; /// <summary> - /// Gets or sets a value indicating whether IP6 is enabled. + /// Gets a value indicating whether IP4 is enabled. /// </summary> - public bool IsIP6Enabled { get; set; } + public bool IsIPv4Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv4; /// <summary> - /// Gets or sets a value indicating whether IP4 is enabled. + /// Gets a value indicating whether IP6 is enabled. /// </summary> - public bool IsIP4Enabled { get; set; } - - /// <inheritdoc/> - public Collection<IPObject> RemoteAddressFilter { get; private set; } + public bool IsIPv6Enabled => _configurationManager.GetNetworkConfiguration().EnableIPv6; /// <summary> /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal. /// </summary> - public bool TrustAllIP6Interfaces { get; internal set; } + public bool TrustAllIPv6Interfaces { get; private set; } /// <summary> /// Gets the Published server override list. /// </summary> - public Dictionary<IPNetAddress, string> PublishedServerUrls => _publishedServerUrls; - - /// <summary> - /// Creates a new network collection. - /// </summary> - /// <param name="source">Items to assign the collection, or null.</param> - /// <returns>The collection created.</returns> - public static Collection<IPObject> CreateCollection(IEnumerable<IPObject>? source = null) - { - var result = new Collection<IPObject>(); - if (source is not null) - { - foreach (var item in source) - { - result.AddItem(item, false); - } - } - - return result; - } + public IReadOnlyList<PublishedServerUriOverride> PublishedServerUrls => _publishedServerUrls; /// <inheritdoc/> public void Dispose() @@ -179,452 +145,495 @@ namespace Jellyfin.Networking.Manager GC.SuppressFinalize(this); } - /// <inheritdoc/> - public IReadOnlyCollection<PhysicalAddress> GetMacAddresses() + /// <summary> + /// Handler for network change events. + /// </summary> + /// <param name="sender">Sender.</param> + /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param> + private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) { - // Populated in construction - so always has values. - return _macAddresses; + _logger.LogDebug("Network availability changed."); + HandleNetworkChange(); } - /// <inheritdoc/> - public bool IsGatewayInterface(IPObject? addressObj) + /// <summary> + /// Handler for network change events. + /// </summary> + /// <param name="sender">Sender.</param> + /// <param name="e">An <see cref="EventArgs"/>.</param> + private void OnNetworkAddressChanged(object? sender, EventArgs e) { - var address = addressObj?.Address ?? IPAddress.None; - return _internalInterfaces.Any(i => i.Address.Equals(address) && i.Tag < 0); + _logger.LogDebug("Network address change detected."); + HandleNetworkChange(); } - /// <inheritdoc/> - public bool IsGatewayInterface(IPAddress? addressObj) + /// <summary> + /// Triggers our event, and re-loads interface information. + /// </summary> + private void HandleNetworkChange() { - return _internalInterfaces.Any(i => i.Address.Equals(addressObj ?? IPAddress.None) && i.Tag < 0); + lock (_networkEventLock) + { + if (!_eventfire) + { + // As network events tend to fire one after the other only fire once every second. + _eventfire = true; + OnNetworkChange(); + } + } } - /// <inheritdoc/> - public Collection<IPObject> GetLoopbacks() + /// <summary> + /// Waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession. + /// </summary> + private void OnNetworkChange() { - Collection<IPObject> nc = new Collection<IPObject>(); - if (IsIP4Enabled) + try { - nc.AddItem(IPAddress.Loopback); - } + Thread.Sleep(2000); + var networkConfig = _configurationManager.GetNetworkConfiguration(); + if (IsIPv6Enabled && !Socket.OSSupportsIPv6) + { + UpdateSettings(networkConfig); + } + else + { + InitializeInterfaces(); + InitializeLan(networkConfig); + EnforceBindSettings(networkConfig); + } - if (IsIP6Enabled) + PrintNetworkInformation(networkConfig); + NetworkChanged?.Invoke(this, EventArgs.Empty); + } + finally { - nc.AddItem(IPAddress.IPv6Loopback); + _eventfire = false; } - - return nc; } - /// <inheritdoc/> - public bool IsExcluded(IPAddress ip) - { - return _excludedSubnets.ContainsAddress(ip); - } - - /// <inheritdoc/> - public bool IsExcluded(EndPoint ip) - { - return ip is not null && IsExcluded(((IPEndPoint)ip).Address); - } - - /// <inheritdoc/> - public Collection<IPObject> CreateIPCollection(string[] values, bool negated = false) + /// <summary> + /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state. + /// Generate a list of all active mac addresses that aren't loopback addresses. + /// </summary> + private void InitializeInterfaces() { - Collection<IPObject> col = new Collection<IPObject>(); - if (values is null) + lock (_initLock) { - return col; - } + _logger.LogDebug("Refreshing interfaces."); - for (int a = 0; a < values.Length; a++) - { - string v = values[a].Trim(); + var interfaces = new List<IPData>(); + var macAddresses = new List<PhysicalAddress>(); try { - if (v.StartsWith('!')) + var nics = NetworkInterface.GetAllNetworkInterfaces() + .Where(i => i.OperationalStatus == OperationalStatus.Up); + + foreach (NetworkInterface adapter in nics) { - if (negated) + try { - AddToCollection(col, v[1..]); - } - } - else if (!negated) - { - AddToCollection(col, v); - } - } - catch (ArgumentException e) - { - _logger.LogWarning(e, "Ignoring LAN value {Value}.", v); - } - } + var ipProperties = adapter.GetIPProperties(); + var mac = adapter.GetPhysicalAddress(); - return col; - } + // Populate MAC list + if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && PhysicalAddress.None.Equals(mac)) + { + macAddresses.Add(mac); + } - /// <inheritdoc/> - public Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false) - { - int count = _bindAddresses.Count; + // Populate interface list + foreach (var info in ipProperties.UnicastAddresses) + { + if (IsIPv4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork) + { + var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) + { + Index = ipProperties.GetIPv4Properties().Index, + Name = adapter.Name, + SupportsMulticast = adapter.SupportsMulticast + }; - if (count == 0) - { - if (_bindExclusions.Count > 0) + interfaces.Add(interfaceObject); + } + else if (IsIPv6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6) + { + var interfaceObject = new IPData(info.Address, new IPNetwork(info.Address, info.PrefixLength), adapter.Name) + { + Index = ipProperties.GetIPv6Properties().Index, + Name = adapter.Name, + SupportsMulticast = adapter.SupportsMulticast + }; + + interfaces.Add(interfaceObject); + } + } + } + catch (Exception ex) + { + // Ignore error, and attempt to continue. + _logger.LogError(ex, "Error encountered parsing interfaces."); + } + } + } + catch (Exception ex) { - // Return all the interfaces except the ones specifically excluded. - return _interfaceAddresses.Exclude(_bindExclusions, false); + _logger.LogError(ex, "Error obtaining interfaces."); } - if (individualInterfaces) + // If no interfaces are found, fallback to loopback interfaces. + if (interfaces.Count == 0) { - return new Collection<IPObject>(_interfaceAddresses); - } + _logger.LogWarning("No interface information available. Using loopback interface(s)."); - // No bind address and no exclusions, so listen on all interfaces. - Collection<IPObject> result = new Collection<IPObject>(); + if (IsIPv4Enabled) + { + interfaces.Add(new IPData(IPAddress.Loopback, Network.IPv4RFC5735Loopback, "lo")); + } - if (IsIP6Enabled && IsIP4Enabled) - { - // Kestrel source code shows it uses Sockets.DualMode - so this also covers IPAddress.Any - result.AddItem(IPAddress.IPv6Any); - } - else if (IsIP4Enabled) - { - result.AddItem(IPAddress.Any); - } - else if (IsIP6Enabled) - { - // Cannot use IPv6Any as Kestrel will bind to IPv4 addresses. - foreach (var iface in _interfaceAddresses) + if (IsIPv6Enabled) { - if (iface.AddressFamily == AddressFamily.InterNetworkV6) - { - result.AddItem(iface.Address); - } + interfaces.Add(new IPData(IPAddress.IPv6Loopback, Network.IPv6RFC4291Loopback, "lo")); } } - return result; - } + _logger.LogDebug("Discovered {NumberOfInterfaces} interfaces.", interfaces.Count); + _logger.LogDebug("Interfaces addresses: {Addresses}", interfaces.OrderByDescending(s => s.AddressFamily == AddressFamily.InterNetwork).Select(s => s.Address.ToString())); - // Remove any excluded bind interfaces. - return _bindAddresses.Exclude(_bindExclusions, false); - } - - /// <inheritdoc/> - public string GetBindInterface(string source, out int? port) - { - if (IPHost.TryParse(source, out IPHost host)) - { - return GetBindInterface(host, out port); + _macAddresses = macAddresses; + _interfaces = interfaces; } - - return GetBindInterface(IPHost.None, out port); } - /// <inheritdoc/> - public string GetBindInterface(IPAddress source, out int? port) - { - return GetBindInterface(new IPNetAddress(source), out port); - } - - /// <inheritdoc/> - public string GetBindInterface(HttpRequest source, out int? port) + /// <summary> + /// Initializes internal LAN cache. + /// </summary> + private void InitializeLan(NetworkConfiguration config) { - string result; - - if (source is not null && IPHost.TryParse(source.Host.Host, out IPHost host)) + lock (_initLock) { - result = GetBindInterface(host, out port); - port ??= source.Host.Port; - } - else - { - result = GetBindInterface(IPNetAddress.None, out port); - port ??= source?.Host.Port; - } - - return result; - } - - /// <inheritdoc/> - public string GetBindInterface(IPObject source, out int? port) - { - port = null; - ArgumentNullException.ThrowIfNull(source); + _logger.LogDebug("Refreshing LAN information."); - // Do we have a source? - bool haveSource = !source.Address.Equals(IPAddress.None); - bool isExternal = false; + // Get configuration options + var subnets = config.LocalNetworkSubnets; - if (haveSource) - { - if (!IsIP6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6) + // If no LAN addresses are specified, all private subnets and Loopback are deemed to be the LAN + if (!NetworkExtensions.TryParseToSubnets(subnets, out var lanSubnets, false) || lanSubnets.Count == 0) { - _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); - } + _logger.LogDebug("Using LAN interface addresses as user provided no LAN details."); - if (!IsIP4Enabled && source.AddressFamily == AddressFamily.InterNetwork) - { - _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); - } + var fallbackLanSubnets = new List<IPNetwork>(); + if (IsIPv6Enabled) + { + fallbackLanSubnets.Add(Network.IPv6RFC4291Loopback); // RFC 4291 (Loopback) + fallbackLanSubnets.Add(Network.IPv6RFC4291SiteLocal); // RFC 4291 (Site local) + fallbackLanSubnets.Add(Network.IPv6RFC4193UniqueLocal); // RFC 4193 (Unique local) + } - isExternal = !IsInLocalNetwork(source); + if (IsIPv4Enabled) + { + fallbackLanSubnets.Add(Network.IPv4RFC5735Loopback); // RFC 5735 (Loopback) + fallbackLanSubnets.Add(Network.IPv4RFC1918PrivateClassA); // RFC 1918 (private Class A) + fallbackLanSubnets.Add(Network.IPv4RFC1918PrivateClassB); // RFC 1918 (private Class B) + fallbackLanSubnets.Add(Network.IPv4RFC1918PrivateClassC); // RFC 1918 (private Class C) + } - if (MatchesPublishedServerUrl(source, isExternal, out string res, out port)) + _lanSubnets = fallbackLanSubnets; + } + else { - _logger.LogDebug("{Source}: Using BindAddress {Address}:{Port}", source, res, port); - return res; + _lanSubnets = lanSubnets; } - } - _logger.LogDebug("GetBindInterface: Source: {HaveSource}, External: {IsExternal}:", haveSource, isExternal); - - // No preference given, so move on to bind addresses. - if (MatchesBindInterface(source, isExternal, out string result)) - { - return result; + _excludedSubnets = NetworkExtensions.TryParseToSubnets(subnets, out var excludedSubnets, true) + ? excludedSubnets + : new List<IPNetwork>(); } + } - if (isExternal && MatchesExternalInterface(source, out result)) - { - return result; - } - - // Get the first LAN interface address that isn't a loopback. - var interfaces = CreateCollection( - _interfaceAddresses - .Exclude(_bindExclusions, false) - .Where(IsInLocalNetwork) - .OrderBy(p => p.Tag)); - - if (interfaces.Count > 0) + /// <summary> + /// Enforce bind addresses and exclusions on available interfaces. + /// </summary> + private void EnforceBindSettings(NetworkConfiguration config) + { + lock (_initLock) { - if (haveSource) + // Respect explicit bind addresses + var interfaces = _interfaces.ToList(); + var localNetworkAddresses = config.LocalNetworkAddresses; + if (localNetworkAddresses.Length > 0 && !string.IsNullOrWhiteSpace(localNetworkAddresses[0])) { - foreach (var intf in interfaces) + var bindAddresses = localNetworkAddresses.Select(p => NetworkExtensions.TryParseToSubnet(p, out var network) + ? network.Prefix + : (interfaces.Where(x => x.Name.Equals(p, StringComparison.OrdinalIgnoreCase)) + .Select(x => x.Address) + .FirstOrDefault() ?? IPAddress.None)) + .Where(x => x != IPAddress.None) + .ToHashSet(); + interfaces = interfaces.Where(x => bindAddresses.Contains(x.Address)).ToList(); + + if (bindAddresses.Contains(IPAddress.Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.Loopback))) { - if (intf.Address.Equals(source.Address)) - { - result = FormatIP6String(intf.Address); - _logger.LogDebug("{Source}: GetBindInterface: Has found matching interface. {Result}", source, result); - return result; - } + interfaces.Add(new IPData(IPAddress.Loopback, Network.IPv4RFC5735Loopback, "lo")); } - // Does the request originate in one of the interface subnets? - // (For systems with multiple internal network cards, and multiple subnets) - foreach (var intf in interfaces) + if (bindAddresses.Contains(IPAddress.IPv6Loopback) && !interfaces.Any(i => i.Address.Equals(IPAddress.IPv6Loopback))) { - if (intf.Contains(source)) - { - result = FormatIP6String(intf.Address); - _logger.LogDebug("{Source}: GetBindInterface: Has source, matched best internal interface on range. {Result}", source, result); - return result; - } + interfaces.Add(new IPData(IPAddress.IPv6Loopback, Network.IPv6RFC4291Loopback, "lo")); } } - result = FormatIP6String(interfaces.First().Address); - _logger.LogDebug("{Source}: GetBindInterface: Matched first internal interface. {Result}", source, result); - return result; - } - - // There isn't any others, so we'll use the loopback. - result = IsIP6Enabled ? "::1" : "127.0.0.1"; - _logger.LogWarning("{Source}: GetBindInterface: Loopback {Result} returned.", source, result); - return result; - } + // Remove all interfaces matching any virtual machine interface prefix + if (config.IgnoreVirtualInterfaces) + { + // Remove potentially existing * and split config string into prefixes + var virtualInterfacePrefixes = config.VirtualInterfaceNames + .Select(i => i.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase)); - /// <inheritdoc/> - public Collection<IPObject> GetInternalBindAddresses() - { - int count = _bindAddresses.Count; + // Check all interfaces for matches against the prefixes and remove them + if (_interfaces.Count > 0) + { + foreach (var virtualInterfacePrefix in virtualInterfacePrefixes) + { + interfaces.RemoveAll(x => x.Name.StartsWith(virtualInterfacePrefix, StringComparison.OrdinalIgnoreCase)); + } + } + } - if (count == 0) - { - if (_bindExclusions.Count > 0) + // Remove all IPv4 interfaces if IPv4 is disabled + if (!IsIPv4Enabled) { - // Return all the internal interfaces except the ones excluded. - return CreateCollection(_internalInterfaces.Where(p => !_bindExclusions.ContainsAddress(p))); + interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetwork); } - // No bind address, so return all internal interfaces. - return CreateCollection(_internalInterfaces); - } - - return new Collection<IPObject>(_bindAddresses.Where(a => IsInLocalNetwork(a)).ToArray()); - } - - /// <inheritdoc/> - public bool IsInLocalNetwork(IPObject address) - { - return IsInLocalNetwork(address.Address); - } - - /// <inheritdoc/> - public bool IsInLocalNetwork(string address) - { - return IPHost.TryParse(address, out IPHost ipHost) && IsInLocalNetwork(ipHost); - } - - /// <inheritdoc/> - public bool IsInLocalNetwork(IPAddress address) - { - ArgumentNullException.ThrowIfNull(address); - - if (address.Equals(IPAddress.None)) - { - return false; - } + // Remove all IPv6 interfaces if IPv6 is disabled + if (!IsIPv6Enabled) + { + interfaces.RemoveAll(x => x.AddressFamily == AddressFamily.InterNetworkV6); + } - // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. - if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) - { - return true; + _interfaces = interfaces; } - - // As private addresses can be redefined by Configuration.LocalNetworkAddresses - return IPAddress.IsLoopback(address) || (_lanSubnets.ContainsAddress(address) && !_excludedSubnets.ContainsAddress(address)); } - /// <inheritdoc/> - public bool IsPrivateAddressRange(IPObject address) + /// <summary> + /// Initializes the remote address values. + /// </summary> + private void InitializeRemote(NetworkConfiguration config) { - ArgumentNullException.ThrowIfNull(address); - - // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. - if (TrustAllIP6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) + lock (_initLock) { - return true; - } - - return address.IsPrivateAddressRange(); - } + // Parse config values into filter collection + var remoteIPFilter = config.RemoteIPFilter; + if (remoteIPFilter.Any() && !string.IsNullOrWhiteSpace(remoteIPFilter.First())) + { + // Parse all IPs with netmask to a subnet + var remoteAddressFilter = new List<IPNetwork>(); + var remoteFilteredSubnets = remoteIPFilter.Where(x => x.Contains('/', StringComparison.OrdinalIgnoreCase)).ToArray(); + if (NetworkExtensions.TryParseToSubnets(remoteFilteredSubnets, out var remoteAddressFilterResult, false)) + { + remoteAddressFilter = remoteAddressFilterResult.ToList(); + } - /// <inheritdoc/> - public bool IsExcludedInterface(IPAddress address) - { - return _bindExclusions.ContainsAddress(address); - } + // Parse everything else as an IP and construct subnet with a single IP + var remoteFilteredIPs = remoteIPFilter.Where(x => !x.Contains('/', StringComparison.OrdinalIgnoreCase)); + foreach (var ip in remoteFilteredIPs) + { + if (IPAddress.TryParse(ip, out var ipp)) + { + remoteAddressFilter.Add(new IPNetwork(ipp, ipp.AddressFamily == AddressFamily.InterNetwork ? Network.MinimumIPv4PrefixSize : Network.MinimumIPv6PrefixSize)); + } + } - /// <inheritdoc/> - public Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null) - { - if (filter is null) - { - return _lanSubnets.Exclude(_excludedSubnets, true).AsNetworks(); + _remoteAddressFilter = remoteAddressFilter; + } } - - return _lanSubnets.Exclude(filter, true); - } - - /// <inheritdoc/> - public bool IsValidInterfaceAddress(IPAddress address) - { - return _interfaceAddresses.ContainsAddress(address); } - /// <inheritdoc/> - public bool TryParseInterface(string token, out Collection<IPObject>? result) + /// <summary> + /// Parses the user defined overrides into the dictionary object. + /// Overrides are the equivalent of localised publishedServerUrl, enabling + /// different addresses to be advertised over different subnets. + /// format is subnet=ipaddress|host|uri + /// when subnet = 0.0.0.0, any external address matches. + /// </summary> + private void InitializeOverrides(NetworkConfiguration config) { - result = null; - if (string.IsNullOrEmpty(token)) - { - return false; - } - - if (_interfaceNames is not null && _interfaceNames.TryGetValue(token.ToLower(CultureInfo.InvariantCulture), out int index)) + lock (_initLock) { - result = new Collection<IPObject>(); + var publishedServerUrls = new List<PublishedServerUriOverride>(); - _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token); + // Prefer startup configuration. + var startupOverrideKey = _startupConfig[AddressOverrideKey]; + if (!string.IsNullOrEmpty(startupOverrideKey)) + { + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.Any, Network.IPv4Any), + startupOverrideKey, + true, + true)); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.IPv6Any, Network.IPv6Any), + startupOverrideKey, + true, + true)); + _publishedServerUrls = publishedServerUrls; + return; + } - // Replace interface tags with the interface IP's. - foreach (IPNetAddress iface in _interfaceAddresses) + var overrides = config.PublishedServerUriBySubnet; + foreach (var entry in overrides) { - if (Math.Abs(iface.Tag) == index - && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork) - || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6))) + var parts = entry.Split('='); + if (parts.Length != 2) + { + _logger.LogError("Unable to parse bind override: {Entry}", entry); + return; + } + + var replacement = parts[1].Trim(); + var identifier = parts[0]; + if (string.Equals(identifier, "all", StringComparison.OrdinalIgnoreCase)) { - result.AddItem(iface, false); + // Drop any other overrides in case an "all" override exists + publishedServerUrls.Clear(); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.Any, Network.IPv4Any), + replacement, + true, + true)); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.IPv6Any, Network.IPv6Any), + replacement, + true, + true)); + break; + } + else if (string.Equals(identifier, "external", StringComparison.OrdinalIgnoreCase)) + { + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.Any, Network.IPv4Any), + replacement, + false, + true)); + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(IPAddress.IPv6Any, Network.IPv6Any), + replacement, + false, + true)); + } + else if (string.Equals(identifier, "internal", StringComparison.OrdinalIgnoreCase)) + { + foreach (var lan in _lanSubnets) + { + var lanPrefix = lan.Prefix; + publishedServerUrls.Add( + new PublishedServerUriOverride( + new IPData(lanPrefix, new IPNetwork(lanPrefix, lan.PrefixLength)), + replacement, + true, + false)); + } + } + else if (NetworkExtensions.TryParseToSubnet(identifier, out var result) && result is not null) + { + var data = new IPData(result.Prefix, result); + publishedServerUrls.Add( + new PublishedServerUriOverride( + data, + replacement, + true, + true)); + } + else if (TryParseInterface(identifier, out var ifaces)) + { + foreach (var iface in ifaces) + { + publishedServerUrls.Add( + new PublishedServerUriOverride( + iface, + replacement, + true, + true)); + } + } + else + { + _logger.LogError("Unable to parse bind override: {Entry}", entry); } } - return true; + _publishedServerUrls = publishedServerUrls; } - - return false; } - /// <inheritdoc/> - public bool HasRemoteAccess(IPAddress remoteIp) + private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) { - var config = _configurationManager.GetNetworkConfiguration(); - if (config.EnableRemoteAccess) - { - // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. - // If left blank, all remote addresses will be allowed. - if (RemoteAddressFilter.Count > 0 && !IsInLocalNetwork(remoteIp)) - { - // remoteAddressFilter is a whitelist or blacklist. - return RemoteAddressFilter.ContainsAddress(remoteIp) == !config.IsRemoteIPFilterBlacklist; - } - } - else if (!IsInLocalNetwork(remoteIp)) + if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) { - // Remote not enabled. So everyone should be LAN. - return false; + UpdateSettings((NetworkConfiguration)evt.NewConfiguration); } - - return true; } /// <summary> - /// Reloads all settings and re-initialises the instance. + /// Reloads all settings and re-Initializes the instance. /// </summary> /// <param name="configuration">The <see cref="NetworkConfiguration"/> to use.</param> public void UpdateSettings(object configuration) { - NetworkConfiguration config = (NetworkConfiguration)configuration ?? throw new ArgumentNullException(nameof(configuration)); - - IsIP4Enabled = Socket.OSSupportsIPv4 && config.EnableIPV4; - IsIP6Enabled = Socket.OSSupportsIPv6 && config.EnableIPV6; - HappyEyeballs.HttpClientExtension.UseIPv6 = IsIP6Enabled; + ArgumentNullException.ThrowIfNull(configuration); - if (!IsIP6Enabled && !IsIP4Enabled) - { - _logger.LogError("IPv4 and IPv6 cannot both be disabled."); - IsIP4Enabled = true; - } + var config = (NetworkConfiguration)configuration; + HappyEyeballs.HttpClientExtension.UseIPv6 = config.EnableIPv6; - TrustAllIP6Interfaces = config.TrustAllIP6Interfaces; + InitializeLan(config); + InitializeRemote(config); if (string.IsNullOrEmpty(MockNetworkSettings)) { - InitialiseInterfaces(); + InitializeInterfaces(); } else // Used in testing only. { // Format is <IPAddress>,<Index>,<Name>: <next interface>. Set index to -ve to simulate a gateway. var interfaceList = MockNetworkSettings.Split('|'); + var interfaces = new List<IPData>(); foreach (var details in interfaceList) { var parts = details.Split(','); - var address = IPNetAddress.Parse(parts[0]); - var index = int.Parse(parts[1], CultureInfo.InvariantCulture); - address.Tag = index; - _interfaceAddresses.AddItem(address, false); - _interfaceNames[parts[2]] = Math.Abs(index); + if (NetworkExtensions.TryParseToSubnet(parts[0], out var subnet)) + { + var address = subnet.Prefix; + var index = int.Parse(parts[1], CultureInfo.InvariantCulture); + if (address.AddressFamily == AddressFamily.InterNetwork || address.AddressFamily == AddressFamily.InterNetworkV6) + { + var data = new IPData(address, subnet, parts[2]) + { + Index = index + }; + interfaces.Add(data); + } + } + else + { + _logger.LogWarning("Could not parse mock interface settings: {Part}", details); + } } + + _interfaces = interfaces; } - InitialiseLAN(config); - InitialiseBind(config); - InitialiseRemote(config); - InitialiseOverrides(config); + EnforceBindSettings(config); + InitializeOverrides(config); + + PrintNetworkInformation(config, false); } /// <summary> @@ -646,562 +655,341 @@ namespace Jellyfin.Networking.Manager } } - /// <summary> - /// Tries to identify the string and return an object of that class. - /// </summary> - /// <param name="addr">String to parse.</param> - /// <param name="result">IPObject to return.</param> - /// <returns><c>true</c> if the value parsed successfully, <c>false</c> otherwise.</returns> - private static bool TryParse(string addr, out IPObject result) + /// <inheritdoc/> + public bool TryParseInterface(string intf, [NotNullWhen(true)] out IReadOnlyList<IPData>? result) { - if (!string.IsNullOrEmpty(addr)) + if (string.IsNullOrEmpty(intf) + || _interfaces is null + || _interfaces.Count == 0) { - // Is it an IP address - if (IPNetAddress.TryParse(addr, out IPNetAddress nw)) - { - result = nw; - return true; - } - - if (IPHost.TryParse(addr, out IPHost h)) - { - result = h; - return true; - } + result = null; + return false; } - result = IPNetAddress.None; - return false; + // Match all interfaces starting with names starting with token + result = _interfaces + .Where(i => i.Name.Equals(intf, StringComparison.OrdinalIgnoreCase) + && ((IsIPv4Enabled && i.Address.AddressFamily == AddressFamily.InterNetwork) + || (IsIPv6Enabled && i.Address.AddressFamily == AddressFamily.InterNetworkV6))) + .OrderBy(x => x.Index) + .ToArray(); + return result.Count > 0; } - /// <summary> - /// Converts an IPAddress into a string. - /// Ipv6 addresses are returned in [ ], with their scope removed. - /// </summary> - /// <param name="address">Address to convert.</param> - /// <returns>URI safe conversion of the address.</returns> - private static string FormatIP6String(IPAddress address) + /// <inheritdoc/> + public bool HasRemoteAccess(IPAddress remoteIP) { - var str = address.ToString(); - if (address.AddressFamily == AddressFamily.InterNetworkV6) + var config = _configurationManager.GetNetworkConfiguration(); + if (config.EnableRemoteAccess) { - int i = str.IndexOf("%", StringComparison.OrdinalIgnoreCase); - if (i != -1) + // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. + // If left blank, all remote addresses will be allowed. + if (_remoteAddressFilter.Any() && !_lanSubnets.Any(x => x.Contains(remoteIP))) { - str = str.Substring(0, i); - } + // remoteAddressFilter is a whitelist or blacklist. + var matches = _remoteAddressFilter.Count(remoteNetwork => remoteNetwork.Contains(remoteIP)); + if ((!config.IsRemoteIPFilterBlacklist && matches > 0) + || (config.IsRemoteIPFilterBlacklist && matches == 0)) + { + return true; + } - return $"[{str}]"; + return false; + } + } + else if (!_lanSubnets.Any(x => x.Contains(remoteIP))) + { + // Remote not enabled. So everyone should be LAN. + return false; } - return str; + return true; } - private void ConfigurationUpdated(object? sender, ConfigurationUpdateEventArgs evt) + /// <inheritdoc/> + public IReadOnlyList<PhysicalAddress> GetMacAddresses() { - if (evt.Key.Equals(NetworkConfigurationStore.StoreKey, StringComparison.Ordinal)) - { - UpdateSettings((NetworkConfiguration)evt.NewConfiguration); - } + // Populated in construction - so always has values. + return _macAddresses; } - /// <summary> - /// Checks the string to see if it matches any interface names. - /// </summary> - /// <param name="token">String to check.</param> - /// <param name="index">Interface index numbers that match.</param> - /// <returns><c>true</c> if an interface name matches the token, <c>False</c> otherwise.</returns> - private bool TryGetInterfaces(string token, [NotNullWhen(true)] out List<int>? index) + /// <inheritdoc/> + public IReadOnlyList<IPData> GetLoopbacks() { - index = null; + if (!IsIPv4Enabled && !IsIPv6Enabled) + { + return Array.Empty<IPData>(); + } - // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1. - // Null check required here for automated testing. - if (_interfaceNames is not null && token.Length > 1) + var loopbackNetworks = new List<IPData>(); + if (IsIPv4Enabled) { - bool partial = token[^1] == '*'; - if (partial) - { - token = token[..^1]; - } + loopbackNetworks.Add(new IPData(IPAddress.Loopback, Network.IPv4RFC5735Loopback, "lo")); + } - foreach ((string interfc, int interfcIndex) in _interfaceNames) - { - if ((!partial && string.Equals(interfc, token, StringComparison.OrdinalIgnoreCase)) - || (partial && interfc.StartsWith(token, true, CultureInfo.InvariantCulture))) - { - index ??= new List<int>(); - index.Add(interfcIndex); - } - } + if (IsIPv6Enabled) + { + loopbackNetworks.Add(new IPData(IPAddress.IPv6Loopback, Network.IPv6RFC4291Loopback, "lo")); } - return index is not null; + return loopbackNetworks; } - /// <summary> - /// Parses a string and adds it into the collection, replacing any interface references. - /// </summary> - /// <param name="col"><see cref="Collection{IPObject}"/>Collection.</param> - /// <param name="token">String value to parse.</param> - private void AddToCollection(Collection<IPObject> col, string token) + /// <inheritdoc/> + public IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false) { - // Is it the name of an interface (windows) eg, Wireless LAN adapter Wireless Network Connection 1. - // Null check required here for automated testing. - if (TryGetInterfaces(token, out var indices)) + if (_interfaces.Count > 0 || individualInterfaces) { - _logger.LogInformation("Interface {Token} used in settings. Using its interface addresses.", token); + return _interfaces; + } - // Replace all the interface tags with the interface IP's. - foreach (IPNetAddress iface in _interfaceAddresses) - { - if (indices.Contains(Math.Abs(iface.Tag)) - && ((IsIP4Enabled && iface.Address.AddressFamily == AddressFamily.InterNetwork) - || (IsIP6Enabled && iface.Address.AddressFamily == AddressFamily.InterNetworkV6))) - { - col.AddItem(iface); - } - } + // No bind address and no exclusions, so listen on all interfaces. + var result = new List<IPData>(); + if (IsIPv4Enabled && IsIPv6Enabled) + { + // Kestrel source code shows it uses Sockets.DualMode - so this also covers IPAddress.Any by default + result.Add(new IPData(IPAddress.IPv6Any, Network.IPv6Any)); } - else if (TryParse(token, out IPObject obj)) + else if (IsIPv4Enabled) { - // Expand if the ip address is "any". - if ((obj.Address.Equals(IPAddress.Any) && IsIP4Enabled) - || (obj.Address.Equals(IPAddress.IPv6Any) && IsIP6Enabled)) - { - foreach (IPNetAddress iface in _interfaceAddresses) - { - if (obj.AddressFamily == iface.AddressFamily) - { - col.AddItem(iface); - } - } - } - else if (!IsIP6Enabled) - { - // Remove IP6 addresses from multi-homed IPHosts. - obj.Remove(AddressFamily.InterNetworkV6); - if (!obj.IsIP6()) - { - col.AddItem(obj); - } - } - else if (!IsIP4Enabled) + result.Add(new IPData(IPAddress.Any, Network.IPv4Any)); + } + else if (IsIPv6Enabled) + { + // Cannot use IPv6Any as Kestrel will bind to IPv4 addresses too. + foreach (var iface in _interfaces) { - // Remove IP4 addresses from multi-homed IPHosts. - obj.Remove(AddressFamily.InterNetwork); - if (obj.IsIP6()) + if (iface.AddressFamily == AddressFamily.InterNetworkV6) { - col.AddItem(obj); + result.Add(iface); } } - else - { - col.AddItem(obj); - } - } - else - { - _logger.LogDebug("Invalid or unknown object {Token}.", token); } + + return result; } - /// <summary> - /// Handler for network change events. - /// </summary> - /// <param name="sender">Sender.</param> - /// <param name="e">A <see cref="NetworkAvailabilityEventArgs"/> containing network availability information.</param> - private void OnNetworkAvailabilityChanged(object? sender, NetworkAvailabilityEventArgs e) + /// <inheritdoc/> + public string GetBindAddress(string source, out int? port) { - _logger.LogDebug("Network availability changed."); - OnNetworkChanged(); + if (!NetworkExtensions.TryParseHost(source, out var addresses, IsIPv4Enabled, IsIPv6Enabled)) + { + addresses = Array.Empty<IPAddress>(); + } + + var result = GetBindAddress(addresses.FirstOrDefault(), out port); + return result; } - /// <summary> - /// Handler for network change events. - /// </summary> - /// <param name="sender">Sender.</param> - /// <param name="e">An <see cref="EventArgs"/>.</param> - private void OnNetworkAddressChanged(object? sender, EventArgs e) + /// <inheritdoc/> + public string GetBindAddress(HttpRequest source, out int? port) { - _logger.LogDebug("Network address change detected."); - OnNetworkChanged(); + var result = GetBindAddress(source.Host.Host, out port); + port ??= source.Host.Port; + + return result; } - /// <summary> - /// Async task that waits for 2 seconds before re-initialising the settings, as typically these events fire multiple times in succession. - /// </summary> - /// <returns>A <see cref="Task"/> representing the asynchronous operation.</returns> - private async Task OnNetworkChangeAsync() + /// <inheritdoc/> + public string GetBindAddress(IPAddress? source, out int? port, bool skipOverrides = false) { - try - { - await Task.Delay(2000).ConfigureAwait(false); + port = null; + + string result; - var config = _configurationManager.GetNetworkConfiguration(); - // Have we lost IPv6 capability? - if (IsIP6Enabled && !Socket.OSSupportsIPv6) + if (source is not null) + { + if (IsIPv4Enabled && !IsIPv6Enabled && source.AddressFamily == AddressFamily.InterNetworkV6) { - UpdateSettings(config); + _logger.LogWarning("IPv6 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); } - else + + if (!IsIPv4Enabled && IsIPv6Enabled && source.AddressFamily == AddressFamily.InterNetwork) { - InitialiseInterfaces(); - // Recalculate LAN caches. - InitialiseLAN(config); + _logger.LogWarning("IPv4 is disabled in Jellyfin, but enabled in the OS. This may affect how the interface is selected."); } - NetworkChanged?.Invoke(this, EventArgs.Empty); - } - finally - { - _eventfire = false; - } - } + bool isExternal = !_lanSubnets.Any(network => network.Contains(source)); + _logger.LogDebug("Trying to get bind address for source {Source} - External: {IsExternal}", source, isExternal); - /// <summary> - /// Triggers our event, and re-loads interface information. - /// </summary> - private void OnNetworkChanged() - { - lock (_eventFireLock) - { - if (!_eventfire) + if (!skipOverrides && MatchesPublishedServerUrl(source, isExternal, out result)) { - _logger.LogDebug("Network Address Change Event."); - // As network events tend to fire one after the other only fire once every second. - _eventfire = true; - OnNetworkChangeAsync().GetAwaiter().GetResult(); + return result; } - } - } - /// <summary> - /// Parses the user defined overrides into the dictionary object. - /// Overrides are the equivalent of localised publishedServerUrl, enabling - /// different addresses to be advertised over different subnets. - /// format is subnet=ipaddress|host|uri - /// when subnet = 0.0.0.0, any external address matches. - /// </summary> - private void InitialiseOverrides(NetworkConfiguration config) - { - lock (_intLock) - { - _publishedServerUrls.Clear(); - string[] overrides = config.PublishedServerUriBySubnet; - if (overrides is null) + // No preference given, so move on to bind addresses. + if (MatchesBindInterface(source, isExternal, out result)) { - return; + return result; } - foreach (var entry in overrides) + if (isExternal && MatchesExternalInterface(source, out result)) { - var parts = entry.Split('='); - if (parts.Length != 2) - { - _logger.LogError("Unable to parse bind override: {Entry}", entry); - } - else - { - var replacement = parts[1].Trim(); - if (string.Equals(parts[0], "all", StringComparison.OrdinalIgnoreCase)) - { - _publishedServerUrls[new IPNetAddress(IPAddress.Broadcast)] = replacement; - } - else if (string.Equals(parts[0], "external", StringComparison.OrdinalIgnoreCase)) - { - _publishedServerUrls[new IPNetAddress(IPAddress.Any)] = replacement; - } - else if (TryParseInterface(parts[0], out Collection<IPObject>? addresses) && addresses is not null) - { - foreach (IPNetAddress na in addresses) - { - _publishedServerUrls[na] = replacement; - } - } - else if (IPNetAddress.TryParse(parts[0], out IPNetAddress result)) - { - _publishedServerUrls[result] = replacement; - } - else - { - _logger.LogError("Unable to parse bind ip address. {Parts}", parts[1]); - } - } + return result; } } - } - /// <summary> - /// Initialises the network bind addresses. - /// </summary> - private void InitialiseBind(NetworkConfiguration config) - { - lock (_intLock) + // Get the first LAN interface address that's not excluded and not a loopback address. + // Get all available interfaces, prefer local interfaces + var availableInterfaces = _interfaces.Where(x => !IPAddress.IsLoopback(x.Address)) + .OrderByDescending(x => IsInLocalNetwork(x.Address)) + .ThenBy(x => x.Index) + .ToList(); + + if (availableInterfaces.Count == 0) { - string[] lanAddresses = config.LocalNetworkAddresses; + // There isn't any others, so we'll use the loopback. + result = IsIPv4Enabled && !IsIPv6Enabled ? "127.0.0.1" : "::1"; + _logger.LogWarning("{Source}: Only loopback {Result} returned, using that as bind address.", source, result); + return result; + } - // Add virtual machine interface names to the list of bind exclusions, so that they are auto-excluded. - if (config.IgnoreVirtualInterfaces) + // If no source address is given, use the preferred (first) interface + if (source is null) + { + result = NetworkExtensions.FormatIPString(availableInterfaces.First().Address); + _logger.LogDebug("{Source}: Using first internal interface as bind address: {Result}", source, result); + return result; + } + + // Does the request originate in one of the interface subnets? + // (For systems with multiple internal network cards, and multiple subnets) + foreach (var intf in availableInterfaces) + { + if (intf.Subnet.Contains(source)) { - // each virtual interface name must be prepended with the exclusion symbol ! - var virtualInterfaceNames = config.VirtualInterfaceNames.Split(',').Select(p => "!" + p).ToArray(); - if (lanAddresses.Length > 0) - { - var newList = new string[lanAddresses.Length + virtualInterfaceNames.Length]; - Array.Copy(lanAddresses, newList, lanAddresses.Length); - Array.Copy(virtualInterfaceNames, 0, newList, lanAddresses.Length, virtualInterfaceNames.Length); - lanAddresses = newList; - } - else - { - lanAddresses = virtualInterfaceNames; - } + result = NetworkExtensions.FormatIPString(intf.Address); + _logger.LogDebug("{Source}: Found interface with matching subnet, using it as bind address: {Result}", source, result); + return result; } - - // Read and parse bind addresses and exclusions, removing ones that don't exist. - _bindAddresses = CreateIPCollection(lanAddresses).ThatAreContainedInNetworks(_interfaceAddresses); - _bindExclusions = CreateIPCollection(lanAddresses, true).ThatAreContainedInNetworks(_interfaceAddresses); - _logger.LogInformation("Using bind addresses: {0}", _bindAddresses.AsString()); - _logger.LogInformation("Using bind exclusions: {0}", _bindExclusions.AsString()); } + + // Fallback to first available interface + result = NetworkExtensions.FormatIPString(availableInterfaces[0].Address); + _logger.LogDebug("{Source}: No matching interfaces found, using preferred interface as bind address: {Result}", source, result); + return result; } - /// <summary> - /// Initialises the remote address values. - /// </summary> - private void InitialiseRemote(NetworkConfiguration config) + /// <inheritdoc/> + public IReadOnlyList<IPData> GetInternalBindAddresses() { - lock (_intLock) - { - RemoteAddressFilter = CreateIPCollection(config.RemoteIPFilter); - } + // Select all local bind addresses + return _interfaces.Where(x => IsInLocalNetwork(x.Address)) + .OrderBy(x => x.Index) + .ToList(); } - /// <summary> - /// Initialises internal LAN cache settings. - /// </summary> - private void InitialiseLAN(NetworkConfiguration config) + /// <inheritdoc/> + public bool IsInLocalNetwork(string address) { - lock (_intLock) + if (NetworkExtensions.TryParseToSubnet(address, out var subnet)) { - _logger.LogDebug("Refreshing LAN information."); - - // Get configuration options. - string[] subnets = config.LocalNetworkSubnets; - - // Create lists from user settings. - - _lanSubnets = CreateIPCollection(subnets); - _excludedSubnets = CreateIPCollection(subnets, true).AsNetworks(); - - // If no LAN addresses are specified - all private subnets are deemed to be the LAN - _usingPrivateAddresses = _lanSubnets.Count == 0; + return IPAddress.IsLoopback(subnet.Prefix) || (_lanSubnets.Any(x => x.Contains(subnet.Prefix)) && !_excludedSubnets.Any(x => x.Contains(subnet.Prefix))); + } - // NOTE: The order of the commands generating the collection in this statement matters. - // Altering the order will cause the collections to be created incorrectly. - if (_usingPrivateAddresses) + if (NetworkExtensions.TryParseHost(address, out var addresses, IsIPv4Enabled, IsIPv6Enabled)) + { + foreach (var ept in addresses) { - _logger.LogDebug("Using LAN interface addresses as user provided no LAN details."); - // Internal interfaces must be private and not excluded. - _internalInterfaces = CreateCollection(_interfaceAddresses.Where(i => IsPrivateAddressRange(i) && !_excludedSubnets.ContainsAddress(i))); - - // Subnets are the same as the calculated internal interface. - _lanSubnets = new Collection<IPObject>(); - - if (IsIP6Enabled) - { - _lanSubnets.AddItem(IPNetAddress.Parse("fc00::/7")); // ULA - _lanSubnets.AddItem(IPNetAddress.Parse("fe80::/10")); // Site local - } - - if (IsIP4Enabled) + if (IPAddress.IsLoopback(ept) || (_lanSubnets.Any(x => x.Contains(ept)) && !_excludedSubnets.Any(x => x.Contains(ept)))) { - _lanSubnets.AddItem(IPNetAddress.Parse("10.0.0.0/8")); - _lanSubnets.AddItem(IPNetAddress.Parse("172.16.0.0/12")); - _lanSubnets.AddItem(IPNetAddress.Parse("192.168.0.0/16")); + return true; } } - else - { - // Internal interfaces must be private, not excluded and part of the LocalNetworkSubnet. - _internalInterfaces = CreateCollection(_interfaceAddresses.Where(IsInLocalNetwork)); - } - - _logger.LogInformation("Defined LAN addresses: {0}", _lanSubnets.AsString()); - _logger.LogInformation("Defined LAN exclusions: {0}", _excludedSubnets.AsString()); - _logger.LogInformation("Using LAN addresses: {0}", _lanSubnets.Exclude(_excludedSubnets, true).AsNetworks().AsString()); } + + return false; } - /// <summary> - /// Generate a list of all the interface ip addresses and submasks where that are in the active/unknown state. - /// Generate a list of all active mac addresses that aren't loopback addresses. - /// </summary> - private void InitialiseInterfaces() + /// <inheritdoc/> + public bool IsInLocalNetwork(IPAddress address) { - lock (_intLock) + ArgumentNullException.ThrowIfNull(address); + + // See conversation at https://github.com/jellyfin/jellyfin/pull/3515. + if ((TrustAllIPv6Interfaces && address.AddressFamily == AddressFamily.InterNetworkV6) + || address.Equals(IPAddress.Loopback) + || address.Equals(IPAddress.IPv6Loopback)) { - _logger.LogDebug("Refreshing interfaces."); + return true; + } - _interfaceNames.Clear(); - _interfaceAddresses.Clear(); - _macAddresses.Clear(); + // As private addresses can be redefined by Configuration.LocalNetworkAddresses + return CheckIfLanAndNotExcluded(address); + } - try + private bool CheckIfLanAndNotExcluded(IPAddress address) + { + foreach (var lanSubnet in _lanSubnets) + { + if (lanSubnet.Contains(address)) { - IEnumerable<NetworkInterface> nics = NetworkInterface.GetAllNetworkInterfaces() - .Where(i => i.SupportsMulticast && i.OperationalStatus == OperationalStatus.Up); - - foreach (NetworkInterface adapter in nics) + foreach (var excludedSubnet in _excludedSubnets) { - try + if (excludedSubnet.Contains(address)) { - IPInterfaceProperties ipProperties = adapter.GetIPProperties(); - PhysicalAddress mac = adapter.GetPhysicalAddress(); - - // populate mac list - if (adapter.NetworkInterfaceType != NetworkInterfaceType.Loopback && mac is not null && mac != PhysicalAddress.None) - { - _macAddresses.Add(mac); - } - - // populate interface address list - foreach (UnicastIPAddressInformation info in ipProperties.UnicastAddresses) - { - if (IsIP4Enabled && info.Address.AddressFamily == AddressFamily.InterNetwork) - { - IPNetAddress nw = new IPNetAddress(info.Address, IPObject.MaskToCidr(info.IPv4Mask)) - { - // Keep the number of gateways on this interface, along with its index. - Tag = ipProperties.GetIPv4Properties().Index - }; - - int tag = nw.Tag; - if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback()) - { - // -ve Tags signify the interface has a gateway. - nw.Tag *= -1; - } - - _interfaceAddresses.AddItem(nw, false); - - // Store interface name so we can use the name in Collections. - _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag; - _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag; - } - else if (IsIP6Enabled && info.Address.AddressFamily == AddressFamily.InterNetworkV6) - { - IPNetAddress nw = new IPNetAddress(info.Address, (byte)info.PrefixLength) - { - // Keep the number of gateways on this interface, along with its index. - Tag = ipProperties.GetIPv6Properties().Index - }; - - int tag = nw.Tag; - if (ipProperties.GatewayAddresses.Count > 0 && !nw.IsLoopback()) - { - // -ve Tags signify the interface has a gateway. - nw.Tag *= -1; - } - - _interfaceAddresses.AddItem(nw, false); - - // Store interface name so we can use the name in Collections. - _interfaceNames[adapter.Description.ToLower(CultureInfo.InvariantCulture)] = tag; - _interfaceNames["eth" + tag.ToString(CultureInfo.InvariantCulture)] = tag; - } - } - } -#pragma warning disable CA1031 // Do not catch general exception types - catch (Exception ex) - { - // Ignore error, and attempt to continue. - _logger.LogError(ex, "Error encountered parsing interfaces."); + return false; } -#pragma warning restore CA1031 // Do not catch general exception types - } - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in InitialiseInterfaces."); - } - - // If for some reason we don't have an interface info, resolve our DNS name. - if (_interfaceAddresses.Count == 0) - { - _logger.LogError("No interfaces information available. Resolving DNS name."); - IPHost host = new IPHost(Dns.GetHostName()); - foreach (var a in host.GetAddresses()) - { - _interfaceAddresses.AddItem(a); - } - - if (_interfaceAddresses.Count == 0) - { - _logger.LogWarning("No interfaces information available. Using loopback."); } - } - - if (IsIP4Enabled) - { - _interfaceAddresses.AddItem(IPNetAddress.IP4Loopback); - } - if (IsIP6Enabled) - { - _interfaceAddresses.AddItem(IPNetAddress.IP6Loopback); + return true; } - - _logger.LogDebug("Discovered {0} interfaces.", _interfaceAddresses.Count); - _logger.LogDebug("Interfaces addresses: {0}", _interfaceAddresses.AsString()); } + + return false; } /// <summary> - /// Attempts to match the source against a user defined bind interface. + /// Attempts to match the source against the published server URL overrides. /// </summary> /// <param name="source">IP source address to use.</param> - /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param> - /// <param name="bindPreference">The published server url that matches the source address.</param> - /// <param name="port">The resultant port, if one exists.</param> + /// <param name="isInExternalSubnet">True if the source is in an external subnet.</param> + /// <param name="bindPreference">The published server URL that matches the source address.</param> /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> - private bool MatchesPublishedServerUrl(IPObject source, bool isInExternalSubnet, out string bindPreference, out int? port) + private bool MatchesPublishedServerUrl(IPAddress source, bool isInExternalSubnet, out string bindPreference) { bindPreference = string.Empty; - port = null; + int? port = null; - // Check for user override. - foreach (var addr in _publishedServerUrls) + // Only consider subnets including the source IP, prefering specific overrides + List<PublishedServerUriOverride> validPublishedServerUrls; + if (!isInExternalSubnet) { - // Remaining. Match anything. - if (addr.Key.Address.Equals(IPAddress.Broadcast)) - { - bindPreference = addr.Value; - break; - } + // Only use matching internal subnets + // Prefer more specific (bigger subnet prefix) overrides + validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsInternalOverride && x.Data.Subnet.Contains(source)) + .OrderByDescending(x => x.Data.Subnet.PrefixLength) + .ToList(); + } + else + { + // Only use matching external subnets + // Prefer more specific (bigger subnet prefix) overrides + validPublishedServerUrls = _publishedServerUrls.Where(x => x.IsExternalOverride && x.Data.Subnet.Contains(source)) + .OrderByDescending(x => x.Data.Subnet.PrefixLength) + .ToList(); + } - if ((addr.Key.Address.Equals(IPAddress.Any) || addr.Key.Address.Equals(IPAddress.IPv6Any)) && isInExternalSubnet) - { - // External. - bindPreference = addr.Value; - break; - } + foreach (var data in validPublishedServerUrls) + { + // Get interface matching override subnet + var intf = _interfaces.OrderBy(x => x.Index).FirstOrDefault(x => data.Data.Subnet.Contains(x.Address)); - if (addr.Key.Contains(source)) + if (intf?.Address is not null) { - // Match ip address. - bindPreference = addr.Value; + // If matching interface is found, use override + bindPreference = data.OverrideUri; break; } } if (string.IsNullOrEmpty(bindPreference)) { + _logger.LogDebug("{Source}: No matching bind address override found", source); return false; } - // Has it got a port defined? + // Handle override specifying port var parts = bindPreference.Split(':'); if (parts.Length > 1) { @@ -1209,132 +997,131 @@ namespace Jellyfin.Networking.Manager { bindPreference = parts[0]; port = p; + _logger.LogDebug("{Source}: Matching bind address override found: {Address}:{Port}", source, bindPreference, port); + return true; } } + _logger.LogDebug("{Source}: Matching bind address override found: {Address}", source, bindPreference); return true; } /// <summary> - /// Attempts to match the source against a user defined bind interface. + /// Attempts to match the source against the user defined bind interfaces. /// </summary> /// <param name="source">IP source address to use.</param> /// <param name="isInExternalSubnet">True if the source is in the external subnet.</param> /// <param name="result">The result, if a match is found.</param> /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> - private bool MatchesBindInterface(IPObject source, bool isInExternalSubnet, out string result) + private bool MatchesBindInterface(IPAddress source, bool isInExternalSubnet, out string result) { result = string.Empty; - var addresses = _bindAddresses.Exclude(_bindExclusions, false); - int count = addresses.Count; - if (count == 1 && (_bindAddresses[0].Equals(IPAddress.Any) || _bindAddresses[0].Equals(IPAddress.IPv6Any))) + int count = _interfaces.Count; + if (count == 1 && (_interfaces[0].Equals(IPAddress.Any) || _interfaces[0].Equals(IPAddress.IPv6Any))) { // Ignore IPAny addresses. count = 0; } - if (count != 0) + if (count == 0) { - // Check to see if any of the bind interfaces are in the same subnet. - - IPAddress? defaultGateway = null; - IPAddress? bindAddress = null; - - if (isInExternalSubnet) - { - // Find all external bind addresses. Store the default gateway, but check to see if there is a better match first. - foreach (var addr in addresses.OrderBy(p => p.Tag)) - { - if (defaultGateway is null && !IsInLocalNetwork(addr)) - { - defaultGateway = addr.Address; - } - - if (bindAddress is null && addr.Contains(source)) - { - bindAddress = addr.Address; - } - - if (defaultGateway is not null && bindAddress is not null) - { - break; - } - } - } - else - { - // Look for the best internal address. - bindAddress = addresses - .Where(p => IsInLocalNetwork(p) && (p.Contains(source) || p.Equals(IPAddress.None))) - .MinBy(p => p.Tag)?.Address; - } - - if (bindAddress is not null) - { - result = FormatIP6String(bindAddress); - _logger.LogDebug("{Source}: GetBindInterface: Has source, found a match bind interface subnets. {Result}", source, result); - return true; - } + return false; + } - if (isInExternalSubnet && defaultGateway is not null) + IPAddress? bindAddress = null; + if (isInExternalSubnet) + { + var externalInterfaces = _interfaces.Where(x => !IsInLocalNetwork(x.Address)) + .OrderBy(x => x.Index) + .ToList(); + if (externalInterfaces.Count > 0) { - result = FormatIP6String(defaultGateway); - _logger.LogDebug("{Source}: GetBindInterface: Using first user defined external interface. {Result}", source, result); + // Check to see if any of the external bind interfaces are in the same subnet as the source. + // If none exists, this will select the first external interface if there is one. + bindAddress = externalInterfaces + .OrderByDescending(x => x.Subnet.Contains(source)) + .ThenBy(x => x.Index) + .Select(x => x.Address) + .First(); + + result = NetworkExtensions.FormatIPString(bindAddress); + _logger.LogDebug("{Source}: External request received, matching external bind address found: {Result}", source, result); return true; } - result = FormatIP6String(addresses[0].Address); - _logger.LogDebug("{Source}: GetBindInterface: Selected first user defined interface. {Result}", source, result); + _logger.LogWarning("{Source}: External request received, no matching external bind address found, trying internal addresses.", source); + } + else + { + // Check to see if any of the internal bind interfaces are in the same subnet as the source. + // If none exists, this will select the first internal interface if there is one. + bindAddress = _interfaces.Where(x => IsInLocalNetwork(x.Address)) + .OrderByDescending(x => x.Subnet.Contains(source)) + .ThenBy(x => x.Index) + .Select(x => x.Address) + .FirstOrDefault(); - if (isInExternalSubnet) + if (bindAddress is not null) { - _logger.LogWarning("{Source}: External request received, however, only an internal interface bind found.", source); + result = NetworkExtensions.FormatIPString(bindAddress); + _logger.LogDebug("{Source}: Internal request received, matching internal bind address found: {Result}", source, result); + return true; } - - return true; } return false; } /// <summary> - /// Attempts to match the source against an external interface. + /// Attempts to match the source against external interfaces. /// </summary> /// <param name="source">IP source address to use.</param> /// <param name="result">The result, if a match is found.</param> /// <returns><c>true</c> if a match is found, <c>false</c> otherwise.</returns> - private bool MatchesExternalInterface(IPObject source, out string result) + private bool MatchesExternalInterface(IPAddress source, out string result) { - result = string.Empty; - // Get the first WAN interface address that isn't a loopback. - var extResult = _interfaceAddresses - .Exclude(_bindExclusions, false) - .Where(p => !IsInLocalNetwork(p)) - .OrderBy(p => p.Tag) - .ToList(); + // Get the first external interface address that isn't a loopback. + var extResult = _interfaces.Where(p => !IsInLocalNetwork(p.Address)).OrderBy(x => x.Index).ToArray(); - if (extResult.Any()) + // No external interface found + if (extResult.Length == 0) { - // Does the request originate in one of the interface subnets? - // (For systems with multiple internal network cards, and multiple subnets) - foreach (var intf in extResult) + result = string.Empty; + _logger.LogWarning("{Source}: External request received, but no external interface found. Need to route through internal network.", source); + return false; + } + + // Does the request originate in one of the interface subnets? + // (For systems with multiple network cards and/or multiple subnets) + foreach (var intf in extResult) + { + if (intf.Subnet.Contains(source)) { - if (!IsInLocalNetwork(intf) && intf.Contains(source)) - { - result = FormatIP6String(intf.Address); - _logger.LogDebug("{Source}: GetBindInterface: Selected best external on interface on range. {Result}", source, result); - return true; - } + result = NetworkExtensions.FormatIPString(intf.Address); + _logger.LogDebug("{Source}: Found external interface with matching subnet, using it as bind address: {Result}", source, result); + return true; } - - result = FormatIP6String(extResult.First().Address); - _logger.LogDebug("{Source}: GetBindInterface: Selected first external interface. {Result}", source, result); - return true; } - _logger.LogDebug("{Source}: External request received, but no WAN interface found. Need to route through internal network.", source); - return false; + // Fallback to first external interface. + result = NetworkExtensions.FormatIPString(extResult[0].Address); + _logger.LogDebug("{Source}: Using first external interface as bind address: {Result}", source, result); + return true; + } + + private void PrintNetworkInformation(NetworkConfiguration config, bool debug = true) + { + var logLevel = debug ? LogLevel.Debug : LogLevel.Information; + if (_logger.IsEnabled(logLevel)) + { + _logger.Log(logLevel, "Defined LAN addresses: {0}", _lanSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Defined LAN exclusions: {0}", _excludedSubnets.Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Using LAN addresses: {0}", _lanSubnets.Where(s => !_excludedSubnets.Contains(s)).Select(s => s.Prefix + "/" + s.PrefixLength)); + _logger.Log(logLevel, "Using bind addresses: {0}", _interfaces.OrderByDescending(x => x.AddressFamily == AddressFamily.InterNetwork).Select(x => x.Address)); + _logger.Log(logLevel, "Remote IP filter is {0}", config.IsRemoteIPFilterBlacklist ? "Blocklist" : "Allowlist"); + _logger.Log(logLevel, "Filter list: {0}", _remoteAddressFilter.Select(s => s.Prefix + "/" + s.PrefixLength)); + } } } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs index f899b4497..b5f18d983 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationFailedLogger.cs @@ -2,9 +2,8 @@ using System.Globalization; using System.Threading.Tasks; using Jellyfin.Data.Entities; -using Jellyfin.Data.Events; using MediaBrowser.Controller.Events; -using MediaBrowser.Controller.Session; +using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; using Microsoft.Extensions.Logging; @@ -14,7 +13,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security /// <summary> /// Creates an entry in the activity log when there is a failed login attempt. /// </summary> - public class AuthenticationFailedLogger : IEventConsumer<GenericEventArgs<AuthenticationRequest>> + public class AuthenticationFailedLogger : IEventConsumer<AuthenticationRequestEventArgs> { private readonly ILocalizationManager _localizationManager; private readonly IActivityManager _activityManager; @@ -31,13 +30,13 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security } /// <inheritdoc /> - public async Task OnEvent(GenericEventArgs<AuthenticationRequest> eventArgs) + public async Task OnEvent(AuthenticationRequestEventArgs eventArgs) { await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("FailedLoginAttemptWithUserName"), - eventArgs.Argument.Username), + eventArgs.Username), "AuthenticationFailed", Guid.Empty) { @@ -45,7 +44,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security ShortOverview = string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("LabelIpAddressValue"), - eventArgs.Argument.RemoteEndPoint), + eventArgs.RemoteEndPoint), }).ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs index 8b0bd84c6..3f3a0dec5 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Security/AuthenticationSucceededLogger.cs @@ -1,9 +1,8 @@ using System.Globalization; using System.Threading.Tasks; using Jellyfin.Data.Entities; -using Jellyfin.Data.Events; -using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Model.Activity; using MediaBrowser.Model.Globalization; @@ -12,7 +11,7 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security /// <summary> /// Creates an entry in the activity log when there is a successful login attempt. /// </summary> - public class AuthenticationSucceededLogger : IEventConsumer<GenericEventArgs<AuthenticationResult>> + public class AuthenticationSucceededLogger : IEventConsumer<AuthenticationResultEventArgs> { private readonly ILocalizationManager _localizationManager; private readonly IActivityManager _activityManager; @@ -29,20 +28,20 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Security } /// <inheritdoc /> - public async Task OnEvent(GenericEventArgs<AuthenticationResult> eventArgs) + public async Task OnEvent(AuthenticationResultEventArgs eventArgs) { await _activityManager.CreateAsync(new ActivityLog( string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("AuthenticationSucceededWithUserName"), - eventArgs.Argument.User.Name), + eventArgs.User.Name), "AuthenticationSucceeded", - eventArgs.Argument.User.Id) + eventArgs.User.Id) { ShortOverview = string.Format( CultureInfo.InvariantCulture, _localizationManager.GetLocalizedString("LabelIpAddressValue"), - eventArgs.Argument.SessionInfo.RemoteEndPoint), + eventArgs.SessionInfo?.RemoteEndPoint), }).ConfigureAwait(false); } } diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs index aeb62e814..27726a57a 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStartLogger.cs @@ -58,15 +58,18 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session var user = eventArgs.Users[0]; await _activityManager.CreateAsync(new ActivityLog( - string.Format( - CultureInfo.InvariantCulture, - _localizationManager.GetLocalizedString("UserStartedPlayingItemWithValues"), - user.Username, - GetItemName(eventArgs.MediaInfo), - eventArgs.DeviceName), - GetPlaybackNotificationType(eventArgs.MediaInfo.MediaType), - user.Id)) - .ConfigureAwait(false); + string.Format( + CultureInfo.InvariantCulture, + _localizationManager.GetLocalizedString("UserStartedPlayingItemWithValues"), + user.Username, + GetItemName(eventArgs.MediaInfo), + eventArgs.DeviceName), + GetPlaybackNotificationType(eventArgs.MediaInfo.MediaType), + user.Id) + { + ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture), + }) + .ConfigureAwait(false); } private static string GetItemName(BaseItemDto item) diff --git a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs index dd7290fb8..6b16477aa 100644 --- a/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs +++ b/Jellyfin.Server.Implementations/Events/Consumers/Session/PlaybackStopLogger.cs @@ -73,7 +73,10 @@ namespace Jellyfin.Server.Implementations.Events.Consumers.Session GetItemName(item), eventArgs.DeviceName), notificationType, - user.Id)) + user.Id) + { + ItemId = eventArgs.Item?.Id.ToString("N", CultureInfo.InvariantCulture), + }) .ConfigureAwait(false); } diff --git a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs index 5d558189b..9626817e9 100644 --- a/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs +++ b/Jellyfin.Server.Implementations/Events/EventingServiceCollectionExtensions.cs @@ -1,5 +1,4 @@ -using Jellyfin.Data.Events; -using Jellyfin.Data.Events.System; +using Jellyfin.Data.Events.System; using Jellyfin.Data.Events.Users; using Jellyfin.Server.Implementations.Events.Consumers.Library; using Jellyfin.Server.Implementations.Events.Consumers.Security; @@ -8,12 +7,11 @@ using Jellyfin.Server.Implementations.Events.Consumers.System; using Jellyfin.Server.Implementations.Events.Consumers.Updates; using Jellyfin.Server.Implementations.Events.Consumers.Users; using MediaBrowser.Common.Updates; -using MediaBrowser.Controller.Authentication; using MediaBrowser.Controller.Events; +using MediaBrowser.Controller.Events.Authentication; using MediaBrowser.Controller.Events.Session; using MediaBrowser.Controller.Events.Updates; using MediaBrowser.Controller.Library; -using MediaBrowser.Controller.Session; using MediaBrowser.Controller.Subtitles; using MediaBrowser.Model.Tasks; using Microsoft.Extensions.DependencyInjection; @@ -35,8 +33,8 @@ namespace Jellyfin.Server.Implementations.Events collection.AddScoped<IEventConsumer<SubtitleDownloadFailureEventArgs>, SubtitleDownloadFailureLogger>(); // Security consumers - collection.AddScoped<IEventConsumer<GenericEventArgs<AuthenticationRequest>>, AuthenticationFailedLogger>(); - collection.AddScoped<IEventConsumer<GenericEventArgs<AuthenticationResult>>, AuthenticationSucceededLogger>(); + collection.AddScoped<IEventConsumer<AuthenticationRequestEventArgs>, AuthenticationFailedLogger>(); + collection.AddScoped<IEventConsumer<AuthenticationResultEventArgs>, AuthenticationSucceededLogger>(); // Session consumers collection.AddScoped<IEventConsumer<PlaybackStartEventArgs>, PlaybackStartLogger>(); diff --git a/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs new file mode 100644 index 000000000..2884d4256 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.Designer.cs @@ -0,0 +1,654 @@ +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDbContext))] + [Migration("20230923170422_UserCastReceiver")] + partial class UserCastReceiver + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); + + 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.HasIndex("DateCreated"); + + 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<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + 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/20230923170422_UserCastReceiver.cs b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs new file mode 100644 index 000000000..f06410c15 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20230923170422_UserCastReceiver.cs @@ -0,0 +1,29 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + /// <inheritdoc /> + public partial class UserCastReceiver : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "CastReceiverId", + table: "Users", + type: "TEXT", + maxLength: 32, + nullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "CastReceiverId", + table: "Users"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index 3c06e1cfc..f725ababe 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -15,7 +15,7 @@ namespace Jellyfin.Server.Implementations.Migrations protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "7.0.7"); + modelBuilder.HasAnnotation("ProductVersion", "7.0.11"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -488,6 +488,10 @@ namespace Jellyfin.Server.Implementations.Migrations .HasMaxLength(255) .HasColumnType("TEXT"); + b.Property<string>("CastReceiverId") + .HasMaxLength(32) + .HasColumnType("TEXT"); + b.Property<bool>("DisplayCollectionsView") .HasColumnType("INTEGER"); diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 700e63970..77f8f7071 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -49,14 +49,13 @@ namespace Jellyfin.Server.Implementations.Security /// <summary> /// Gets the authorization. /// </summary> - /// <param name="httpReq">The HTTP req.</param> + /// <param name="httpContext">The HTTP context.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> - private async Task<AuthorizationInfo> GetAuthorization(HttpContext httpReq) + private async Task<AuthorizationInfo> GetAuthorization(HttpContext httpContext) { - var auth = GetAuthorizationDictionary(httpReq); - var authInfo = await GetAuthorizationInfoFromDictionary(auth, httpReq.Request.Headers, httpReq.Request.Query).ConfigureAwait(false); + var authInfo = await GetAuthorizationInfo(httpContext.Request).ConfigureAwait(false); - httpReq.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; + httpContext.Request.HttpContext.Items["AuthorizationInfo"] = authInfo; return authInfo; } @@ -80,7 +79,6 @@ namespace Jellyfin.Server.Implementations.Security auth.TryGetValue("Token", out token); } -#pragma warning disable CA1508 // string.IsNullOrEmpty(token) is always false. if (string.IsNullOrEmpty(token)) { token = headers["X-Emby-Token"]; @@ -118,7 +116,6 @@ namespace Jellyfin.Server.Implementations.Security // Request doesn't contain a token. return authInfo; } -#pragma warning restore CA1508 authInfo.HasToken = true; var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false); @@ -219,24 +216,7 @@ namespace Jellyfin.Server.Implementations.Security /// <summary> /// Gets the auth. /// </summary> - /// <param name="httpReq">The HTTP req.</param> - /// <returns>Dictionary{System.StringSystem.String}.</returns> - private static Dictionary<string, string>? GetAuthorizationDictionary(HttpContext httpReq) - { - var auth = httpReq.Request.Headers["X-Emby-Authorization"]; - - if (string.IsNullOrEmpty(auth)) - { - auth = httpReq.Request.Headers[HeaderNames.Authorization]; - } - - return auth.Count > 0 ? GetAuthorization(auth[0]) : null; - } - - /// <summary> - /// Gets the auth. - /// </summary> - /// <param name="httpReq">The HTTP req.</param> + /// <param name="httpReq">The HTTP request.</param> /// <returns>Dictionary{System.StringSystem.String}.</returns> private static Dictionary<string, string>? GetAuthorizationDictionary(HttpRequest httpReq) { diff --git a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs index 72f3d6e8e..cb2d09a67 100644 --- a/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs +++ b/Jellyfin.Server.Implementations/Users/DefaultAuthenticationProvider.cs @@ -1,4 +1,5 @@ using System; +using System.Diagnostics.CodeAnalysis; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Authentication; @@ -39,14 +40,18 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc /> // This is the version that we need to use for local users. Because reasons. - public Task<ProviderAuthenticationResult> Authenticate(string username, string password, User resolvedUser) + public Task<ProviderAuthenticationResult> Authenticate(string username, string password, User? resolvedUser) { - if (resolvedUser is null) + [DoesNotReturn] + static void ThrowAuthenticationException() { - throw new AuthenticationException("Specified user does not exist."); + throw new AuthenticationException("Invalid username or password"); } - bool success = false; + if (resolvedUser is null) + { + ThrowAuthenticationException(); + } // As long as jellyfin supports password-less users, we need this little block here to accommodate if (!HasPassword(resolvedUser) && string.IsNullOrEmpty(password)) @@ -60,15 +65,13 @@ namespace Jellyfin.Server.Implementations.Users // Handle the case when the stored password is null, but the user tried to login with a password if (resolvedUser.Password is null) { - throw new AuthenticationException("Invalid username or password"); + ThrowAuthenticationException(); } PasswordHash readyHash = PasswordHash.Parse(resolvedUser.Password); - success = _cryptographyProvider.Verify(readyHash, password); - - if (!success) + if (!_cryptographyProvider.Verify(readyHash, password)) { - throw new AuthenticationException("Invalid username or password"); + ThrowAuthenticationException(); } // Migrate old hashes to the new default diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index 1d03baa4c..b2cb589f7 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -15,12 +15,12 @@ using MediaBrowser.Common; using MediaBrowser.Common.Extensions; using MediaBrowser.Common.Net; using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Events; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; using MediaBrowser.Model.Configuration; -using MediaBrowser.Model.Cryptography; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Users; using Microsoft.EntityFrameworkCore; @@ -31,11 +31,10 @@ namespace Jellyfin.Server.Implementations.Users /// <summary> /// Manages the creation and retrieval of <see cref="User"/> instances. /// </summary> - public class UserManager : IUserManager + public partial class UserManager : IUserManager { private readonly IDbContextFactory<JellyfinDbContext> _dbProvider; private readonly IEventManager _eventManager; - private readonly ICryptoProvider _cryptoProvider; private readonly INetworkManager _networkManager; private readonly IApplicationHost _appHost; private readonly IImageProcessor _imageProcessor; @@ -45,6 +44,7 @@ namespace Jellyfin.Server.Implementations.Users private readonly InvalidAuthProvider _invalidAuthProvider; private readonly DefaultAuthenticationProvider _defaultAuthenticationProvider; private readonly DefaultPasswordResetProvider _defaultPasswordResetProvider; + private readonly IServerConfigurationManager _serverConfigurationManager; private readonly IDictionary<Guid, User> _users; @@ -53,27 +53,27 @@ namespace Jellyfin.Server.Implementations.Users /// </summary> /// <param name="dbProvider">The database provider.</param> /// <param name="eventManager">The event manager.</param> - /// <param name="cryptoProvider">The cryptography provider.</param> /// <param name="networkManager">The network manager.</param> /// <param name="appHost">The application host.</param> /// <param name="imageProcessor">The image processor.</param> /// <param name="logger">The logger.</param> + /// <param name="serverConfigurationManager">The system config manager.</param> public UserManager( IDbContextFactory<JellyfinDbContext> dbProvider, IEventManager eventManager, - ICryptoProvider cryptoProvider, INetworkManager networkManager, IApplicationHost appHost, IImageProcessor imageProcessor, - ILogger<UserManager> logger) + ILogger<UserManager> logger, + IServerConfigurationManager serverConfigurationManager) { _dbProvider = dbProvider; _eventManager = eventManager; - _cryptoProvider = cryptoProvider; _networkManager = networkManager; _appHost = appHost; _imageProcessor = imageProcessor; _logger = logger; + _serverConfigurationManager = serverConfigurationManager; _passwordResetProviders = appHost.GetExports<IPasswordResetProvider>(); _authenticationProviders = appHost.GetExports<IAuthenticationProvider>(); @@ -105,6 +105,12 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public IEnumerable<Guid> UsersIds => _users.Keys; + // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ + // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness + // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) + [GeneratedRegex(@"^[\w\ \-'._@]+$")] + private static partial Regex ValidUsernameRegex(); + /// <inheritdoc/> public User? GetUserById(Guid id) { @@ -287,6 +293,7 @@ namespace Jellyfin.Server.Implementations.Users public UserDto GetUserDto(User user, string? remoteEndPoint = null) { var hasPassword = GetAuthenticationProvider(user).HasPassword(user); + var castReceiverApplications = _serverConfigurationManager.Configuration.CastReceiverApplications; return new UserDto { Name = user.Username, @@ -314,7 +321,11 @@ namespace Jellyfin.Server.Implementations.Users OrderedViews = user.GetPreferenceValues<Guid>(PreferenceKind.OrderedViews), GroupedFolders = user.GetPreferenceValues<Guid>(PreferenceKind.GroupedFolders), MyMediaExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.MyMediaExcludes), - LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes) + LatestItemsExcludes = user.GetPreferenceValues<Guid>(PreferenceKind.LatestItemExcludes), + CastReceiverId = string.IsNullOrEmpty(user.CastReceiverId) + ? castReceiverApplications.FirstOrDefault()?.Id + : castReceiverApplications.FirstOrDefault(c => string.Equals(c.Id, user.CastReceiverId, StringComparison.Ordinal))?.Id + ?? castReceiverApplications.FirstOrDefault()?.Id }, Policy = new UserPolicy { @@ -378,7 +389,7 @@ namespace Jellyfin.Server.Implementations.Users } var user = Users.FirstOrDefault(i => string.Equals(username, i.Username, StringComparison.OrdinalIgnoreCase)); - var authResult = await AuthenticateLocalUser(username, password, user, remoteEndPoint) + var authResult = await AuthenticateLocalUser(username, password, user) .ConfigureAwait(false); var authenticationProvider = authResult.AuthenticationProvider; var success = authResult.Success; @@ -527,7 +538,7 @@ namespace Jellyfin.Server.Implementations.Users } var defaultName = Environment.UserName; - if (string.IsNullOrWhiteSpace(defaultName) || !IsValidUsername(defaultName)) + if (string.IsNullOrWhiteSpace(defaultName) || !ValidUsernameRegex().IsMatch(defaultName)) { defaultName = "MyJellyfinUser"; } @@ -603,6 +614,13 @@ namespace Jellyfin.Server.Implementations.Users user.RememberSubtitleSelections = config.RememberSubtitleSelections; user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + // Only set cast receiver id if it is passed in and it exists in the server config. + if (!string.IsNullOrEmpty(config.CastReceiverId) + && _serverConfigurationManager.Configuration.CastReceiverApplications.Any(c => string.Equals(c.Id, config.CastReceiverId, StringComparison.Ordinal))) + { + user.CastReceiverId = config.CastReceiverId; + } + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); @@ -710,7 +728,7 @@ namespace Jellyfin.Server.Implementations.Users internal static void ThrowIfInvalidUsername(string name) { - if (!string.IsNullOrWhiteSpace(name) && IsValidUsername(name)) + if (!string.IsNullOrWhiteSpace(name) && ValidUsernameRegex().IsMatch(name)) { return; } @@ -718,14 +736,6 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("Usernames can contain unicode symbols, numbers (0-9), dashes (-), underscores (_), apostrophes ('), and periods (.)", nameof(name)); } - private static bool IsValidUsername(ReadOnlySpan<char> name) - { - // This is some regex that matches only on unicode "word" characters, as well as -, _ and @ - // In theory this will cut out most if not all 'control' characters which should help minimize any weirdness - // Usernames can contain letters (a-z + whatever else unicode is cool with), numbers (0-9), at-signs (@), dashes (-), underscores (_), apostrophes ('), periods (.) and spaces ( ) - return Regex.IsMatch(name, @"^[\w\ \-'._@]+$"); - } - private IAuthenticationProvider GetAuthenticationProvider(User user) { return GetAuthenticationProviders(user)[0]; @@ -789,8 +799,7 @@ namespace Jellyfin.Server.Implementations.Users private async Task<(IAuthenticationProvider? AuthenticationProvider, string Username, bool Success)> AuthenticateLocalUser( string username, string password, - User? user, - string remoteEndPoint) + User? user) { bool success = false; IAuthenticationProvider? authenticationProvider = null; @@ -835,7 +844,7 @@ namespace Jellyfin.Server.Implementations.Users } catch (AuthenticationException ex) { - _logger.LogError(ex, "Error authenticating with provider {Provider}", provider.Name); + _logger.LogDebug(ex, "Error authenticating with provider {Provider}", provider.Name); return (username, false); } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 18d924aa8..c12c90a68 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -24,6 +24,7 @@ using MediaBrowser.Controller.Net; using MediaBrowser.Controller.Security; using MediaBrowser.Controller.Trickplay; using MediaBrowser.Model.Activity; +using MediaBrowser.Providers.Lyric; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -96,13 +97,15 @@ namespace Jellyfin.Server serviceCollection.AddSingleton(typeof(ILyricProvider), type); } + foreach (var type in GetExportTypes<ILyricParser>()) + { + serviceCollection.AddSingleton(typeof(ILyricParser), type); + } + base.RegisterServices(serviceCollection); } /// <inheritdoc /> - protected override void RestartInternal() => Program.Restart(); - - /// <inheritdoc /> protected override IEnumerable<Assembly> GetAssembliesWithPartsInternal() { // Jellyfin.Server @@ -111,8 +114,5 @@ namespace Jellyfin.Server // Jellyfin.Server.Implementations yield return typeof(JellyfinDbContext).Assembly; } - - /// <inheritdoc /> - protected override void ShutdownInternal() => Program.Shutdown(); } } diff --git a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs index 463ca7321..b6af9baec 100644 --- a/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiApplicationBuilderExtensions.cs @@ -63,9 +63,9 @@ namespace Jellyfin.Server.Extensions /// </summary> /// <param name="appBuilder">The application builder.</param> /// <returns>The updated application builder.</returns> - public static IApplicationBuilder UseIpBasedAccessValidation(this IApplicationBuilder appBuilder) + public static IApplicationBuilder UseIPBasedAccessValidation(this IApplicationBuilder appBuilder) { - return appBuilder.UseMiddleware<IpBasedAccessValidationMiddleware>(); + return appBuilder.UseMiddleware<IPBasedAccessValidationMiddleware>(); } /// <summary> diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 9867c9e47..cb1680558 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -21,9 +21,10 @@ using Jellyfin.Api.ModelBinders; using Jellyfin.Data.Enums; using Jellyfin.Extensions.Json; using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Constants; +using Jellyfin.Networking.Extensions; using Jellyfin.Server.Configuration; using Jellyfin.Server.Filters; -using MediaBrowser.Common.Net; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Authentication; @@ -58,6 +59,7 @@ namespace Jellyfin.Server.Extensions serviceCollection.AddSingleton<IAuthorizationHandler, FirstTimeSetupHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, AnonymousLanAccessHandler>(); serviceCollection.AddSingleton<IAuthorizationHandler, SyncPlayAccessHandler>(); + serviceCollection.AddSingleton<IAuthorizationHandler, LocalAccessOrRequiresElevationHandler>(); return serviceCollection.AddAuthorizationCore(options => { @@ -99,7 +101,7 @@ namespace Jellyfin.Server.Extensions } /// <summary> - /// Extension method for adding the jellyfin API to the service collection. + /// Extension method for adding the Jellyfin API to the service collection. /// </summary> /// <param name="serviceCollection">The service collection.</param> /// <param name="pluginAssemblies">An IEnumerable containing all plugin assemblies with API controllers.</param> @@ -260,7 +262,7 @@ namespace Jellyfin.Server.Extensions } /// <summary> - /// Sets up the proxy configuration based on the addresses in <paramref name="allowedProxies"/>. + /// Sets up the proxy configuration based on the addresses/subnets in <paramref name="allowedProxies"/>. /// </summary> /// <param name="config">The <see cref="NetworkConfiguration"/> containing the config settings.</param> /// <param name="allowedProxies">The string array to parse.</param> @@ -269,36 +271,40 @@ namespace Jellyfin.Server.Extensions { for (var i = 0; i < allowedProxies.Length; i++) { - if (IPNetAddress.TryParse(allowedProxies[i], out var addr)) + if (IPAddress.TryParse(allowedProxies[i], out var addr)) { - AddIpAddress(config, options, addr.Address, addr.PrefixLength); + AddIPAddress(config, options, addr, addr.AddressFamily == AddressFamily.InterNetwork ? Network.MinimumIPv4PrefixSize : Network.MinimumIPv6PrefixSize); } - else if (IPHost.TryParse(allowedProxies[i], out var host)) + else if (NetworkExtensions.TryParseToSubnet(allowedProxies[i], out var subnet)) { - foreach (var address in host.GetAddresses()) + if (subnet is not null) { - AddIpAddress(config, options, address, address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128); + AddIPAddress(config, options, subnet.Prefix, subnet.PrefixLength); + } + } + else if (NetworkExtensions.TryParseHost(allowedProxies[i], out var addresses, config.EnableIPv4, config.EnableIPv6)) + { + foreach (var address in addresses) + { + AddIPAddress(config, options, address, address.AddressFamily == AddressFamily.InterNetwork ? Network.MinimumIPv4PrefixSize : Network.MinimumIPv6PrefixSize); } } } } - private static void AddIpAddress(NetworkConfiguration config, ForwardedHeadersOptions options, IPAddress addr, int prefixLength) + private static void AddIPAddress(NetworkConfiguration config, ForwardedHeadersOptions options, IPAddress addr, int prefixLength) { - if ((!config.EnableIPV4 && addr.AddressFamily == AddressFamily.InterNetwork) || (!config.EnableIPV6 && addr.AddressFamily == AddressFamily.InterNetworkV6)) + if (addr.IsIPv4MappedToIPv6) { - return; + addr = addr.MapToIPv4(); } - // In order for dual-mode sockets to be used, IP6 has to be enabled in JF and an interface has to have an IP6 address. - if (addr.AddressFamily == AddressFamily.InterNetwork && config.EnableIPV6) + if ((!config.EnableIPv4 && addr.AddressFamily == AddressFamily.InterNetwork) || (!config.EnableIPv6 && addr.AddressFamily == AddressFamily.InterNetworkV6)) { - // If the server is using dual-mode sockets, IPv4 addresses are supplied in an IPv6 format. - // https://docs.microsoft.com/en-us/aspnet/core/host-and-deploy/proxy-load-balancer?view=aspnetcore-5.0 . - addr = addr.MapToIPv6(); + return; } - if (prefixLength == 32) + if (prefixLength == Network.MinimumIPv4PrefixSize) { options.KnownProxies.Add(addr); } diff --git a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs index 58d3e1b2d..c9d5b54de 100644 --- a/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs +++ b/Jellyfin.Server/Extensions/WebHostBuilderExtensions.cs @@ -3,7 +3,6 @@ using System.IO; using System.Net; using Jellyfin.Server.Helpers; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; using MediaBrowser.Controller.Extensions; using Microsoft.AspNetCore.Hosting; using Microsoft.Extensions.Configuration; @@ -36,12 +35,12 @@ public static class WebHostBuilderExtensions return builder .UseKestrel((builderContext, options) => { - var addresses = appHost.NetManager.GetAllBindInterfaces(); + var addresses = appHost.NetManager.GetAllBindInterfaces(true); bool flagged = false; - foreach (IPObject netAdd in addresses) + foreach (var netAdd in addresses) { - logger.LogInformation("Kestrel listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All Addresses" : netAdd); + logger.LogInformation("Kestrel is listening on {Address}", IPAddress.IPv6Any.Equals(netAdd.Address) ? "All IPv6 addresses" : netAdd.Address); options.Listen(netAdd.Address, appHost.HttpPort); if (appHost.ListenWithHttps) { diff --git a/Jellyfin.Server/Helpers/StartupHelpers.cs b/Jellyfin.Server/Helpers/StartupHelpers.cs index fda6e5465..66d393dec 100644 --- a/Jellyfin.Server/Helpers/StartupHelpers.cs +++ b/Jellyfin.Server/Helpers/StartupHelpers.cs @@ -15,7 +15,6 @@ using MediaBrowser.Model.IO; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging; using Serilog; -using SQLitePCL; using ILogger = Microsoft.Extensions.Logging.ILogger; namespace Jellyfin.Server.Helpers; @@ -297,7 +296,5 @@ public static class StartupHelpers // Disable the "Expect: 100-Continue" header by default // http://stackoverflow.com/questions/566437/http-post-returns-the-error-417-expectation-failed-c ServicePointManager.Expect100Continue = false; - - Batteries_V2.Init(); } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index 146de3ae1..62abb8935 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -48,7 +48,6 @@ <PackageReference Include="Serilog.Sinks.Console" /> <PackageReference Include="Serilog.Sinks.File" /> <PackageReference Include="Serilog.Sinks.Graylog" /> - <PackageReference Include="SQLitePCLRaw.bundle_e_sqlite3" /> </ItemGroup> <ItemGroup> diff --git a/Jellyfin.Server/Migrations/MigrationRunner.cs b/Jellyfin.Server/Migrations/MigrationRunner.cs index abfdcd77d..757b56a49 100644 --- a/Jellyfin.Server/Migrations/MigrationRunner.cs +++ b/Jellyfin.Server/Migrations/MigrationRunner.cs @@ -22,7 +22,8 @@ namespace Jellyfin.Server.Migrations private static readonly Type[] _preStartupMigrationTypes = { typeof(PreStartupRoutines.CreateNetworkConfiguration), - typeof(PreStartupRoutines.MigrateMusicBrainzTimeout) + typeof(PreStartupRoutines.MigrateMusicBrainzTimeout), + typeof(PreStartupRoutines.MigrateNetworkConfiguration) }; /// <summary> @@ -41,7 +42,8 @@ namespace Jellyfin.Server.Migrations typeof(Routines.RemoveDownloadImagesInAdvance), typeof(Routines.MigrateAuthenticationDb), typeof(Routines.FixPlaylistOwner), - typeof(Routines.MigrateRatingLevels) + typeof(Routines.MigrateRatingLevels), + typeof(Routines.AddDefaultCastReceivers) }; /// <summary> @@ -92,7 +94,7 @@ namespace Jellyfin.Server.Migrations private static void HandleStartupWizardCondition(IEnumerable<IMigrationRoutine> migrations, MigrationOptions migrationOptions, bool isStartWizardCompleted, ILogger logger) { - if (isStartWizardCompleted || migrationOptions.Applied.Count != 0) + if (isStartWizardCompleted) { return; } @@ -105,6 +107,8 @@ namespace Jellyfin.Server.Migrations private static void PerformMigrations(IMigrationRoutine[] migrations, MigrationOptions migrationOptions, Action<MigrationOptions> saveConfiguration, ILogger logger) { + // save already applied migrations, and skip them thereafter + saveConfiguration(migrationOptions); var appliedMigrationIds = migrationOptions.Applied.Select(m => m.Id).ToHashSet(); for (var i = 0; i < migrations.Length; i++) diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs index 5e601ca84..2c2715526 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/CreateNetworkConfiguration.cs @@ -114,9 +114,7 @@ public class CreateNetworkConfiguration : IMigrationRoutine public bool IgnoreVirtualInterfaces { get; set; } = true; - public string VirtualInterfaceNames { get; set; } = "vEthernet*"; - - public bool TrustAllIP6Interfaces { get; set; } + public string[] VirtualInterfaceNames { get; set; } = new string[] { "veth" }; public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs index bee135efd..0544fe561 100644 --- a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateMusicBrainzTimeout.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.IO; using System.Xml; using System.Xml.Serialization; @@ -59,21 +59,17 @@ public class MigrateMusicBrainzTimeout : IMigrationRoutine private OldMusicBrainzConfiguration? ReadOld(string path) { - using (var xmlReader = XmlReader.Create(path)) - { - var serverConfigSerializer = new XmlSerializer(typeof(OldMusicBrainzConfiguration), new XmlRootAttribute("PluginConfiguration")); - return serverConfigSerializer.Deserialize(xmlReader) as OldMusicBrainzConfiguration; - } + using var xmlReader = XmlReader.Create(path); + var serverConfigSerializer = new XmlSerializer(typeof(OldMusicBrainzConfiguration), new XmlRootAttribute("PluginConfiguration")); + return serverConfigSerializer.Deserialize(xmlReader) as OldMusicBrainzConfiguration; } private void WriteNew(string path, PluginConfiguration newPluginConfiguration) { var pluginConfigurationSerializer = new XmlSerializer(typeof(PluginConfiguration), new XmlRootAttribute("PluginConfiguration")); var xmlWriterSettings = new XmlWriterSettings { Indent = true }; - using (var xmlWriter = XmlWriter.Create(path, xmlWriterSettings)) - { - pluginConfigurationSerializer.Serialize(xmlWriter, newPluginConfiguration); - } + using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings); + pluginConfigurationSerializer.Serialize(xmlWriter, newPluginConfiguration); } #pragma warning disable diff --git a/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs new file mode 100644 index 000000000..c6d86b8cd --- /dev/null +++ b/Jellyfin.Server/Migrations/PreStartupRoutines/MigrateNetworkConfiguration.cs @@ -0,0 +1,208 @@ +using System; +using System.IO; +using System.Xml; +using System.Xml.Serialization; +using Emby.Server.Implementations; +using Jellyfin.Networking.Configuration; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Migrations.PreStartupRoutines; + +/// <inheritdoc /> +public class MigrateNetworkConfiguration : IMigrationRoutine +{ + private readonly ServerApplicationPaths _applicationPaths; + private readonly ILogger<MigrateNetworkConfiguration> _logger; + + /// <summary> + /// Initializes a new instance of the <see cref="MigrateNetworkConfiguration"/> class. + /// </summary> + /// <param name="applicationPaths">An instance of <see cref="ServerApplicationPaths"/>.</param> + /// <param name="loggerFactory">An instance of the <see cref="ILoggerFactory"/> interface.</param> + public MigrateNetworkConfiguration(ServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory) + { + _applicationPaths = applicationPaths; + _logger = loggerFactory.CreateLogger<MigrateNetworkConfiguration>(); + } + + /// <inheritdoc /> + public Guid Id => Guid.Parse("4FB5C950-1991-11EE-9B4B-0800200C9A66"); + + /// <inheritdoc /> + public string Name => nameof(MigrateNetworkConfiguration); + + /// <inheritdoc /> + public bool PerformOnNewInstall => false; + + /// <inheritdoc /> + public void Perform() + { + string path = Path.Combine(_applicationPaths.ConfigurationDirectoryPath, "network.xml"); + var oldNetworkConfigSerializer = new XmlSerializer(typeof(OldNetworkConfiguration), new XmlRootAttribute("NetworkConfiguration")); + OldNetworkConfiguration? oldNetworkConfiguration = null; + + try + { + using var xmlReader = XmlReader.Create(path); + oldNetworkConfiguration = (OldNetworkConfiguration?)oldNetworkConfigSerializer.Deserialize(xmlReader); + } + catch (InvalidOperationException ex) + { + _logger.LogError(ex, "Migrate NetworkConfiguration deserialize Invalid Operation error"); + } + catch (Exception ex) + { + _logger.LogError(ex, "Migrate NetworkConfiguration deserialize error"); + } + + if (oldNetworkConfiguration is not null) + { + // Migrate network config values to new config schema + var networkConfiguration = new NetworkConfiguration(); + networkConfiguration.AutoDiscovery = oldNetworkConfiguration.AutoDiscovery; + networkConfiguration.BaseUrl = oldNetworkConfiguration.BaseUrl; + networkConfiguration.CertificatePassword = oldNetworkConfiguration.CertificatePassword; + networkConfiguration.CertificatePath = oldNetworkConfiguration.CertificatePath; + networkConfiguration.EnableHttps = oldNetworkConfiguration.EnableHttps; + networkConfiguration.EnableIPv4 = oldNetworkConfiguration.EnableIPV4; + networkConfiguration.EnableIPv6 = oldNetworkConfiguration.EnableIPV6; + networkConfiguration.EnablePublishedServerUriByRequest = oldNetworkConfiguration.EnablePublishedServerUriByRequest; + networkConfiguration.EnableRemoteAccess = oldNetworkConfiguration.EnableRemoteAccess; + networkConfiguration.EnableUPnP = oldNetworkConfiguration.EnableUPnP; + networkConfiguration.IgnoreVirtualInterfaces = oldNetworkConfiguration.IgnoreVirtualInterfaces; + networkConfiguration.InternalHttpPort = oldNetworkConfiguration.HttpServerPortNumber; + networkConfiguration.InternalHttpsPort = oldNetworkConfiguration.HttpsPortNumber; + networkConfiguration.IsRemoteIPFilterBlacklist = oldNetworkConfiguration.IsRemoteIPFilterBlacklist; + networkConfiguration.KnownProxies = oldNetworkConfiguration.KnownProxies; + networkConfiguration.LocalNetworkAddresses = oldNetworkConfiguration.LocalNetworkAddresses; + networkConfiguration.LocalNetworkSubnets = oldNetworkConfiguration.LocalNetworkSubnets; + networkConfiguration.PublicHttpPort = oldNetworkConfiguration.PublicPort; + networkConfiguration.PublicHttpsPort = oldNetworkConfiguration.PublicHttpsPort; + networkConfiguration.PublishedServerUriBySubnet = oldNetworkConfiguration.PublishedServerUriBySubnet; + networkConfiguration.RemoteIPFilter = oldNetworkConfiguration.RemoteIPFilter; + networkConfiguration.RequireHttps = oldNetworkConfiguration.RequireHttps; + + // Migrate old virtual interface name schema + var oldVirtualInterfaceNames = oldNetworkConfiguration.VirtualInterfaceNames; + if (oldVirtualInterfaceNames.Equals("vEthernet*", StringComparison.OrdinalIgnoreCase)) + { + networkConfiguration.VirtualInterfaceNames = new string[] { "veth" }; + } + else + { + networkConfiguration.VirtualInterfaceNames = oldVirtualInterfaceNames.Replace("*", string.Empty, StringComparison.OrdinalIgnoreCase).Split(','); + } + + var networkConfigSerializer = new XmlSerializer(typeof(NetworkConfiguration)); + var xmlWriterSettings = new XmlWriterSettings { Indent = true }; + using var xmlWriter = XmlWriter.Create(path, xmlWriterSettings); + networkConfigSerializer.Serialize(xmlWriter, networkConfiguration); + } + } + +#pragma warning disable + public sealed class OldNetworkConfiguration + { + public const int DefaultHttpPort = 8096; + + public const int DefaultHttpsPort = 8920; + + private string _baseUrl = string.Empty; + + public bool RequireHttps { get; set; } + + public string CertificatePath { get; set; } = string.Empty; + + public string CertificatePassword { get; set; } = string.Empty; + + public string BaseUrl + { + get => _baseUrl; + set + { + // Normalize the start of the string + if (string.IsNullOrWhiteSpace(value)) + { + // If baseUrl is empty, set an empty prefix string + _baseUrl = string.Empty; + return; + } + + if (value[0] != '/') + { + // If baseUrl was not configured with a leading slash, append one for consistency + value = "/" + value; + } + + // Normalize the end of the string + if (value[^1] == '/') + { + // If baseUrl was configured with a trailing slash, remove it for consistency + value = value.Remove(value.Length - 1); + } + + _baseUrl = value; + } + } + + public int PublicHttpsPort { get; set; } = DefaultHttpsPort; + + public int HttpServerPortNumber { get; set; } = DefaultHttpPort; + + public int HttpsPortNumber { get; set; } = DefaultHttpsPort; + + public bool EnableHttps { get; set; } + + public int PublicPort { get; set; } = DefaultHttpPort; + + public bool UPnPCreateHttpPortMap { get; set; } + + public string UDPPortRange { get; set; } = string.Empty; + + public bool EnableIPV6 { get; set; } + + public bool EnableIPV4 { get; set; } = true; + + public bool EnableSSDPTracing { get; set; } + + public string SSDPTracingFilter { get; set; } = string.Empty; + + public int UDPSendCount { get; set; } = 2; + + public int UDPSendDelay { get; set; } = 100; + + public bool IgnoreVirtualInterfaces { get; set; } = true; + + public string VirtualInterfaceNames { get; set; } = "vEthernet*"; + + public int GatewayMonitorPeriod { get; set; } = 60; + + public bool EnableMultiSocketBinding { get; } = true; + + public bool TrustAllIP6Interfaces { get; set; } + + public string HDHomerunPortRange { get; set; } = string.Empty; + + public string[] PublishedServerUriBySubnet { get; set; } = Array.Empty<string>(); + + public bool AutoDiscoveryTracing { get; set; } + + public bool AutoDiscovery { get; set; } = true; + + public string[] RemoteIPFilter { get; set; } = Array.Empty<string>(); + + public bool IsRemoteIPFilterBlacklist { get; set; } + + public bool EnableUPnP { get; set; } + + public bool EnableRemoteAccess { get; set; } = true; + + public string[] LocalNetworkSubnets { get; set; } = Array.Empty<string>(); + + public string[] LocalNetworkAddresses { get; set; } = Array.Empty<string>(); + public string[] KnownProxies { get; set; } = Array.Empty<string>(); + + public bool EnablePublishedServerUriByRequest { get; set; } = false; + } +#pragma warning restore +} diff --git a/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs new file mode 100644 index 000000000..75a6a6176 --- /dev/null +++ b/Jellyfin.Server/Migrations/Routines/AddDefaultCastReceivers.cs @@ -0,0 +1,55 @@ +using System; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Model.System; + +namespace Jellyfin.Server.Migrations.Routines; + +/// <summary> +/// Migration to add the default cast receivers to the system config. +/// </summary> +public class AddDefaultCastReceivers : IMigrationRoutine +{ + private readonly IServerConfigurationManager _serverConfigurationManager; + + /// <summary> + /// Initializes a new instance of the <see cref="AddDefaultCastReceivers"/> class. + /// </summary> + /// <param name="serverConfigurationManager">Instance of the <see cref="IServerConfigurationManager"/> interface.</param> + public AddDefaultCastReceivers(IServerConfigurationManager serverConfigurationManager) + { + _serverConfigurationManager = serverConfigurationManager; + } + + /// <inheritdoc /> + public Guid Id => new("34A1A1C4-5572-418E-A2F8-32CDFE2668E8"); + + /// <inheritdoc /> + public string Name => "AddDefaultCastReceivers"; + + /// <inheritdoc /> + public bool PerformOnNewInstall => true; + + /// <inheritdoc /> + public void Perform() + { + // Only add if receiver list is empty. + if (_serverConfigurationManager.Configuration.CastReceiverApplications.Length == 0) + { + _serverConfigurationManager.Configuration.CastReceiverApplications = new CastReceiverApplication[] + { + new() + { + Id = "F007D354", + Name = "Stable" + }, + new() + { + Id = "6F511C87", + Name = "Unstable" + } + }; + + _serverConfigurationManager.SaveConfiguration(); + } + } +} diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index e8a0af9f8..2f23cb1f8 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -5,9 +5,9 @@ using Emby.Server.Implementations.Data; using Jellyfin.Data.Entities; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Jellyfin.Server.Migrations.Routines { @@ -61,17 +61,15 @@ namespace Jellyfin.Server.Migrations.Routines }; var dataPath = _paths.DataPath; - using (var connection = SQLite3.Open( - Path.Combine(dataPath, DbFilename), - ConnectionFlags.ReadOnly, - null)) + using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) { - using var userDbConnection = SQLite3.Open(Path.Combine(dataPath, "users.db"), ConnectionFlags.ReadOnly, null); + connection.Open(); + + using var userDbConnection = new SqliteConnection($"Filename={Path.Combine(dataPath, "users.db")}"); + userDbConnection.Open(); _logger.LogWarning("Migrating the activity database may take a while, do not stop Jellyfin."); using var dbContext = _provider.CreateDbContext(); - var queryResult = connection.Query("SELECT * FROM ActivityLog ORDER BY Id"); - // Make sure that the database is empty in case of failed migration due to power outages, etc. dbContext.ActivityLogs.RemoveRange(dbContext.ActivityLogs); dbContext.SaveChanges(); @@ -81,51 +79,52 @@ namespace Jellyfin.Server.Migrations.Routines var newEntries = new List<ActivityLog>(); + var queryResult = connection.Query("SELECT * FROM ActivityLog ORDER BY Id"); + foreach (var entry in queryResult) { - if (!logLevelDictionary.TryGetValue(entry[8].ToString(), out var severity)) + if (!logLevelDictionary.TryGetValue(entry.GetString(8), out var severity)) { severity = LogLevel.Trace; } var guid = Guid.Empty; - if (entry[6].SQLiteType != SQLiteType.Null && !Guid.TryParse(entry[6].ToString(), out guid)) + if (!entry.IsDBNull(6) && !entry.TryGetGuid(6, out guid)) { + var id = entry.GetString(6); // 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: {Guid}", entry[6].ToString()); + _logger.LogWarning("Invalid Guid in UserId column: {Guid}", id); using var statement = userDbConnection.PrepareStatement("SELECT guid FROM LocalUsersv2 WHERE Id=@Id"); - statement.TryBind("@Id", entry[6].ToString()); + statement.TryBind("@Id", id); - foreach (var row in statement.Query()) + using var reader = statement.ExecuteReader(); + if (reader.HasRows && reader.Read() && reader.TryGetGuid(0, out guid)) { - if (row.Count > 0 && Guid.TryParse(row[0].ToString(), out guid)) - { - // Successfully parsed a Guid from the user table. - break; - } + // Successfully parsed a Guid from the user table. + break; } } - var newEntry = new ActivityLog(entry[1].ToString(), entry[4].ToString(), guid) + var newEntry = new ActivityLog(entry.GetString(1), entry.GetString(4), guid) { - DateCreated = entry[7].ReadDateTime(), + DateCreated = entry.GetDateTime(7), LogSeverity = severity }; - if (entry[2].SQLiteType != SQLiteType.Null) + if (entry.TryGetString(2, out var result)) { - newEntry.Overview = entry[2].ToString(); + newEntry.Overview = result; } - if (entry[3].SQLiteType != SQLiteType.Null) + if (entry.TryGetString(3, out result)) { - newEntry.ShortOverview = entry[3].ToString(); + newEntry.ShortOverview = result; } - if (entry[5].SQLiteType != SQLiteType.Null) + if (entry.TryGetString(5, out result)) { - newEntry.ItemId = entry[5].ToString(); + newEntry.ItemId = result; } newEntries.Add(newEntry); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index 09daae0ff..c845beef2 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -6,9 +6,9 @@ using Jellyfin.Data.Entities.Security; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Jellyfin.Server.Migrations.Routines { @@ -56,34 +56,32 @@ namespace Jellyfin.Server.Migrations.Routines public void Perform() { var dataPath = _appPaths.DataPath; - using (var connection = SQLite3.Open( - Path.Combine(dataPath, DbFilename), - ConnectionFlags.ReadOnly, - null)) + using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) { + connection.Open(); using var dbContext = _dbProvider.CreateDbContext(); var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); foreach (var row in authenticatedDevices) { - var dateCreatedStr = row[9].ToString(); + var dateCreatedStr = row.GetString(9); _ = DateTime.TryParse(dateCreatedStr, out var dateCreated); - var dateLastActivityStr = row[10].ToString(); + var dateLastActivityStr = row.GetString(10); _ = DateTime.TryParse(dateLastActivityStr, out var dateLastActivity); - if (row[6].IsDbNull()) + if (row.IsDBNull(6)) { - dbContext.ApiKeys.Add(new ApiKey(row[3].ToString()) + dbContext.ApiKeys.Add(new ApiKey(row.GetString(3)) { - AccessToken = row[1].ToString(), + AccessToken = row.GetString(1), DateCreated = dateCreated, DateLastActivity = dateLastActivity }); } else { - var userId = new Guid(row[6].ToString()); + var userId = row.GetGuid(6); var user = _userManager.GetUserById(userId); if (user is null) { @@ -92,14 +90,14 @@ namespace Jellyfin.Server.Migrations.Routines } dbContext.Devices.Add(new Device( - new Guid(row[6].ToString()), - row[3].ToString(), - row[4].ToString(), - row[5].ToString(), - row[2].ToString()) + userId, + row.GetString(3), + row.GetString(4), + row.GetString(5), + row.GetString(2)) { - AccessToken = row[1].ToString(), - IsActive = row[8].ToBool(), + AccessToken = row.GetString(1), + IsActive = row.GetBoolean(8), DateCreated = dateCreated, DateLastActivity = dateLastActivity }); @@ -110,12 +108,12 @@ namespace Jellyfin.Server.Migrations.Routines var deviceIds = new HashSet<string>(); foreach (var row in deviceOptions) { - if (row[2].IsDbNull()) + if (row.IsDBNull(2)) { continue; } - var deviceId = row[2].ToString(); + var deviceId = row.GetString(2); if (deviceIds.Contains(deviceId)) { continue; @@ -125,7 +123,7 @@ namespace Jellyfin.Server.Migrations.Routines dbContext.DeviceOptions.Add(new DeviceOptions(deviceId) { - CustomName = row[1].IsDbNull() ? null : row[1].ToString() + CustomName = row.IsDBNull(1) ? null : row.GetString(1) }); } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 8fe2b087d..249b39ae4 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -4,15 +4,16 @@ using System.IO; using System.Linq; using System.Text.Json; using System.Text.Json.Serialization; +using Emby.Server.Implementations.Data; using Jellyfin.Data.Entities; using Jellyfin.Data.Enums; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Jellyfin.Server.Migrations.Routines { @@ -83,22 +84,23 @@ namespace Jellyfin.Server.Migrations.Routines var displayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var customDisplayPrefs = new HashSet<string>(StringComparer.OrdinalIgnoreCase); var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); - using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null)) + using (var connection = new SqliteConnection($"Filename={dbFilePath}")) { + connection.Open(); using var dbContext = _provider.CreateDbContext(); var results = connection.Query("SELECT * FROM userdisplaypreferences"); foreach (var result in results) { - var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result[3].ToBlob(), _jsonOptions); + var dto = JsonSerializer.Deserialize<DisplayPreferencesDto>(result.GetStream(3), _jsonOptions); if (dto is null) { continue; } - var itemId = new Guid(result[1].ToBlob()); - var dtoUserId = new Guid(result[1].ToBlob()); - var client = result[2].ToString(); + var itemId = result.GetGuid(1); + var dtoUserId = itemId; + var client = result.GetString(2); var displayPreferencesKey = $"{dtoUserId}|{itemId}|{client}"; if (displayPrefs.Contains(displayPreferencesKey)) { diff --git a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs index 9dee520a5..ac5047401 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateRatingLevels.cs @@ -1,13 +1,11 @@ using System; using System.Globalization; using System.IO; - using Emby.Server.Implementations.Data; using MediaBrowser.Controller; -using MediaBrowser.Controller.Persistence; using MediaBrowser.Model.Globalization; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Jellyfin.Server.Migrations.Routines { @@ -20,17 +18,14 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateRatingLevels> _logger; private readonly IServerApplicationPaths _applicationPaths; private readonly ILocalizationManager _localizationManager; - private readonly IItemRepository _repository; public MigrateRatingLevels( IServerApplicationPaths applicationPaths, ILoggerFactory loggerFactory, - ILocalizationManager localizationManager, - IItemRepository repository) + ILocalizationManager localizationManager) { _applicationPaths = applicationPaths; _localizationManager = localizationManager; - _repository = repository; _logger = loggerFactory.CreateLogger<MigrateRatingLevels>(); } @@ -70,16 +65,14 @@ namespace Jellyfin.Server.Migrations.Routines // Migrate parental rating strings to new levels _logger.LogInformation("Recalculating parental rating levels based on rating string."); - using (var connection = SQLite3.Open( - dbPath, - ConnectionFlags.ReadWrite, - null)) + using var connection = new SqliteConnection($"Filename={dbPath}"); + connection.Open(); + using (var transaction = connection.BeginTransaction()) { var queryResult = connection.Query("SELECT DISTINCT OfficialRating FROM TypedBaseItems"); foreach (var entry in queryResult) { - var ratingString = entry[0].ToString(); - if (string.IsNullOrEmpty(ratingString)) + if (!entry.TryGetString(0, out var ratingString) || string.IsNullOrEmpty(ratingString)) { connection.Execute("UPDATE TypedBaseItems SET InheritedParentalRatingValue = NULL WHERE OfficialRating IS NULL OR OfficialRating='';"); } @@ -91,12 +84,14 @@ namespace Jellyfin.Server.Migrations.Routines ratingValue = "NULL"; } - var statement = connection.PrepareStatement("UPDATE TypedBaseItems SET InheritedParentalRatingValue = @Value WHERE OfficialRating = @Rating;"); + using var statement = connection.PrepareStatement("UPDATE TypedBaseItems SET InheritedParentalRatingValue = @Value WHERE OfficialRating = @Rating;"); statement.TryBind("@Value", ratingValue); statement.TryBind("@Rating", ratingString); - statement.ExecuteQuery(); + statement.ExecuteNonQuery(); } } + + transaction.Commit(); } } } diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 0186500a1..4fee88b68 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -11,9 +11,9 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Users; +using Microsoft.Data.Sqlite; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; using JsonSerializer = System.Text.Json.JsonSerializer; namespace Jellyfin.Server.Migrations.Routines @@ -64,8 +64,9 @@ namespace Jellyfin.Server.Migrations.Routines var dataPath = _paths.DataPath; _logger.LogInformation("Migrating the user database may take a while, do not stop Jellyfin."); - using (var connection = SQLite3.Open(Path.Combine(dataPath, DbFilename), ConnectionFlags.ReadOnly, null)) + using (var connection = new SqliteConnection($"Filename={Path.Combine(dataPath, DbFilename)}")) { + connection.Open(); var dbContext = _provider.CreateDbContext(); var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); @@ -75,7 +76,7 @@ namespace Jellyfin.Server.Migrations.Routines foreach (var entry in queryResult) { - UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry[2].ToBlob(), JsonDefaults.Options); + UserMockup? mockup = JsonSerializer.Deserialize<UserMockup>(entry.GetStream(2), JsonDefaults.Options); if (mockup is null) { continue; @@ -108,8 +109,8 @@ namespace Jellyfin.Server.Migrations.Routines var user = new User(mockup.Name, policy.AuthenticationProviderId!, policy.PasswordResetProviderId!) { - Id = entry[1].ReadGuidFromBlob(), - InternalId = entry[0].ToInt64(), + Id = entry.GetGuid(1), + InternalId = entry.GetInt64(0), MaxParentalAgeRating = policy.MaxParentalRating, EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess, RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit, diff --git a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs index 6c26e47e1..7b0d9456d 100644 --- a/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs +++ b/Jellyfin.Server/Migrations/Routines/RemoveDuplicateExtras.cs @@ -1,10 +1,11 @@ using System; using System.Globalization; using System.IO; - +using System.Linq; +using Emby.Server.Implementations.Data; using MediaBrowser.Controller; +using Microsoft.Data.Sqlite; using Microsoft.Extensions.Logging; -using SQLitePCL.pretty; namespace Jellyfin.Server.Migrations.Routines { @@ -37,14 +38,13 @@ namespace Jellyfin.Server.Migrations.Routines { var dataPath = _paths.DataPath; var dbPath = Path.Combine(dataPath, DbFilename); - using (var connection = SQLite3.Open( - dbPath, - ConnectionFlags.ReadWrite, - null)) + using var connection = new SqliteConnection($"Filename={dbPath}"); + connection.Open(); + using (var transaction = connection.BeginTransaction()) { // Query the database for the ids of duplicate extras var queryResult = connection.Query("SELECT t1.Path FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video'"); - var bads = string.Join(", ", queryResult.SelectScalarString()); + var bads = string.Join(", ", queryResult.Select(x => x.GetString(0))); // Do nothing if no duplicate extras were detected if (bads.Length == 0) @@ -76,6 +76,7 @@ namespace Jellyfin.Server.Migrations.Routines // Delete all duplicate extras _logger.LogInformation("Removing found duplicated extras for the following items: {DuplicateExtras}", bads); connection.Execute("DELETE FROM TypedBaseItems WHERE rowid IN (SELECT t1.rowid FROM TypedBaseItems AS t1, TypedBaseItems AS t2 WHERE t1.Path=t2.Path AND t1.Type!=t2.Type AND t1.Type='MediaBrowser.Controller.Entities.Video')"); + transaction.Commit(); } } } diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index 6e8b17a73..f9259d0d9 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -4,7 +4,6 @@ using System.Diagnostics; using System.IO; using System.Linq; using System.Reflection; -using System.Threading; using System.Threading.Tasks; using CommandLine; using Emby.Server.Implementations; @@ -42,7 +41,6 @@ namespace Jellyfin.Server public const string LoggingConfigFileSystem = "logging.json"; private static readonly ILoggerFactory _loggerFactory = new SerilogLoggerFactory(); - private static CancellationTokenSource _tokenSource = new(); private static long _startTimestamp; private static ILogger _logger = NullLogger.Instance; private static bool _restartOnShutdown; @@ -65,36 +63,9 @@ namespace Jellyfin.Server .MapResult(StartApp, ErrorParsingArguments); } - /// <summary> - /// Shuts down the application. - /// </summary> - internal static void Shutdown() - { - if (!_tokenSource.IsCancellationRequested) - { - _tokenSource.Cancel(); - } - } - - /// <summary> - /// Restarts the application. - /// </summary> - internal static void Restart() - { - _restartOnShutdown = true; - - Shutdown(); - } - private static async Task StartApp(StartupOptions options) { _startTimestamp = Stopwatch.GetTimestamp(); - - // Log all uncaught exceptions to std error - static void UnhandledExceptionToConsole(object sender, UnhandledExceptionEventArgs e) => - Console.Error.WriteLine("Unhandled Exception\n" + e.ExceptionObject); - AppDomain.CurrentDomain.UnhandledException += UnhandledExceptionToConsole; - ServerApplicationPaths appPaths = StartupHelpers.CreateApplicationPaths(options); // $JELLYFIN_LOG_DIR needs to be set for the logger configuration manager @@ -112,38 +83,10 @@ namespace Jellyfin.Server StartupHelpers.InitializeLoggingFramework(startupConfig, appPaths); _logger = _loggerFactory.CreateLogger("Main"); - // Log uncaught exceptions to the logging instead of std error - AppDomain.CurrentDomain.UnhandledException -= UnhandledExceptionToConsole; + // Use the logging framework for uncaught exceptions instead of std error AppDomain.CurrentDomain.UnhandledException += (_, e) => _logger.LogCritical((Exception)e.ExceptionObject, "Unhandled Exception"); - // Intercept Ctrl+C and Ctrl+Break - Console.CancelKeyPress += (_, e) => - { - if (_tokenSource.IsCancellationRequested) - { - return; // Already shutting down - } - - e.Cancel = true; - _logger.LogInformation("Ctrl+C, shutting down"); - Environment.ExitCode = 128 + 2; - Shutdown(); - }; - - // Register a SIGTERM handler - AppDomain.CurrentDomain.ProcessExit += (_, _) => - { - if (_tokenSource.IsCancellationRequested) - { - return; // Already shutting down - } - - _logger.LogInformation("Received a SIGTERM signal, shutting down"); - Environment.ExitCode = 128 + 15; - Shutdown(); - }; - _logger.LogInformation( "Jellyfin version: {Version}", Assembly.GetEntryAssembly()!.GetName().Version!.ToString(3)); @@ -173,12 +116,10 @@ namespace Jellyfin.Server do { - _restartOnShutdown = false; await StartServer(appPaths, options, startupConfig).ConfigureAwait(false); if (_restartOnShutdown) { - _tokenSource = new CancellationTokenSource(); _startTimestamp = Stopwatch.GetTimestamp(); } } while (_restartOnShutdown); @@ -186,7 +127,7 @@ namespace Jellyfin.Server private static async Task StartServer(IServerApplicationPaths appPaths, StartupOptions options, IConfiguration startupConfig) { - var appHost = new CoreAppHost( + using var appHost = new CoreAppHost( appPaths, _loggerFactory, options, @@ -196,6 +137,7 @@ namespace Jellyfin.Server try { host = Host.CreateDefaultBuilder() + .UseConsoleLifetime() .ConfigureServices(services => appHost.Init(services)) .ConfigureWebHostDefaults(webHostBuilder => webHostBuilder.ConfigureWebHostBuilder(appHost, startupConfig, appPaths, _logger)) .ConfigureAppConfiguration(config => config.ConfigureAppConfiguration(options, appPaths, startupConfig)) @@ -210,7 +152,7 @@ namespace Jellyfin.Server try { - await host.StartAsync(_tokenSource.Token).ConfigureAwait(false); + await host.StartAsync().ConfigureAwait(false); if (!OperatingSystem.IsWindows() && startupConfig.UseUnixSocket()) { @@ -219,22 +161,18 @@ namespace Jellyfin.Server StartupHelpers.SetUnixSocketPermissions(startupConfig, socketPath, _logger); } } - catch (Exception ex) when (ex is not TaskCanceledException) + catch (Exception) { _logger.LogError("Kestrel failed to start! This is most likely due to an invalid address or port bind - correct your bind configuration in network.xml and try again"); throw; } - await appHost.RunStartupTasksAsync(_tokenSource.Token).ConfigureAwait(false); + await appHost.RunStartupTasksAsync().ConfigureAwait(false); _logger.LogInformation("Startup complete {Time:g}", Stopwatch.GetElapsedTime(_startTimestamp)); - // Block main thread until shutdown - await Task.Delay(-1, _tokenSource.Token).ConfigureAwait(false); - } - catch (TaskCanceledException) - { - // Don't throw on cancellation + await host.WaitForShutdownAsync().ConfigureAwait(false); + _restartOnShutdown = appHost.ShouldRestart; } catch (Exception ex) { @@ -257,7 +195,6 @@ namespace Jellyfin.Server } } - await appHost.DisposeAsync().ConfigureAwait(false); host?.Dispose(); } } diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 6394800f7..2acddb243 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -1,10 +1,10 @@ using System; -using System.Globalization; using System.Net; using System.Net.Http; using System.Net.Http.Headers; using System.Net.Mime; using System.Text; +using Emby.Dlna.Extensions; using Jellyfin.Api.Middleware; using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking.Configuration; @@ -27,7 +27,6 @@ using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.FileProviders; using Microsoft.Extensions.Hosting; -using Microsoft.VisualBasic; using Prometheus; namespace Jellyfin.Server @@ -120,26 +119,11 @@ namespace Jellyfin.Server }) .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); - services.AddHttpClient(NamedClient.Dlna, c => - { - c.DefaultRequestHeaders.UserAgent.ParseAdd( - string.Format( - CultureInfo.InvariantCulture, - "{0}/{1} UPnP/1.0 {2}/{3}", - Environment.OSVersion.Platform, - Environment.OSVersion, - _serverApplicationHost.Name, - _serverApplicationHost.ApplicationVersionString)); - - c.DefaultRequestHeaders.Add("CPFN.UPNP.ORG", _serverApplicationHost.FriendlyName); // Required for UPnP DeviceArchitecture v2.0 - c.DefaultRequestHeaders.Add("FriendlyName.DLNA.ORG", _serverApplicationHost.FriendlyName); // REVIEW: where does this come from? - }) - .ConfigurePrimaryHttpMessageHandler(defaultHttpClientHandlerDelegate); - services.AddHealthChecks() .AddCheck<DbContextFactoryHealthCheck<JellyfinDbContext>>(nameof(JellyfinDbContext)); services.AddHlsPlaylistGenerator(); + services.AddDlnaServices(_serverApplicationHost); } /// <summary> @@ -213,7 +197,7 @@ namespace Jellyfin.Server mainApp.UseAuthorization(); mainApp.UseLanFiltering(); - mainApp.UseIpBasedAccessValidation(); + mainApp.UseIPBasedAccessValidation(); mainApp.UseWebSocketHandler(); mainApp.UseServerStartupMessage(); diff --git a/MediaBrowser.Common/Extensions/BaseExtensions.cs b/MediaBrowser.Common/Extensions/BaseExtensions.cs index e3775021e..3615b662b 100644 --- a/MediaBrowser.Common/Extensions/BaseExtensions.cs +++ b/MediaBrowser.Common/Extensions/BaseExtensions.cs @@ -8,20 +8,19 @@ namespace MediaBrowser.Common.Extensions /// <summary> /// Class BaseExtensions. /// </summary> - public static class BaseExtensions + public static partial class BaseExtensions { + // http://stackoverflow.com/questions/1349023/how-can-i-strip-html-from-text-in-net + [GeneratedRegex(@"<(.|\n)*?>")] + private static partial Regex StripHtmlRegex(); + /// <summary> /// Strips the HTML. /// </summary> /// <param name="htmlString">The HTML string.</param> /// <returns><see cref="string" />.</returns> public static string StripHtml(this string htmlString) - { - // http://stackoverflow.com/questions/1349023/how-can-i-strip-html-from-text-in-net - const string Pattern = @"<(.|\n)*?>"; - - return Regex.Replace(htmlString, Pattern, string.Empty).Trim(); - } + => StripHtmlRegex().Replace(htmlString, string.Empty).Trim(); /// <summary> /// Gets the Md5. diff --git a/MediaBrowser.Common/Extensions/HttpContextExtensions.cs b/MediaBrowser.Common/Extensions/HttpContextExtensions.cs index 6608704c0..a1056b7c8 100644 --- a/MediaBrowser.Common/Extensions/HttpContextExtensions.cs +++ b/MediaBrowser.Common/Extensions/HttpContextExtensions.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Common.Extensions /// </summary> /// <param name="context">The HTTP context.</param> /// <returns>The remote caller IP address.</returns> - public static IPAddress GetNormalizedRemoteIp(this HttpContext context) + public static IPAddress GetNormalizedRemoteIP(this HttpContext context) { // Default to the loopback address if no RemoteIpAddress is specified (i.e. during integration tests) var ip = context.Connection.RemoteIpAddress ?? IPAddress.Loopback; diff --git a/MediaBrowser.Common/Extensions/ProcessExtensions.cs b/MediaBrowser.Common/Extensions/ProcessExtensions.cs index c3a7cb394..bb8ab130d 100644 --- a/MediaBrowser.Common/Extensions/ProcessExtensions.cs +++ b/MediaBrowser.Common/Extensions/ProcessExtensions.cs @@ -15,65 +15,13 @@ namespace MediaBrowser.Common.Extensions /// </summary> /// <param name="process">The process to wait for.</param> /// <param name="timeout">The duration to wait before cancelling waiting for the task.</param> - /// <returns>True if the task exited normally, false if the timeout elapsed before the process exited.</returns> - /// <exception cref="InvalidOperationException">If <see cref="Process.EnableRaisingEvents"/> is not set to true for the process.</exception> - public static async Task<bool> WaitForExitAsync(this Process process, TimeSpan timeout) + /// <returns>A task that will complete when the process has exited, cancellation has been requested, or an error occurs.</returns> + /// <exception cref="OperationCanceledException">The timeout ended.</exception> + public static async Task WaitForExitAsync(this Process process, TimeSpan timeout) { using (var cancelTokenSource = new CancellationTokenSource(timeout)) { - return await WaitForExitAsync(process, cancelTokenSource.Token).ConfigureAwait(false); - } - } - - /// <summary> - /// Asynchronously wait for the process to exit. - /// </summary> - /// <param name="process">The process to wait for.</param> - /// <param name="cancelToken">A <see cref="CancellationToken"/> to observe while waiting for the process to exit.</param> - /// <returns>True if the task exited normally, false if cancelled before the process exited.</returns> - public static async Task<bool> WaitForExitAsync(this Process process, CancellationToken cancelToken) - { - if (!process.EnableRaisingEvents) - { - throw new InvalidOperationException("EnableRisingEvents must be enabled to async wait for a task to exit."); - } - - // Add an event handler for the process exit event - var tcs = new TaskCompletionSource<bool>(TaskCreationOptions.RunContinuationsAsynchronously); - process.Exited += (_, _) => tcs.TrySetResult(true); - - // Return immediately if the process has already exited - if (process.HasExitedSafe()) - { - return true; - } - - // Register with the cancellation token then await - using (var cancelRegistration = cancelToken.Register(() => tcs.TrySetResult(process.HasExitedSafe()))) - { - return await tcs.Task.ConfigureAwait(false); - } - } - - /// <summary> - /// Gets a value indicating whether the associated process has been terminated using - /// <see cref="Process.HasExited"/>. This is safe to call even if there is no operating system process - /// associated with the <see cref="Process"/>. - /// </summary> - /// <param name="process">The process to check the exit status for.</param> - /// <returns> - /// True if the operating system process referenced by the <see cref="Process"/> component has - /// terminated, or if there is no associated operating system process; otherwise, false. - /// </returns> - private static bool HasExitedSafe(this Process process) - { - try - { - return process.HasExited; - } - catch (InvalidOperationException) - { - return true; + await process.WaitForExitAsync(cancelTokenSource.Token).ConfigureAwait(false); } } } diff --git a/MediaBrowser.Common/IApplicationHost.cs b/MediaBrowser.Common/IApplicationHost.cs index 96ee701b3..23795c6be 100644 --- a/MediaBrowser.Common/IApplicationHost.cs +++ b/MediaBrowser.Common/IApplicationHost.cs @@ -1,7 +1,6 @@ using System; using System.Collections.Generic; using System.Reflection; -using System.Threading.Tasks; using Microsoft.Extensions.DependencyInjection; namespace MediaBrowser.Common @@ -36,16 +35,15 @@ namespace MediaBrowser.Common string SystemId { get; } /// <summary> - /// Gets a value indicating whether this instance has pending kernel reload. + /// Gets a value indicating whether this instance has pending changes requiring a restart. /// </summary> - /// <value><c>true</c> if this instance has pending kernel reload; otherwise, <c>false</c>.</value> + /// <value><c>true</c> if this instance has a pending restart; otherwise, <c>false</c>.</value> bool HasPendingRestart { get; } /// <summary> - /// Gets a value indicating whether this instance is currently shutting down. + /// Gets or sets a value indicating whether the application should restart. /// </summary> - /// <value><c>true</c> if this instance is shutting down; otherwise, <c>false</c>.</value> - bool IsShuttingDown { get; } + bool ShouldRestart { get; set; } /// <summary> /// Gets the application version. @@ -88,11 +86,6 @@ namespace MediaBrowser.Common void NotifyPendingRestart(); /// <summary> - /// Restarts this instance. - /// </summary> - void Restart(); - - /// <summary> /// Gets the exports. /// </summary> /// <typeparam name="T">The type.</typeparam> @@ -124,12 +117,6 @@ namespace MediaBrowser.Common T Resolve<T>(); /// <summary> - /// Shuts down. - /// </summary> - /// <returns>A task.</returns> - Task Shutdown(); - - /// <summary> /// Initializes this instance. /// </summary> /// <param name="serviceCollection">Instance of the <see cref="IServiceCollection"/> interface.</param> diff --git a/MediaBrowser.Common/MediaBrowser.Common.csproj b/MediaBrowser.Common/MediaBrowser.Common.csproj index 3f1a098e4..7015d991f 100644 --- a/MediaBrowser.Common/MediaBrowser.Common.csproj +++ b/MediaBrowser.Common/MediaBrowser.Common.csproj @@ -38,10 +38,6 @@ <SymbolPackageFormat>snupkg</SymbolPackageFormat> </PropertyGroup> - <PropertyGroup Condition=" '$(Configuration)' == 'Debug' "> - <CodeAnalysisTreatWarningsAsErrors>false</CodeAnalysisTreatWarningsAsErrors> - </PropertyGroup> - <PropertyGroup Condition=" '$(Stability)'=='Unstable'"> <!-- Include all symbols in the main nupkg until Azure Artifact Feed starts supporting ingesting NuGet symbol packages. --> <AllowedOutputExtensionsInPackageBuildOutputFolder>$(AllowedOutputExtensionsInPackageBuildOutputFolder);.pdb</AllowedOutputExtensionsInPackageBuildOutputFolder> diff --git a/MediaBrowser.Common/Net/INetworkManager.cs b/MediaBrowser.Common/Net/INetworkManager.cs index b93939730..c51090e38 100644 --- a/MediaBrowser.Common/Net/INetworkManager.cs +++ b/MediaBrowser.Common/Net/INetworkManager.cs @@ -1,8 +1,9 @@ using System; using System.Collections.Generic; -using System.Collections.ObjectModel; +using System.Diagnostics.CodeAnalysis; using System.Net; using System.Net.NetworkInformation; +using MediaBrowser.Model.Net; using Microsoft.AspNetCore.Http; namespace MediaBrowser.Common.Net @@ -18,47 +19,32 @@ namespace MediaBrowser.Common.Net event EventHandler NetworkChanged; /// <summary> - /// Gets the published server urls list. + /// Gets a value indicating whether IPv4 is enabled. /// </summary> - Dictionary<IPNetAddress, string> PublishedServerUrls { get; } + bool IsIPv4Enabled { get; } /// <summary> - /// Gets a value indicating whether is all IPv6 interfaces are trusted as internal. + /// Gets a value indicating whether IPv6 is enabled. /// </summary> - bool TrustAllIP6Interfaces { get; } - - /// <summary> - /// Gets the remote address filter. - /// </summary> - Collection<IPObject> RemoteAddressFilter { get; } - - /// <summary> - /// Gets or sets a value indicating whether iP6 is enabled. - /// </summary> - bool IsIP6Enabled { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether iP4 is enabled. - /// </summary> - bool IsIP4Enabled { get; set; } + bool IsIPv6Enabled { get; } /// <summary> /// Calculates the list of interfaces to use for Kestrel. /// </summary> - /// <returns>A Collection{IPObject} object containing all the interfaces to bind. + /// <returns>A IReadOnlyList{IPData} object containing all the interfaces to bind. /// If all the interfaces are specified, and none are excluded, it returns zero items /// to represent any address.</returns> /// <param name="individualInterfaces">When false, return <see cref="IPAddress.Any"/> or <see cref="IPAddress.IPv6Any"/> for all interfaces.</param> - Collection<IPObject> GetAllBindInterfaces(bool individualInterfaces = false); + IReadOnlyList<IPData> GetAllBindInterfaces(bool individualInterfaces = false); /// <summary> - /// Returns a collection containing the loopback interfaces. + /// Returns a list containing the loopback interfaces. /// </summary> - /// <returns>Collection{IPObject}.</returns> - Collection<IPObject> GetLoopbacks(); + /// <returns>IReadOnlyList{IPData}.</returns> + IReadOnlyList<IPData> GetLoopbacks(); /// <summary> - /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// Retrieves the bind address to use in system URLs. (Server Discovery, PlayTo, LiveTV, SystemInfo) /// If no bind addresses are specified, an internal interface address is selected. /// The priority of selection is as follows:- /// @@ -72,90 +58,50 @@ namespace MediaBrowser.Common.Net /// /// If the source is from a public subnet address range and the user hasn't specified any bind addresses:- /// The first public interface that isn't a loopback and contains the source subnet. - /// The first public interface that isn't a loopback. Priority is given to interfaces with gateways. - /// An internal interface if there are no public ip addresses. + /// The first public interface that isn't a loopback. + /// The first internal interface that isn't a loopback. /// /// If the source is from a private subnet address range and the user hasn't specified any bind addresses:- /// The first private interface that contains the source subnet. - /// The first private interface that isn't a loopback. Priority is given to interfaces with gateways. + /// The first private interface that isn't a loopback. /// /// If no interfaces meet any of these criteria, then a loopback address is returned. /// - /// Interface that have been specifically excluded from binding are not used in any of the calculations. - /// </summary> - /// <param name="source">Source of the request.</param> - /// <param name="port">Optional port returned, if it's part of an override.</param> - /// <returns>IP Address to use, or loopback address if all else fails.</returns> - string GetBindInterface(IPObject source, out int? port); - - /// <summary> - /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) - /// If no bind addresses are specified, an internal interface address is selected. - /// (See <see cref="GetBindInterface(IPObject, out int?)"/>. + /// Interfaces that have been specifically excluded from binding are not used in any of the calculations. /// </summary> /// <param name="source">Source of the request.</param> /// <param name="port">Optional port returned, if it's part of an override.</param> - /// <returns>IP Address to use, or loopback address if all else fails.</returns> - string GetBindInterface(HttpRequest source, out int? port); + /// <returns>IP address to use, or loopback address if all else fails.</returns> + string GetBindAddress(HttpRequest source, out int? port); /// <summary> - /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// Retrieves the bind address to use in system URLs. (Server Discovery, PlayTo, LiveTV, SystemInfo) /// If no bind addresses are specified, an internal interface address is selected. - /// (See <see cref="GetBindInterface(IPObject, out int?)"/>. /// </summary> /// <param name="source">IP address of the request.</param> /// <param name="port">Optional port returned, if it's part of an override.</param> - /// <returns>IP Address to use, or loopback address if all else fails.</returns> - string GetBindInterface(IPAddress source, out int? port); + /// <param name="skipOverrides">Optional boolean denoting if published server overrides should be ignored. Defaults to false.</param> + /// <returns>IP address to use, or loopback address if all else fails.</returns> + string GetBindAddress(IPAddress source, out int? port, bool skipOverrides = false); /// <summary> - /// Retrieves the bind address to use in system url's. (Server Discovery, PlayTo, LiveTV, SystemInfo) + /// Retrieves the bind address to use in system URLs. (Server Discovery, PlayTo, LiveTV, SystemInfo) /// If no bind addresses are specified, an internal interface address is selected. - /// (See <see cref="GetBindInterface(IPObject, out int?)"/>. + /// (See <see cref="GetBindAddress(IPAddress, out int?, bool)"/>. /// </summary> /// <param name="source">Source of the request.</param> /// <param name="port">Optional port returned, if it's part of an override.</param> - /// <returns>IP Address to use, or loopback address if all else fails.</returns> - string GetBindInterface(string source, out int? port); - - /// <summary> - /// Checks to see if the ip address is specifically excluded in LocalNetworkAddresses. - /// </summary> - /// <param name="address">IP address to check.</param> - /// <returns>True if it is.</returns> - bool IsExcludedInterface(IPAddress address); + /// <returns>IP address to use, or loopback address if all else fails.</returns> + string GetBindAddress(string source, out int? port); /// <summary> /// Get a list of all the MAC addresses associated with active interfaces. /// </summary> /// <returns>List of MAC addresses.</returns> - IReadOnlyCollection<PhysicalAddress> GetMacAddresses(); - - /// <summary> - /// Checks to see if the IP Address provided matches an interface that has a gateway. - /// </summary> - /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param> - /// <returns>Result of the check.</returns> - bool IsGatewayInterface(IPObject? addressObj); - - /// <summary> - /// Checks to see if the IP Address provided matches an interface that has a gateway. - /// </summary> - /// <param name="addressObj">IP to check. Can be an IPAddress or an IPObject.</param> - /// <returns>Result of the check.</returns> - bool IsGatewayInterface(IPAddress? addressObj); - - /// <summary> - /// Returns true if the address is a private address. - /// The configuration option TrustIP6Interfaces overrides this functions behaviour. - /// </summary> - /// <param name="address">Address to check.</param> - /// <returns>True or False.</returns> - bool IsPrivateAddressRange(IPObject address); + IReadOnlyList<PhysicalAddress> GetMacAddresses(); /// <summary> /// Returns true if the address is part of the user defined LAN. - /// The configuration option TrustIP6Interfaces overrides this functions behaviour. /// </summary> /// <param name="address">IP to check.</param> /// <returns>True if endpoint is within the LAN range.</returns> @@ -163,76 +109,31 @@ namespace MediaBrowser.Common.Net /// <summary> /// Returns true if the address is part of the user defined LAN. - /// The configuration option TrustIP6Interfaces overrides this functions behaviour. - /// </summary> - /// <param name="address">IP to check.</param> - /// <returns>True if endpoint is within the LAN range.</returns> - bool IsInLocalNetwork(IPObject address); - - /// <summary> - /// Returns true if the address is part of the user defined LAN. - /// The configuration option TrustIP6Interfaces overrides this functions behaviour. /// </summary> /// <param name="address">IP to check.</param> /// <returns>True if endpoint is within the LAN range.</returns> bool IsInLocalNetwork(IPAddress address); /// <summary> - /// Attempts to convert the token to an IP address, permitting for interface descriptions and indexes. - /// eg. "eth1", or "TP-LINK Wireless USB Adapter". + /// Attempts to convert the interface name to an IP address. + /// eg. "eth1", or "enp3s5". /// </summary> - /// <param name="token">Token to parse.</param> - /// <param name="result">Resultant object's ip addresses, if successful.</param> + /// <param name="intf">Interface name.</param> + /// <param name="result">Resulting object's IP addresses, if successful.</param> /// <returns>Success of the operation.</returns> - bool TryParseInterface(string token, out Collection<IPObject>? result); - - /// <summary> - /// Parses an array of strings into a Collection{IPObject}. - /// </summary> - /// <param name="values">Values to parse.</param> - /// <param name="negated">When true, only include values beginning with !. When false, ignore ! values.</param> - /// <returns>IPCollection object containing the value strings.</returns> - Collection<IPObject> CreateIPCollection(string[] values, bool negated = false); - - /// <summary> - /// Returns all the internal Bind interface addresses. - /// </summary> - /// <returns>An internal list of interfaces addresses.</returns> - Collection<IPObject> GetInternalBindAddresses(); - - /// <summary> - /// Checks to see if an IP address is still a valid interface address. - /// </summary> - /// <param name="address">IP address to check.</param> - /// <returns>True if it is.</returns> - bool IsValidInterfaceAddress(IPAddress address); - - /// <summary> - /// Returns true if the IP address is in the excluded list. - /// </summary> - /// <param name="ip">IP to check.</param> - /// <returns>True if excluded.</returns> - bool IsExcluded(IPAddress ip); - - /// <summary> - /// Returns true if the IP address is in the excluded list. - /// </summary> - /// <param name="ip">IP to check.</param> - /// <returns>True if excluded.</returns> - bool IsExcluded(EndPoint ip); + bool TryParseInterface(string intf, [NotNullWhen(true)] out IReadOnlyList<IPData>? result); /// <summary> - /// Gets the filtered LAN ip addresses. + /// Returns all internal (LAN) bind interface addresses. /// </summary> - /// <param name="filter">Optional filter for the list.</param> - /// <returns>Returns a filtered list of LAN addresses.</returns> - Collection<IPObject> GetFilteredLANSubnets(Collection<IPObject>? filter = null); + /// <returns>An list of internal (LAN) interfaces addresses.</returns> + IReadOnlyList<IPData> GetInternalBindAddresses(); /// <summary> - /// Checks to see if <paramref name="remoteIp"/> has access. + /// Checks if <paramref name="remoteIP"/> has access to the server. /// </summary> - /// <param name="remoteIp">IP Address of client.</param> - /// <returns><b>True</b> if has access, otherwise <b>false</b>.</returns> - bool HasRemoteAccess(IPAddress remoteIp); + /// <param name="remoteIP">IP address of the client.</param> + /// <returns><b>True</b> if it has access, otherwise <b>false</b>.</returns> + bool HasRemoteAccess(IPAddress remoteIP); } } diff --git a/MediaBrowser.Common/Net/IPHost.cs b/MediaBrowser.Common/Net/IPHost.cs deleted file mode 100644 index ec76a43b6..000000000 --- a/MediaBrowser.Common/Net/IPHost.cs +++ /dev/null @@ -1,441 +0,0 @@ -using System; -using System.Diagnostics; -using System.Linq; -using System.Net; -using System.Net.Sockets; -using System.Text.RegularExpressions; - -namespace MediaBrowser.Common.Net -{ - /// <summary> - /// Object that holds a host name. - /// </summary> - public class IPHost : IPObject - { - /// <summary> - /// Gets or sets timeout value before resolve required, in minutes. - /// </summary> - public const int Timeout = 30; - - /// <summary> - /// Represents an IPHost that has no value. - /// </summary> - public static readonly IPHost None = new IPHost(string.Empty, IPAddress.None); - - /// <summary> - /// Time when last resolved in ticks. - /// </summary> - private DateTime? _lastResolved = null; - - /// <summary> - /// Gets the IP Addresses, attempting to resolve the name, if there are none. - /// </summary> - private IPAddress[] _addresses; - - /// <summary> - /// Initializes a new instance of the <see cref="IPHost"/> class. - /// </summary> - /// <param name="name">Host name to assign.</param> - public IPHost(string name) - { - HostName = name ?? throw new ArgumentNullException(nameof(name)); - _addresses = Array.Empty<IPAddress>(); - Resolved = false; - } - - /// <summary> - /// Initializes a new instance of the <see cref="IPHost"/> class. - /// </summary> - /// <param name="name">Host name to assign.</param> - /// <param name="address">Address to assign.</param> - private IPHost(string name, IPAddress address) - { - HostName = name ?? throw new ArgumentNullException(nameof(name)); - _addresses = new IPAddress[] { address ?? throw new ArgumentNullException(nameof(address)) }; - Resolved = !address.Equals(IPAddress.None); - } - - /// <summary> - /// Gets or sets the object's first IP address. - /// </summary> - public override IPAddress Address - { - get - { - return ResolveHost() ? this[0] : IPAddress.None; - } - - set - { - // Not implemented, as a host's address is determined by DNS. - throw new NotImplementedException("The address of a host is determined by DNS."); - } - } - - /// <summary> - /// Gets or sets the object's first IP's subnet prefix. - /// The setter does nothing, but shouldn't raise an exception. - /// </summary> - public override byte PrefixLength - { - get => (byte)(ResolveHost() ? 128 : 32); - - // Not implemented, as a host object can only have a prefix length of 128 (IPv6) or 32 (IPv4) prefix length, - // which is automatically determined by it's IP type. Anything else is meaningless. - set => throw new NotImplementedException(); - } - - /// <summary> - /// Gets a value indicating whether the address has a value. - /// </summary> - public bool HasAddress => _addresses.Length != 0; - - /// <summary> - /// Gets the host name of this object. - /// </summary> - public string HostName { get; } - - /// <summary> - /// Gets a value indicating whether this host has attempted to be resolved. - /// </summary> - public bool Resolved { get; private set; } - - /// <summary> - /// Gets or sets the IP Addresses associated with this object. - /// </summary> - /// <param name="index">Index of address.</param> - public IPAddress this[int index] - { - get - { - ResolveHost(); - return index >= 0 && index < _addresses.Length ? _addresses[index] : IPAddress.None; - } - } - - /// <summary> - /// Attempts to parse the host string. - /// </summary> - /// <param name="host">Host name to parse.</param> - /// <param name="hostObj">Object representing the string, if it has successfully been parsed.</param> - /// <returns><c>true</c> if the parsing is successful, <c>false</c> if not.</returns> - public static bool TryParse(string host, out IPHost hostObj) - { - if (string.IsNullOrWhiteSpace(host)) - { - hostObj = IPHost.None; - return false; - } - - // See if it's an IPv6 with port address e.g. [::1] or [::1]:120. - int i = host.IndexOf(']', StringComparison.Ordinal); - if (i != -1) - { - return TryParse(host.Remove(i - 1).TrimStart(' ', '['), out hostObj); - } - - if (IPNetAddress.TryParse(host, out var netAddress)) - { - // Host name is an ip address, so fake resolve. - hostObj = new IPHost(host, netAddress.Address); - return true; - } - - // Is it a host, IPv4/6 with/out port? - string[] hosts = host.Split(':'); - - if (hosts.Length <= 2) - { - // This is either a hostname: port, or an IP4:port. - host = hosts[0]; - - if (string.Equals("localhost", host, StringComparison.OrdinalIgnoreCase)) - { - hostObj = new IPHost(host); - return true; - } - - if (IPAddress.TryParse(host, out var netIP)) - { - // Host name is an ip address, so fake resolve. - hostObj = new IPHost(host, netIP); - return true; - } - } - else - { - // Invalid host name, as it cannot contain : - hostObj = new IPHost(string.Empty, IPAddress.None); - return false; - } - - // Use regular expression as CheckHostName isn't RFC5892 compliant. - // Modified from gSkinner's expression at https://stackoverflow.com/questions/11809631/fully-qualified-domain-name-validation - string pattern = @"(?im)^(?!:\/\/)(?=.{1,255}$)((.{1,63}\.){0,127}(?![0-9]*$)[a-z0-9-]+\.?)$"; - - if (Regex.IsMatch(host, pattern)) - { - hostObj = new IPHost(host); - return true; - } - - hostObj = IPHost.None; - return false; - } - - /// <summary> - /// Attempts to parse the host string. - /// </summary> - /// <param name="host">Host name to parse.</param> - /// <returns>Object representing the string, if it has successfully been parsed.</returns> - public static IPHost Parse(string host) - { - if (IPHost.TryParse(host, out IPHost res)) - { - return res; - } - - throw new InvalidCastException($"Host does not contain a valid value. {host}"); - } - - /// <summary> - /// Attempts to parse the host string, ensuring that it resolves only to a specific IP type. - /// </summary> - /// <param name="host">Host name to parse.</param> - /// <param name="family">Addressfamily filter.</param> - /// <returns>Object representing the string, if it has successfully been parsed.</returns> - public static IPHost Parse(string host, AddressFamily family) - { - if (IPHost.TryParse(host, out IPHost res)) - { - if (family == AddressFamily.InterNetwork) - { - res.Remove(AddressFamily.InterNetworkV6); - } - else - { - res.Remove(AddressFamily.InterNetwork); - } - - return res; - } - - throw new InvalidCastException($"Host does not contain a valid value. {host}"); - } - - /// <summary> - /// Returns the Addresses that this item resolved to. - /// </summary> - /// <returns>IPAddress Array.</returns> - public IPAddress[] GetAddresses() - { - ResolveHost(); - return _addresses; - } - - /// <inheritdoc/> - public override bool Contains(IPAddress address) - { - if (address is not null && !Address.Equals(IPAddress.None)) - { - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - foreach (var addr in GetAddresses()) - { - if (address.Equals(addr)) - { - return true; - } - } - } - - return false; - } - - /// <inheritdoc/> - public override bool Equals(IPObject? other) - { - if (other is IPHost otherObj) - { - // Do we have the name Hostname? - if (string.Equals(otherObj.HostName, HostName, StringComparison.OrdinalIgnoreCase)) - { - return true; - } - - if (!ResolveHost() || !otherObj.ResolveHost()) - { - return false; - } - - // Do any of our IP addresses match? - foreach (IPAddress addr in _addresses) - { - foreach (IPAddress otherAddress in otherObj._addresses) - { - if (addr.Equals(otherAddress)) - { - return true; - } - } - } - } - - return false; - } - - /// <inheritdoc/> - public override bool IsIP6() - { - // Returns true if interfaces are only IP6. - if (ResolveHost()) - { - foreach (IPAddress i in _addresses) - { - if (i.AddressFamily != AddressFamily.InterNetworkV6) - { - return false; - } - } - - return true; - } - - return false; - } - - /// <inheritdoc/> - public override string ToString() - { - // StringBuilder not optimum here. - string output = string.Empty; - if (_addresses.Length > 0) - { - bool moreThanOne = _addresses.Length > 1; - if (moreThanOne) - { - output = "["; - } - - foreach (var i in _addresses) - { - if (Address.Equals(IPAddress.None) && Address.AddressFamily == AddressFamily.Unspecified) - { - output += HostName + ","; - } - else if (i.Equals(IPAddress.Any)) - { - output += "Any IP4 Address,"; - } - else if (Address.Equals(IPAddress.IPv6Any)) - { - output += "Any IP6 Address,"; - } - else if (i.Equals(IPAddress.Broadcast)) - { - output += "Any Address,"; - } - else if (i.AddressFamily == AddressFamily.InterNetwork) - { - output += $"{i}/32,"; - } - else - { - output += $"{i}/128,"; - } - } - - output = output[..^1]; - - if (moreThanOne) - { - output += "]"; - } - } - else - { - output = HostName; - } - - return output; - } - - /// <inheritdoc/> - public override void Remove(AddressFamily family) - { - if (ResolveHost()) - { - _addresses = _addresses.Where(p => p.AddressFamily != family).ToArray(); - } - } - - /// <inheritdoc/> - public override bool Contains(IPObject address) - { - // An IPHost cannot contain another IPObject, it can only be equal. - return Equals(address); - } - - /// <inheritdoc/> - protected override IPObject CalculateNetworkAddress() - { - var (address, prefixLength) = NetworkAddressOf(this[0], PrefixLength); - return new IPNetAddress(address, prefixLength); - } - - /// <summary> - /// Attempt to resolve the ip address of a host. - /// </summary> - /// <returns><c>true</c> if any addresses have been resolved, otherwise <c>false</c>.</returns> - private bool ResolveHost() - { - // When was the last time we resolved? - _lastResolved ??= DateTime.UtcNow; - - // If we haven't resolved before, or our timer has run out... - if ((_addresses.Length == 0 && !Resolved) || (DateTime.UtcNow > _lastResolved.Value.AddMinutes(Timeout))) - { - _lastResolved = DateTime.UtcNow; - ResolveHostInternal(); - Resolved = true; - } - - return _addresses.Length > 0; - } - - /// <summary> - /// Task that looks up a Host name and returns its IP addresses. - /// </summary> - private void ResolveHostInternal() - { - var hostName = HostName; - if (string.IsNullOrEmpty(hostName)) - { - return; - } - - // Resolves the host name - so save a DNS lookup. - if (string.Equals(hostName, "localhost", StringComparison.OrdinalIgnoreCase)) - { - _addresses = new IPAddress[] { IPAddress.Loopback, IPAddress.IPv6Loopback }; - return; - } - - if (Uri.CheckHostName(hostName) == UriHostNameType.Dns) - { - try - { - _addresses = Dns.GetHostEntry(hostName).AddressList; - } - catch (SocketException ex) - { - // Log and then ignore socket errors, as the result value will just be an empty array. - Debug.WriteLine("GetHostAddresses failed with {Message}.", ex.Message); - } - } - } - } -} diff --git a/MediaBrowser.Common/Net/IPNetAddress.cs b/MediaBrowser.Common/Net/IPNetAddress.cs deleted file mode 100644 index de72d978e..000000000 --- a/MediaBrowser.Common/Net/IPNetAddress.cs +++ /dev/null @@ -1,278 +0,0 @@ -using System; -using System.Net; -using System.Net.Sockets; - -namespace MediaBrowser.Common.Net -{ - /// <summary> - /// An object that holds and IP address and subnet mask. - /// </summary> - public class IPNetAddress : IPObject - { - /// <summary> - /// Represents an IPNetAddress that has no value. - /// </summary> - public static readonly IPNetAddress None = new IPNetAddress(IPAddress.None); - - /// <summary> - /// IPv4 multicast address. - /// </summary> - public static readonly IPAddress SSDPMulticastIPv4 = IPAddress.Parse("239.255.255.250"); - - /// <summary> - /// IPv6 local link multicast address. - /// </summary> - public static readonly IPAddress SSDPMulticastIPv6LinkLocal = IPAddress.Parse("ff02::C"); - - /// <summary> - /// IPv6 site local multicast address. - /// </summary> - public static readonly IPAddress SSDPMulticastIPv6SiteLocal = IPAddress.Parse("ff05::C"); - - /// <summary> - /// IP4Loopback address host. - /// </summary> - public static readonly IPNetAddress IP4Loopback = IPNetAddress.Parse("127.0.0.1/8"); - - /// <summary> - /// IP6Loopback address host. - /// </summary> - public static readonly IPNetAddress IP6Loopback = new IPNetAddress(IPAddress.IPv6Loopback); - - /// <summary> - /// Object's IP address. - /// </summary> - private IPAddress _address; - - /// <summary> - /// Initializes a new instance of the <see cref="IPNetAddress"/> class. - /// </summary> - /// <param name="address">Address to assign.</param> - public IPNetAddress(IPAddress address) - { - _address = address ?? throw new ArgumentNullException(nameof(address)); - PrefixLength = (byte)(address.AddressFamily == AddressFamily.InterNetwork ? 32 : 128); - } - - /// <summary> - /// Initializes a new instance of the <see cref="IPNetAddress"/> class. - /// </summary> - /// <param name="address">IP Address.</param> - /// <param name="prefixLength">Mask as a CIDR.</param> - public IPNetAddress(IPAddress address, byte prefixLength) - { - if (address?.IsIPv4MappedToIPv6 ?? throw new ArgumentNullException(nameof(address))) - { - _address = address.MapToIPv4(); - } - else - { - _address = address; - } - - PrefixLength = prefixLength; - } - - /// <summary> - /// Gets or sets the object's IP address. - /// </summary> - public override IPAddress Address - { - get - { - return _address; - } - - set - { - _address = value ?? IPAddress.None; - } - } - - /// <inheritdoc/> - public override byte PrefixLength { get; set; } - - /// <summary> - /// Try to parse the address and subnet strings into an IPNetAddress object. - /// </summary> - /// <param name="addr">IP address to parse. Can be CIDR or X.X.X.X notation.</param> - /// <param name="ip">Resultant object.</param> - /// <returns>True if the values parsed successfully. False if not, resulting in the IP being null.</returns> - public static bool TryParse(string addr, out IPNetAddress ip) - { - if (!string.IsNullOrEmpty(addr)) - { - addr = addr.Trim(); - - // Try to parse it as is. - if (IPAddress.TryParse(addr, out IPAddress? res)) - { - ip = new IPNetAddress(res); - return true; - } - - // Is it a network? - string[] tokens = addr.Split('/'); - - if (tokens.Length == 2) - { - tokens[0] = tokens[0].TrimEnd(); - tokens[1] = tokens[1].TrimStart(); - - if (IPAddress.TryParse(tokens[0], out res)) - { - // Is the subnet part a cidr? - if (byte.TryParse(tokens[1], out byte cidr)) - { - ip = new IPNetAddress(res, cidr); - return true; - } - - // Is the subnet in x.y.a.b form? - if (IPAddress.TryParse(tokens[1], out IPAddress? mask)) - { - ip = new IPNetAddress(res, MaskToCidr(mask)); - return true; - } - } - } - } - - ip = None; - return false; - } - - /// <summary> - /// Parses the string provided, throwing an exception if it is badly formed. - /// </summary> - /// <param name="addr">String to parse.</param> - /// <returns>IPNetAddress object.</returns> - public static IPNetAddress Parse(string addr) - { - if (TryParse(addr, out IPNetAddress o)) - { - return o; - } - - throw new ArgumentException("Unable to recognise object :" + addr); - } - - /// <inheritdoc/> - public override bool Contains(IPAddress address) - { - ArgumentNullException.ThrowIfNull(address); - - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - if (address.AddressFamily != AddressFamily) - { - return false; - } - - var (altAddress, altPrefix) = NetworkAddressOf(address, PrefixLength); - return NetworkAddress.Address.Equals(altAddress) && NetworkAddress.PrefixLength >= altPrefix; - } - - /// <inheritdoc/> - public override bool Contains(IPObject address) - { - if (address is IPHost addressObj && addressObj.HasAddress) - { - foreach (IPAddress addr in addressObj.GetAddresses()) - { - if (Contains(addr)) - { - return true; - } - } - } - else if (address is IPNetAddress netaddrObj) - { - // Have the same network address, but different subnets? - if (NetworkAddress.Address.Equals(netaddrObj.NetworkAddress.Address)) - { - return NetworkAddress.PrefixLength <= netaddrObj.PrefixLength; - } - - var altAddress = NetworkAddressOf(netaddrObj.Address, PrefixLength).Address; - return NetworkAddress.Address.Equals(altAddress); - } - - return false; - } - - /// <inheritdoc/> - public override bool Equals(IPObject? other) - { - if (other is IPNetAddress otherObj && !Address.Equals(IPAddress.None) && !otherObj.Address.Equals(IPAddress.None)) - { - return Address.Equals(otherObj.Address) && - PrefixLength == otherObj.PrefixLength; - } - - return false; - } - - /// <inheritdoc/> - public override bool Equals(IPAddress ip) - { - if (ip is not null && !ip.Equals(IPAddress.None) && !Address.Equals(IPAddress.None)) - { - return ip.Equals(Address); - } - - return false; - } - - /// <inheritdoc/> - public override string ToString() - { - return ToString(false); - } - - /// <summary> - /// Returns a textual representation of this object. - /// </summary> - /// <param name="shortVersion">Set to true, if the subnet is to be excluded as part of the address.</param> - /// <returns>String representation of this object.</returns> - public string ToString(bool shortVersion) - { - if (!Address.Equals(IPAddress.None)) - { - if (Address.Equals(IPAddress.Any)) - { - return "Any IP4 Address"; - } - - if (Address.Equals(IPAddress.IPv6Any)) - { - return "Any IP6 Address"; - } - - if (Address.Equals(IPAddress.Broadcast)) - { - return "Any Address"; - } - - if (shortVersion) - { - return Address.ToString(); - } - - return $"{Address}/{PrefixLength}"; - } - - return string.Empty; - } - - /// <inheritdoc/> - protected override IPObject CalculateNetworkAddress() - { - var (address, prefixLength) = NetworkAddressOf(_address, PrefixLength); - return new IPNetAddress(address, prefixLength); - } - } -} diff --git a/MediaBrowser.Common/Net/IPObject.cs b/MediaBrowser.Common/Net/IPObject.cs deleted file mode 100644 index 93655234b..000000000 --- a/MediaBrowser.Common/Net/IPObject.cs +++ /dev/null @@ -1,355 +0,0 @@ -using System; -using System.Net; -using System.Net.Sockets; - -namespace MediaBrowser.Common.Net -{ - /// <summary> - /// Base network object class. - /// </summary> - public abstract class IPObject : IEquatable<IPObject> - { - /// <summary> - /// The network address of this object. - /// </summary> - private IPObject? _networkAddress; - - /// <summary> - /// Gets or sets a user defined value that is associated with this object. - /// </summary> - public int Tag { get; set; } - - /// <summary> - /// Gets or sets the object's IP address. - /// </summary> - public abstract IPAddress Address { get; set; } - - /// <summary> - /// Gets the object's network address. - /// </summary> - public IPObject NetworkAddress => _networkAddress ??= CalculateNetworkAddress(); - - /// <summary> - /// Gets or sets the object's IP address. - /// </summary> - public abstract byte PrefixLength { get; set; } - - /// <summary> - /// Gets the AddressFamily of this object. - /// </summary> - public AddressFamily AddressFamily - { - get - { - // Keep terms separate as Address performs other functions in inherited objects. - IPAddress address = Address; - return address.Equals(IPAddress.None) ? AddressFamily.Unspecified : address.AddressFamily; - } - } - - /// <summary> - /// Returns the network address of an object. - /// </summary> - /// <param name="address">IP Address to convert.</param> - /// <param name="prefixLength">Subnet prefix.</param> - /// <returns>IPAddress.</returns> - public static (IPAddress Address, byte PrefixLength) NetworkAddressOf(IPAddress address, byte prefixLength) - { - ArgumentNullException.ThrowIfNull(address); - - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - if (IPAddress.IsLoopback(address)) - { - return (address, prefixLength); - } - - // An ip address is just a list of bytes, each one representing a segment on the network. - // This separates the IP address into octets and calculates how many octets will need to be altered or set to zero dependant upon the - // prefix length value. eg. /16 on a 4 octet ip4 address (192.168.2.240) will result in the 2 and the 240 being zeroed out. - // Where there is not an exact boundary (eg /23), mod is used to calculate how many bits of this value are to be kept. - - // GetAddressBytes - Span<byte> addressBytes = stackalloc byte[address.AddressFamily == AddressFamily.InterNetwork ? 4 : 16]; - address.TryWriteBytes(addressBytes, out _); - - int div = prefixLength / 8; - int mod = prefixLength % 8; - if (mod != 0) - { - // Prefix length is counted right to left, so subtract 8 so we know how many bits to clear. - mod = 8 - mod; - - // Shift out the bits from the octet that we don't want, by moving right then back left. - addressBytes[div] = (byte)((int)addressBytes[div] >> mod << mod); - // Move on the next byte. - div++; - } - - // Blank out the remaining octets from mod + 1 to the end of the byte array. (192.168.2.240/16 becomes 192.168.0.0) - for (int octet = div; octet < addressBytes.Length; octet++) - { - addressBytes[octet] = 0; - } - - // Return the network address for the prefix. - return (new IPAddress(addressBytes), prefixLength); - } - - /// <summary> - /// Tests to see if the ip address is an IP6 address. - /// </summary> - /// <param name="address">Value to test.</param> - /// <returns>True if it is.</returns> - public static bool IsIP6(IPAddress address) - { - ArgumentNullException.ThrowIfNull(address); - - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - return !address.Equals(IPAddress.None) && (address.AddressFamily == AddressFamily.InterNetworkV6); - } - - /// <summary> - /// Tests to see if the address in the private address range. - /// </summary> - /// <param name="address">Object to test.</param> - /// <returns>True if it contains a private address.</returns> - public static bool IsPrivateAddressRange(IPAddress address) - { - ArgumentNullException.ThrowIfNull(address); - - if (!address.Equals(IPAddress.None)) - { - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - if (address.AddressFamily == AddressFamily.InterNetwork) - { - // GetAddressBytes - Span<byte> octet = stackalloc byte[4]; - address.TryWriteBytes(octet, out _); - - return (octet[0] == 10) - || (octet[0] == 172 && octet[1] >= 16 && octet[1] <= 31) // RFC1918 - || (octet[0] == 192 && octet[1] == 168) // RFC1918 - || (octet[0] == 127); // RFC1122 - } - else - { - // GetAddressBytes - Span<byte> octet = stackalloc byte[16]; - address.TryWriteBytes(octet, out _); - - uint word = (uint)(octet[0] << 8) + octet[1]; - - return (word >= 0xfe80 && word <= 0xfebf) // fe80::/10 :Local link. - || (word >= 0xfc00 && word <= 0xfdff); // fc00::/7 :Unique local address. - } - } - - return false; - } - - /// <summary> - /// Returns true if the IPAddress contains an IP6 Local link address. - /// </summary> - /// <param name="address">IPAddress object to check.</param> - /// <returns>True if it is a local link address.</returns> - /// <remarks> - /// See https://stackoverflow.com/questions/6459928/explain-the-instance-properties-of-system-net-ipaddress - /// it appears that the IPAddress.IsIPv6LinkLocal is out of date. - /// </remarks> - public static bool IsIPv6LinkLocal(IPAddress address) - { - ArgumentNullException.ThrowIfNull(address); - - if (address.IsIPv4MappedToIPv6) - { - address = address.MapToIPv4(); - } - - if (address.AddressFamily != AddressFamily.InterNetworkV6) - { - return false; - } - - // GetAddressBytes - Span<byte> octet = stackalloc byte[16]; - address.TryWriteBytes(octet, out _); - uint word = (uint)(octet[0] << 8) + octet[1]; - - return word >= 0xfe80 && word <= 0xfebf; // fe80::/10 :Local link. - } - - /// <summary> - /// Convert a subnet mask in CIDR notation to a dotted decimal string value. IPv4 only. - /// </summary> - /// <param name="cidr">Subnet mask in CIDR notation.</param> - /// <param name="family">IPv4 or IPv6 family.</param> - /// <returns>String value of the subnet mask in dotted decimal notation.</returns> - public static IPAddress CidrToMask(byte cidr, AddressFamily family) - { - uint addr = 0xFFFFFFFF << (family == AddressFamily.InterNetwork ? 32 : 128 - cidr); - addr = ((addr & 0xff000000) >> 24) - | ((addr & 0x00ff0000) >> 8) - | ((addr & 0x0000ff00) << 8) - | ((addr & 0x000000ff) << 24); - return new IPAddress(addr); - } - - /// <summary> - /// Convert a mask to a CIDR. IPv4 only. - /// https://stackoverflow.com/questions/36954345/get-cidr-from-netmask. - /// </summary> - /// <param name="mask">Subnet mask.</param> - /// <returns>Byte CIDR representing the mask.</returns> - public static byte MaskToCidr(IPAddress mask) - { - ArgumentNullException.ThrowIfNull(mask); - - byte cidrnet = 0; - if (!mask.Equals(IPAddress.Any)) - { - // GetAddressBytes - Span<byte> bytes = stackalloc byte[mask.AddressFamily == AddressFamily.InterNetwork ? 4 : 16]; - mask.TryWriteBytes(bytes, out _); - - var zeroed = false; - for (var i = 0; i < bytes.Length; i++) - { - for (int v = bytes[i]; (v & 0xFF) != 0; v <<= 1) - { - if (zeroed) - { - // Invalid netmask. - return (byte)~cidrnet; - } - - if ((v & 0x80) == 0) - { - zeroed = true; - } - else - { - cidrnet++; - } - } - } - } - - return cidrnet; - } - - /// <summary> - /// Tests to see if this object is a Loopback address. - /// </summary> - /// <returns>True if it is.</returns> - public virtual bool IsLoopback() - { - return IPAddress.IsLoopback(Address); - } - - /// <summary> - /// Removes all addresses of a specific type from this object. - /// </summary> - /// <param name="family">Type of address to remove.</param> - public virtual void Remove(AddressFamily family) - { - // This method only performs a function in the IPHost implementation of IPObject. - } - - /// <summary> - /// Tests to see if this object is an IPv6 address. - /// </summary> - /// <returns>True if it is.</returns> - public virtual bool IsIP6() - { - return IsIP6(Address); - } - - /// <summary> - /// Returns true if this IP address is in the RFC private address range. - /// </summary> - /// <returns>True this object has a private address.</returns> - public virtual bool IsPrivateAddressRange() - { - return IsPrivateAddressRange(Address); - } - - /// <summary> - /// Compares this to the object passed as a parameter. - /// </summary> - /// <param name="ip">Object to compare to.</param> - /// <returns>Equality result.</returns> - public virtual bool Equals(IPAddress ip) - { - if (ip is not null) - { - if (ip.IsIPv4MappedToIPv6) - { - ip = ip.MapToIPv4(); - } - - return !Address.Equals(IPAddress.None) && Address.Equals(ip); - } - - return false; - } - - /// <summary> - /// Compares this to the object passed as a parameter. - /// </summary> - /// <param name="other">Object to compare to.</param> - /// <returns>Equality result.</returns> - public virtual bool Equals(IPObject? other) - { - if (other is not null) - { - return !Address.Equals(IPAddress.None) && Address.Equals(other.Address); - } - - return false; - } - - /// <summary> - /// Compares the address in this object and the address in the object passed as a parameter. - /// </summary> - /// <param name="address">Object's IP address to compare to.</param> - /// <returns>Comparison result.</returns> - public abstract bool Contains(IPObject address); - - /// <summary> - /// Compares the address in this object and the address in the object passed as a parameter. - /// </summary> - /// <param name="address">Object's IP address to compare to.</param> - /// <returns>Comparison result.</returns> - public abstract bool Contains(IPAddress address); - - /// <inheritdoc/> - public override int GetHashCode() - { - return Address.GetHashCode(); - } - - /// <inheritdoc/> - public override bool Equals(object? obj) - { - return Equals(obj as IPObject); - } - - /// <summary> - /// Calculates the network address of this object. - /// </summary> - /// <returns>Returns the network address of this object.</returns> - protected abstract IPObject CalculateNetworkAddress(); - } -} diff --git a/MediaBrowser.Common/Net/NetworkExtensions.cs b/MediaBrowser.Common/Net/NetworkExtensions.cs deleted file mode 100644 index 5e5e5b81b..000000000 --- a/MediaBrowser.Common/Net/NetworkExtensions.cs +++ /dev/null @@ -1,248 +0,0 @@ -using System; -using System.Collections.ObjectModel; -using System.Net; - -namespace MediaBrowser.Common.Net -{ - /// <summary> - /// Defines the <see cref="NetworkExtensions" />. - /// </summary> - public static class NetworkExtensions - { - /// <summary> - /// Add an address to the collection. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="ip">Item to add.</param> - public static void AddItem(this Collection<IPObject> source, IPAddress ip) - { - if (!source.ContainsAddress(ip)) - { - source.Add(new IPNetAddress(ip, 32)); - } - } - - /// <summary> - /// Adds a network to the collection. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="item">Item to add.</param> - /// <param name="itemsAreNetworks">If <c>true</c> the values are treated as subnets. - /// If <b>false</b> items are addresses.</param> - public static void AddItem(this Collection<IPObject> source, IPObject item, bool itemsAreNetworks = true) - { - if (!source.ContainsAddress(item) || !itemsAreNetworks) - { - source.Add(item); - } - } - - /// <summary> - /// Converts this object to a string. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <returns>Returns a string representation of this object.</returns> - public static string AsString(this Collection<IPObject> source) - { - return $"[{string.Join(',', source)}]"; - } - - /// <summary> - /// Returns true if the collection contains an item with the ip address, - /// or the ip address falls within any of the collection's network ranges. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="item">The item to look for.</param> - /// <returns>True if the collection contains the item.</returns> - public static bool ContainsAddress(this Collection<IPObject> source, IPAddress item) - { - if (source.Count == 0) - { - return false; - } - - ArgumentNullException.ThrowIfNull(item); - - if (item.IsIPv4MappedToIPv6) - { - item = item.MapToIPv4(); - } - - foreach (var i in source) - { - if (i.Contains(item)) - { - return true; - } - } - - return false; - } - - /// <summary> - /// Returns true if the collection contains an item with the ip address, - /// or the ip address falls within any of the collection's network ranges. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="item">The item to look for.</param> - /// <returns>True if the collection contains the item.</returns> - public static bool ContainsAddress(this Collection<IPObject> source, IPObject item) - { - if (source.Count == 0) - { - return false; - } - - ArgumentNullException.ThrowIfNull(item); - - foreach (var i in source) - { - if (i.Contains(item)) - { - return true; - } - } - - return false; - } - - /// <summary> - /// Compares two Collection{IPObject} objects. The order is ignored. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="dest">Item to compare to.</param> - /// <returns>True if both are equal.</returns> - public static bool Compare(this Collection<IPObject> source, Collection<IPObject> dest) - { - if (dest is null || source.Count != dest.Count) - { - return false; - } - - foreach (var sourceItem in source) - { - bool found = false; - foreach (var destItem in dest) - { - if (sourceItem.Equals(destItem)) - { - found = true; - break; - } - } - - if (!found) - { - return false; - } - } - - return true; - } - - /// <summary> - /// Returns a collection containing the subnets of this collection given. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <returns>Collection{IPObject} object containing the subnets.</returns> - public static Collection<IPObject> AsNetworks(this Collection<IPObject> source) - { - ArgumentNullException.ThrowIfNull(source); - - Collection<IPObject> res = new Collection<IPObject>(); - - foreach (IPObject i in source) - { - if (i is IPNetAddress nw) - { - // Add the subnet calculated from the interface address/mask. - var na = nw.NetworkAddress; - na.Tag = i.Tag; - res.AddItem(na); - } - else if (i is IPHost ipHost) - { - // Flatten out IPHost and add all its ip addresses. - foreach (var addr in ipHost.GetAddresses()) - { - IPNetAddress host = new IPNetAddress(addr) - { - Tag = i.Tag - }; - - res.AddItem(host); - } - } - } - - return res; - } - - /// <summary> - /// Excludes all the items from this list that are found in excludeList. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="excludeList">Items to exclude.</param> - /// <param name="isNetwork">Collection is a network collection.</param> - /// <returns>A new collection, with the items excluded.</returns> - public static Collection<IPObject> Exclude(this Collection<IPObject> source, Collection<IPObject> excludeList, bool isNetwork) - { - if (source.Count == 0 || excludeList is null) - { - return new Collection<IPObject>(source); - } - - Collection<IPObject> results = new Collection<IPObject>(); - - bool found; - foreach (var outer in source) - { - found = false; - - foreach (var inner in excludeList) - { - if (outer.Equals(inner)) - { - found = true; - break; - } - } - - if (!found) - { - results.AddItem(outer, isNetwork); - } - } - - return results; - } - - /// <summary> - /// Returns all items that co-exist in this object and target. - /// </summary> - /// <param name="source">The <see cref="Collection{IPObject}"/>.</param> - /// <param name="target">Collection to compare with.</param> - /// <returns>A collection containing all the matches.</returns> - public static Collection<IPObject> ThatAreContainedInNetworks(this Collection<IPObject> source, Collection<IPObject> target) - { - if (source.Count == 0) - { - return new Collection<IPObject>(); - } - - ArgumentNullException.ThrowIfNull(target); - - Collection<IPObject> nc = new Collection<IPObject>(); - - foreach (IPObject i in source) - { - if (target.ContainsAddress(i)) - { - nc.AddItem(i); - } - } - - return nc; - } - } -} diff --git a/MediaBrowser.Common/Plugins/IPluginManager.cs b/MediaBrowser.Common/Plugins/IPluginManager.cs index 1d73de3c9..0ff9719e9 100644 --- a/MediaBrowser.Common/Plugins/IPluginManager.cs +++ b/MediaBrowser.Common/Plugins/IPluginManager.cs @@ -30,11 +30,6 @@ namespace MediaBrowser.Common.Plugins IEnumerable<Assembly> LoadAssemblies(); /// <summary> - /// Unloads all of the assemblies. - /// </summary> - void UnloadAssemblies(); - - /// <summary> /// Registers the plugin's services with the DI. /// Note: DI is not yet instantiated yet. /// </summary> diff --git a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs index a56d3c822..81b532fda 100644 --- a/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs +++ b/MediaBrowser.Controller/Authentication/IAuthenticationProvider.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System.Threading.Tasks; @@ -23,7 +21,7 @@ namespace MediaBrowser.Controller.Authentication public interface IRequiresResolvedUser { - Task<ProviderAuthenticationResult> Authenticate(string username, string password, User resolvedUser); + Task<ProviderAuthenticationResult> Authenticate(string username, string password, User? resolvedUser); } public interface IHasNewUserPolicy @@ -33,8 +31,8 @@ namespace MediaBrowser.Controller.Authentication public class ProviderAuthenticationResult { - public string Username { get; set; } + public required string Username { get; set; } - public string DisplayName { get; set; } + public string? DisplayName { get; set; } } } diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index b263c173e..6acab13fe 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -1,5 +1,4 @@ using System; -using System.Linq; using Jellyfin.Extensions; using MediaBrowser.Controller.Channels; using MediaBrowser.Controller.Configuration; diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs index ac20120d9..975218ad7 100644 --- a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -1,4 +1,3 @@ -using System.Threading; using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; diff --git a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs index dea1c2f32..2a7e6be0f 100644 --- a/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs +++ b/MediaBrowser.Controller/ClientEvent/ClientEventLogger.cs @@ -23,9 +23,12 @@ namespace MediaBrowser.Controller.ClientEvent { var fileName = $"upload_{clientName}_{clientVersion}_{DateTime.UtcNow:yyyyMMddHHmmss}_{Guid.NewGuid():N}.log"; var logFilePath = Path.Combine(_applicationPaths.LogDirectoryPath, fileName); - await using var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); - await fileContents.CopyToAsync(fileStream).ConfigureAwait(false); - return fileName; + var fileStream = new FileStream(logFilePath, FileMode.CreateNew, FileAccess.Write, FileShare.None); + await using (fileStream.ConfigureAwait(false)) + { + await fileContents.CopyToAsync(fileStream).ConfigureAwait(false); + return fileName; + } } } } diff --git a/MediaBrowser.Controller/Drawing/IImageProcessor.cs b/MediaBrowser.Controller/Drawing/IImageProcessor.cs index e5ce0aa21..0d1e2a5a0 100644 --- a/MediaBrowser.Controller/Drawing/IImageProcessor.cs +++ b/MediaBrowser.Controller/Drawing/IImageProcessor.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Generic; -using System.IO; using System.Threading.Tasks; using Jellyfin.Data.Entities; using MediaBrowser.Controller.Entities; @@ -66,7 +65,7 @@ namespace MediaBrowser.Controller.Drawing /// <returns>Guid.</returns> string GetImageCacheTag(BaseItem item, ItemImageInfo image); - string GetImageCacheTag(BaseItem item, ChapterInfo chapter); + string? GetImageCacheTag(BaseItem item, ChapterInfo chapter); string? GetImageCacheTag(User user); @@ -74,14 +73,6 @@ namespace MediaBrowser.Controller.Drawing /// Processes the image. /// </summary> /// <param name="options">The options.</param> - /// <param name="toStream">To stream.</param> - /// <returns>Task.</returns> - Task ProcessImage(ImageProcessingOptions options, Stream toStream); - - /// <summary> - /// Processes the image. - /// </summary> - /// <param name="options">The options.</param> /// <returns>Task.</returns> Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options); @@ -97,7 +88,5 @@ namespace MediaBrowser.Controller.Drawing /// <param name="options">The options.</param> /// <param name="libraryName">The library name to draw onto the collage.</param> void CreateImageCollage(ImageCollageOptions options, string? libraryName); - - bool SupportsTransparency(string path); } } diff --git a/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs b/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs index 7912c5e87..953cfe698 100644 --- a/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs +++ b/MediaBrowser.Controller/Drawing/ImageProcessingOptions.cs @@ -119,7 +119,8 @@ namespace MediaBrowser.Controller.Drawing private bool IsFormatSupported(string originalImagePath) { var ext = Path.GetExtension(originalImagePath); - return SupportedOutputFormats.Any(outputFormat => string.Equals(ext, "." + outputFormat, StringComparison.OrdinalIgnoreCase)); + ext = ext.Replace(".jpeg", ".jpg", StringComparison.OrdinalIgnoreCase); + return SupportedOutputFormats.Any(outputFormat => string.Equals(ext, outputFormat.GetExtension(), StringComparison.OrdinalIgnoreCase)); } } } diff --git a/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs b/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs index 62b70ce53..10326363a 100644 --- a/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs +++ b/MediaBrowser.Controller/Drawing/ImageProcessorExtensions.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using MediaBrowser.Controller.Entities; @@ -9,12 +7,12 @@ namespace MediaBrowser.Controller.Drawing { public static class ImageProcessorExtensions { - public static string GetImageCacheTag(this IImageProcessor processor, BaseItem item, ImageType imageType) + public static string? GetImageCacheTag(this IImageProcessor processor, BaseItem item, ImageType imageType) { return processor.GetImageCacheTag(item, imageType, 0); } - public static string GetImageCacheTag(this IImageProcessor processor, BaseItem item, ImageType imageType, int imageIndex) + public static string? GetImageCacheTag(this IImageProcessor processor, BaseItem item, ImageType imageType, int imageIndex) { var imageInfo = item.GetImageInfo(imageType, imageIndex); diff --git a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs index 2dbd513a1..237345206 100644 --- a/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs +++ b/MediaBrowser.Controller/Entities/Audio/MusicAlbum.cs @@ -183,6 +183,9 @@ namespace MediaBrowser.Controller.Entities.Audio progress.Report(percent * 95); } + // get album LUFS + LUFS = items.OfType<Audio>().Max(item => item.LUFS); + var parentRefreshOptions = refreshOptions; if (childUpdateType > ItemUpdateType.None) { diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 501811003..9f3e8eec9 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -1864,7 +1864,7 @@ namespace MediaBrowser.Controller.Entities /// <exception cref="ArgumentException">Backdrops should be accessed using Item.Backdrops.</exception> public bool HasImage(ImageType type, int imageIndex) { - return GetImageInfo(type, imageIndex) != null; + return GetImageInfo(type, imageIndex) is not null; } public void SetImage(ItemImageInfo image, int index) diff --git a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs index 615d236c7..dcd22a3b4 100644 --- a/MediaBrowser.Controller/Entities/BaseItemExtensions.cs +++ b/MediaBrowser.Controller/Entities/BaseItemExtensions.cs @@ -95,10 +95,7 @@ namespace MediaBrowser.Controller.Entities } var p = destProps.Find(x => x.Name == sourceProp.Name); - if (p is not null) - { - p.SetValue(dest, v); - } + p?.SetValue(dest, v); } } diff --git a/MediaBrowser.Controller/Entities/CollectionFolder.cs b/MediaBrowser.Controller/Entities/CollectionFolder.cs index 095b261c0..f51162f9d 100644 --- a/MediaBrowser.Controller/Entities/CollectionFolder.cs +++ b/MediaBrowser.Controller/Entities/CollectionFolder.cs @@ -3,6 +3,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; @@ -29,7 +30,7 @@ namespace MediaBrowser.Controller.Entities public class CollectionFolder : Folder, ICollectionFolder { private static readonly JsonSerializerOptions _jsonOptions = JsonDefaults.Options; - private static readonly Dictionary<string, LibraryOptions> _libraryOptions = new Dictionary<string, LibraryOptions>(); + private static readonly ConcurrentDictionary<string, LibraryOptions> _libraryOptions = new ConcurrentDictionary<string, LibraryOptions>(); private bool _requiresRefresh; /// <summary> @@ -139,45 +140,26 @@ namespace MediaBrowser.Controller.Entities } public static LibraryOptions GetLibraryOptions(string path) - { - lock (_libraryOptions) - { - if (!_libraryOptions.TryGetValue(path, out var options)) - { - options = LoadLibraryOptions(path); - _libraryOptions[path] = options; - } - - return options; - } - } + => _libraryOptions.GetOrAdd(path, LoadLibraryOptions); public static void SaveLibraryOptions(string path, LibraryOptions options) { - lock (_libraryOptions) - { - _libraryOptions[path] = options; + _libraryOptions[path] = options; - var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, _jsonOptions), _jsonOptions); - foreach (var mediaPath in clone.PathInfos) + var clone = JsonSerializer.Deserialize<LibraryOptions>(JsonSerializer.SerializeToUtf8Bytes(options, _jsonOptions), _jsonOptions); + foreach (var mediaPath in clone.PathInfos) + { + if (!string.IsNullOrEmpty(mediaPath.Path)) { - if (!string.IsNullOrEmpty(mediaPath.Path)) - { - mediaPath.Path = ApplicationHost.ReverseVirtualPath(mediaPath.Path); - } + mediaPath.Path = ApplicationHost.ReverseVirtualPath(mediaPath.Path); } - - XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); } + + XmlSerializer.SerializeToFile(clone, GetLibraryOptionsPath(path)); } public static void OnCollectionFolderChange() - { - lock (_libraryOptions) - { - _libraryOptions.Clear(); - } - } + => _libraryOptions.Clear(); public override bool IsSaveLocalMetadataEnabled() { diff --git a/MediaBrowser.Controller/Entities/Folder.cs b/MediaBrowser.Controller/Entities/Folder.cs index 44fe65103..e707eedbf 100644 --- a/MediaBrowser.Controller/Entities/Folder.cs +++ b/MediaBrowser.Controller/Entities/Folder.cs @@ -598,7 +598,7 @@ namespace MediaBrowser.Controller.Entities for (var i = 0; i < childrenCount; i++) { - await actionBlock.SendAsync(i).ConfigureAwait(false); + await actionBlock.SendAsync(i, cancellationToken).ConfigureAwait(false); } actionBlock.Complete(); diff --git a/MediaBrowser.Controller/Entities/ItemImageInfo.cs b/MediaBrowser.Controller/Entities/ItemImageInfo.cs index 0171af27c..1d45d4da0 100644 --- a/MediaBrowser.Controller/Entities/ItemImageInfo.cs +++ b/MediaBrowser.Controller/Entities/ItemImageInfo.cs @@ -1,5 +1,3 @@ -#nullable disable - #pragma warning disable CS1591 using System; @@ -14,7 +12,7 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the path. /// </summary> /// <value>The path.</value> - public string Path { get; set; } + public required string Path { get; set; } /// <summary> /// Gets or sets the type. @@ -36,9 +34,9 @@ namespace MediaBrowser.Controller.Entities /// Gets or sets the blurhash. /// </summary> /// <value>The blurhash.</value> - public string BlurHash { get; set; } + public string? BlurHash { get; set; } [JsonIgnore] - public bool IsLocalFile => Path is null || !Path.StartsWith("http", StringComparison.OrdinalIgnoreCase); + public bool IsLocalFile => !Path.StartsWith("http", StringComparison.OrdinalIgnoreCase); } } diff --git a/MediaBrowser.Controller/Entities/TV/Episode.cs b/MediaBrowser.Controller/Entities/TV/Episode.cs index 597b4cecb..bf31508c1 100644 --- a/MediaBrowser.Controller/Entities/TV/Episode.cs +++ b/MediaBrowser.Controller/Entities/TV/Episode.cs @@ -99,7 +99,7 @@ namespace MediaBrowser.Controller.Entities.TV } [JsonIgnore] - public bool IsInSeasonFolder => FindParent<Season>() != null; + public bool IsInSeasonFolder => FindParent<Season>() is not null; [JsonIgnore] public string SeriesPresentationUniqueKey { get; set; } diff --git a/MediaBrowser.Controller/Entities/Video.cs b/MediaBrowser.Controller/Entities/Video.cs index 5b7abea10..9f685b7e2 100644 --- a/MediaBrowser.Controller/Entities/Video.cs +++ b/MediaBrowser.Controller/Entities/Video.cs @@ -333,7 +333,7 @@ namespace MediaBrowser.Controller.Entities protected override bool IsActiveRecording() { - return LiveTvManager.GetActiveRecordingInfo(Path) != null; + return LiveTvManager.GetActiveRecordingInfo(Path) is not null; } public override bool CanDelete() diff --git a/MediaBrowser.Controller/Events/Authentication/AuthenticationRequestEventArgs.cs b/MediaBrowser.Controller/Events/Authentication/AuthenticationRequestEventArgs.cs new file mode 100644 index 000000000..2143c6998 --- /dev/null +++ b/MediaBrowser.Controller/Events/Authentication/AuthenticationRequestEventArgs.cs @@ -0,0 +1,60 @@ +using System; +using MediaBrowser.Controller.Session; + +namespace MediaBrowser.Controller.Events.Authentication; + +/// <summary> +/// A class representing an authentication result event. +/// </summary> +public class AuthenticationRequestEventArgs : EventArgs +{ + /// <summary> + /// Initializes a new instance of the <see cref="AuthenticationRequestEventArgs"/> class. + /// </summary> + /// <param name="request">The <see cref="AuthenticationRequest"/>.</param> + public AuthenticationRequestEventArgs(AuthenticationRequest request) + { + Username = request.Username; + UserId = request.UserId; + App = request.App; + AppVersion = request.AppVersion; + DeviceId = request.DeviceId; + DeviceName = request.DeviceName; + RemoteEndPoint = request.RemoteEndPoint; + } + + /// <summary> + /// Gets or sets the user name. + /// </summary> + public string? Username { get; set; } + + /// <summary> + /// Gets or sets the user id. + /// </summary> + public Guid? UserId { get; set; } + + /// <summary> + /// Gets or sets the app. + /// </summary> + public string? App { get; set; } + + /// <summary> + /// Gets or sets the app version. + /// </summary> + public string? AppVersion { get; set; } + + /// <summary> + /// Gets or sets the device id. + /// </summary> + public string? DeviceId { get; set; } + + /// <summary> + /// Gets or sets the device name. + /// </summary> + public string? DeviceName { get; set; } + + /// <summary> + /// Gets or sets the remote endpoint. + /// </summary> + public string? RemoteEndPoint { get; set; } +} diff --git a/MediaBrowser.Controller/Events/Authentication/AuthenticationResultEventArgs.cs b/MediaBrowser.Controller/Events/Authentication/AuthenticationResultEventArgs.cs new file mode 100644 index 000000000..357ef9406 --- /dev/null +++ b/MediaBrowser.Controller/Events/Authentication/AuthenticationResultEventArgs.cs @@ -0,0 +1,38 @@ +using System; +using MediaBrowser.Controller.Authentication; +using MediaBrowser.Controller.Session; +using MediaBrowser.Model.Dto; + +namespace MediaBrowser.Controller.Events.Authentication; + +/// <summary> +/// A class representing an authentication result event. +/// </summary> +public class AuthenticationResultEventArgs : EventArgs +{ + /// <summary> + /// Initializes a new instance of the <see cref="AuthenticationResultEventArgs"/> class. + /// </summary> + /// <param name="result">The <see cref="AuthenticationResult"/>.</param> + public AuthenticationResultEventArgs(AuthenticationResult result) + { + User = result.User; + SessionInfo = result.SessionInfo; + ServerId = result.ServerId; + } + + /// <summary> + /// Gets or sets the user. + /// </summary> + public UserDto User { get; set; } + + /// <summary> + /// Gets or sets the session information. + /// </summary> + public SessionInfo? SessionInfo { get; set; } + + /// <summary> + /// Gets or sets the server id. + /// </summary> + public string? ServerId { get; set; } +} diff --git a/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs new file mode 100644 index 000000000..2742f21e3 --- /dev/null +++ b/MediaBrowser.Controller/Extensions/XmlReaderExtensions.cs @@ -0,0 +1,193 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Xml; +using Jellyfin.Data.Enums; +using MediaBrowser.Controller.Entities; + +namespace MediaBrowser.Controller.Extensions; + +/// <summary> +/// Provides extension methods for <see cref="XmlReader"/> to parse <see cref="BaseItem"/>'s. +/// </summary> +public static class XmlReaderExtensions +{ + /// <summary> + /// Reads a trimmed string from the current node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <returns>The trimmed content.</returns> + public static string ReadNormalizedString(this XmlReader reader) + { + ArgumentNullException.ThrowIfNull(reader); + + return reader.ReadElementContentAsString().Trim(); + } + + /// <summary> + /// Reads an int from the current node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <param name="value">The parsed <c>int</c>.</param> + /// <returns>A value indicating whether the parsing succeeded.</returns> + public static bool TryReadInt(this XmlReader reader, out int value) + { + ArgumentNullException.ThrowIfNull(reader); + + return int.TryParse(reader.ReadElementContentAsString(), CultureInfo.InvariantCulture, out value); + } + + /// <summary> + /// Parses a <see cref="DateTime"/> from the current node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <param name="value">The parsed <see cref="DateTime"/>.</param> + /// <returns>A value indicating whether the parsing succeeded.</returns> + public static bool TryReadDateTime(this XmlReader reader, out DateTime value) + { + ArgumentNullException.ThrowIfNull(reader); + + return DateTime.TryParse( + reader.ReadElementContentAsString(), + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out value); + } + + /// <summary> + /// Parses a <see cref="DateTime"/> from the current node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <param name="formatString">The date format string.</param> + /// <param name="value">The parsed <see cref="DateTime"/>.</param> + /// <returns>A value indicating whether the parsing succeeded.</returns> + public static bool TryReadDateTimeExact(this XmlReader reader, string formatString, out DateTime value) + { + ArgumentNullException.ThrowIfNull(reader); + ArgumentNullException.ThrowIfNull(formatString); + + return DateTime.TryParseExact( + reader.ReadElementContentAsString(), + formatString, + CultureInfo.InvariantCulture, + DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, + out value); + } + + /// <summary> + /// Parses a <see cref="PersonInfo"/> from the xml node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <returns>A <see cref="PersonInfo"/>, or <c>null</c> if none is found.</returns> + public static PersonInfo? GetPersonFromXmlNode(this XmlReader reader) + { + ArgumentNullException.ThrowIfNull(reader); + + if (reader.IsEmptyElement) + { + reader.Read(); + return null; + } + + var name = string.Empty; + var type = PersonKind.Actor; // If type is not specified assume actor + var role = string.Empty; + int? sortOrder = null; + string? imageUrl = null; + + using var subtree = reader.ReadSubtree(); + subtree.MoveToContent(); + subtree.Read(); + + while (subtree is { EOF: false, ReadState: ReadState.Interactive }) + { + if (subtree.NodeType != XmlNodeType.Element) + { + subtree.Read(); + continue; + } + + switch (subtree.Name) + { + case "name": + case "Name": + name = subtree.ReadNormalizedString(); + break; + case "role": + case "Role": + role = subtree.ReadNormalizedString(); + break; + case "type": + case "Type": + Enum.TryParse(subtree.ReadElementContentAsString(), true, out type); + break; + case "order": + case "sortorder": + case "SortOrder": + if (subtree.TryReadInt(out var sortOrderVal)) + { + sortOrder = sortOrderVal; + } + + break; + case "thumb": + imageUrl = subtree.ReadNormalizedString(); + break; + default: + subtree.Skip(); + break; + } + } + + if (string.IsNullOrWhiteSpace(name)) + { + return null; + } + + return new PersonInfo + { + Name = name, + Role = role, + Type = type, + SortOrder = sortOrder, + ImageUrl = imageUrl + }; + } + + /// <summary> + /// Used to split names of comma or pipe delimited genres and people. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <returns>IEnumerable{System.String}.</returns> + public static IEnumerable<string> GetStringArray(this XmlReader reader) + { + ArgumentNullException.ThrowIfNull(reader); + var value = reader.ReadElementContentAsString(); + + // 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) + && !value.Contains(';', StringComparison.Ordinal) + ? new[] { ',' } + : new[] { '|', ';' }; + + foreach (var part in value.Trim().Trim(separator).Split(separator)) + { + if (!string.IsNullOrWhiteSpace(part)) + { + yield return part.Trim(); + } + } + } + + /// <summary> + /// Parses a <see cref="PersonInfo"/> array from the xml node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <param name="personKind">The <see cref="PersonKind"/>.</param> + /// <returns>The <see cref="IEnumerable{PersonInfo}"/>.</returns> + public static IEnumerable<PersonInfo> GetPersonArray(this XmlReader reader, PersonKind personKind) + => reader.GetStringArray() + .Select(part => new PersonInfo { Name = part, Type = personKind }); +} diff --git a/MediaBrowser.Controller/IServerApplicationHost.cs b/MediaBrowser.Controller/IServerApplicationHost.cs index 11afdc4ae..e9c4d9e19 100644 --- a/MediaBrowser.Controller/IServerApplicationHost.cs +++ b/MediaBrowser.Controller/IServerApplicationHost.cs @@ -4,8 +4,6 @@ using System.Net; using MediaBrowser.Common; -using MediaBrowser.Common.Net; -using MediaBrowser.Model.System; using Microsoft.AspNetCore.Http; namespace MediaBrowser.Controller @@ -17,8 +15,6 @@ namespace MediaBrowser.Controller { bool CoreStartupHasCompleted { get; } - bool CanLaunchWebBrowser { get; } - /// <summary> /// Gets the HTTP server port. /// </summary> @@ -43,15 +39,6 @@ namespace MediaBrowser.Controller string FriendlyName { get; } /// <summary> - /// Gets the system info. - /// </summary> - /// <param name="request">The HTTP request.</param> - /// <returns>SystemInfo.</returns> - SystemInfo GetSystemInfo(HttpRequest request); - - PublicSystemInfo GetPublicSystemInfo(HttpRequest request); - - /// <summary> /// Gets a URL specific for the request. /// </summary> /// <param name="request">The <see cref="HttpRequest"/> instance.</param> @@ -75,10 +62,10 @@ namespace MediaBrowser.Controller /// <summary> /// Gets an URL that can be used to access the API over LAN. /// </summary> - /// <param name="hostname">An optional hostname to use.</param> + /// <param name="ipAddress">An optional IP address to use.</param> /// <param name="allowHttps">A value indicating whether to allow HTTPS.</param> /// <returns>The API URL.</returns> - string GetApiUrlForLocalAccess(IPObject hostname = null, bool allowHttps = true); + string GetApiUrlForLocalAccess(IPAddress ipAddress = null, bool allowHttps = true); /// <summary> /// Gets a local (LAN) URL that can be used to access the API. diff --git a/MediaBrowser.Controller/ISystemManager.cs b/MediaBrowser.Controller/ISystemManager.cs new file mode 100644 index 000000000..ef3034d2f --- /dev/null +++ b/MediaBrowser.Controller/ISystemManager.cs @@ -0,0 +1,34 @@ +using MediaBrowser.Model.System; +using Microsoft.AspNetCore.Http; + +namespace MediaBrowser.Controller; + +/// <summary> +/// A service for managing the application instance. +/// </summary> +public interface ISystemManager +{ + /// <summary> + /// Gets the system info. + /// </summary> + /// <param name="request">The HTTP request.</param> + /// <returns>The <see cref="SystemInfo"/>.</returns> + SystemInfo GetSystemInfo(HttpRequest request); + + /// <summary> + /// Gets the public system info. + /// </summary> + /// <param name="request">The HTTP request.</param> + /// <returns>The <see cref="PublicSystemInfo"/>.</returns> + PublicSystemInfo GetPublicSystemInfo(HttpRequest request); + + /// <summary> + /// Starts the application restart process. + /// </summary> + void Restart(); + + /// <summary> + /// Starts the application shutdown process. + /// </summary> + void Shutdown(); +} diff --git a/MediaBrowser.Controller/Library/ItemResolveArgs.cs b/MediaBrowser.Controller/Library/ItemResolveArgs.cs index c70102167..dcd0110fb 100644 --- a/MediaBrowser.Controller/Library/ItemResolveArgs.cs +++ b/MediaBrowser.Controller/Library/ItemResolveArgs.cs @@ -217,7 +217,7 @@ namespace MediaBrowser.Controller.Library /// <returns><c>true</c> if [contains file system entry by name] [the specified name]; otherwise, <c>false</c>.</returns> public bool ContainsFileSystemEntryByName(string name) { - return GetFileSystemEntryByName(name) != null; + return GetFileSystemEntryByName(name) is not null; } public string GetCollectionType() diff --git a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs index c721fb778..05540d490 100644 --- a/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs +++ b/MediaBrowser.Controller/LiveTv/LiveTvProgram.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Controller.LiveTv { public class LiveTvProgram : BaseItem, IHasLookupInfo<ItemLookupInfo>, IHasStartDate, IHasProgramAttributes { - private static string EmbyServiceName = "Emby"; + private const string EmbyServiceName = "Emby"; public LiveTvProgram() { diff --git a/MediaBrowser.Controller/Lyrics/ILyricParser.cs b/MediaBrowser.Controller/Lyrics/ILyricParser.cs new file mode 100644 index 000000000..65a9471a3 --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/ILyricParser.cs @@ -0,0 +1,28 @@ +using MediaBrowser.Controller.Resolvers; +using MediaBrowser.Providers.Lyric; + +namespace MediaBrowser.Controller.Lyrics; + +/// <summary> +/// Interface ILyricParser. +/// </summary> +public interface ILyricParser +{ + /// <summary> + /// Gets a value indicating the provider name. + /// </summary> + string Name { get; } + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + ResolverPriority Priority { get; } + + /// <summary> + /// Parses the raw lyrics into a response. + /// </summary> + /// <param name="lyrics">The raw lyrics content.</param> + /// <returns>The parsed lyrics or null if invalid.</returns> + LyricResponse? ParseLyrics(LyricFile lyrics); +} diff --git a/MediaBrowser.Controller/Lyrics/LyricFile.cs b/MediaBrowser.Controller/Lyrics/LyricFile.cs new file mode 100644 index 000000000..ede89403c --- /dev/null +++ b/MediaBrowser.Controller/Lyrics/LyricFile.cs @@ -0,0 +1,28 @@ +namespace MediaBrowser.Providers.Lyric; + +/// <summary> +/// The information for a raw lyrics file before parsing. +/// </summary> +public class LyricFile +{ + /// <summary> + /// Initializes a new instance of the <see cref="LyricFile"/> class. + /// </summary> + /// <param name="name">The name.</param> + /// <param name="content">The content, must not be empty.</param> + public LyricFile(string name, string content) + { + Name = name; + Content = content; + } + + /// <summary> + /// Gets or sets the name of the lyrics file. This must include the file extension. + /// </summary> + public string Name { get; set; } + + /// <summary> + /// Gets or sets the contents of the file. + /// </summary> + public string Content { get; set; } +} diff --git a/MediaBrowser.Controller/Lyrics/LyricInfo.cs b/MediaBrowser.Controller/Lyrics/LyricInfo.cs deleted file mode 100644 index 6ec6df582..000000000 --- a/MediaBrowser.Controller/Lyrics/LyricInfo.cs +++ /dev/null @@ -1,49 +0,0 @@ -using System; -using System.IO; -using Jellyfin.Extensions; - -namespace MediaBrowser.Controller.Lyrics; - -/// <summary> -/// Lyric helper methods. -/// </summary> -public static class LyricInfo -{ - /// <summary> - /// Gets matching lyric file for a requested item. - /// </summary> - /// <param name="lyricProvider">The lyricProvider interface to use.</param> - /// <param name="itemPath">Path of requested item.</param> - /// <returns>Lyric file path if passed lyric provider's supported media type is found; otherwise, null.</returns> - public static string? GetLyricFilePath(this ILyricProvider lyricProvider, string itemPath) - { - // Ensure we have a provider - if (lyricProvider is null) - { - return null; - } - - // Ensure the path to the item is not null - string? itemDirectoryPath = Path.GetDirectoryName(itemPath); - if (itemDirectoryPath is null) - { - return null; - } - - // Ensure the directory path exists - if (!Directory.Exists(itemDirectoryPath)) - { - return null; - } - - foreach (var lyricFilePath in Directory.GetFiles(itemDirectoryPath, $"{Path.GetFileNameWithoutExtension(itemPath)}.*")) - { - if (lyricProvider.SupportedMediaTypes.Contains(Path.GetExtension(lyricFilePath.AsSpan())[1..], StringComparison.OrdinalIgnoreCase)) - { - return lyricFilePath; - } - } - - return null; - } -} diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index 0c2a85a02..c3a20cdb4 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -23,7 +23,7 @@ using Microsoft.Extensions.Configuration; namespace MediaBrowser.Controller.MediaEncoding { - public class EncodingHelper + public partial class EncodingHelper { private const string QsvAlias = "qs"; private const string VaapiAlias = "va"; @@ -37,7 +37,8 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly IMediaEncoder _mediaEncoder; private readonly ISubtitleEncoder _subtitleEncoder; private readonly IConfiguration _config; - private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15); + private readonly IConfigurationManager _configurationManager; + // i915 hang was fixed by linux 6.2 (3f882f2) private readonly Version _minKerneli915Hang = new Version(5, 18); private readonly Version _maxKerneli915Hang = new Version(6, 1, 3); @@ -47,6 +48,7 @@ namespace MediaBrowser.Controller.MediaEncoding private readonly Version _minFFmpegHwaUnsafeOutput = new Version(6, 0); private readonly Version _minFFmpegOclCuTonemapMode = new Version(5, 1, 3); private readonly Version _minFFmpegSvtAv1Params = new Version(5, 1); + private readonly Version _minFFmpegVaapiH26xEncA53CcSei = new Version(6, 0); private static readonly string[] _videoProfilesH264 = new[] { @@ -119,14 +121,19 @@ namespace MediaBrowser.Controller.MediaEncoding IApplicationPaths appPaths, IMediaEncoder mediaEncoder, ISubtitleEncoder subtitleEncoder, - IConfiguration config) + IConfiguration config, + IConfigurationManager configurationManager) { _appPaths = appPaths; _mediaEncoder = mediaEncoder; _subtitleEncoder = subtitleEncoder; _config = config; + _configurationManager = configurationManager; } + [GeneratedRegex(@"\s+")] + private static partial Regex WhiteSpaceRegex(); + public string GetH264Encoder(EncodingJobInfo state, EncodingOptions encodingOptions) => GetH26xOrAv1Encoder("libx264", "h264", state, encodingOptions); @@ -571,25 +578,25 @@ namespace MediaBrowser.Controller.MediaEncoding /// <returns>System.Nullable{VideoCodecs}.</returns> public string InferVideoCodec(string url) { - var ext = Path.GetExtension(url); + var ext = Path.GetExtension(url.AsSpan()); - if (string.Equals(ext, ".asf", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".asf", StringComparison.OrdinalIgnoreCase)) { return "wmv"; } - if (string.Equals(ext, ".webm", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".webm", StringComparison.OrdinalIgnoreCase)) { // 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)) + if (ext.Equals(".ogg", StringComparison.OrdinalIgnoreCase) || ext.Equals(".ogv", StringComparison.OrdinalIgnoreCase)) { return "theora"; } - if (string.Equals(ext, ".m3u8", StringComparison.OrdinalIgnoreCase) || string.Equals(ext, ".ts", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".m3u8", StringComparison.OrdinalIgnoreCase) || ext.Equals(".ts", StringComparison.OrdinalIgnoreCase)) { return "h264"; } @@ -918,9 +925,11 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (_mediaEncoder.IsVaapiDeviceAmd) { + // Disable AMD EFC feature since it's still unstable in upstream Mesa. + Environment.SetEnvironmentVariable("AMD_DEBUG", "noefc"); + if (IsVulkanFullSupported() - && _mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier - && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) + && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop) { args.Append(GetDrmDeviceArgs(options.VaapiDevice, DrmAlias)); args.Append(GetVaapiDeviceArgs(null, null, null, DrmAlias, VaapiAlias)); @@ -1083,7 +1092,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (state.MediaSource.VideoType == VideoType.Dvd || state.MediaSource.VideoType == VideoType.BluRay) { - var tmpConcatPath = Path.Join(options.TranscodingTempPath, state.MediaSource.Id + ".concat"); + var tmpConcatPath = Path.Join(_configurationManager.GetTranscodePath(), state.MediaSource.Id + ".concat"); _mediaEncoder.GenerateConcatConfig(state.MediaSource, tmpConcatPath); arg.Append(" -f concat -safe 0 -i ") .Append(tmpConcatPath); @@ -1101,10 +1110,10 @@ namespace MediaBrowser.Controller.MediaEncoding && state.SubtitleStream.IsExternal) { var subtitlePath = state.SubtitleStream.Path; - var subtitleExtension = Path.GetExtension(subtitlePath); + var subtitleExtension = Path.GetExtension(subtitlePath.AsSpan()); - if (string.Equals(subtitleExtension, ".sub", StringComparison.OrdinalIgnoreCase) - || string.Equals(subtitleExtension, ".sup", StringComparison.OrdinalIgnoreCase)) + if (subtitleExtension.Equals(".sub", StringComparison.OrdinalIgnoreCase) + || subtitleExtension.Equals(".sup", StringComparison.OrdinalIgnoreCase)) { var idxFile = Path.ChangeExtension(subtitlePath, ".idx"); if (File.Exists(idxFile)) @@ -1238,6 +1247,12 @@ namespace MediaBrowser.Controller.MediaEncoding int bitrate = state.OutputVideoBitrate.Value; + // Bit rate under 1000k is not allowed in h264_qsv + if (string.Equals(videoCodec, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + { + bitrate = Math.Max(bitrate, 1000); + } + // Currently use the same buffer size for all encoders int bufsize = bitrate * 2; @@ -1484,6 +1499,13 @@ namespace MediaBrowser.Controller.MediaEncoding args += keyFrameArg + gopArg; } + // global_header produced by AMD HEVC VA-API encoder causes non-playable fMP4 on iOS + if (string.Equals(codec, "hevc_vaapi", StringComparison.OrdinalIgnoreCase) + && _mediaEncoder.IsVaapiDeviceAmd) + { + args += " -flags:v -global_header"; + } + return args; } @@ -1753,11 +1775,9 @@ namespace MediaBrowser.Controller.MediaEncoding // Values 0-3, 0 being highest quality but slower var profileScore = 0; - string crf; var qmin = "0"; var qmax = "50"; - - crf = "10"; + var crf = "10"; if (isVc1) { @@ -1854,7 +1874,7 @@ namespace MediaBrowser.Controller.MediaEncoding } var profile = state.GetRequestedProfiles(targetVideoCodec).FirstOrDefault() ?? string.Empty; - profile = Regex.Replace(profile, @"\s+", string.Empty); + profile = WhiteSpaceRegex().Replace(profile, string.Empty); // We only transcode to HEVC 8-bit for now, force Main Profile. if (profile.Contains("main10", StringComparison.OrdinalIgnoreCase) @@ -1925,7 +1945,9 @@ namespace MediaBrowser.Controller.MediaEncoding if (!string.IsNullOrEmpty(profile)) { - if (!string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase)) + // Currently there's no profile option in av1_nvenc encoder + if (!(string.Equals(videoEncoder, "av1_nvenc", StringComparison.OrdinalIgnoreCase) + || string.Equals(videoEncoder, "h264_v4l2m2m", StringComparison.OrdinalIgnoreCase))) { param += " -profile:v:0 " + profile; } @@ -2013,6 +2035,14 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -svtav1-params:0 rc=1:tune=0:film-grain=0:enable-overlays=1:enable-tf=0"; } + /* Access unit too large: 8192 < 20880 error */ + if ((string.Equals(videoEncoder, "h264_vaapi", StringComparison.OrdinalIgnoreCase) || + string.Equals(videoEncoder, "hevc_vaapi", StringComparison.OrdinalIgnoreCase)) && + _mediaEncoder.EncoderVersion >= _minFFmpegVaapiH26xEncA53CcSei) + { + param += " -sei -a53_cc"; + } + return param; } @@ -2710,7 +2740,7 @@ namespace MediaBrowser.Controller.MediaEncoding string args = string.Empty; // http://ffmpeg.org/ffmpeg-all.html#toc-Complex-filtergraphs-1 - if (state.VideoStream != null && videoProcessFilters.Contains("-filter_complex", StringComparison.Ordinal)) + if (state.VideoStream is not null && videoProcessFilters.Contains("-filter_complex", StringComparison.Ordinal)) { int videoStreamIndex = FindIndex(state.MediaSource.MediaStreams, state.VideoStream); @@ -2945,7 +2975,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*a)\\,min({0}\\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\\,ih)\\,min({0}/a\\,{1}))/2)*2", + @"scale=trunc(min(max(iw\,ih*a)\,min({0}\,{1}*a))/{2})*{2}:trunc(min(max(iw/a\,ih)\,min({0}/a\,{1}))/2)*2", maxWidthParam, maxHeightParam, scaleVal); @@ -2987,7 +3017,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(min(max(iw\\,ih*a)\\,{0})/{1})*{1}:trunc(ow/a/2)*2", + @"scale=trunc(min(max(iw\,ih*a)\,{0})/{1})*{1}:trunc(ow/a/2)*2", maxWidthParam, scaleVal); } @@ -2999,7 +3029,7 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Format( CultureInfo.InvariantCulture, - "scale=trunc(oh*a/{1})*{1}:min(max(iw/a\\,ih)\\,{0})", + @"scale=trunc(oh*a/{1})*{1}:min(max(iw/a\,ih)\,{0})", maxHeightParam, scaleVal); } @@ -3019,19 +3049,19 @@ namespace MediaBrowser.Controller.MediaEncoding switch (threedFormat.Value) { case Video3DFormat.HalfSideBySide: - filter = "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to requestedWidth. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. break; case Video3DFormat.FullSideBySide: - filter = "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // fsbs crop width in half,set the display aspect,crop out any black bars we may have made the scale width to requestedWidth. break; case Video3DFormat.HalfTopAndBottom: - filter = "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // htab crop height in half,scale to correct size, set the display aspect,crop out any black bars we may have made the scale width to requestedWidth break; case Video3DFormat.FullTopAndBottom: - filter = "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; + filter = @"crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1,scale={0}:trunc({0}/dar/2)*2"; // ftab crop height in half, set the display aspect,crop out any black bars we may have made the scale width to requestedWidth break; default: @@ -3829,12 +3859,6 @@ namespace MediaBrowser.Controller.MediaEncoding // map from d3d11va to qsv. mainFilters.Add("hwmap=derive_device=qsv"); } - else - { - // Insert a qsv scaler to sync the decoder surface, - // msdk will passthrough this internally. - mainFilters.Add("hwmap=derive_device=qsv,scale_qsv"); - } } // hw deint @@ -4225,14 +4249,13 @@ namespace MediaBrowser.Controller.MediaEncoding // prefered vaapi + vulkan filters pipeline if (_mediaEncoder.IsVaapiDeviceAmd && isVaapiVkSupported - && _mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier - && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) + && _mediaEncoder.IsVaapiDeviceSupportVulkanDrmInterop) { - // AMD radeonsi path(Vega/gfx9+, kernel>=5.15), with extra vulkan tonemap and overlay support. + // AMD radeonsi path(targeting Polaris/gfx8+), with extra vulkan tonemap and overlay support. return GetAmdVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - // Intel i965 and Amd radeonsi/r600 path(Polaris/gfx8-), only featuring scale and deinterlace support. + // Intel i965 and Amd legacy driver path, only featuring scale and deinterlace support. return GetVaapiLimitedVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } @@ -4504,7 +4527,7 @@ namespace MediaBrowser.Controller.MediaEncoding // INPUT vaapi surface(vram) if (doVkTonemap || hasSubs) { - // map from vaapi to vulkan/drm via interop (Vega/gfx9+). + // map from vaapi to vulkan/drm via interop (Polaris/gfx8+). mainFilters.Add("hwmap=derive_device=vulkan"); mainFilters.Add("format=vulkan"); } @@ -4533,9 +4556,7 @@ namespace MediaBrowser.Controller.MediaEncoding if (doVkTonemap && !hasSubs) { // OUTPUT vaapi(nv12) surface(vram) - // map from vulkan/drm to vaapi via interop (Vega/gfx9+). - mainFilters.Add("hwmap=derive_device=drm"); - mainFilters.Add("format=drm_prime"); + // map from vulkan/drm to vaapi via interop (Polaris/gfx8+). mainFilters.Add("hwmap=derive_device=vaapi"); mainFilters.Add("format=vaapi"); @@ -4601,9 +4622,7 @@ namespace MediaBrowser.Controller.MediaEncoding else if (isVaapiEncoder) { // OUTPUT vaapi(nv12) surface(vram) - // map from vulkan/drm to vaapi via interop (Vega/gfx9+). - overlayFilters.Add("hwmap=derive_device=drm"); - overlayFilters.Add("format=drm_prime"); + // map from vulkan/drm to vaapi via interop (Polaris/gfx8+). overlayFilters.Add("hwmap=derive_device=vaapi"); overlayFilters.Add("format=vaapi"); @@ -5273,10 +5292,8 @@ namespace MediaBrowser.Controller.MediaEncoding if (isD3d11Supported && isCodecAvailable) { - // set -threads 3 to intel d3d11va decoder explicitly. Lower threads may result in dead lock. - // on newer devices such as Xe, the larger the init_pool_size, the longer the initialization time for opencl to derive from d3d11. return " -hwaccel d3d11va" + (outputHwSurface ? " -hwaccel_output_format d3d11" : string.Empty) - + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + " -threads 3" + (isAv1 ? " -c:v av1" : string.Empty); + + (profileMismatch ? " -hwaccel_flags +allow_profile_mismatch" : string.Empty) + " -threads 2" + (isAv1 ? " -c:v av1" : string.Empty); } } else @@ -5710,7 +5727,6 @@ namespace MediaBrowser.Controller.MediaEncoding // Apply -analyzeduration as per the environment variable, // otherwise ffmpeg will break on certain files due to default value is 0. - // The default value of -probesize is more than enough, so leave it as is. var ffmpegAnalyzeDuration = _config.GetFFmpegAnalyzeDuration() ?? string.Empty; if (state.MediaSource.AnalyzeDurationMs > 0) @@ -5729,6 +5745,14 @@ namespace MediaBrowser.Controller.MediaEncoding inputModifier = inputModifier.Trim(); + // Apply -probesize if configured + var ffmpegProbeSize = _config.GetFFmpegProbeSize(); + + if (!string.IsNullOrEmpty(ffmpegProbeSize)) + { + inputModifier += $" -probesize {ffmpegProbeSize}"; + } + var userAgentParam = GetUserAgentParam(state); if (!string.IsNullOrEmpty(userAgentParam)) @@ -6053,7 +6077,7 @@ namespace MediaBrowser.Controller.MediaEncoding var format = string.Empty; var keyFrame = string.Empty; - if (string.Equals(Path.GetExtension(outputPath), ".mp4", StringComparison.OrdinalIgnoreCase) + if (Path.GetExtension(outputPath.AsSpan()).Equals(".mp4", StringComparison.OrdinalIgnoreCase) && state.BaseRequest.Context == EncodingContext.Streaming) { // Comparison: https://github.com/jansmolders86/mediacenterjs/blob/master/lib/transcoding/desktop.js @@ -6262,6 +6286,12 @@ namespace MediaBrowser.Controller.MediaEncoding audioTranscodeParams.Add("-acodec " + GetAudioEncoder(state)); } + if (GetAudioEncoder(state).StartsWith("pcm_", StringComparison.Ordinal)) + { + audioTranscodeParams.Add(string.Concat("-f ", GetAudioEncoder(state).AsSpan(4))); + audioTranscodeParams.Add("-ar " + state.BaseRequest.AudioBitRate); + } + if (!string.Equals(outputCodec, "opus", StringComparison.OrdinalIgnoreCase)) { // opus only supports specific sampling rates diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index f5e3d03cb..c2cef4978 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -66,8 +66,8 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// Gets a value indicating whether the configured Vaapi device supports vulkan drm format modifier. /// </summary> - /// <value><c>true</c> if the Vaapi device supports vulkan drm format modifier, <c>false</c> otherwise.</value> - bool IsVaapiDeviceSupportVulkanFmtModifier { get; } + /// <value><c>true</c> if the Vaapi device supports vulkan drm interop, <c>false</c> otherwise.</value> + bool IsVaapiDeviceSupportVulkanDrmInterop { get; } /// <summary> /// Whether given encoder codec is supported. diff --git a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs index 3b34af4e9..3d288b9f8 100644 --- a/MediaBrowser.Controller/MediaEncoding/JobLogger.cs +++ b/MediaBrowser.Controller/MediaEncoding/JobLogger.cs @@ -20,12 +20,12 @@ namespace MediaBrowser.Controller.MediaEncoding _logger = logger; } - public async Task StartStreamingLog(EncodingJobInfo state, Stream source, Stream target) + public async Task StartStreamingLog(EncodingJobInfo state, StreamReader reader, Stream target) { try { using (target) - using (var reader = new StreamReader(source)) + using (reader) { while (!reader.EndOfStream && reader.BaseStream.CanRead) { diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 0524999c7..0a706c307 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -9,7 +9,7 @@ using System.Linq; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; using MediaBrowser.Model.Session; using Microsoft.AspNetCore.Http; using Microsoft.Extensions.Logging; @@ -34,7 +34,7 @@ namespace MediaBrowser.Controller.Net /// <summary> /// The logger. /// </summary> - protected ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; + protected readonly ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> Logger; protected BasePeriodicWebSocketListener(ILogger<BasePeriodicWebSocketListener<TReturnDataType, TStateType>> logger) { @@ -96,7 +96,7 @@ namespace MediaBrowser.Controller.Net /// Starts sending messages over a web socket. /// </summary> /// <param name="message">The message.</param> - private void Start(WebSocketMessageInfo message) + protected virtual void Start(WebSocketMessageInfo message) { var vals = message.Data.Split(','); @@ -169,9 +169,8 @@ namespace MediaBrowser.Controller.Net if (data is not null) { await connection.SendAsync( - new WebSocketMessage<TReturnDataType> + new OutboundWebSocketMessage<TReturnDataType> { - MessageId = Guid.NewGuid(), MessageType = Type, Data = data }, diff --git a/MediaBrowser.Controller/Net/IWebSocketConnection.cs b/MediaBrowser.Controller/Net/IWebSocketConnection.cs index 4f2492b89..bba5a6b85 100644 --- a/MediaBrowser.Controller/Net/IWebSocketConnection.cs +++ b/MediaBrowser.Controller/Net/IWebSocketConnection.cs @@ -1,14 +1,15 @@ -#pragma warning disable CS1591 - using System; using System.Net; using System.Net.WebSockets; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; namespace MediaBrowser.Controller.Net { + /// <summary> + /// Interface for WebSocket connections. + /// </summary> public interface IWebSocketConnection : IAsyncDisposable, IDisposable { /// <summary> @@ -41,6 +42,11 @@ namespace MediaBrowser.Controller.Net WebSocketState State { get; } /// <summary> + /// Gets the authorization information. + /// </summary> + public AuthorizationInfo AuthorizationInfo { get; } + + /// <summary> /// Gets the remote end point. /// </summary> /// <value>The remote end point.</value> @@ -49,13 +55,27 @@ namespace MediaBrowser.Controller.Net /// <summary> /// Sends a message asynchronously. /// </summary> + /// <param name="message">The message.</param> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + /// <exception cref="ArgumentNullException">The message is null.</exception> + Task SendAsync(OutboundWebSocketMessage message, CancellationToken cancellationToken); + + /// <summary> + /// Sends a message asynchronously. + /// </summary> /// <typeparam name="T">The type of websocket message data.</typeparam> /// <param name="message">The message.</param> /// <param name="cancellationToken">The cancellation token.</param> /// <returns>Task.</returns> /// <exception cref="ArgumentNullException">The message is null.</exception> - Task SendAsync<T>(WebSocketMessage<T> message, CancellationToken cancellationToken); + Task SendAsync<T>(OutboundWebSocketMessage<T> message, CancellationToken cancellationToken); - Task ProcessAsync(CancellationToken cancellationToken = default); + /// <summary> + /// Receives a message asynchronously. + /// </summary> + /// <param name="cancellationToken">The cancellation token.</param> + /// <returns>Task.</returns> + Task ReceiveAsync(CancellationToken cancellationToken = default); } } diff --git a/MediaBrowser.Controller/Net/WebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessage.cs index c02bcd70b..92183e792 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessage.cs @@ -1,4 +1,3 @@ -using System; using System.Text.Json.Serialization; using MediaBrowser.Model.Session; @@ -16,11 +15,6 @@ public abstract class WebSocketMessage public virtual SessionMessageType MessageType { get; set; } /// <summary> - /// Gets or sets the message id. - /// </summary> - public Guid MessageId { get; set; } - - /// <summary> /// Gets or sets the server id. /// </summary> [JsonIgnore] diff --git a/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs b/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs index 6f7ebf156..f7a9ccc44 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessageInfo.cs @@ -1,13 +1,13 @@ #nullable disable -using MediaBrowser.Model.Net; +using MediaBrowser.Controller.Net.WebSocketMessages; namespace MediaBrowser.Controller.Net { /// <summary> /// Class WebSocketMessageInfo. /// </summary> - public class WebSocketMessageInfo : WebSocketMessage<string> + public class WebSocketMessageInfo : InboundWebSocketMessage<string> { /// <summary> /// Gets or sets the connection. diff --git a/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs index 7c35c8010..11e5a6bb2 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessageOfT.cs @@ -6,13 +6,12 @@ namespace MediaBrowser.Controller.Net; /// Class WebSocketMessage. /// </summary> /// <typeparam name="T">The type of the data.</typeparam> -// TODO make this abstract, remove empty ctor. -public class WebSocketMessage<T> : WebSocketMessage +public abstract class WebSocketMessage<T> : WebSocketMessage { /// <summary> /// Initializes a new instance of the <see cref="WebSocketMessage{T}"/> class. /// </summary> - public WebSocketMessage() + protected WebSocketMessage() { } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs index b9f71b922..b3a60199a 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStartMessage.cs @@ -1,20 +1,20 @@ -using System.Collections.Generic; using System.ComponentModel; -using MediaBrowser.Model.Activity; using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; /// <summary> /// Activity log entry start message. +/// Data is the timing data encoded as "$initialDelay,$interval" in ms. /// </summary> -public class ActivityLogEntryStartMessage : WebSocketMessage<IReadOnlyCollection<ActivityLogEntry>>, IInboundWebSocketMessage +public class ActivityLogEntryStartMessage : InboundWebSocketMessage<string> { /// <summary> /// Initializes a new instance of the <see cref="ActivityLogEntryStartMessage"/> class. + /// Data is the timing data encoded as "$initialDelay,$interval" in ms. /// </summary> - /// <param name="data">Collection of activity log entries.</param> - public ActivityLogEntryStartMessage(IReadOnlyCollection<ActivityLogEntry> data) + /// <param name="data">The timing data encoded as "$initialDelay,$interval".</param> + public ActivityLogEntryStartMessage(string data) : base(data) { } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs index eac129b20..6f65cb2c7 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ActivityLogEntryStopMessage.cs @@ -1,6 +1,4 @@ -using System.Collections.Generic; using System.ComponentModel; -using MediaBrowser.Model.Activity; using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; @@ -8,17 +6,8 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; /// <summary> /// Activity log entry stop message. /// </summary> -public class ActivityLogEntryStopMessage : WebSocketMessage<IReadOnlyCollection<ActivityLogEntry>>, IInboundWebSocketMessage +public class ActivityLogEntryStopMessage : InboundWebSocketMessage { - /// <summary> - /// Initializes a new instance of the <see cref="ActivityLogEntryStopMessage"/> class. - /// </summary> - /// <param name="data">Collection of activity log entries.</param> - public ActivityLogEntryStopMessage(IReadOnlyCollection<ActivityLogEntry> data) - : base(data) - { - } - /// <inheritdoc /> [DefaultValue(SessionMessageType.ActivityLogEntryStop)] public override SessionMessageType MessageType => SessionMessageType.ActivityLogEntryStop; diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/InboundKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/InboundKeepAliveMessage.cs new file mode 100644 index 000000000..fec7cb4e4 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/InboundKeepAliveMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; + +/// <summary> +/// Keep alive websocket messages. +/// </summary> +public class InboundKeepAliveMessage : InboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.KeepAlive)] + public override SessionMessageType MessageType => SessionMessageType.KeepAlive; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs index dd2a7145e..bf98470bf 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStartMessage.cs @@ -1,20 +1,19 @@ -using System.Collections.Generic; using System.ComponentModel; using MediaBrowser.Model.Session; -using MediaBrowser.Model.Tasks; namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; /// <summary> /// Scheduled tasks info start message. +/// Data is the timing data encoded as "$initialDelay,$interval" in ms. /// </summary> -public class ScheduledTasksInfoStartMessage : WebSocketMessage<IReadOnlyCollection<TaskInfo>>, IInboundWebSocketMessage +public class ScheduledTasksInfoStartMessage : InboundWebSocketMessage<string> { /// <summary> /// Initializes a new instance of the <see cref="ScheduledTasksInfoStartMessage"/> class. /// </summary> - /// <param name="data">Collection of task info.</param> - public ScheduledTasksInfoStartMessage(IReadOnlyCollection<TaskInfo> data) + /// <param name="data">The timing data encoded as $initialDelay,$interval.</param> + public ScheduledTasksInfoStartMessage(string data) : base(data) { } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs index 84e1f0166..f36739c70 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/ScheduledTasksInfoStopMessage.cs @@ -1,24 +1,13 @@ -using System.Collections.Generic; using System.ComponentModel; using MediaBrowser.Model.Session; -using MediaBrowser.Model.Tasks; namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; /// <summary> /// Scheduled tasks info stop message. /// </summary> -public class ScheduledTasksInfoStopMessage : WebSocketMessage<IReadOnlyCollection<TaskInfo>>, IInboundWebSocketMessage +public class ScheduledTasksInfoStopMessage : InboundWebSocketMessage { - /// <summary> - /// Initializes a new instance of the <see cref="ScheduledTasksInfoStopMessage"/> class. - /// </summary> - /// <param name="data">Collection of task info.</param> - public ScheduledTasksInfoStopMessage(IReadOnlyCollection<TaskInfo> data) - : base(data) - { - } - /// <inheritdoc /> [DefaultValue(SessionMessageType.ScheduledTasksInfoStop)] public override SessionMessageType MessageType => SessionMessageType.ScheduledTasksInfoStop; diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs index e35a5dc3a..a40a0c79e 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStartMessage.cs @@ -1,19 +1,19 @@ using System.ComponentModel; -using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; /// <summary> /// Sessions start message. +/// Data is the timing data encoded as "$initialDelay,$interval" in ms. /// </summary> -public class SessionsStartMessage : WebSocketMessage<SessionInfo>, IInboundWebSocketMessage +public class SessionsStartMessage : InboundWebSocketMessage<string> { /// <summary> /// Initializes a new instance of the <see cref="SessionsStartMessage"/> class. /// </summary> - /// <param name="data">Session info.</param> - public SessionsStartMessage(SessionInfo data) + /// <param name="data">The timing data encoded as $initialDelay,$interval.</param> + public SessionsStartMessage(string data) : base(data) { } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs index 7e3582d64..288d111c5 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Inbound/SessionsStopMessage.cs @@ -1,5 +1,4 @@ using System.ComponentModel; -using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; @@ -7,17 +6,8 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Inbound; /// <summary> /// Sessions stop message. /// </summary> -public class SessionsStopMessage : WebSocketMessage<SessionInfo>, IInboundWebSocketMessage +public class SessionsStopMessage : InboundWebSocketMessage { - /// <summary> - /// Initializes a new instance of the <see cref="SessionsStopMessage"/> class. - /// </summary> - /// <param name="data">Session info.</param> - public SessionsStopMessage(SessionInfo data) - : base(data) - { - } - /// <inheritdoc /> [DefaultValue(SessionMessageType.SessionsStop)] public override SessionMessageType MessageType => SessionMessageType.SessionsStop; diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs index 20ca888e1..8d6e821df 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessage.cs @@ -1,9 +1,8 @@ -namespace MediaBrowser.Controller.Net.WebSocketMessages; +namespace MediaBrowser.Controller.Net.WebSocketMessages; /// <summary> -/// Class representing the list of outbound websocket message types. -/// Only used in openapi generation. +/// Inbound websocket message. /// </summary> -public class InboundWebSocketMessage : WebSocketMessage +public class InboundWebSocketMessage : WebSocketMessage, IInboundWebSocketMessage { } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessageOfT.cs new file mode 100644 index 000000000..4da5e7d31 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/InboundWebSocketMessageOfT.cs @@ -0,0 +1,26 @@ +#pragma warning disable SA1649 // File name must equal class name. + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Inbound websocket message with data. +/// </summary> +/// <typeparam name="T">The data type.</typeparam> +public class InboundWebSocketMessage<T> : WebSocketMessage<T>, IInboundWebSocketMessage +{ + /// <summary> + /// Initializes a new instance of the <see cref="InboundWebSocketMessage{T}"/> class. + /// </summary> + public InboundWebSocketMessage() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="InboundWebSocketMessage{T}"/> class. + /// </summary> + /// <param name="data">The data to send.</param> + protected InboundWebSocketMessage(T data) + { + Data = data; + } +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs index 5650ee4bb..2a098615d 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ActivityLogEntryMessage.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Activity log created message. /// </summary> -public class ActivityLogEntryMessage : WebSocketMessage<IReadOnlyList<ActivityLogEntry>>, IOutboundWebSocketMessage +public class ActivityLogEntryMessage : OutboundWebSocketMessage<IReadOnlyList<ActivityLogEntry>> { /// <summary> /// Initializes a new instance of the <see cref="ActivityLogEntryMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs index 94ade5e81..ca55340a0 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ForceKeepAliveMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Force keep alive websocket messages. /// </summary> -public class ForceKeepAliveMessage : WebSocketMessage<int>, IOutboundWebSocketMessage +public class ForceKeepAliveMessage : OutboundWebSocketMessage<int> { /// <summary> /// Initializes a new instance of the <see cref="ForceKeepAliveMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs index 6c71e73f9..5fbbb0624 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/GeneralCommandMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// General command websocket message. /// </summary> -public class GeneralCommandMessage : WebSocketMessage<GeneralCommand>, IOutboundWebSocketMessage +public class GeneralCommandMessage : OutboundWebSocketMessage<GeneralCommand> { /// <summary> /// Initializes a new instance of the <see cref="GeneralCommandMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs index 6432ae8ef..47417c405 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/LibraryChangedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Library changed message. /// </summary> -public class LibraryChangedMessage : WebSocketMessage<LibraryUpdateInfo>, IOutboundWebSocketMessage +public class LibraryChangedMessage : OutboundWebSocketMessage<LibraryUpdateInfo> { /// <summary> /// Initializes a new instance of the <see cref="LibraryChangedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/OutboundKeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/OutboundKeepAliveMessage.cs new file mode 100644 index 000000000..d907dcff9 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/OutboundKeepAliveMessage.cs @@ -0,0 +1,14 @@ +using System.ComponentModel; +using MediaBrowser.Model.Session; + +namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; + +/// <summary> +/// Keep alive websocket messages. +/// </summary> +public class OutboundKeepAliveMessage : OutboundWebSocketMessage +{ + /// <inheritdoc /> + [DefaultValue(SessionMessageType.KeepAlive)] + public override SessionMessageType MessageType => SessionMessageType.KeepAlive; +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs index 7f943bda1..86ee2ff90 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlayMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Play command websocket message. /// </summary> -public class PlayMessage : WebSocketMessage<PlayRequest>, IOutboundWebSocketMessage +public class PlayMessage : OutboundWebSocketMessage<PlayRequest> { /// <summary> /// Initializes a new instance of the <see cref="PlayMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs index 804ccb37d..cd6d28cb3 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PlaystateMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Playstate message. /// </summary> -public class PlaystateMessage : WebSocketMessage<PlaystateRequest>, IOutboundWebSocketMessage +public class PlaystateMessage : OutboundWebSocketMessage<PlaystateRequest> { /// <summary> /// Initializes a new instance of the <see cref="PlaystateMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs index 3d7dc5c93..17fd25938 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCancelledMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Plugin installation cancelled message. /// </summary> -public class PluginInstallationCancelledMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage +public class PluginInstallationCancelledMessage : OutboundWebSocketMessage<InstallationInfo> { /// <summary> /// Initializes a new instance of the <see cref="PluginInstallationCancelledMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs index 81268007f..3e60198ba 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationCompletedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Plugin installation completed message. /// </summary> -public class PluginInstallationCompletedMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage +public class PluginInstallationCompletedMessage : OutboundWebSocketMessage<InstallationInfo> { /// <summary> /// Initializes a new instance of the <see cref="PluginInstallationCompletedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs index 9177f1293..40032f16e 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallationFailedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Plugin installation failed message. /// </summary> -public class PluginInstallationFailedMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage +public class PluginInstallationFailedMessage : OutboundWebSocketMessage<InstallationInfo> { /// <summary> /// Initializes a new instance of the <see cref="PluginInstallationFailedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs index e371440a0..28861896f 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginInstallingMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Package installing message. /// </summary> -public class PluginInstallingMessage : WebSocketMessage<InstallationInfo>, IOutboundWebSocketMessage +public class PluginInstallingMessage : OutboundWebSocketMessage<InstallationInfo> { /// <summary> /// Initializes a new instance of the <see cref="PluginInstallingMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs index b2994fc95..ca4959119 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/PluginUninstalledMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Plugin uninstalled message. /// </summary> -public class PluginUninstalledMessage : WebSocketMessage<PluginInfo>, IOutboundWebSocketMessage +public class PluginUninstalledMessage : OutboundWebSocketMessage<PluginInfo> { /// <summary> /// Initializes a new instance of the <see cref="PluginUninstalledMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs index 42dbc3029..41b3cd46a 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RefreshProgressMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Refresh progress message. /// </summary> -public class RefreshProgressMessage : WebSocketMessage<Dictionary<string, string>>, IOutboundWebSocketMessage +public class RefreshProgressMessage : OutboundWebSocketMessage<Dictionary<string, string>> { /// <summary> /// Initializes a new instance of the <see cref="RefreshProgressMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs index 3f3d9e4c8..a89f19b61 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/RestartRequiredMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Restart required. /// </summary> -public class RestartRequiredMessage : WebSocketMessage, IOutboundWebSocketMessage +public class RestartRequiredMessage : OutboundWebSocketMessage { /// <inheritdoc /> [DefaultValue(SessionMessageType.RestartRequired)] diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs index d69662b00..afa36fb72 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTaskEndedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Scheduled task ended message. /// </summary> -public class ScheduledTaskEndedMessage : WebSocketMessage<TaskResult>, IOutboundWebSocketMessage +public class ScheduledTaskEndedMessage : OutboundWebSocketMessage<TaskResult> { /// <summary> /// Initializes a new instance of the <see cref="ScheduledTaskEndedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs index 41a05b0de..c7360779f 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ScheduledTasksInfoMessage.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Scheduled tasks info message. /// </summary> -public class ScheduledTasksInfoMessage : WebSocketMessage<IReadOnlyList<TaskInfo>>, IOutboundWebSocketMessage +public class ScheduledTasksInfoMessage : OutboundWebSocketMessage<IReadOnlyList<TaskInfo>> { /// <summary> /// Initializes a new instance of the <see cref="ScheduledTasksInfoMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs index d4950b8b6..f832c8935 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCancelledMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Series timer cancelled message. /// </summary> -public class SeriesTimerCancelledMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage +public class SeriesTimerCancelledMessage : OutboundWebSocketMessage<TimerEventInfo> { /// <summary> /// Initializes a new instance of the <see cref="SeriesTimerCancelledMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs index 091c10be6..450b4c799 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SeriesTimerCreatedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Series timer created message. /// </summary> -public class SeriesTimerCreatedMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage +public class SeriesTimerCreatedMessage : OutboundWebSocketMessage<TimerEventInfo> { /// <summary> /// Initializes a new instance of the <see cref="SeriesTimerCreatedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs index a465d8b00..8f09c802f 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerRestartingMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Server restarting down message. /// </summary> -public class ServerRestartingMessage : WebSocketMessage, IOutboundWebSocketMessage +public class ServerRestartingMessage : OutboundWebSocketMessage { /// <inheritdoc /> [DefaultValue(SessionMessageType.ServerRestarting)] diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs index 0b998a523..485e71b6e 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/ServerShuttingDownMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Server shutting down message. /// </summary> -public class ServerShuttingDownMessage : WebSocketMessage, IOutboundWebSocketMessage +public class ServerShuttingDownMessage : OutboundWebSocketMessage { /// <inheritdoc /> [DefaultValue(SessionMessageType.ServerShuttingDown)] diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs index 4c91e0bca..3504831b8 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SessionsMessage.cs @@ -1,3 +1,4 @@ +using System.Collections.Generic; using System.ComponentModel; using MediaBrowser.Controller.Session; using MediaBrowser.Model.Session; @@ -7,13 +8,13 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Sessions message. /// </summary> -public class SessionsMessage : WebSocketMessage<SessionInfo>, IOutboundWebSocketMessage +public class SessionsMessage : OutboundWebSocketMessage<IReadOnlyList<SessionInfo>> { /// <summary> /// Initializes a new instance of the <see cref="SessionsMessage"/> class. /// </summary> /// <param name="data">Session info.</param> - public SessionsMessage(SessionInfo data) + public SessionsMessage(IReadOnlyList<SessionInfo> data) : base(data) { } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs index 17a0fc66e..d0624ec01 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayCommandMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Sync play command. /// </summary> -public class SyncPlayCommandMessage : WebSocketMessage<SendCommand>, IOutboundWebSocketMessage +public class SyncPlayCommandMessage : OutboundWebSocketMessage<SendCommand> { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayCommandMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs index d145d0e01..6a501aa7e 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Untyped sync play command. /// </summary> -public class SyncPlayGroupUpdateCommandMessage : WebSocketMessage<GroupUpdate>, IOutboundWebSocketMessage +public class SyncPlayGroupUpdateCommandMessage : OutboundWebSocketMessage<GroupUpdate> { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs index 668392c66..47f706e2a 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupInfoMessage.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// Sync play group update command with group info. /// GroupUpdateTypes: GroupJoined. /// </summary> -public class SyncPlayGroupUpdateCommandOfGroupInfoMessage : WebSocketMessage<GroupUpdate<GroupInfoDto>>, IOutboundWebSocketMessage +public class SyncPlayGroupUpdateCommandOfGroupInfoMessage : OutboundWebSocketMessage<GroupUpdate<GroupInfoDto>> { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfGroupInfoMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs index ec8c3344f..11ddb1e25 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// Sync play group update command with group state update. /// GroupUpdateTypes: StateUpdate. /// </summary> -public class SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage : WebSocketMessage<GroupUpdate<GroupStateUpdate>>, IOutboundWebSocketMessage +public class SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage : OutboundWebSocketMessage<GroupUpdate<GroupStateUpdate>> { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfGroupStateUpdateMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs index 465363f14..7e73399b1 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// Sync play group update command with play queue update. /// GroupUpdateTypes: PlayQueue. /// </summary> -public class SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage : WebSocketMessage<GroupUpdate<PlayQueueUpdate>>, IOutboundWebSocketMessage +public class SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage : OutboundWebSocketMessage<GroupUpdate<PlayQueueUpdate>> { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfPlayQueueUpdateMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs index b87e9bf71..5b5ccd3ed 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/SyncPlayGroupUpdateCommandOfStringMessage.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// Sync play group update command with string. /// GroupUpdateTypes: GroupDoesNotExist (error), LibraryAccessDenied (error), NotInGroup (error), GroupLeft (groupId), UserJoined (username), UserLeft (username). /// </summary> -public class SyncPlayGroupUpdateCommandOfStringMessage : WebSocketMessage<GroupUpdate<string>>, IOutboundWebSocketMessage +public class SyncPlayGroupUpdateCommandOfStringMessage : OutboundWebSocketMessage<GroupUpdate<string>> { /// <summary> /// Initializes a new instance of the <see cref="SyncPlayGroupUpdateCommandOfStringMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs index 0e70549ef..f44fd126b 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCancelledMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Timer cancelled message. /// </summary> -public class TimerCancelledMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage +public class TimerCancelledMessage : OutboundWebSocketMessage<TimerEventInfo> { /// <summary> /// Initializes a new instance of the <see cref="TimerCancelledMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs index 295b3081c..8c1e102eb 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/TimerCreatedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// Timer created message. /// </summary> -public class TimerCreatedMessage : WebSocketMessage<TimerEventInfo>, IOutboundWebSocketMessage +public class TimerCreatedMessage : OutboundWebSocketMessage<TimerEventInfo> { /// <summary> /// Initializes a new instance of the <see cref="TimerCreatedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs index b60769540..6a053643d 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDataChangedMessage.cs @@ -6,7 +6,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// User data changed message. /// </summary> -public class UserDataChangedMessage : WebSocketMessage<UserDataChangeInfo>, IOutboundWebSocketMessage +public class UserDataChangedMessage : OutboundWebSocketMessage<UserDataChangeInfo> { /// <summary> /// Initializes a new instance of the <see cref="UserDataChangedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs index 6d527be7f..add3f7771 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserDeletedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// User deleted message. /// </summary> -public class UserDeletedMessage : WebSocketMessage<Guid>, IOutboundWebSocketMessage +public class UserDeletedMessage : OutboundWebSocketMessage<Guid> { /// <summary> /// Initializes a new instance of the <see cref="UserDeletedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs index 99e9a1f91..9a72deae1 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/Outbound/UserUpdatedMessage.cs @@ -7,7 +7,7 @@ namespace MediaBrowser.Controller.Net.WebSocketMessages.Outbound; /// <summary> /// User updated message. /// </summary> -public class UserUpdatedMessage : WebSocketMessage<UserDto>, IOutboundWebSocketMessage +public class UserUpdatedMessage : OutboundWebSocketMessage<UserDto> { /// <summary> /// Initializes a new instance of the <see cref="UserUpdatedMessage"/> class. diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs index dba3c8392..178245851 100644 --- a/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs +++ b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessage.cs @@ -1,9 +1,14 @@ +using System; + namespace MediaBrowser.Controller.Net.WebSocketMessages; /// <summary> -/// Class representing the list of outbound websocket message types. -/// Only used in openapi generation. +/// Outbound websocket message. /// </summary> -public class OutboundWebSocketMessage : WebSocketMessage +public class OutboundWebSocketMessage : WebSocketMessage, IOutboundWebSocketMessage { + /// <summary> + /// Gets or sets the message id. + /// </summary> + public Guid MessageId { get; set; } = Guid.NewGuid(); } diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessageOfT.cs b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessageOfT.cs new file mode 100644 index 000000000..cce331805 --- /dev/null +++ b/MediaBrowser.Controller/Net/WebSocketMessages/OutboundWebSocketMessageOfT.cs @@ -0,0 +1,33 @@ +#pragma warning disable SA1649 // File name must equal class name. + +using System; + +namespace MediaBrowser.Controller.Net.WebSocketMessages; + +/// <summary> +/// Outbound websocket message with data. +/// </summary> +/// <typeparam name="T">The data type.</typeparam> +public class OutboundWebSocketMessage<T> : WebSocketMessage<T>, IOutboundWebSocketMessage +{ + /// <summary> + /// Initializes a new instance of the <see cref="OutboundWebSocketMessage{T}"/> class. + /// </summary> + public OutboundWebSocketMessage() + { + } + + /// <summary> + /// Initializes a new instance of the <see cref="OutboundWebSocketMessage{T}"/> class. + /// </summary> + /// <param name="data">The data to send.</param> + protected OutboundWebSocketMessage(T data) + { + Data = data; + } + + /// <summary> + /// Gets or sets the message id. + /// </summary> + public Guid MessageId { get; set; } = Guid.NewGuid(); +} diff --git a/MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs b/MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs deleted file mode 100644 index 7f636212c..000000000 --- a/MediaBrowser.Controller/Net/WebSocketMessages/Shared/KeepAliveMessage.cs +++ /dev/null @@ -1,23 +0,0 @@ -using System.ComponentModel; -using MediaBrowser.Model.Session; - -namespace MediaBrowser.Controller.Net.WebSocketMessages.Shared; - -/// <summary> -/// Keep alive websocket messages. -/// </summary> -public class KeepAliveMessage : WebSocketMessage<int>, IInboundWebSocketMessage, IOutboundWebSocketMessage -{ - /// <summary> - /// Initializes a new instance of the <see cref="KeepAliveMessage"/> class. - /// </summary> - /// <param name="data">The seconds to keep alive for.</param> - public KeepAliveMessage(int data) - : base(data) - { - } - - /// <inheritdoc /> - [DefaultValue(SessionMessageType.KeepAlive)] - public override SessionMessageType MessageType => SessionMessageType.KeepAlive; -} diff --git a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs index d1a51c2cf..bb68a3b6d 100644 --- a/MediaBrowser.Controller/Playlists/IPlaylistManager.cs +++ b/MediaBrowser.Controller/Playlists/IPlaylistManager.cs @@ -44,6 +44,12 @@ namespace MediaBrowser.Controller.Playlists /// <summary> /// Gets the playlists folder. /// </summary> + /// <returns>Folder.</returns> + Folder GetPlaylistsFolder(); + + /// <summary> + /// Gets the playlists folder for a user. + /// </summary> /// <param name="userId">The user identifier.</param> /// <returns>Folder.</returns> Folder GetPlaylistsFolder(Guid userId); diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 16943f6aa..eb5069b06 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -5,7 +5,6 @@ using System; using System.Collections.Generic; using System.IO; -using System.Net.Http; using System.Threading; using System.Threading.Tasks; using Jellyfin.Data.Events; diff --git a/MediaBrowser.Controller/Resolvers/IItemResolver.cs b/MediaBrowser.Controller/Resolvers/IItemResolver.cs index b95d00aa3..282aa721e 100644 --- a/MediaBrowser.Controller/Resolvers/IItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/IItemResolver.cs @@ -24,7 +24,7 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>BaseItem.</returns> - BaseItem ResolvePath(ItemResolveArgs args); + BaseItem? ResolvePath(ItemResolveArgs args); } public interface IMultiItemResolver diff --git a/MediaBrowser.Controller/Resolvers/ItemResolver.cs b/MediaBrowser.Controller/Resolvers/ItemResolver.cs index a6da8384e..5c9dd6f07 100644 --- a/MediaBrowser.Controller/Resolvers/ItemResolver.cs +++ b/MediaBrowser.Controller/Resolvers/ItemResolver.cs @@ -1,5 +1,3 @@ -#nullable disable - using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Library; @@ -23,7 +21,7 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>`0.</returns> - protected internal virtual T Resolve(ItemResolveArgs args) + protected internal virtual T? Resolve(ItemResolveArgs args) { return null; } @@ -42,7 +40,7 @@ namespace MediaBrowser.Controller.Resolvers /// </summary> /// <param name="args">The args.</param> /// <returns>BaseItem.</returns> - public BaseItem ResolvePath(ItemResolveArgs args) + public BaseItem? ResolvePath(ItemResolveArgs args) { var item = Resolve(args); diff --git a/MediaBrowser.Controller/Security/IAuthenticationManager.cs b/MediaBrowser.Controller/Security/IAuthenticationManager.cs index e3d18c8c0..070ab7a85 100644 --- a/MediaBrowser.Controller/Security/IAuthenticationManager.cs +++ b/MediaBrowser.Controller/Security/IAuthenticationManager.cs @@ -1,6 +1,4 @@ -#nullable enable - -using System.Collections.Generic; +using System.Collections.Generic; using System.Threading.Tasks; namespace MediaBrowser.Controller.Security diff --git a/MediaBrowser.Controller/Session/ISessionManager.cs b/MediaBrowser.Controller/Session/ISessionManager.cs index 0c4719a0e..53df7133b 100644 --- a/MediaBrowser.Controller/Session/ISessionManager.cs +++ b/MediaBrowser.Controller/Session/ISessionManager.cs @@ -233,20 +233,6 @@ namespace MediaBrowser.Controller.Session Task SendRestartRequiredNotification(CancellationToken cancellationToken); /// <summary> - /// Sends the server shutdown notification. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task SendServerShutdownNotification(CancellationToken cancellationToken); - - /// <summary> - /// Sends the server restart notification. - /// </summary> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> - Task SendServerRestartNotification(CancellationToken cancellationToken); - - /// <summary> /// Adds the additional user. /// </summary> /// <param name="sessionId">The session identifier.</param> diff --git a/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs b/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs index 85b3e6fbd..51c29c7a2 100644 --- a/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs +++ b/MediaBrowser.Controller/Subtitles/SubtitleResponse.cs @@ -14,6 +14,8 @@ namespace MediaBrowser.Controller.Subtitles public bool IsForced { get; set; } + public bool IsHearingImpaired { get; set; } + public Stream Stream { get; set; } } } diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index cb369d837..8a870e0d9 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -9,6 +9,7 @@ using System.Xml; using Jellyfin.Data.Enums; using Jellyfin.Extensions; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -128,42 +129,19 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { - // DateCreated case "Added": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) + if (reader.TryReadDateTime(out var dateCreated)) { - if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AdjustToUniversal, out var added)) - { - item.DateCreated = added; - } - else - { - Logger.LogWarning("Invalid Added value found: {Value}", val); - } + item.DateCreated = dateCreated; } break; - } - case "OriginalTitle": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrEmpty(val)) - { - item.OriginalTitle = val; - } - + item.OriginalTitle = reader.ReadNormalizedString(); break; - } - case "LocalTitle": - item.Name = reader.ReadElementContentAsString(); + item.Name = reader.ReadNormalizedString(); break; - case "CriticRating": { var text = reader.ReadElementContentAsString(); @@ -177,63 +155,26 @@ namespace MediaBrowser.LocalMetadata.Parsers } case "SortTitle": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.ForcedSortName = val; - } - + item.ForcedSortName = reader.ReadNormalizedString(); break; - } - case "Overview": case "Description": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.Overview = val; - } - + item.Overview = reader.ReadNormalizedString(); break; - } - case "Language": - { - var val = reader.ReadElementContentAsString(); - - item.PreferredMetadataLanguage = val; - + item.PreferredMetadataLanguage = reader.ReadNormalizedString(); break; - } - case "CountryCode": - { - var val = reader.ReadElementContentAsString(); - - item.PreferredMetadataCountryCode = val; - + item.PreferredMetadataCountryCode = reader.ReadNormalizedString(); break; - } - case "PlaceOfBirth": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) + var placeOfBirth = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(placeOfBirth) && item is Person person) { - if (item is Person person) - { - person.ProductionLocations = new[] { val }; - } + person.ProductionLocations = new[] { placeOfBirth }; } break; - } - case "LockedFields": { var val = reader.ReadElementContentAsString(); @@ -275,10 +216,7 @@ namespace MediaBrowser.LocalMetadata.Parsers { if (!reader.IsEmptyElement) { - using (var subtree = reader.ReadSubtree()) - { - FetchFromCountriesNode(subtree); - } + reader.Skip(); } else { @@ -290,183 +228,84 @@ namespace MediaBrowser.LocalMetadata.Parsers case "ContentRating": case "MPAARating": - { - var rating = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(rating)) - { - item.OfficialRating = rating; - } - + item.OfficialRating = reader.ReadNormalizedString(); break; - } - case "CustomRating": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.CustomRating = val; - } - + item.CustomRating = reader.ReadNormalizedString(); break; - } - case "RunningTime": - { - var text = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(text)) + var runtimeText = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(runtimeText)) { - if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) + if (int.TryParse(runtimeText.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) { item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; } } break; - } - case "AspectRatio": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val) && item is IHasAspectRatio hasAspectRatio) + var aspectRatio = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(aspectRatio) && item is IHasAspectRatio hasAspectRatio) { - hasAspectRatio.AspectRatio = val; + hasAspectRatio.AspectRatio = aspectRatio; } break; - } - case "LockData": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.IsLocked = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - + item.IsLocked = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); break; - } - case "Network": - { - foreach (var name in SplitNames(reader.ReadElementContentAsString())) + foreach (var name in reader.GetStringArray()) { - if (string.IsNullOrWhiteSpace(name)) - { - continue; - } - item.AddStudio(name); } break; - } - case "Director": - { - foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Director })) + foreach (var director in reader.GetPersonArray(PersonKind.Director)) { - if (string.IsNullOrWhiteSpace(p.Name)) - { - continue; - } - - itemResult.AddPerson(p); + itemResult.AddPerson(director); } break; - } - case "Writer": - { - foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Writer })) + foreach (var writer in reader.GetPersonArray(PersonKind.Writer)) { - if (string.IsNullOrWhiteSpace(p.Name)) - { - continue; - } - - itemResult.AddPerson(p); + itemResult.AddPerson(writer); } break; - } - case "Actors": - { - var actors = reader.ReadInnerXml(); - - if (actors.Contains('<', StringComparison.Ordinal)) + foreach (var actor in reader.GetPersonArray(PersonKind.Actor)) { - // This is one of the mis-named "Actors" full nodes created by MB2 - // Create a reader and pass it to the persons node processor - using var xmlReader = XmlReader.Create(new StringReader($"<Persons>{actors}</Persons>")); - FetchDataFromPersonsNode(xmlReader, itemResult); - } - else - { - // Old-style piped string - foreach (var p in SplitNames(actors).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Actor })) - { - if (string.IsNullOrWhiteSpace(p.Name)) - { - continue; - } - - itemResult.AddPerson(p); - } + itemResult.AddPerson(actor); } break; - } - case "GuestStars": - { - foreach (var p in SplitNames(reader.ReadElementContentAsString()).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.GuestStar })) + foreach (var guestStar in reader.GetPersonArray(PersonKind.GuestStar)) { - if (string.IsNullOrWhiteSpace(p.Name)) - { - continue; - } - - itemResult.AddPerson(p); + itemResult.AddPerson(guestStar); } break; - } - case "Trailer": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) + var trailer = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(trailer)) { - item.AddTrailerUrl(val); + item.AddTrailerUrl(trailer); } break; - } - case "DisplayOrder": - { - var val = reader.ReadElementContentAsString(); - - if (item is IHasDisplayOrder hasDisplayOrder) + var displayOrder = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(displayOrder) && item is IHasDisplayOrder hasDisplayOrder) { - if (!string.IsNullOrWhiteSpace(val)) - { - hasDisplayOrder.DisplayOrder = val; - } + hasDisplayOrder.DisplayOrder = displayOrder; } break; - } - case "Trailers": { if (!reader.IsEmptyElement) @@ -483,20 +322,12 @@ namespace MediaBrowser.LocalMetadata.Parsers } case "ProductionYear": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) + if (reader.TryReadInt(out var productionYear) && productionYear > 1850) { - if (int.TryParse(val, out var productionYear) && productionYear > 1850) - { - item.ProductionYear = productionYear; - } + item.ProductionYear = productionYear; } break; - } - case "Rating": case "IMDBrating": { @@ -517,40 +348,24 @@ namespace MediaBrowser.LocalMetadata.Parsers case "BirthDate": case "PremiereDate": case "FirstAired": - { - var firstAired = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(firstAired)) + if (reader.TryReadDateTimeExact("yyyy-MM-dd", out var firstAired)) { - if (DateTime.TryParseExact(firstAired, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal, out var airDate) && airDate.Year > 1850) - { - item.PremiereDate = airDate; - item.ProductionYear = airDate.Year; - } + item.PremiereDate = firstAired; + item.ProductionYear = firstAired.Year; } break; - } - case "DeathDate": case "EndDate": - { - var firstAired = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(firstAired)) + if (reader.TryReadDateTimeExact("yyyy-MM-dd", out var endDate)) { - if (DateTime.TryParseExact(firstAired, "yyyy-MM-dd", CultureInfo.InvariantCulture, DateTimeStyles.AssumeLocal | DateTimeStyles.AdjustToUniversal, out var airDate) && airDate.Year > 1850) - { - item.EndDate = airDate; - } + item.EndDate = endDate; } break; - } - case "CollectionNumber": - var tmdbCollection = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(tmdbCollection)) + var tmdbCollection = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(tmdbCollection)) { item.SetProviderId(MetadataProvider.TmdbCollection, tmdbCollection); } @@ -753,41 +568,6 @@ namespace MediaBrowser.LocalMetadata.Parsers item.Shares = list.ToArray(); } - private void FetchFromCountriesNode(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 "Country": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - } - - break; - } - - default: - reader.Skip(); - break; - } - } - else - { - reader.Read(); - } - } - } - /// <summary> /// Fetches from taglines node. /// </summary> @@ -806,17 +586,8 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Tagline": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.Tagline = val; - } - + item.Tagline = reader.ReadNormalizedString(); break; - } - default: reader.Skip(); break; @@ -847,17 +618,13 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Genre": - { - var genre = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(genre)) + var genre = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(genre)) { item.AddGenre(genre); } break; - } - default: reader.Skip(); break; @@ -885,17 +652,13 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Tag": - { - var tag = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(tag)) + var tag = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(tag)) { tags.Add(tag); } break; - } - default: reader.Skip(); break; @@ -929,29 +692,13 @@ namespace MediaBrowser.LocalMetadata.Parsers { case "Person": case "Actor": - { - if (reader.IsEmptyElement) + var person = reader.GetPersonFromXmlNode(); + if (person is not null) { - reader.Read(); - continue; - } - - using (var subtree = reader.ReadSubtree()) - { - foreach (var person in GetPersonsFromXmlNode(subtree)) - { - if (string.IsNullOrWhiteSpace(person.Name)) - { - continue; - } - - item.AddPerson(person); - } + item.AddPerson(person); } break; - } - default: reader.Skip(); break; @@ -977,17 +724,13 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Trailer": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) + var trailer = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(trailer)) { - item.AddTrailerUrl(val); + item.AddTrailerUrl(trailer); } break; - } - default: reader.Skip(); break; @@ -1018,17 +761,13 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Studio": - { - var studio = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(studio)) + var studio = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(studio)) { item.AddStudio(studio); } break; - } - default: reader.Skip(); break; @@ -1042,83 +781,6 @@ namespace MediaBrowser.LocalMetadata.Parsers } /// <summary> - /// Gets the persons from XML node. - /// </summary> - /// <param name="reader">The reader.</param> - /// <returns>IEnumerable{PersonInfo}.</returns> - private IEnumerable<PersonInfo> GetPersonsFromXmlNode(XmlReader reader) - { - var name = string.Empty; - var type = PersonKind.Actor; // If type is not specified assume actor - var role = string.Empty; - int? sortOrder = null; - - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "Name": - name = reader.ReadElementContentAsString(); - break; - - case "Type": - { - var val = reader.ReadElementContentAsString(); - _ = Enum.TryParse(val, true, out type); - - break; - } - - case "Role": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - role = val; - } - - break; - } - - case "SortOrder": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) - { - sortOrder = intVal; - } - } - - break; - } - - default: - reader.Skip(); - break; - } - } - else - { - reader.Read(); - } - } - - var personInfo = new PersonInfo { Name = name.Trim(), Role = role, Type = type, SortOrder = sortOrder }; - - return new[] { personInfo }; - } - - /// <summary> /// Get linked child. /// </summary> /// <param name="reader">The xml reader.</param> @@ -1138,17 +800,11 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "Path": - { - linkedItem.Path = reader.ReadElementContentAsString(); + linkedItem.Path = reader.ReadNormalizedString(); break; - } - case "ItemId": - { - linkedItem.LibraryItemId = reader.ReadElementContentAsString(); + linkedItem.LibraryItemId = reader.ReadNormalizedString(); break; - } - default: reader.Skip(); break; @@ -1189,22 +845,14 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "UserId": - { - item.UserId = reader.ReadElementContentAsString(); + item.UserId = reader.ReadNormalizedString(); break; - } - case "CanEdit": - { item.CanEdit = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); break; - } - default: - { reader.Skip(); break; - } } } else @@ -1221,34 +869,5 @@ namespace MediaBrowser.LocalMetadata.Parsers return null; } - - /// <summary> - /// Used to split names of comma or pipe delimited genres and people. - /// </summary> - /// <param name="value">The value.</param> - /// <returns>IEnumerable{System.String}.</returns> - private IEnumerable<string> SplitNames(string value) - { - // 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) - && !value.Contains(';', StringComparison.Ordinal) ? new[] { ',' } : new[] { '|', ';' }; - - value = value.Trim().Trim(separator); - - return string.IsNullOrWhiteSpace(value) ? Array.Empty<string>() : Split(value, separator, StringSplitOptions.RemoveEmptyEntries); - } - - /// <summary> - /// Provides an additional overload for string.split. - /// </summary> - /// <param name="val">The val.</param> - /// <param name="separators">The separators.</param> - /// <param name="options">The options.</param> - /// <returns>System.String[][].</returns> - private string[] Split(string val, char[] separators, StringSplitOptions options) - { - return val.Split(separators, options); - } } } diff --git a/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs index 88b190f2b..879a3616b 100644 --- a/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/PlaylistXmlParser.cs @@ -1,6 +1,7 @@ using System.Collections.Generic; using System.Xml; using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Playlists; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -30,12 +31,8 @@ namespace MediaBrowser.LocalMetadata.Parsers switch (reader.Name) { case "PlaylistMediaType": - { - item.PlaylistMediaType = reader.ReadElementContentAsString(); - + item.PlaylistMediaType = reader.ReadNormalizedString(); break; - } - case "PlaylistItems": if (!reader.IsEmptyElement) @@ -94,10 +91,8 @@ namespace MediaBrowser.LocalMetadata.Parsers } default: - { reader.Skip(); break; - } } } else diff --git a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs index 989e386a5..299f294b2 100644 --- a/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs +++ b/MediaBrowser.MediaEncoding/Attachments/AttachmentExtractor.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -23,7 +22,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Attachments { - public class AttachmentExtractor : IAttachmentExtractor, IDisposable + public sealed class AttachmentExtractor : IAttachmentExtractor { private readonly ILogger<AttachmentExtractor> _logger; private readonly IApplicationPaths _appPaths; @@ -34,8 +33,6 @@ namespace MediaBrowser.MediaEncoding.Attachments private readonly ConcurrentDictionary<string, SemaphoreSlim> _semaphoreLocks = new ConcurrentDictionary<string, SemaphoreSlim>(); - private bool _disposed = false; - public AttachmentExtractor( ILogger<AttachmentExtractor> logger, IApplicationPaths appPaths, @@ -177,22 +174,16 @@ namespace MediaBrowser.MediaEncoding.Attachments process.Start(); - var ranToCompletion = await ProcessExtensions.WaitForExitAsync(process, cancellationToken).ConfigureAwait(false); - - if (!ranToCompletion) + try { - try - { - _logger.LogWarning("Killing ffmpeg attachment extraction process"); - process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing attachment extraction process"); - } + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; } - - exitCode = ranToCompletion ? process.ExitCode : -1; } var failed = false; @@ -296,7 +287,7 @@ namespace MediaBrowser.MediaEncoding.Attachments ArgumentException.ThrowIfNullOrEmpty(outputPath); - Directory.CreateDirectory(Path.GetDirectoryName(outputPath)); + Directory.CreateDirectory(Path.GetDirectoryName(outputPath) ?? throw new ArgumentException("Path can't be a root directory.", nameof(outputPath))); var processArgs = string.Format( CultureInfo.InvariantCulture, @@ -325,22 +316,16 @@ namespace MediaBrowser.MediaEncoding.Attachments process.Start(); - var ranToCompletion = await ProcessExtensions.WaitForExitAsync(process, cancellationToken).ConfigureAwait(false); - - if (!ranToCompletion) + try { - try - { - _logger.LogWarning("Killing ffmpeg attachment extraction process"); - process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing attachment extraction process"); - } + await process.WaitForExitAsync(cancellationToken).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; } - - exitCode = ranToCompletion ? process.ExitCode : -1; } var failed = false; @@ -391,33 +376,8 @@ namespace MediaBrowser.MediaEncoding.Attachments filename = (mediaPath + attachmentStreamIndex.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("D", CultureInfo.InvariantCulture); } - var prefix = filename.Substring(0, 1); - return Path.Combine(_appPaths.DataPath, "attachments", prefix, filename); - } - - /// <inheritdoc /> - public void Dispose() - { - Dispose(true); - GC.SuppressFinalize(this); - } - - /// <summary> - /// Releases unmanaged and - optionally - managed resources. - /// </summary> - /// <param name="disposing"><c>true</c> to release both managed and unmanaged resources; <c>false</c> to release only unmanaged resources.</param> - protected virtual void Dispose(bool disposing) - { - if (_disposed) - { - return; - } - - if (disposing) - { - } - - _disposed = true; + var prefix = filename.AsSpan(0, 1); + return Path.Join(_appPaths.DataPath, "attachments", prefix, filename); } } } diff --git a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs index 9e7a1d50a..1f94d9b23 100644 --- a/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs +++ b/MediaBrowser.MediaEncoding/BdInfo/BdInfoFileInfo.cs @@ -8,7 +8,7 @@ namespace MediaBrowser.MediaEncoding.BdInfo; /// </summary> public class BdInfoFileInfo : BDInfo.IO.IFileInfo { - private FileSystemMetadata _impl; + private readonly FileSystemMetadata _impl; /// <summary> /// Initializes a new instance of the <see cref="BdInfoFileInfo" /> class. diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index e1a0e8d67..f12ef7e63 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -10,7 +10,7 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Encoder { - public class EncoderValidator + public partial class EncoderValidator { private static readonly string[] _requiredDecoders = new[] { @@ -165,6 +165,12 @@ namespace MediaBrowser.MediaEncoding.Encoder public static Version? MaxVersion { get; } = null; + [GeneratedRegex(@"^ffmpeg version n?((?:[0-9]+\.?)+)")] + private static partial Regex FfmpegVersionRegex(); + + [GeneratedRegex(@"((?<name>lib\w+)\s+(?<major>[0-9]+)\.\s*(?<minor>[0-9]+))", RegexOptions.Multiline)] + private static partial Regex LibraryRegex(); + public bool ValidateVersion() { string output; @@ -283,7 +289,7 @@ namespace MediaBrowser.MediaEncoding.Encoder 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]+\.?)+)"); + var match = FfmpegVersionRegex().Match(output); if (match.Success) { @@ -331,10 +337,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { var map = new Dictionary<string, Version>(); - foreach (Match match in Regex.Matches( - output, - @"((?<name>lib\w+)\s+(?<major>[0-9]+)\.\s*(?<minor>[0-9]+))", - RegexOptions.Multiline)) + foreach (Match match in LibraryRegex().Matches(output)) { var version = new Version( int.Parse(match.Groups["major"].ValueSpan, CultureInfo.InvariantCulture), @@ -496,8 +499,8 @@ namespace MediaBrowser.MediaEncoding.Encoder var required = codec == Codec.Encoder ? _requiredEncoders : _requiredDecoders; - var found = Regex - .Matches(output, @"^\s\S{6}\s(?<codec>[\w|-]+)\s+.+$", RegexOptions.Multiline) + var found = CodecRegex() + .Matches(output) .Select(x => x.Groups["codec"].Value) .Where(x => required.Contains(x)); @@ -524,8 +527,8 @@ namespace MediaBrowser.MediaEncoding.Encoder return Enumerable.Empty<string>(); } - var found = Regex - .Matches(output, @"^\s\S{3}\s(?<filter>[\w|-]+)\s+.+$", RegexOptions.Multiline) + var found = FilterRegex() + .Matches(output) .Select(x => x.Groups["filter"].Value) .Where(x => _requiredFilters.Contains(x)); @@ -550,7 +553,8 @@ namespace MediaBrowser.MediaEncoding.Encoder private string GetProcessOutput(string path, string arguments, bool readStdErr, string? testKey) { - using (var process = new Process() + var redirectStandardIn = !string.IsNullOrEmpty(testKey); + using (var process = new Process { StartInfo = new ProcessStartInfo(path, arguments) { @@ -558,7 +562,7 @@ namespace MediaBrowser.MediaEncoding.Encoder UseShellExecute = false, WindowStyle = ProcessWindowStyle.Hidden, ErrorDialog = false, - RedirectStandardInput = !string.IsNullOrEmpty(testKey), + RedirectStandardInput = redirectStandardIn, RedirectStandardOutput = true, RedirectStandardError = true } @@ -568,13 +572,21 @@ namespace MediaBrowser.MediaEncoding.Encoder process.Start(); - if (!string.IsNullOrEmpty(testKey)) + if (redirectStandardIn) { - process.StandardInput.Write(testKey); + using var writer = process.StandardInput; + writer.Write(testKey); } - return readStdErr ? process.StandardError.ReadToEnd() : process.StandardOutput.ReadToEnd(); + using var reader = readStdErr ? process.StandardError : process.StandardOutput; + return reader.ReadToEnd(); } } + + [GeneratedRegex("^\\s\\S{6}\\s(?<codec>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] + private static partial Regex CodecRegex(); + + [GeneratedRegex("^\\s\\S{3}\\s(?<filter>[\\w|-]+)\\s+.+$", RegexOptions.Multiline)] + private static partial Regex FilterRegex(); } } diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index fa695bbdc..0b603715d 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -39,7 +39,7 @@ namespace MediaBrowser.MediaEncoding.Encoder /// <summary> /// Class MediaEncoder. /// </summary> - public class MediaEncoder : IMediaEncoder, IDisposable + public partial class MediaEncoder : IMediaEncoder, IDisposable { /// <summary> /// The default SDR image extraction timeout in milliseconds. @@ -79,12 +79,10 @@ namespace MediaBrowser.MediaEncoding.Encoder private bool _isVaapiDeviceAmd = false; private bool _isVaapiDeviceInteliHD = false; private bool _isVaapiDeviceInteli965 = false; - private bool _isVaapiDeviceSupportVulkanFmtModifier = false; + private bool _isVaapiDeviceSupportVulkanDrmInterop = false; - private static string[] _vulkanFmtModifierExts = + private static string[] _vulkanExternalMemoryDmaBufExts = { - "VK_KHR_sampler_ycbcr_conversion", - "VK_EXT_image_drm_format_modifier", "VK_KHR_external_memory_fd", "VK_EXT_external_memory_dma_buf", "VK_KHR_external_semaphore_fd", @@ -143,7 +141,10 @@ namespace MediaBrowser.MediaEncoding.Encoder public bool IsVaapiDeviceInteli965 => _isVaapiDeviceInteli965; /// <inheritdoc /> - public bool IsVaapiDeviceSupportVulkanFmtModifier => _isVaapiDeviceSupportVulkanFmtModifier; + public bool IsVaapiDeviceSupportVulkanDrmInterop => _isVaapiDeviceSupportVulkanDrmInterop; + + [GeneratedRegex(@"[^\/\\]+?(\.[^\/\\\n.]+)?$")] + private static partial Regex FfprobePathRegex(); /// <summary> /// Run at startup or if the user removes a Custom path from transcode page. @@ -179,7 +180,7 @@ namespace MediaBrowser.MediaEncoding.Encoder if (_ffmpegPath is not null) { // Determine a probe path from the mpeg path - _ffprobePath = Regex.Replace(_ffmpegPath, @"[^\/\\]+?(\.[^\/\\\n.]+)?$", @"ffprobe$1"); + _ffprobePath = FfprobePathRegex().Replace(_ffmpegPath, "ffprobe$1"); // Interrogate to understand what coders are supported var validator = new EncoderValidator(_logger, _ffmpegPath); @@ -204,7 +205,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _isVaapiDeviceAmd = validator.CheckVaapiDeviceByDriverName("Mesa Gallium driver", options.VaapiDevice); _isVaapiDeviceInteliHD = validator.CheckVaapiDeviceByDriverName("Intel iHD driver", options.VaapiDevice); _isVaapiDeviceInteli965 = validator.CheckVaapiDeviceByDriverName("Intel i965 driver", options.VaapiDevice); - _isVaapiDeviceSupportVulkanFmtModifier = validator.CheckVulkanDrmDeviceByExtensionName(options.VaapiDevice, _vulkanFmtModifierExts); + _isVaapiDeviceSupportVulkanDrmInterop = validator.CheckVulkanDrmDeviceByExtensionName(options.VaapiDevice, _vulkanExternalMemoryDmaBufExts); if (_isVaapiDeviceAmd) { @@ -219,9 +220,9 @@ namespace MediaBrowser.MediaEncoding.Encoder _logger.LogInformation("VAAPI device {RenderNodePath} is Intel GPU (i965)", options.VaapiDevice); } - if (_isVaapiDeviceSupportVulkanFmtModifier) + if (_isVaapiDeviceSupportVulkanDrmInterop) { - _logger.LogInformation("VAAPI device {RenderNodePath} supports Vulkan DRM format modifier", options.VaapiDevice); + _logger.LogInformation("VAAPI device {RenderNodePath} supports Vulkan DRM interop", options.VaapiDevice); } } } @@ -318,10 +319,8 @@ namespace MediaBrowser.MediaEncoding.Encoder { var files = _fileSystem.GetFilePaths(path, recursive); - var excludeExtensions = new[] { ".c" }; - - return files.FirstOrDefault(i => string.Equals(Path.GetFileNameWithoutExtension(i), filename, StringComparison.OrdinalIgnoreCase) - && !excludeExtensions.Contains(Path.GetExtension(i) ?? string.Empty)); + return files.FirstOrDefault(i => Path.GetFileNameWithoutExtension(i.AsSpan()).Equals(filename, StringComparison.OrdinalIgnoreCase) + && !Path.GetExtension(i.AsSpan()).Equals(".c", StringComparison.OrdinalIgnoreCase)); } catch (Exception) { @@ -419,24 +418,36 @@ namespace MediaBrowser.MediaEncoding.Encoder public Task<MediaInfo> GetMediaInfo(MediaInfoRequest request, CancellationToken cancellationToken) { var extractChapters = request.MediaType == DlnaProfileType.Video && request.ExtractChapters; - string analyzeDuration = string.Empty; - string ffmpegAnalyzeDuration = _config.GetFFmpegAnalyzeDuration() ?? string.Empty; + var analyzeDuration = string.Empty; + var ffmpegAnalyzeDuration = _config.GetFFmpegAnalyzeDuration() ?? string.Empty; + var ffmpegProbeSize = _config.GetFFmpegProbeSize() ?? string.Empty; + var extraArgs = string.Empty; if (request.MediaSource.AnalyzeDurationMs > 0) { - analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000).ToString(); + analyzeDuration = "-analyzeduration " + (request.MediaSource.AnalyzeDurationMs * 1000); } else if (!string.IsNullOrEmpty(ffmpegAnalyzeDuration)) { analyzeDuration = "-analyzeduration " + ffmpegAnalyzeDuration; } + if (!string.IsNullOrEmpty(analyzeDuration)) + { + extraArgs = analyzeDuration; + } + + if (!string.IsNullOrEmpty(ffmpegProbeSize)) + { + extraArgs += " -probesize " + ffmpegProbeSize; + } + return GetMediaInfoInternal( GetInputArgument(request.MediaSource.Path, request.MediaSource), request.MediaSource.Path, request.MediaSource.Protocol, extractChapters, - analyzeDuration, + extraArgs, request.MediaType == DlnaProfileType.Audio, request.MediaSource.VideoType, cancellationToken); @@ -513,7 +524,8 @@ namespace MediaBrowser.MediaEncoding.Encoder using (var processWrapper = new ProcessWrapper(process, this)) { StartProcess(processWrapper); - await process.StandardOutput.BaseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); + using var reader = process.StandardOutput; + await reader.BaseStream.CopyToAsync(memoryStream, cancellationToken).ConfigureAwait(false); memoryStream.Seek(0, SeekOrigin.Begin); InternalMediaInfoResult result; try @@ -614,9 +626,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private string GetImageResolutionParameter() { - string imageResolutionParameter; - - imageResolutionParameter = _serverConfig.Configuration.ChapterImageResolution switch + var imageResolutionParameter = _serverConfig.Configuration.ChapterImageResolution switch { ImageResolution.P144 => "256x144", ImageResolution.P240 => "426x240", @@ -641,15 +651,7 @@ namespace MediaBrowser.MediaEncoding.Encoder { ArgumentException.ThrowIfNullOrEmpty(inputPath); - var outputExtension = targetFormat switch - { - ImageFormat.Bmp => ".bmp", - ImageFormat.Gif => ".gif", - ImageFormat.Jpg => ".jpg", - ImageFormat.Png => ".png", - ImageFormat.Webp => ".webp", - _ => ".jpg" - }; + var outputExtension = targetFormat?.GetExtension() ?? ".jpg"; var tempExtractPath = Path.Combine(_configurationManager.ApplicationPaths.TempDirectory, Guid.NewGuid() + outputExtension); Directory.CreateDirectory(Path.GetDirectoryName(tempExtractPath)); @@ -669,13 +671,13 @@ namespace MediaBrowser.MediaEncoding.Encoder var scaler = threedFormat switch { // hsbs crop width in half,scale to correct size, set the display aspect,crop out any black bars we may have made. Work out the correct height based on the display aspect it will maintain the aspect where -1 in this case (3d) may not. - Video3DFormat.HalfSideBySide => "crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.HalfSideBySide => @"crop=iw/2:ih:0:0,scale=(iw*2):ih,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", // fsbs crop width in half,set the display aspect,crop out any black bars we may have made - Video3DFormat.FullSideBySide => "crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.FullSideBySide => @"crop=iw/2:ih:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", // htab crop height in half,scale to correct size, set the display aspect,crop out any black bars we may have made - Video3DFormat.HalfTopAndBottom => "crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.HalfTopAndBottom => @"crop=iw:ih/2:0:0,scale=(iw*2):ih),setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", // ftab crop height in half, set the display aspect,crop out any black bars we may have made - Video3DFormat.FullTopAndBottom => "crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\\,ih*dar):min(ih\\,iw/dar):(iw-min(iw\\,iw*sar))/2:(ih - min (ih\\,ih/sar))/2,setsar=sar=1", + Video3DFormat.FullTopAndBottom => @"crop=iw:ih/2:0:0,setdar=dar=a,crop=min(iw\,ih*dar):min(ih\,iw/dar):(iw-min(iw\,iw*sar))/2:(ih - min (ih\,ih/sar))/2,setsar=sar=1", _ => "scale=trunc(iw*sar):ih" }; @@ -751,11 +753,15 @@ namespace MediaBrowser.MediaEncoding.Encoder timeoutMs = enableHdrExtraction ? DefaultHdrImageExtractionTimeout : DefaultSdrImageExtractionTimeout; } - ranToCompletion = await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false); - - if (!ranToCompletion) + try { - StopProcess(processWrapper, 1000); + await process.WaitForExitAsync(TimeSpan.FromMilliseconds(timeoutMs)).ConfigureAwait(false); + ranToCompletion = true; + } + catch (OperationCanceledException) + { + process.Kill(true); + ranToCompletion = false; } } finally @@ -1024,7 +1030,7 @@ namespace MediaBrowser.MediaEncoding.Encoder // https://ffmpeg.org/ffmpeg-filters.html#Notes-on-filtergraph-escaping // We need to double escape - return path.Replace('\\', '/').Replace(":", "\\:", StringComparison.Ordinal).Replace("'", "'\\\\\\''", StringComparison.Ordinal); + return path.Replace('\\', '/').Replace(":", "\\:", StringComparison.Ordinal).Replace("'", @"'\\\''", StringComparison.Ordinal); } /// <inheritdoc /> @@ -1167,7 +1173,7 @@ namespace MediaBrowser.MediaEncoding.Encoder return true; } - private class ProcessWrapper : IDisposable + private sealed class ProcessWrapper : IDisposable { private readonly MediaEncoder _mediaEncoder; @@ -1210,13 +1216,7 @@ namespace MediaBrowser.MediaEncoding.Encoder _mediaEncoder._runningProcesses.Remove(this); } - try - { - process.Dispose(); - } - catch - { - } + process.Dispose(); } public void Dispose() diff --git a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs index 7d655240b..be1e8a172 100644 --- a/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs +++ b/MediaBrowser.MediaEncoding/Probing/ProbeResultNormalizer.cs @@ -1,5 +1,4 @@ #nullable disable -#pragma warning disable CS1591 using System; using System.Collections.Generic; @@ -20,7 +19,10 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.MediaEncoding.Probing { - public class ProbeResultNormalizer + /// <summary> + /// Class responsible for normalizing FFprobe output. + /// </summary> + public partial class ProbeResultNormalizer { // When extracting subtitles, the maximum length to consider (to avoid invalid filenames) private const int MaxSubtitleDescriptionExtractionLength = 100; @@ -29,13 +31,16 @@ namespace MediaBrowser.MediaEncoding.Probing private readonly char[] _nameDelimiters = { '/', '|', ';', '\\' }; - private static readonly Regex _performerPattern = new(@"(?<name>.*) \((?<instrument>.*)\)"); - private readonly ILogger _logger; private readonly ILocalizationManager _localization; private string[] _splitWhiteList; + /// <summary> + /// Initializes a new instance of the <see cref="ProbeResultNormalizer"/> class. + /// </summary> + /// <param name="logger">The <see cref="ILogger{ProbeResultNormalizer}"/> for use with the <see cref="ProbeResultNormalizer"/> instance.</param> + /// <param name="localization">The <see cref="ILocalizationManager"/> for use with the <see cref="ProbeResultNormalizer"/> instance.</param> public ProbeResultNormalizer(ILogger logger, ILocalizationManager localization) { _logger = logger; @@ -71,8 +76,18 @@ namespace MediaBrowser.MediaEncoding.Probing "She/Her/Hers", "5/8erl in Ehr'n", "Smith/Kotzen", + "We;Na", }; + /// <summary> + /// Transforms a FFprobe response into its <see cref="MediaInfo"/> equivalent. + /// </summary> + /// <param name="data">The <see cref="InternalMediaInfoResult"/>.</param> + /// <param name="videoType">The <see cref="VideoType"/>.</param> + /// <param name="isAudio">A boolean indicating whether the media is audio.</param> + /// <param name="path">Path to media file.</param> + /// <param name="protocol">Path media protocol.</param> + /// <returns>The <see cref="MediaInfo"/>.</returns> public MediaInfo GetMediaInfo(InternalMediaInfoResult data, VideoType? videoType, bool isAudio, string path, MediaProtocol protocol) { var info = new MediaInfo @@ -252,25 +267,30 @@ namespace MediaBrowser.MediaEncoding.Probing return null; } - // Handle MPEG-1 container - if (string.Equals(format, "mpegvideo", StringComparison.OrdinalIgnoreCase)) + // Input can be a list of multiple, comma-delimited formats - each of them needs to be checked + var splitFormat = format.Split(','); + for (var i = 0; i < splitFormat.Length; i++) { - return "mpeg"; - } + // Handle MPEG-1 container + if (string.Equals(splitFormat[i], "mpegvideo", StringComparison.OrdinalIgnoreCase)) + { + splitFormat[i] = "mpeg"; + } - // Handle MPEG-2 container - if (string.Equals(format, "mpeg", StringComparison.OrdinalIgnoreCase)) - { - return "ts"; - } + // Handle MPEG-2 container + else if (string.Equals(splitFormat[i], "mpeg", StringComparison.OrdinalIgnoreCase)) + { + splitFormat[i] = "ts"; + } - // Handle matroska container - if (string.Equals(format, "matroska", StringComparison.OrdinalIgnoreCase)) - { - return "mkv"; + // Handle matroska container + else if (string.Equals(splitFormat[i], "matroska", StringComparison.OrdinalIgnoreCase)) + { + splitFormat[i] = "mkv"; + } } - return format; + return string.Join(',', splitFormat); } private int? GetEstimatedAudioBitrate(string codec, int? channels) @@ -741,9 +761,11 @@ namespace MediaBrowser.MediaEncoding.Probing && !string.Equals(streamInfo.FieldOrder, "progressive", StringComparison.OrdinalIgnoreCase); if (isAudio - || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) - || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) - || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase)) + && (string.Equals(stream.Codec, "bmp", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "gif", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "mjpeg", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "png", StringComparison.OrdinalIgnoreCase) + || string.Equals(stream.Codec, "webp", StringComparison.OrdinalIgnoreCase))) { stream.Type = MediaStreamType.EmbeddedImage; } @@ -1191,7 +1213,7 @@ namespace MediaBrowser.MediaEncoding.Probing { foreach (var person in Split(performer, false)) { - Match match = _performerPattern.Match(person); + Match match = PerformerRegex().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) @@ -1630,5 +1652,8 @@ namespace MediaBrowser.MediaEncoding.Probing return TransportStreamTimestamp.Valid; } + + [GeneratedRegex("(?<name>.*) \\((?<instrument>.*)\\)")] + private static partial Regex PerformerRegex(); } } diff --git a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs index 0d1cf6e25..7d7b80e99 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/AssWriter.cs @@ -11,8 +11,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// ASS subtitle writer. /// </summary> - public class AssWriter : ISubtitleWriter + public partial class AssWriter : ISubtitleWriter { + [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)] + private static partial Regex NewLineRegex(); + /// <inheritdoc /> public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) { @@ -40,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var trackEvent = trackEvents[i]; var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var text = Regex.Replace(trackEvent.Text, @"\n", "\\n", RegexOptions.IgnoreCase); + var text = NewLineRegex().Replace(trackEvent.Text, "\\n"); writer.WriteLine( "Dialogue: 0,{0},{1},Default,{2}", diff --git a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs index 143c010b7..86f77aa06 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SrtWriter.cs @@ -11,8 +11,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// SRT subtitle writer. /// </summary> - public class SrtWriter : ISubtitleWriter + public partial class SrtWriter : ISubtitleWriter { + [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] + private static partial Regex NewLineEscapedRegex(); + /// <inheritdoc /> public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) { @@ -35,7 +38,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var text = trackEvent.Text; // TODO: Not sure how to handle these - text = Regex.Replace(text, @"\\n", " ", RegexOptions.IgnoreCase); + text = NewLineEscapedRegex().Replace(text, " "); writer.WriteLine(text); writer.WriteLine(); diff --git a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs index 6761cd309..b5fd1ed93 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SsaWriter.cs @@ -11,8 +11,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// SSA subtitle writer. /// </summary> - public class SsaWriter : ISubtitleWriter + public partial class SsaWriter : ISubtitleWriter { + [GeneratedRegex(@"\n", RegexOptions.IgnoreCase)] + private static partial Regex NewLineRegex(); + /// <inheritdoc /> public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) { @@ -40,7 +43,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var trackEvent = trackEvents[i]; var startTime = TimeSpan.FromTicks(trackEvent.StartPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); var endTime = TimeSpan.FromTicks(trackEvent.EndPositionTicks).ToString(timeFormat, CultureInfo.InvariantCulture); - var text = Regex.Replace(trackEvent.Text, @"\n", "\\n", RegexOptions.IgnoreCase); + var text = NewLineRegex().Replace(trackEvent.Text, "\\n"); writer.WriteLine( "Dialogue: 0,{0},{1},Default,{2}", diff --git a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs index 794906c3b..21fa4468e 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/SubtitleEncoder.cs @@ -293,7 +293,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles return true; } - if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase)) + if (string.Equals(format, SubtitleFormat.VTT, StringComparison.OrdinalIgnoreCase) || string.Equals(format, SubtitleFormat.WEBVTT, StringComparison.OrdinalIgnoreCase)) { value = new VttWriter(); return true; @@ -420,23 +420,16 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw; } - var ranToCompletion = await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); - - if (!ranToCompletion) + try { - try - { - _logger.LogInformation("Killing ffmpeg subtitle conversion process"); - - process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing subtitle conversion process"); - } + await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; } - - exitCode = ranToCompletion ? process.ExitCode : -1; } var failed = false; @@ -574,23 +567,16 @@ namespace MediaBrowser.MediaEncoding.Subtitles throw; } - var ranToCompletion = await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); - - if (!ranToCompletion) + try { - try - { - _logger.LogWarning("Killing ffmpeg subtitle extraction process"); - - process.Kill(); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error killing subtitle extraction process"); - } + await process.WaitForExitAsync(TimeSpan.FromMinutes(30)).ConfigureAwait(false); + exitCode = process.ExitCode; + } + catch (OperationCanceledException) + { + process.Kill(true); + exitCode = -1; } - - exitCode = ranToCompletion ? process.ExitCode : -1; } var failed = false; diff --git a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs index e5c785bc5..ea45f2070 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/TtmlWriter.cs @@ -9,8 +9,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// TTML subtitle writer. /// </summary> - public class TtmlWriter : ISubtitleWriter + public partial class TtmlWriter : ISubtitleWriter { + [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] + private static partial Regex NewLineEscapeRegex(); + /// <inheritdoc /> public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) { @@ -38,7 +41,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles { var text = trackEvent.Text; - text = Regex.Replace(text, @"\\n", "<br/>", RegexOptions.IgnoreCase); + text = NewLineEscapeRegex().Replace(text, "<br/>"); writer.WriteLine( "<p begin=\"{0}\" dur=\"{1}\">{2}</p>", diff --git a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs index 38ef57dee..3e0f47b5a 100644 --- a/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs +++ b/MediaBrowser.MediaEncoding/Subtitles/VttWriter.cs @@ -10,8 +10,11 @@ namespace MediaBrowser.MediaEncoding.Subtitles /// <summary> /// Subtitle writer for the WebVTT format. /// </summary> - public class VttWriter : ISubtitleWriter + public partial class VttWriter : ISubtitleWriter { + [GeneratedRegex(@"\\n", RegexOptions.IgnoreCase)] + private static partial Regex NewlineEscapeRegex(); + /// <inheritdoc /> public void Write(SubtitleTrackInfo info, Stream stream, CancellationToken cancellationToken) { @@ -39,7 +42,7 @@ namespace MediaBrowser.MediaEncoding.Subtitles var text = trackEvent.Text; // TODO: Not sure how to handle these - text = Regex.Replace(text, @"\\n", " ", RegexOptions.IgnoreCase); + text = NewlineEscapeRegex().Replace(text, " "); writer.WriteLine(text); writer.WriteLine(); diff --git a/MediaBrowser.Model/Configuration/LibraryOptions.cs b/MediaBrowser.Model/Configuration/LibraryOptions.cs index 7718f822b..fbad29143 100644 --- a/MediaBrowser.Model/Configuration/LibraryOptions.cs +++ b/MediaBrowser.Model/Configuration/LibraryOptions.cs @@ -20,7 +20,6 @@ namespace MediaBrowser.Model.Configuration AutomaticallyAddToCollection = false; EnablePhotos = true; SaveSubtitlesWithMedia = true; - EnableRealtimeMonitor = true; PathInfos = Array.Empty<MediaPathInfo>(); EnableAutomaticSeriesGrouping = true; SeasonZeroDisplayName = "Specials"; diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index 18f107503..1c9cc6c01 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -4,271 +4,276 @@ using System; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; +using MediaBrowser.Model.System; using MediaBrowser.Model.Updates; -namespace MediaBrowser.Model.Configuration +namespace MediaBrowser.Model.Configuration; + +/// <summary> +/// Represents the server configuration. +/// </summary> +public class ServerConfiguration : BaseApplicationConfiguration { /// <summary> - /// Represents the server configuration. + /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. /// </summary> - public class ServerConfiguration : BaseApplicationConfiguration + public ServerConfiguration() { - /// <summary> - /// Initializes a new instance of the <see cref="ServerConfiguration" /> class. - /// </summary> - public ServerConfiguration() + MetadataOptions = new[] { - MetadataOptions = new[] + new MetadataOptions() + { + ItemType = "Book" + }, + new MetadataOptions() + { + ItemType = "Movie" + }, + new MetadataOptions + { + ItemType = "MusicVideo", + DisabledMetadataFetchers = new[] { "The Open Movie Database" }, + DisabledImageFetchers = new[] { "The Open Movie Database" } + }, + new MetadataOptions + { + ItemType = "Series", + }, + new MetadataOptions + { + ItemType = "MusicAlbum", + DisabledMetadataFetchers = new[] { "TheAudioDB" } + }, + new MetadataOptions + { + ItemType = "MusicArtist", + DisabledMetadataFetchers = new[] { "TheAudioDB" } + }, + new MetadataOptions + { + ItemType = "BoxSet" + }, + new MetadataOptions { - new MetadataOptions() - { - ItemType = "Book" - }, - new MetadataOptions() - { - ItemType = "Movie" - }, - new MetadataOptions - { - ItemType = "MusicVideo", - DisabledMetadataFetchers = new[] { "The Open Movie Database" }, - DisabledImageFetchers = new[] { "The Open Movie Database" } - }, - new MetadataOptions - { - ItemType = "Series", - }, - new MetadataOptions - { - ItemType = "MusicAlbum", - DisabledMetadataFetchers = new[] { "TheAudioDB" } - }, - new MetadataOptions - { - ItemType = "MusicArtist", - DisabledMetadataFetchers = new[] { "TheAudioDB" } - }, - new MetadataOptions - { - ItemType = "BoxSet" - }, - new MetadataOptions - { - ItemType = "Season", - }, - new MetadataOptions - { - ItemType = "Episode", - } - }; - } - - /// <summary> - /// Gets or sets a value indicating whether to enable prometheus metrics exporting. - /// </summary> - public bool EnableMetrics { get; set; } = false; - - public bool EnableNormalizedItemByNameIds { get; set; } = true; - - /// <summary> - /// Gets or sets a value indicating whether this instance is port authorized. - /// </summary> - /// <value><c>true</c> if this instance is port authorized; otherwise, <c>false</c>.</value> - public bool IsPortAuthorized { get; set; } - - /// <summary> - /// Gets or sets a value indicating whether quick connect is available for use on this server. - /// </summary> - public bool QuickConnectAvailable { get; set; } = true; - - /// <summary> - /// Gets or sets a value indicating whether [enable case sensitive item ids]. - /// </summary> - /// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value> - public bool EnableCaseSensitiveItemIds { get; set; } = true; - - public bool DisableLiveTvChannelUserDataName { get; set; } = true; - - /// <summary> - /// Gets or sets the metadata path. - /// </summary> - /// <value>The metadata path.</value> - public string MetadataPath { get; set; } = string.Empty; - - public string MetadataNetworkPath { get; set; } = string.Empty; - - /// <summary> - /// Gets or sets the preferred metadata language. - /// </summary> - /// <value>The preferred metadata language.</value> - public string PreferredMetadataLanguage { get; set; } = "en"; - - /// <summary> - /// Gets or sets the metadata country code. - /// </summary> - /// <value>The metadata country code.</value> - public string MetadataCountryCode { get; set; } = "US"; - - /// <summary> - /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name. - /// </summary> - /// <value>The sort replace characters.</value> - public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" }; - - /// <summary> - /// Gets or sets characters to be removed from strings to create a sort name. - /// </summary> - /// <value>The sort remove characters.</value> - public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" }; - - /// <summary> - /// Gets or sets words to be removed from strings to create a sort name. - /// </summary> - /// <value>The sort remove words.</value> - public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" }; - - /// <summary> - /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated. - /// </summary> - /// <value>The min resume PCT.</value> - public int MinResumePct { get; set; } = 5; - - /// <summary> - /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. - /// </summary> - /// <value>The max resume PCT.</value> - public int MaxResumePct { get; set; } = 90; - - /// <summary> - /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates.. - /// </summary> - /// <value>The min resume duration seconds.</value> - public int MinResumeDurationSeconds { get; set; } = 300; - - /// <summary> - /// Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated. - /// </summary> - /// <value>The min resume in minutes.</value> - public int MinAudiobookResume { get; set; } = 5; - - /// <summary> - /// Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. - /// </summary> - /// <value>The remaining time in minutes.</value> - public int MaxAudiobookResume { get; set; } = 5; - - /// <summary> - /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed - /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several - /// different directories and files. - /// </summary> - /// <value>The file watcher delay.</value> - public int LibraryMonitorDelay { get; set; } = 60; - - /// <summary> - /// Gets or sets the duration in seconds that we will wait after a library updated event before executing the library changed notification. - /// </summary> - /// <value>The library update duration.</value> - public int LibraryUpdateDuration { get; set; } = 30; - - /// <summary> - /// Gets or sets the image saving convention. - /// </summary> - /// <value>The image saving convention.</value> - public ImageSavingConvention ImageSavingConvention { get; set; } - - public MetadataOptions[] MetadataOptions { get; set; } - - public bool SkipDeserializationForBasicTypes { get; set; } = true; - - public string ServerName { get; set; } = string.Empty; - - public string UICulture { get; set; } = "en-US"; - - public bool SaveMetadataHidden { get; set; } = false; - - public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>(); - - public int RemoteClientBitrateLimit { get; set; } - - public bool EnableFolderView { get; set; } = false; - - public bool EnableGroupingIntoCollections { get; set; } = false; - - public bool DisplaySpecialsWithinSeasons { get; set; } = true; - - public string[] CodecsUsed { get; set; } = Array.Empty<string>(); - - public RepositoryInfo[] PluginRepositories { get; set; } = Array.Empty<RepositoryInfo>(); - - public bool EnableExternalContentInSuggestions { get; set; } = true; - - public int ImageExtractionTimeoutMs { get; set; } - - public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); - - /// <summary> - /// Gets or sets a value indicating whether slow server responses should be logged as a warning. - /// </summary> - public bool EnableSlowResponseWarning { get; set; } = true; - - /// <summary> - /// Gets or sets the threshold for the slow response time warning in ms. - /// </summary> - public long SlowResponseThresholdMs { get; set; } = 500; - - /// <summary> - /// Gets or sets the cors hosts. - /// </summary> - public string[] CorsHosts { get; set; } = new[] { "*" }; - - /// <summary> - /// Gets or sets the number of days we should retain activity logs. - /// </summary> - public int? ActivityLogRetentionDays { get; set; } = 30; - - /// <summary> - /// Gets or sets the how the library scan fans out. - /// </summary> - public int LibraryScanFanoutConcurrency { get; set; } + ItemType = "Season", + }, + new MetadataOptions + { + ItemType = "Episode", + } + }; + } - /// <summary> - /// Gets or sets the how many metadata refreshes can run concurrently. - /// </summary> - public int LibraryMetadataRefreshConcurrency { get; set; } + /// <summary> + /// Gets or sets a value indicating whether to enable prometheus metrics exporting. + /// </summary> + public bool EnableMetrics { get; set; } = false; - /// <summary> - /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. - /// </summary> - public bool RemoveOldPlugins { get; set; } + public bool EnableNormalizedItemByNameIds { get; set; } = true; - /// <summary> - /// Gets or sets a value indicating whether clients should be allowed to upload logs. - /// </summary> - public bool AllowClientLogUpload { get; set; } = true; + /// <summary> + /// Gets or sets a value indicating whether this instance is port authorized. + /// </summary> + /// <value><c>true</c> if this instance is port authorized; otherwise, <c>false</c>.</value> + public bool IsPortAuthorized { get; set; } - /// <summary> - /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether. - /// </summary> - /// <value>The dummy chapters duration.</value> - public int DummyChapterDuration { get; set; } + /// <summary> + /// Gets or sets a value indicating whether quick connect is available for use on this server. + /// </summary> + public bool QuickConnectAvailable { get; set; } = true; - /// <summary> - /// Gets or sets the chapter image resolution. - /// </summary> - /// <value>The chapter image resolution.</value> - public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; + /// <summary> + /// Gets or sets a value indicating whether [enable case sensitive item ids]. + /// </summary> + /// <value><c>true</c> if [enable case sensitive item ids]; otherwise, <c>false</c>.</value> + public bool EnableCaseSensitiveItemIds { get; set; } = true; - /// <summary> - /// Gets or sets the limit for parallel image encoding. - /// </summary> - /// <value>The limit for parallel image encoding.</value> - public int ParallelImageEncodingLimit { get; set; } + public bool DisableLiveTvChannelUserDataName { get; set; } = true; - /// <summary> - /// Gets or sets the trickplay options. - /// </summary> - /// <value>The trickplay options.</value> - public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions(); - } + /// <summary> + /// Gets or sets the metadata path. + /// </summary> + /// <value>The metadata path.</value> + public string MetadataPath { get; set; } = string.Empty; + + public string MetadataNetworkPath { get; set; } = string.Empty; + + /// <summary> + /// Gets or sets the preferred metadata language. + /// </summary> + /// <value>The preferred metadata language.</value> + public string PreferredMetadataLanguage { get; set; } = "en"; + + /// <summary> + /// Gets or sets the metadata country code. + /// </summary> + /// <value>The metadata country code.</value> + public string MetadataCountryCode { get; set; } = "US"; + + /// <summary> + /// Gets or sets characters to be replaced with a ' ' in strings to create a sort name. + /// </summary> + /// <value>The sort replace characters.</value> + public string[] SortReplaceCharacters { get; set; } = new[] { ".", "+", "%" }; + + /// <summary> + /// Gets or sets characters to be removed from strings to create a sort name. + /// </summary> + /// <value>The sort remove characters.</value> + public string[] SortRemoveCharacters { get; set; } = new[] { ",", "&", "-", "{", "}", "'" }; + + /// <summary> + /// Gets or sets words to be removed from strings to create a sort name. + /// </summary> + /// <value>The sort remove words.</value> + public string[] SortRemoveWords { get; set; } = new[] { "the", "a", "an" }; + + /// <summary> + /// Gets or sets the minimum percentage of an item that must be played in order for playstate to be updated. + /// </summary> + /// <value>The min resume PCT.</value> + public int MinResumePct { get; set; } = 5; + + /// <summary> + /// Gets or sets the maximum percentage of an item that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. + /// </summary> + /// <value>The max resume PCT.</value> + public int MaxResumePct { get; set; } = 90; + + /// <summary> + /// Gets or sets the minimum duration that an item must have in order to be eligible for playstate updates.. + /// </summary> + /// <value>The min resume duration seconds.</value> + public int MinResumeDurationSeconds { get; set; } = 300; + + /// <summary> + /// Gets or sets the minimum minutes of a book that must be played in order for playstate to be updated. + /// </summary> + /// <value>The min resume in minutes.</value> + public int MinAudiobookResume { get; set; } = 5; + + /// <summary> + /// Gets or sets the remaining minutes of a book that can be played while still saving playstate. If this percentage is crossed playstate will be reset to the beginning and the item will be marked watched. + /// </summary> + /// <value>The remaining time in minutes.</value> + public int MaxAudiobookResume { get; set; } = 5; + + /// <summary> + /// Gets or sets the delay in seconds that we will wait after a file system change to try and discover what has been added/removed + /// Some delay is necessary with some items because their creation is not atomic. It involves the creation of several + /// different directories and files. + /// </summary> + /// <value>The file watcher delay.</value> + public int LibraryMonitorDelay { get; set; } = 60; + + /// <summary> + /// Gets or sets the duration in seconds that we will wait after a library updated event before executing the library changed notification. + /// </summary> + /// <value>The library update duration.</value> + public int LibraryUpdateDuration { get; set; } = 30; + + /// <summary> + /// Gets or sets the image saving convention. + /// </summary> + /// <value>The image saving convention.</value> + public ImageSavingConvention ImageSavingConvention { get; set; } + + public MetadataOptions[] MetadataOptions { get; set; } + + public bool SkipDeserializationForBasicTypes { get; set; } = true; + + public string ServerName { get; set; } = string.Empty; + + public string UICulture { get; set; } = "en-US"; + + public bool SaveMetadataHidden { get; set; } = false; + + public NameValuePair[] ContentTypes { get; set; } = Array.Empty<NameValuePair>(); + + public int RemoteClientBitrateLimit { get; set; } + + public bool EnableFolderView { get; set; } = false; + + public bool EnableGroupingIntoCollections { get; set; } = false; + + public bool DisplaySpecialsWithinSeasons { get; set; } = true; + + public string[] CodecsUsed { get; set; } = Array.Empty<string>(); + + public RepositoryInfo[] PluginRepositories { get; set; } = Array.Empty<RepositoryInfo>(); + + public bool EnableExternalContentInSuggestions { get; set; } = true; + + public int ImageExtractionTimeoutMs { get; set; } + + public PathSubstitution[] PathSubstitutions { get; set; } = Array.Empty<PathSubstitution>(); + + /// <summary> + /// Gets or sets a value indicating whether slow server responses should be logged as a warning. + /// </summary> + public bool EnableSlowResponseWarning { get; set; } = true; + + /// <summary> + /// Gets or sets the threshold for the slow response time warning in ms. + /// </summary> + public long SlowResponseThresholdMs { get; set; } = 500; + + /// <summary> + /// Gets or sets the cors hosts. + /// </summary> + public string[] CorsHosts { get; set; } = new[] { "*" }; + + /// <summary> + /// Gets or sets the number of days we should retain activity logs. + /// </summary> + public int? ActivityLogRetentionDays { get; set; } = 30; + + /// <summary> + /// Gets or sets the how the library scan fans out. + /// </summary> + public int LibraryScanFanoutConcurrency { get; set; } + + /// <summary> + /// Gets or sets the how many metadata refreshes can run concurrently. + /// </summary> + public int LibraryMetadataRefreshConcurrency { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether older plugins should automatically be deleted from the plugin folder. + /// </summary> + public bool RemoveOldPlugins { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether clients should be allowed to upload logs. + /// </summary> + public bool AllowClientLogUpload { get; set; } = true; + + /// <summary> + /// Gets or sets the dummy chapter duration in seconds, use 0 (zero) or less to disable generation alltogether. + /// </summary> + /// <value>The dummy chapters duration.</value> + public int DummyChapterDuration { get; set; } + + /// <summary> + /// Gets or sets the chapter image resolution. + /// </summary> + /// <value>The chapter image resolution.</value> + public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; + + /// <summary> + /// Gets or sets the limit for parallel image encoding. + /// </summary> + /// <value>The limit for parallel image encoding.</value> + public int ParallelImageEncodingLimit { get; set; } + + /// <summary> + /// Gets or sets the list of cast receiver applications. + /// </summary> + public CastReceiverApplication[] CastReceiverApplications { get; set; } = Array.Empty<CastReceiverApplication>(); + + /// <summary> + /// Gets or sets the trickplay options. + /// </summary> + /// <value>The trickplay options.</value> + public TrickplayOptions TrickplayOptions { get; set; } = new TrickplayOptions(); } diff --git a/MediaBrowser.Model/Configuration/UserConfiguration.cs b/MediaBrowser.Model/Configuration/UserConfiguration.cs index 94f354660..b477f2593 100644 --- a/MediaBrowser.Model/Configuration/UserConfiguration.cs +++ b/MediaBrowser.Model/Configuration/UserConfiguration.cs @@ -69,5 +69,10 @@ namespace MediaBrowser.Model.Configuration public bool RememberSubtitleSelections { get; set; } public bool EnableNextEpisodeAutoPlay { get; set; } + + /// <summary> + /// Gets or sets the id of the selected cast receiver. + /// </summary> + public string? CastReceiverId { get; set; } } } diff --git a/MediaBrowser.Model/Dlna/DeviceProfile.cs b/MediaBrowser.Model/Dlna/DeviceProfile.cs index b7c23669d..07bb002ea 100644 --- a/MediaBrowser.Model/Dlna/DeviceProfile.cs +++ b/MediaBrowser.Model/Dlna/DeviceProfile.cs @@ -314,7 +314,7 @@ namespace MediaBrowser.Model.Dlna /// <param name="audioSampleRate">The audio sample rate.</param> /// <param name="audioBitDepth">The audio bit depth.</param> /// <returns>The <see cref="ResponseProfile"/>.</returns> - public ResponseProfile? GetAudioMediaProfile(string container, string? audioCodec, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) + public ResponseProfile? GetAudioMediaProfile(string? container, string? audioCodec, int? audioChannels, int? audioBitrate, int? audioSampleRate, int? audioBitDepth) { foreach (var i in ResponseProfiles) { @@ -438,14 +438,14 @@ namespace MediaBrowser.Model.Dlna /// <param name="isAvc">True if Avc.</param> /// <returns>The <see cref="ResponseProfile"/>.</returns> public ResponseProfile? GetVideoMediaProfile( - string container, + string? container, string? audioCodec, string? videoCodec, int? width, int? height, int? bitDepth, int? videoBitrate, - string videoProfile, + string? videoProfile, VideoRangeType videoRangeType, double? videoLevel, float? videoFramerate, @@ -456,7 +456,7 @@ namespace MediaBrowser.Model.Dlna int? refFrames, int? numVideoStreams, int? numAudioStreams, - string videoCodecTag, + string? videoCodecTag, bool? isAvc) { foreach (var i in ResponseProfiles) diff --git a/MediaBrowser.Model/Dlna/SearchCriteria.cs b/MediaBrowser.Model/Dlna/SearchCriteria.cs index 77d6a55ea..6f4a692c8 100644 --- a/MediaBrowser.Model/Dlna/SearchCriteria.cs +++ b/MediaBrowser.Model/Dlna/SearchCriteria.cs @@ -5,7 +5,7 @@ using System.Text.RegularExpressions; namespace MediaBrowser.Model.Dlna { - public class SearchCriteria + public partial class SearchCriteria { public SearchCriteria(string search) { @@ -13,10 +13,10 @@ namespace MediaBrowser.Model.Dlna SearchType = SearchType.Unknown; - string[] factors = RegexSplit(search, "(and|or)"); + string[] factors = AndOrRegex().Split(search); foreach (string factor in factors) { - string[] subFactors = RegexSplit(factor.Trim().Trim('(').Trim(')').Trim(), "\\s", 3); + string[] subFactors = WhiteSpaceRegex().Split(factor.Trim().Trim('(').Trim(')').Trim(), 3); if (subFactors.Length == 3) { @@ -46,27 +46,10 @@ namespace MediaBrowser.Model.Dlna public SearchType SearchType { get; set; } - /// <summary> - /// Splits the specified string. - /// </summary> - /// <param name="str">The string.</param> - /// <param name="term">The term.</param> - /// <param name="limit">The limit.</param> - /// <returns>System.String[].</returns> - private static string[] RegexSplit(string str, string term, int limit) - { - return new Regex(term).Split(str, limit); - } + [GeneratedRegex("\\s")] + private static partial Regex WhiteSpaceRegex(); - /// <summary> - /// Splits the specified string. - /// </summary> - /// <param name="str">The string.</param> - /// <param name="term">The term.</param> - /// <returns>System.String[].</returns> - private static string[] RegexSplit(string str, string term) - { - return Regex.Split(str, term, RegexOptions.IgnoreCase); - } + [GeneratedRegex("(and|or)", RegexOptions.IgnoreCase)] + private static partial Regex AndOrRegex(); } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index f6b882c3e..666e78795 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -135,7 +135,7 @@ namespace MediaBrowser.Model.Dlna } } - if (transcodingProfile != null) + if (transcodingProfile is not null) { if (!item.SupportsTranscoding) { @@ -179,15 +179,9 @@ namespace MediaBrowser.Model.Dlna { ValidateMediaOptions(options, true); - var mediaSources = new List<MediaSourceInfo>(); - foreach (var mediaSourceInfo in options.MediaSources) - { - if (string.IsNullOrEmpty(options.MediaSourceId) - || string.Equals(mediaSourceInfo.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)) - { - mediaSources.Add(mediaSourceInfo); - } - } + var mediaSources = string.IsNullOrEmpty(options.MediaSourceId) + ? options.MediaSources + : options.MediaSources.Where(x => string.Equals(x.Id, options.MediaSourceId, StringComparison.OrdinalIgnoreCase)); var streams = new List<StreamInfo>(); foreach (var mediaSourceInfo in mediaSources) @@ -216,7 +210,7 @@ namespace MediaBrowser.Model.Dlna return streams.OrderBy(i => { // Nothing beats direct playing a file - if (i.PlayMethod == PlayMethod.DirectPlay && i.MediaSource.Protocol == MediaProtocol.File) + if (i.PlayMethod == PlayMethod.DirectPlay && i.MediaSource?.Protocol == MediaProtocol.File) { return 0; } @@ -235,7 +229,7 @@ namespace MediaBrowser.Model.Dlna } }).ThenBy(i => { - switch (i.MediaSource.Protocol) + switch (i.MediaSource?.Protocol) { case MediaProtocol.File: return 0; @@ -246,7 +240,7 @@ namespace MediaBrowser.Model.Dlna { if (maxBitrate > 0) { - if (i.MediaSource.Bitrate.HasValue) + if (i.MediaSource?.Bitrate is not null) { return Math.Abs(i.MediaSource.Bitrate.Value - maxBitrate); } @@ -585,10 +579,10 @@ namespace MediaBrowser.Model.Dlna MediaSource = item, RunTimeTicks = item.RunTimeTicks, Context = options.Context, - DeviceProfile = options.Profile + DeviceProfile = options.Profile, + SubtitleStreamIndex = options.SubtitleStreamIndex ?? GetDefaultSubtitleStreamIndex(item, options.Profile.SubtitleProfiles) }; - playlistItem.SubtitleStreamIndex = options.SubtitleStreamIndex ?? GetDefaultSubtitleStreamIndex(item, options.Profile.SubtitleProfiles); var subtitleStream = playlistItem.SubtitleStreamIndex.HasValue ? item.GetMediaStream(MediaStreamType.Subtitle, playlistItem.SubtitleStreamIndex.Value) : null; var audioStream = item.GetDefaultAudioStream(options.AudioStreamIndex ?? item.DefaultAudioStreamIndex); @@ -659,7 +653,8 @@ namespace MediaBrowser.Model.Dlna if (audioStreamIndex.HasValue) { playlistItem.AudioStreamIndex = audioStreamIndex; - playlistItem.AudioCodecs = new[] { item.GetMediaStream(MediaStreamType.Audio, audioStreamIndex.Value)?.Codec }; + var audioCodec = item.GetMediaStream(MediaStreamType.Audio, audioStreamIndex.Value)?.Codec; + playlistItem.AudioCodecs = audioCodec is null ? Array.Empty<string>() : new[] { audioCodec }; } } else if (directPlay == PlayMethod.DirectStream) @@ -759,7 +754,7 @@ namespace MediaBrowser.Model.Dlna { // prefer direct copy profile float videoFramerate = videoStream?.AverageFrameRate ?? videoStream?.RealFrameRate ?? 0; - TransportStreamTimestamp? timestamp = videoStream == null ? TransportStreamTimestamp.None : item.Timestamp; + TransportStreamTimestamp? timestamp = videoStream is null ? TransportStreamTimestamp.None : item.Timestamp; int? numAudioStreams = item.GetStreamCount(MediaStreamType.Audio); int? numVideoStreams = item.GetStreamCount(MediaStreamType.Video); @@ -842,7 +837,7 @@ namespace MediaBrowser.Model.Dlna if (videoStream is not null && videoStream.Level != 0) { - playlistItem.SetOption(qualifier, "level", videoStream.Level.ToString()); + playlistItem.SetOption(qualifier, "level", videoStream.Level.ToString() ?? string.Empty); } // Prefer matching audio codecs, could do better here @@ -871,7 +866,7 @@ namespace MediaBrowser.Model.Dlna // Copy matching audio codec options playlistItem.AudioSampleRate = audioStream.SampleRate; - playlistItem.SetOption(qualifier, "audiochannels", audioStream.Channels.ToString()); + playlistItem.SetOption(qualifier, "audiochannels", audioStream.Channels.ToString() ?? string.Empty); if (!string.IsNullOrEmpty(audioStream.Profile)) { @@ -880,7 +875,7 @@ namespace MediaBrowser.Model.Dlna if (audioStream.Level != 0) { - playlistItem.SetOption(audioStream.Codec, "level", audioStream.Level.ToString()); + playlistItem.SetOption(audioStream.Codec, "level", audioStream.Level.ToString() ?? string.Empty); } } @@ -1318,7 +1313,7 @@ namespace MediaBrowser.Model.Dlna var audioFailureConditions = GetProfileConditionsForVideoAudio(profile.CodecProfiles, container, audioStream.Codec, audioStream.Channels, audioStream.BitRate, audioStream.SampleRate, audioStream.BitDepth, audioStream.Profile, mediaSource.IsSecondaryAudio(audioStream)); var audioStreamFailureReasons = AggregateFailureConditions(mediaSource, profile, "VideoAudioCodecProfile", audioFailureConditions); - if (audioStream?.IsExternal == true) + if (audioStream.IsExternal == true) { audioStreamFailureReasons |= TranscodeReason.AudioIsExternal; } diff --git a/MediaBrowser.Model/Dlna/StreamInfo.cs b/MediaBrowser.Model/Dlna/StreamInfo.cs index 00543616d..fc146df30 100644 --- a/MediaBrowser.Model/Dlna/StreamInfo.cs +++ b/MediaBrowser.Model/Dlna/StreamInfo.cs @@ -1,9 +1,9 @@ -#nullable disable #pragma warning disable CS1591 using System; using System.Collections.Generic; using System.Globalization; +using System.Linq; using Jellyfin.Data.Enums; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; @@ -34,9 +34,9 @@ namespace MediaBrowser.Model.Dlna public DlnaProfileType MediaType { get; set; } - public string Container { get; set; } + public string? Container { get; set; } - public string SubProtocol { get; set; } + public string? SubProtocol { get; set; } public long StartPositionTicks { get; set; } @@ -80,11 +80,11 @@ namespace MediaBrowser.Model.Dlna public float? MaxFramerate { get; set; } - public DeviceProfile DeviceProfile { get; set; } + public required DeviceProfile DeviceProfile { get; set; } - public string DeviceProfileId { get; set; } + public string? DeviceProfileId { get; set; } - public string DeviceId { get; set; } + public string? DeviceId { get; set; } public long? RunTimeTicks { get; set; } @@ -92,21 +92,21 @@ namespace MediaBrowser.Model.Dlna public bool EstimateContentLength { get; set; } - public MediaSourceInfo MediaSource { get; set; } + public MediaSourceInfo? MediaSource { get; set; } public string[] SubtitleCodecs { get; set; } public SubtitleDeliveryMethod SubtitleDeliveryMethod { get; set; } - public string SubtitleFormat { get; set; } + public string? SubtitleFormat { get; set; } - public string PlaySessionId { get; set; } + public string? PlaySessionId { get; set; } public TranscodeReason TranscodeReasons { get; set; } public Dictionary<string, string> StreamOptions { get; private set; } - public string MediaSourceId => MediaSource?.Id; + public string? MediaSourceId => MediaSource?.Id; public bool IsDirectStream => MediaSource?.VideoType is not (VideoType.Dvd or VideoType.BluRay) && PlayMethod is PlayMethod.DirectStream or PlayMethod.DirectPlay; @@ -114,12 +114,12 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// Gets the audio stream that will be used. /// </summary> - public MediaStream TargetAudioStream => MediaSource?.GetDefaultAudioStream(AudioStreamIndex); + public MediaStream? TargetAudioStream => MediaSource?.GetDefaultAudioStream(AudioStreamIndex); /// <summary> /// Gets the video stream that will be used. /// </summary> - public MediaStream TargetVideoStream => MediaSource?.VideoStream; + public MediaStream? TargetVideoStream => MediaSource?.VideoStream; /// <summary> /// Gets the audio sample rate that will be in the output stream. @@ -259,7 +259,7 @@ namespace MediaBrowser.Model.Dlna /// <summary> /// Gets the audio sample rate that will be in the output stream. /// </summary> - public string TargetVideoProfile + public string? TargetVideoProfile { get { @@ -307,7 +307,7 @@ namespace MediaBrowser.Model.Dlna /// Gets the target video codec tag. /// </summary> /// <value>The target video codec tag.</value> - public string TargetVideoCodecTag + public string? TargetVideoCodecTag { get { @@ -364,7 +364,7 @@ namespace MediaBrowser.Model.Dlna { var stream = TargetAudioStream; - string inputCodec = stream?.Codec; + string? inputCodec = stream?.Codec; if (IsDirectStream) { @@ -389,7 +389,7 @@ namespace MediaBrowser.Model.Dlna { var stream = TargetVideoStream; - string inputCodec = stream?.Codec; + string? inputCodec = stream?.Codec; if (IsDirectStream) { @@ -417,7 +417,7 @@ namespace MediaBrowser.Model.Dlna { if (IsDirectStream) { - return MediaSource.Size; + return MediaSource?.Size; } if (RunTimeTicks.HasValue) @@ -580,7 +580,7 @@ namespace MediaBrowser.Model.Dlna } } - public void SetOption(string qualifier, string name, string value) + public void SetOption(string? qualifier, string name, string value) { if (string.IsNullOrEmpty(qualifier)) { @@ -597,7 +597,7 @@ namespace MediaBrowser.Model.Dlna StreamOptions[name] = value; } - public string GetOption(string qualifier, string name) + public string? GetOption(string? qualifier, string name) { var value = GetOption(qualifier + "-" + name); @@ -609,7 +609,7 @@ namespace MediaBrowser.Model.Dlna return value; } - public string GetOption(string name) + public string? GetOption(string name) { if (StreamOptions.TryGetValue(name, out var value)) { @@ -619,7 +619,7 @@ namespace MediaBrowser.Model.Dlna return null; } - public string ToUrl(string baseUrl, string accessToken) + public string ToUrl(string baseUrl, string? accessToken) { ArgumentException.ThrowIfNullOrEmpty(baseUrl); @@ -686,7 +686,7 @@ namespace MediaBrowser.Model.Dlna return string.Format(CultureInfo.InvariantCulture, "{0}/videos/{1}/stream{2}?{3}", baseUrl, ItemId, extension, queryString); } - private static IEnumerable<NameValuePair> BuildParams(StreamInfo item, string accessToken) + private static IEnumerable<NameValuePair> BuildParams(StreamInfo item, string? accessToken) { var list = new List<NameValuePair>(); @@ -730,7 +730,7 @@ namespace MediaBrowser.Model.Dlna list.Add(new NameValuePair("PlaySessionId", item.PlaySessionId ?? string.Empty)); list.Add(new NameValuePair("api_key", accessToken ?? string.Empty)); - string liveStreamId = item.MediaSource?.LiveStreamId; + string? liveStreamId = item.MediaSource?.LiveStreamId; list.Add(new NameValuePair("LiveStreamId", liveStreamId ?? string.Empty)); list.Add(new NameValuePair("SubtitleMethod", item.SubtitleStreamIndex.HasValue && item.SubtitleDeliveryMethod != SubtitleDeliveryMethod.External ? item.SubtitleDeliveryMethod.ToString() : string.Empty)); @@ -772,7 +772,7 @@ namespace MediaBrowser.Model.Dlna list.Add(new NameValuePair("RequireAvc", item.RequireAvc.ToString(CultureInfo.InvariantCulture).ToLowerInvariant())); } - list.Add(new NameValuePair("Tag", item.MediaSource.ETag ?? string.Empty)); + list.Add(new NameValuePair("Tag", item.MediaSource?.ETag ?? string.Empty)); string subtitleCodecs = item.SubtitleCodecs.Length == 0 ? string.Empty : @@ -816,13 +816,18 @@ namespace MediaBrowser.Model.Dlna return list; } - public IEnumerable<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string accessToken) + public IEnumerable<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, string baseUrl, string? accessToken) { return GetSubtitleProfiles(transcoderSupport, includeSelectedTrackOnly, false, baseUrl, accessToken); } - public IEnumerable<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string accessToken) + public IEnumerable<SubtitleStreamInfo> GetSubtitleProfiles(ITranscoderSupport transcoderSupport, bool includeSelectedTrackOnly, bool enableAllProfiles, string baseUrl, string? accessToken) { + if (MediaSource is null) + { + return Enumerable.Empty<SubtitleStreamInfo>(); + } + var list = new List<SubtitleStreamInfo>(); // HLS will preserve timestamps so we can just grab the full subtitle stream @@ -856,27 +861,36 @@ namespace MediaBrowser.Model.Dlna return list; } - private void AddSubtitleProfiles(List<SubtitleStreamInfo> list, MediaStream stream, ITranscoderSupport transcoderSupport, bool enableAllProfiles, string baseUrl, string accessToken, long startPositionTicks) + private void AddSubtitleProfiles(List<SubtitleStreamInfo> list, MediaStream stream, ITranscoderSupport transcoderSupport, bool enableAllProfiles, string baseUrl, string? accessToken, long startPositionTicks) { if (enableAllProfiles) { foreach (var profile in DeviceProfile.SubtitleProfiles) { var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, new[] { profile }, transcoderSupport); - - list.Add(info); + if (info is not null) + { + list.Add(info); + } } } else { var info = GetSubtitleStreamInfo(stream, baseUrl, accessToken, startPositionTicks, DeviceProfile.SubtitleProfiles, transcoderSupport); - - list.Add(info); + if (info is not null) + { + list.Add(info); + } } } - private SubtitleStreamInfo GetSubtitleStreamInfo(MediaStream stream, string baseUrl, string accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles, ITranscoderSupport transcoderSupport) + private SubtitleStreamInfo? GetSubtitleStreamInfo(MediaStream stream, string baseUrl, string? accessToken, long startPositionTicks, SubtitleProfile[] subtitleProfiles, ITranscoderSupport transcoderSupport) { + if (MediaSource is null) + { + return null; + } + var subtitleProfile = StreamBuilder.GetSubtitleProfile(MediaSource, stream, subtitleProfiles, PlayMethod, transcoderSupport, Container, SubProtocol); var info = new SubtitleStreamInfo { @@ -920,7 +934,7 @@ namespace MediaBrowser.Model.Dlna return info; } - public int? GetTargetVideoBitDepth(string codec) + public int? GetTargetVideoBitDepth(string? codec) { var value = GetOption(codec, "videobitdepth"); @@ -932,7 +946,7 @@ namespace MediaBrowser.Model.Dlna return null; } - public int? GetTargetAudioBitDepth(string codec) + public int? GetTargetAudioBitDepth(string? codec) { var value = GetOption(codec, "audiobitdepth"); @@ -944,7 +958,7 @@ namespace MediaBrowser.Model.Dlna return null; } - public double? GetTargetVideoLevel(string codec) + public double? GetTargetVideoLevel(string? codec) { var value = GetOption(codec, "level"); @@ -956,7 +970,7 @@ namespace MediaBrowser.Model.Dlna return null; } - public int? GetTargetRefFrames(string codec) + public int? GetTargetRefFrames(string? codec) { var value = GetOption(codec, "maxrefframes"); @@ -968,7 +982,7 @@ namespace MediaBrowser.Model.Dlna return null; } - public int? GetTargetAudioChannels(string codec) + public int? GetTargetAudioChannels(string? codec) { var defaultValue = GlobalMaxAudioChannels ?? TranscodingMaxAudioChannels; @@ -988,7 +1002,7 @@ namespace MediaBrowser.Model.Dlna private int? GetMediaStreamCount(MediaStreamType type, int limit) { - var count = MediaSource.GetStreamCount(type); + var count = MediaSource?.GetStreamCount(type); if (count.HasValue) { diff --git a/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs b/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs index 987a3a908..c7489d57a 100644 --- a/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs +++ b/MediaBrowser.Model/Dlna/UpnpDeviceInfo.cs @@ -13,10 +13,10 @@ namespace MediaBrowser.Model.Dlna public Dictionary<string, string> Headers { get; set; } - public IPAddress LocalIpAddress { get; set; } + public IPAddress LocalIPAddress { get; set; } public int LocalPort { get; set; } - public IPAddress RemoteIpAddress { get; set; } + public IPAddress RemoteIPAddress { get; set; } } } diff --git a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs index 68a5c2534..1bb24112e 100644 --- a/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs +++ b/MediaBrowser.Model/Drawing/ImageFormatExtensions.cs @@ -24,4 +24,21 @@ public static class ImageFormatExtensions ImageFormat.Webp => "image/webp", _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat)) }; + + /// <summary> + /// Returns the correct extension for this <see cref="ImageFormat" />. + /// </summary> + /// <param name="format">This <see cref="ImageFormat" />.</param> + /// <exception cref="InvalidEnumArgumentException">The <paramref name="format"/> is an invalid enumeration value.</exception> + /// <returns>The correct extension for this <see cref="ImageFormat" />.</returns> + public static string GetExtension(this ImageFormat format) + => format switch + { + ImageFormat.Bmp => ".bmp", + ImageFormat.Gif => ".gif", + ImageFormat.Jpg => ".jpg", + ImageFormat.Png => ".png", + ImageFormat.Webp => ".webp", + _ => throw new InvalidEnumArgumentException(nameof(format), (int)format, typeof(ImageFormat)) + }; } diff --git a/MediaBrowser.Model/Entities/ChapterInfo.cs b/MediaBrowser.Model/Entities/ChapterInfo.cs index 45554c3dc..d6b905651 100644 --- a/MediaBrowser.Model/Entities/ChapterInfo.cs +++ b/MediaBrowser.Model/Entities/ChapterInfo.cs @@ -1,4 +1,3 @@ -#nullable disable #pragma warning disable CS1591 using System; @@ -20,16 +19,16 @@ namespace MediaBrowser.Model.Entities /// Gets or sets the name. /// </summary> /// <value>The name.</value> - public string Name { get; set; } + public string? Name { get; set; } /// <summary> /// Gets or sets the image path. /// </summary> /// <value>The image path.</value> - public string ImagePath { get; set; } + public string? ImagePath { get; set; } public DateTime ImageDateModified { get; set; } - public string ImageTag { get; set; } + public string? ImageTag { get; set; } } } diff --git a/MediaBrowser.Model/IO/IFileSystem.cs b/MediaBrowser.Model/IO/IFileSystem.cs index 786b20e9e..ec381d423 100644 --- a/MediaBrowser.Model/IO/IFileSystem.cs +++ b/MediaBrowser.Model/IO/IFileSystem.cs @@ -10,8 +10,6 @@ namespace MediaBrowser.Model.IO /// </summary> public interface IFileSystem { - void AddShortcutHandler(IShortcutHandler handler); - /// <summary> /// Determines whether the specified filename is shortcut. /// </summary> @@ -117,13 +115,6 @@ namespace MediaBrowser.Model.IO bool ContainsSubPath(string parentPath, string path); /// <summary> - /// Normalizes the path. - /// </summary> - /// <param name="path">The path.</param> - /// <returns>System.String.</returns> - string NormalizePath(string path); - - /// <summary> /// Gets the file name without extension. /// </summary> /// <param name="info">The information.</param> diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index 9a5804485..58ba83a35 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -33,6 +33,7 @@ </PropertyGroup> <ItemGroup> + <PackageReference Include="Microsoft.AspNetCore.HttpOverrides" /> <PackageReference Include="Microsoft.SourceLink.GitHub" PrivateAssets="All" /> <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" /> <PackageReference Include="MimeTypes"> diff --git a/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs b/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs index 85de91694..c5a99a9ec 100644 --- a/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs +++ b/MediaBrowser.Model/MediaInfo/SubtitleFormat.cs @@ -9,6 +9,7 @@ namespace MediaBrowser.Model.MediaInfo public const string SSA = "ssa"; public const string ASS = "ass"; public const string VTT = "vtt"; + public const string WEBVTT = "webvtt"; public const string TTML = "ttml"; } } diff --git a/MediaBrowser.Model/Net/IPData.cs b/MediaBrowser.Model/Net/IPData.cs new file mode 100644 index 000000000..e9fcd6797 --- /dev/null +++ b/MediaBrowser.Model/Net/IPData.cs @@ -0,0 +1,79 @@ +using System.Net; +using System.Net.Sockets; +using Microsoft.AspNetCore.HttpOverrides; + +namespace MediaBrowser.Model.Net; + +/// <summary> +/// Base network object class. +/// </summary> +public class IPData +{ + /// <summary> + /// Initializes a new instance of the <see cref="IPData"/> class. + /// </summary> + /// <param name="address">The <see cref="IPAddress"/>.</param> + /// <param name="subnet">The <see cref="IPNetwork"/>.</param> + /// <param name="name">The interface name.</param> + public IPData(IPAddress address, IPNetwork? subnet, string name) + { + Address = address; + Subnet = subnet ?? (address.AddressFamily == AddressFamily.InterNetwork ? new IPNetwork(address, 32) : new IPNetwork(address, 128)); + Name = name; + } + + /// <summary> + /// Initializes a new instance of the <see cref="IPData"/> class. + /// </summary> + /// <param name="address">The <see cref="IPAddress"/>.</param> + /// <param name="subnet">The <see cref="IPNetwork"/>.</param> + public IPData(IPAddress address, IPNetwork? subnet) + : this(address, subnet, string.Empty) + { + } + + /// <summary> + /// Gets or sets the object's IP address. + /// </summary> + public IPAddress Address { get; set; } + + /// <summary> + /// Gets or sets the object's IP address. + /// </summary> + public IPNetwork Subnet { get; set; } + + /// <summary> + /// Gets or sets the interface index. + /// </summary> + public int Index { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the network supports multicast. + /// </summary> + public bool SupportsMulticast { get; set; } = false; + + /// <summary> + /// Gets or sets the interface name. + /// </summary> + public string Name { get; set; } + + /// <summary> + /// Gets the AddressFamily of the object. + /// </summary> + public AddressFamily AddressFamily + { + get + { + if (Address.Equals(IPAddress.None)) + { + return Subnet.Prefix.AddressFamily.Equals(IPAddress.None) + ? AddressFamily.Unspecified + : Subnet.Prefix.AddressFamily; + } + else + { + return Address.AddressFamily; + } + } + } +} diff --git a/MediaBrowser.Model/Net/ISocket.cs b/MediaBrowser.Model/Net/ISocket.cs deleted file mode 100644 index 3de41d565..000000000 --- a/MediaBrowser.Model/Net/ISocket.cs +++ /dev/null @@ -1,34 +0,0 @@ -#pragma warning disable CS1591 - -using System; -using System.Net; -using System.Threading; -using System.Threading.Tasks; - -namespace MediaBrowser.Model.Net -{ - /// <summary> - /// Provides a common interface across platforms for UDP sockets used by this SSDP implementation. - /// </summary> - public interface ISocket : IDisposable - { - IPAddress LocalIPAddress { get; } - - Task<SocketReceiveResult> ReceiveAsync(byte[] buffer, int offset, int count, CancellationToken cancellationToken); - - IAsyncResult BeginReceive(byte[] buffer, int offset, int count, AsyncCallback callback); - - SocketReceiveResult EndReceive(IAsyncResult result); - - /// <summary> - /// Sends a UDP message to a particular end point (uni or multicast). - /// </summary> - /// <param name="buffer">An array of type <see cref="byte" /> that contains the data to send.</param> - /// <param name="offset">The zero-based position in buffer at which to begin sending data.</param> - /// <param name="bytes">The number of bytes to send.</param> - /// <param name="endPoint">An <see cref="IPEndPoint" /> that represents the remote device.</param> - /// <param name="cancellationToken">The cancellation token to cancel operation.</param> - /// <returns>The task object representing the asynchronous operation.</returns> - Task SendToAsync(byte[] buffer, int offset, int bytes, IPEndPoint endPoint, CancellationToken cancellationToken); - } -} diff --git a/MediaBrowser.Model/Net/ISocketFactory.cs b/MediaBrowser.Model/Net/ISocketFactory.cs index a2835b711..128034eb8 100644 --- a/MediaBrowser.Model/Net/ISocketFactory.cs +++ b/MediaBrowser.Model/Net/ISocketFactory.cs @@ -1,31 +1,35 @@ -#pragma warning disable CS1591 - using System.Net; +using System.Net.Sockets; + +namespace MediaBrowser.Model.Net; -namespace MediaBrowser.Model.Net +/// <summary> +/// Implemented by components that can create specific socket configurations. +/// </summary> +public interface ISocketFactory { /// <summary> - /// Implemented by components that can create a platform specific UDP socket implementation, and wrap it in the cross platform <see cref="ISocket"/> interface. + /// Creates a new unicast socket using the specified local port number. /// </summary> - public interface ISocketFactory - { - ISocket CreateUdpBroadcastSocket(int localPort); + /// <param name="localPort">The local port to bind to.</param> + /// <returns>A new unicast socket using the specified local port number.</returns> + Socket CreateUdpBroadcastSocket(int localPort); - /// <summary> - /// Creates a new unicast socket using the specified local port number. - /// </summary> - /// <param name="localIp">The local IP address to bind to.</param> - /// <param name="localPort">The local port to bind to.</param> - /// <returns>A new unicast socket using the specified local port number.</returns> - ISocket CreateSsdpUdpSocket(IPAddress localIp, int localPort); + /// <summary> + /// Creates a new unicast socket using the specified local port number. + /// </summary> + /// <param name="bindInterface">The bind interface.</param> + /// <param name="localPort">The local port to bind to.</param> + /// <returns>A new unicast socket using the specified local port number.</returns> + Socket CreateSsdpUdpSocket(IPData bindInterface, int localPort); - /// <summary> - /// Creates a new multicast socket using the specified multicast IP address, multicast time to live and local port. - /// </summary> - /// <param name="ipAddress">The multicast IP address to bind to.</param> - /// <param name="multicastTimeToLive">The multicast time to live value. Actually a maximum number of network hops for UDP packets.</param> - /// <param name="localPort">The local port to bind to.</param> - /// <returns>A <see cref="ISocket"/> implementation.</returns> - ISocket CreateUdpMulticastSocket(IPAddress ipAddress, int multicastTimeToLive, int localPort); - } + /// <summary> + /// Creates a new multicast socket using the specified multicast IP address, multicast time to live and local port. + /// </summary> + /// <param name="multicastAddress">The multicast IP address to bind to.</param> + /// <param name="bindInterface">The bind interface.</param> + /// <param name="multicastTimeToLive">The multicast time to live value. Actually a maximum number of network hops for UDP packets.</param> + /// <param name="localPort">The local port to bind to.</param> + /// <returns>A new multicast socket using the specfied bind interface, multicast address, multicast time to live and port.</returns> + Socket CreateUdpMulticastSocket(IPAddress multicastAddress, IPData bindInterface, int multicastTimeToLive, int localPort); } diff --git a/MediaBrowser.Model/Net/PublishedServerUriOverride.cs b/MediaBrowser.Model/Net/PublishedServerUriOverride.cs new file mode 100644 index 000000000..476d1ba38 --- /dev/null +++ b/MediaBrowser.Model/Net/PublishedServerUriOverride.cs @@ -0,0 +1,42 @@ +namespace MediaBrowser.Model.Net; + +/// <summary> +/// Class holding information for a published server URI override. +/// </summary> +public class PublishedServerUriOverride +{ + /// <summary> + /// Initializes a new instance of the <see cref="PublishedServerUriOverride"/> class. + /// </summary> + /// <param name="data">The <see cref="IPData"/>.</param> + /// <param name="overrideUri">The override.</param> + /// <param name="internalOverride">A value indicating whether the override is for internal requests.</param> + /// <param name="externalOverride">A value indicating whether the override is for external requests.</param> + public PublishedServerUriOverride(IPData data, string overrideUri, bool internalOverride, bool externalOverride) + { + Data = data; + OverrideUri = overrideUri; + IsInternalOverride = internalOverride; + IsExternalOverride = externalOverride; + } + + /// <summary> + /// Gets or sets the object's IP address. + /// </summary> + public IPData Data { get; set; } + + /// <summary> + /// Gets or sets the override URI. + /// </summary> + public string OverrideUri { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the override should be applied to internal requests. + /// </summary> + public bool IsInternalOverride { get; set; } + + /// <summary> + /// Gets or sets a value indicating whether the override should be applied to external requests. + /// </summary> + public bool IsExternalOverride { get; set; } +} diff --git a/MediaBrowser.Model/Providers/RemoteSubtitleInfo.cs b/MediaBrowser.Model/Providers/RemoteSubtitleInfo.cs index a8d88d8a1..9f9c922a7 100644 --- a/MediaBrowser.Model/Providers/RemoteSubtitleInfo.cs +++ b/MediaBrowser.Model/Providers/RemoteSubtitleInfo.cs @@ -25,8 +25,18 @@ namespace MediaBrowser.Model.Providers public float? CommunityRating { get; set; } + public float? FrameRate { get; set; } + public int? DownloadCount { get; set; } public bool? IsHashMatch { get; set; } + + public bool? AiTranslated { get; set; } + + public bool? MachineTranslated { get; set; } + + public bool? Forced { get; set; } + + public bool? HearingImpaired { get; set; } } } diff --git a/MediaBrowser.Model/Querying/NextUpQuery.cs b/MediaBrowser.Model/Querying/NextUpQuery.cs index 0fb996df9..35353e6fa 100644 --- a/MediaBrowser.Model/Querying/NextUpQuery.cs +++ b/MediaBrowser.Model/Querying/NextUpQuery.cs @@ -14,6 +14,7 @@ namespace MediaBrowser.Model.Querying EnableTotalRecordCount = true; DisableFirstEpisode = false; NextUpDateCutoff = DateTime.MinValue; + EnableResumable = false; EnableRewatching = false; } @@ -84,6 +85,11 @@ namespace MediaBrowser.Model.Querying public DateTime NextUpDateCutoff { get; set; } /// <summary> + /// Gets or sets a value indicating whether to include resumable episodes as next up. + /// </summary> + public bool EnableResumable { get; set; } + + /// <summary> /// Gets or sets a value indicating whether getting rewatching next up list. /// </summary> public bool EnableRewatching { get; set; } diff --git a/MediaBrowser.Model/System/CastReceiverApplication.cs b/MediaBrowser.Model/System/CastReceiverApplication.cs new file mode 100644 index 000000000..6a49a5cac --- /dev/null +++ b/MediaBrowser.Model/System/CastReceiverApplication.cs @@ -0,0 +1,17 @@ +namespace MediaBrowser.Model.System; + +/// <summary> +/// The cast receiver application model. +/// </summary> +public class CastReceiverApplication +{ + /// <summary> + /// Gets or sets the cast receiver application id. + /// </summary> + public required string Id { get; set; } + + /// <summary> + /// Gets or sets the cast receiver application name. + /// </summary> + public required string Name { get; set; } +} diff --git a/MediaBrowser.Model/System/SystemInfo.cs b/MediaBrowser.Model/System/SystemInfo.cs index bd0099af7..aa7c03ebd 100644 --- a/MediaBrowser.Model/System/SystemInfo.cs +++ b/MediaBrowser.Model/System/SystemInfo.cs @@ -2,6 +2,7 @@ #pragma warning disable CS1591 using System; +using System.Collections.Generic; using System.Runtime.InteropServices; using MediaBrowser.Model.Updates; @@ -84,7 +85,8 @@ namespace MediaBrowser.Model.System [Obsolete("This is always true")] public bool CanSelfRestart { get; set; } = true; - public bool CanLaunchWebBrowser { get; set; } + [Obsolete("This is always false")] + public bool CanLaunchWebBrowser { get; set; } = false; /// <summary> /// Gets or sets the program data path. @@ -129,6 +131,11 @@ namespace MediaBrowser.Model.System public string TranscodingTempPath { get; set; } /// <summary> + /// Gets or sets the list of cast receiver applications. + /// </summary> + public IReadOnlyList<CastReceiverApplication> CastReceiverApplications { get; set; } + + /// <summary> /// 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> diff --git a/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs b/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs new file mode 100644 index 000000000..ab09f278a --- /dev/null +++ b/MediaBrowser.Providers/Lyric/DefaultLyricProvider.cs @@ -0,0 +1,69 @@ +using System; +using System.IO; +using System.Threading.Tasks; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Resolvers; + +namespace MediaBrowser.Providers.Lyric; + +/// <inheritdoc /> +public class DefaultLyricProvider : ILyricProvider +{ + private static readonly string[] _lyricExtensions = { ".lrc", ".elrc", ".txt" }; + + /// <inheritdoc /> + public string Name => "DefaultLyricProvider"; + + /// <inheritdoc /> + public ResolverPriority Priority => ResolverPriority.First; + + /// <inheritdoc /> + public bool HasLyrics(BaseItem item) + { + var path = GetLyricsPath(item); + return path is not null; + } + + /// <inheritdoc /> + public async Task<LyricFile?> GetLyrics(BaseItem item) + { + var path = GetLyricsPath(item); + if (path is not null) + { + var content = await File.ReadAllTextAsync(path).ConfigureAwait(false); + if (!string.IsNullOrEmpty(content)) + { + return new LyricFile(path, content); + } + } + + return null; + } + + private string? GetLyricsPath(BaseItem item) + { + // Ensure the path to the item is not null + string? itemDirectoryPath = Path.GetDirectoryName(item.Path); + if (itemDirectoryPath is null) + { + return null; + } + + // Ensure the directory path exists + if (!Directory.Exists(itemDirectoryPath)) + { + return null; + } + + foreach (var lyricFilePath in Directory.GetFiles(itemDirectoryPath, $"{Path.GetFileNameWithoutExtension(item.Path)}.*")) + { + if (_lyricExtensions.Contains(Path.GetExtension(lyricFilePath.AsSpan()), StringComparison.OrdinalIgnoreCase)) + { + return lyricFilePath; + } + } + + return null; + } +} diff --git a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs b/MediaBrowser.Providers/Lyric/ILyricProvider.cs index 2a04c6152..27ceba72b 100644 --- a/MediaBrowser.Controller/Lyrics/ILyricProvider.cs +++ b/MediaBrowser.Providers/Lyric/ILyricProvider.cs @@ -1,9 +1,8 @@ -using System.Collections.Generic; using System.Threading.Tasks; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Resolvers; -namespace MediaBrowser.Controller.Lyrics; +namespace MediaBrowser.Providers.Lyric; /// <summary> /// Interface ILyricsProvider. @@ -22,15 +21,16 @@ public interface ILyricProvider ResolverPriority Priority { get; } /// <summary> - /// Gets the supported media types for this provider. + /// Checks if an item has lyrics available. /// </summary> - /// <value>The supported media types.</value> - IReadOnlyCollection<string> SupportedMediaTypes { get; } + /// <param name="item">The media item.</param> + /// <returns>Whether lyrics where found or not.</returns> + bool HasLyrics(BaseItem item); /// <summary> /// Gets the lyrics. /// </summary> /// <param name="item">The media item.</param> /// <returns>A task representing found lyrics.</returns> - Task<LyricResponse?> GetLyrics(BaseItem item); + Task<LyricFile?> GetLyrics(BaseItem item); } diff --git a/MediaBrowser.Providers/Lyric/LrcLyricProvider.cs b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs index 7b108921b..7f1ecd743 100644 --- a/MediaBrowser.Providers/Lyric/LrcLyricProvider.cs +++ b/MediaBrowser.Providers/Lyric/LrcLyricParser.cs @@ -3,34 +3,29 @@ using System.Collections.Generic; using System.Globalization; using System.IO; using System.Linq; -using System.Threading.Tasks; +using Jellyfin.Extensions; using LrcParser.Model; using LrcParser.Parser; -using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Lyrics; using MediaBrowser.Controller.Resolvers; -using Microsoft.Extensions.Logging; namespace MediaBrowser.Providers.Lyric; /// <summary> -/// LRC Lyric Provider. +/// LRC Lyric Parser. /// </summary> -public class LrcLyricProvider : ILyricProvider +public class LrcLyricParser : ILyricParser { - private readonly ILogger<LrcLyricProvider> _logger; - private readonly LyricParser _lrcLyricParser; + private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc" }; private static readonly string[] _acceptedTimeFormats = { "HH:mm:ss", "H:mm:ss", "mm:ss", "m:ss" }; /// <summary> - /// Initializes a new instance of the <see cref="LrcLyricProvider"/> class. + /// Initializes a new instance of the <see cref="LrcLyricParser"/> class. /// </summary> - /// <param name="logger">Instance of the <see cref="ILogger"/> interface.</param> - public LrcLyricProvider(ILogger<LrcLyricProvider> logger) + public LrcLyricParser() { - _logger = logger; _lrcLyricParser = new LrcParser.Parser.Lrc.LrcParser(); } @@ -41,37 +36,25 @@ public class LrcLyricProvider : ILyricProvider /// Gets the priority. /// </summary> /// <value>The priority.</value> - public ResolverPriority Priority => ResolverPriority.First; + public ResolverPriority Priority => ResolverPriority.Fourth; /// <inheritdoc /> - public IReadOnlyCollection<string> SupportedMediaTypes { get; } = new[] { "lrc", "elrc" }; - - /// <summary> - /// Opens lyric file for the requested item, and processes it for API return. - /// </summary> - /// <param name="item">The item to to process.</param> - /// <returns>If provider can determine lyrics, returns a <see cref="LyricResponse"/> with or without metadata; otherwise, null.</returns> - public async Task<LyricResponse?> GetLyrics(BaseItem item) + public LyricResponse? ParseLyrics(LyricFile lyrics) { - string? lyricFilePath = this.GetLyricFilePath(item.Path); - - if (string.IsNullOrEmpty(lyricFilePath)) + if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase)) { return null; } - var fileMetaData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - string lrcFileContent = await File.ReadAllTextAsync(lyricFilePath).ConfigureAwait(false); - Song lyricData; try { - lyricData = _lrcLyricParser.Decode(lrcFileContent); + lyricData = _lrcLyricParser.Decode(lyrics.Content); } - catch (Exception ex) + catch (Exception) { - _logger.LogError(ex, "Error parsing lyric file {LyricFilePath} from {Provider}", lyricFilePath, Name); + // Failed to parse, return null so the next parser will be tried return null; } @@ -84,6 +67,7 @@ public class LrcLyricProvider : ILyricProvider .Select(x => x.Text) .ToList(); + var fileMetaData = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); foreach (string metaDataRow in metaDataRows) { var index = metaDataRow.IndexOf(':', StringComparison.OrdinalIgnoreCase); @@ -130,17 +114,10 @@ public class LrcLyricProvider : ILyricProvider // Map metaData values from LRC file to LyricMetadata properties LyricMetadata lyricMetadata = MapMetadataValues(fileMetaData); - return new LyricResponse - { - Metadata = lyricMetadata, - Lyrics = lyricList - }; + return new LyricResponse { Metadata = lyricMetadata, Lyrics = lyricList }; } - return new LyricResponse - { - Lyrics = lyricList - }; + return new LyricResponse { Lyrics = lyricList }; } /// <summary> diff --git a/MediaBrowser.Providers/Lyric/LyricManager.cs b/MediaBrowser.Providers/Lyric/LyricManager.cs index f9547e0f0..6da811927 100644 --- a/MediaBrowser.Providers/Lyric/LyricManager.cs +++ b/MediaBrowser.Providers/Lyric/LyricManager.cs @@ -12,14 +12,17 @@ namespace MediaBrowser.Providers.Lyric; public class LyricManager : ILyricManager { private readonly ILyricProvider[] _lyricProviders; + private readonly ILyricParser[] _lyricParsers; /// <summary> /// Initializes a new instance of the <see cref="LyricManager"/> class. /// </summary> /// <param name="lyricProviders">All found lyricProviders.</param> - public LyricManager(IEnumerable<ILyricProvider> lyricProviders) + /// <param name="lyricParsers">All found lyricParsers.</param> + public LyricManager(IEnumerable<ILyricProvider> lyricProviders, IEnumerable<ILyricParser> lyricParsers) { _lyricProviders = lyricProviders.OrderBy(i => i.Priority).ToArray(); + _lyricParsers = lyricParsers.OrderBy(i => i.Priority).ToArray(); } /// <inheritdoc /> @@ -27,10 +30,19 @@ public class LyricManager : ILyricManager { foreach (ILyricProvider provider in _lyricProviders) { - var results = await provider.GetLyrics(item).ConfigureAwait(false); - if (results is not null) + var lyrics = await provider.GetLyrics(item).ConfigureAwait(false); + if (lyrics is null) { - return results; + continue; + } + + foreach (ILyricParser parser in _lyricParsers) + { + var result = parser.ParseLyrics(lyrics); + if (result is not null) + { + return result; + } } } @@ -47,7 +59,7 @@ public class LyricManager : ILyricManager continue; } - if (provider.GetLyricFilePath(item.Path) is not null) + if (provider.HasLyrics(item)) { return true; } diff --git a/MediaBrowser.Providers/Lyric/TxtLyricParser.cs b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs new file mode 100644 index 000000000..706f13dbc --- /dev/null +++ b/MediaBrowser.Providers/Lyric/TxtLyricParser.cs @@ -0,0 +1,44 @@ +using System; +using System.IO; +using Jellyfin.Extensions; +using MediaBrowser.Controller.Lyrics; +using MediaBrowser.Controller.Resolvers; + +namespace MediaBrowser.Providers.Lyric; + +/// <summary> +/// TXT Lyric Parser. +/// </summary> +public class TxtLyricParser : ILyricParser +{ + private static readonly string[] _supportedMediaTypes = { ".lrc", ".elrc", ".txt" }; + private static readonly string[] _lineBreakCharacters = { "\r\n", "\r", "\n" }; + + /// <inheritdoc /> + public string Name => "TxtLyricProvider"; + + /// <summary> + /// Gets the priority. + /// </summary> + /// <value>The priority.</value> + public ResolverPriority Priority => ResolverPriority.Fifth; + + /// <inheritdoc /> + public LyricResponse? ParseLyrics(LyricFile lyrics) + { + if (!_supportedMediaTypes.Contains(Path.GetExtension(lyrics.Name.AsSpan()), StringComparison.OrdinalIgnoreCase)) + { + return null; + } + + string[] lyricTextLines = lyrics.Content.Split(_lineBreakCharacters, StringSplitOptions.None); + LyricLine[] lyricList = new LyricLine[lyricTextLines.Length]; + + for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++) + { + lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]); + } + + return new LyricResponse { Lyrics = lyricList }; + } +} diff --git a/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs b/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs deleted file mode 100644 index a9099d192..000000000 --- a/MediaBrowser.Providers/Lyric/TxtLyricProvider.cs +++ /dev/null @@ -1,60 +0,0 @@ -using System.Collections.Generic; -using System.IO; -using System.Threading.Tasks; -using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.Lyrics; -using MediaBrowser.Controller.Resolvers; - -namespace MediaBrowser.Providers.Lyric; - -/// <summary> -/// TXT Lyric Provider. -/// </summary> -public class TxtLyricProvider : ILyricProvider -{ - /// <inheritdoc /> - public string Name => "TxtLyricProvider"; - - /// <summary> - /// Gets the priority. - /// </summary> - /// <value>The priority.</value> - public ResolverPriority Priority => ResolverPriority.Second; - - /// <inheritdoc /> - public IReadOnlyCollection<string> SupportedMediaTypes { get; } = new[] { "lrc", "elrc", "txt" }; - - /// <summary> - /// Opens lyric file for the requested item, and processes it for API return. - /// </summary> - /// <param name="item">The item to to process.</param> - /// <returns>If provider can determine lyrics, returns a <see cref="LyricResponse"/>; otherwise, null.</returns> - public async Task<LyricResponse?> GetLyrics(BaseItem item) - { - string? lyricFilePath = this.GetLyricFilePath(item.Path); - - if (string.IsNullOrEmpty(lyricFilePath)) - { - return null; - } - - string[] lyricTextLines = await File.ReadAllLinesAsync(lyricFilePath).ConfigureAwait(false); - - if (lyricTextLines.Length == 0) - { - return null; - } - - LyricLine[] lyricList = new LyricLine[lyricTextLines.Length]; - - for (int lyricLineIndex = 0; lyricLineIndex < lyricTextLines.Length; lyricLineIndex++) - { - lyricList[lyricLineIndex] = new LyricLine(lyricTextLines[lyricLineIndex]); - } - - return new LyricResponse - { - Lyrics = lyricList - }; - } -} diff --git a/MediaBrowser.Providers/Manager/ImageSaver.cs b/MediaBrowser.Providers/Manager/ImageSaver.cs index e7c2cd255..d82716831 100644 --- a/MediaBrowser.Providers/Manager/ImageSaver.cs +++ b/MediaBrowser.Providers/Manager/ImageSaver.cs @@ -263,7 +263,11 @@ namespace MediaBrowser.Providers.Manager var fileStreamOptions = AsyncFile.WriteOptions; fileStreamOptions.Mode = FileMode.Create; - fileStreamOptions.PreallocationSize = source.Length; + if (source.CanSeek) + { + fileStreamOptions.PreallocationSize = source.Length; + } + var fs = new FileStream(path, fileStreamOptions); await using (fs.ConfigureAwait(false)) { diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 834ef29f5..e336c8825 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -12,7 +12,6 @@ using Jellyfin.Extensions; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Audio; -using MediaBrowser.Controller.Entities.TV; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; @@ -720,7 +719,7 @@ namespace MediaBrowser.Providers.Manager refreshResult.UpdateType |= ItemUpdateType.ImageUpdate; } - MergeData(localItem, temp, Array.Empty<MetadataField>(), !options.ReplaceAllMetadata, true); + MergeData(localItem, temp, Array.Empty<MetadataField>(), options.ReplaceAllMetadata, true); refreshResult.UpdateType |= ItemUpdateType.MetadataImport; // Only one local provider allowed per item diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index 5cb28402e..d0bb34d52 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -765,10 +765,12 @@ namespace MediaBrowser.Providers.Manager { try { - var results = await GetSearchResults(provider, searchInfo.SearchInfo, cancellationToken).ConfigureAwait(false); + var results = await provider.GetSearchResults(searchInfo.SearchInfo, cancellationToken).ConfigureAwait(false); foreach (var result in results) { + result.SearchProviderName = provider.Name; + var existingMatch = resultList.FirstOrDefault(i => i.ProviderIds.Any(p => string.Equals(result.GetProviderId(p.Key), p.Value, StringComparison.OrdinalIgnoreCase))); if (existingMatch is null) @@ -800,22 +802,6 @@ namespace MediaBrowser.Providers.Manager return resultList; } - private async Task<IEnumerable<RemoteSearchResult>> GetSearchResults<TLookupType>( - IRemoteSearchProvider<TLookupType> provider, - TLookupType searchInfo, - CancellationToken cancellationToken) - where TLookupType : ItemLookupInfo - { - var results = await provider.GetSearchResults(searchInfo, cancellationToken).ConfigureAwait(false); - - foreach (var item in results) - { - item.SearchProviderName = provider.Name; - } - - return results; - } - private IEnumerable<IExternalId> GetExternalIds(IHasProviderIds item) { return _externalIds.Where(i => @@ -957,6 +943,12 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void QueueRefresh(Guid itemId, MetadataRefreshOptions options, RefreshPriority priority) { + ArgumentNullException.ThrowIfNull(itemId); + if (itemId.Equals(default)) + { + throw new ArgumentException("Guid can't be empty", nameof(itemId)); + } + if (_disposed) { return; diff --git a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs index e1dcbc993..d81704227 100644 --- a/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs +++ b/MediaBrowser.Providers/MediaInfo/AudioFileProber.cs @@ -25,7 +25,7 @@ namespace MediaBrowser.Providers.MediaInfo /// <summary> /// Probes audio files for metadata. /// </summary> - public class AudioFileProber + public partial class AudioFileProber { // Default LUFS value for use with the web interface, at -18db gain will be 1(no db gain). private const float DefaultLUFSValue = -18; @@ -58,6 +58,9 @@ namespace MediaBrowser.Providers.MediaInfo _mediaSourceManager = mediaSourceManager; } + [GeneratedRegex(@"I:\s+(.*?)\s+LUFS")] + private static partial Regex LUFSRegex(); + /// <summary> /// Probes the specified item for metadata. /// </summary> @@ -104,7 +107,6 @@ namespace MediaBrowser.Providers.MediaInfo if (libraryOptions.EnableLUFSScan) { - string output; using (var process = new Process() { StartInfo = new ProcessStartInfo @@ -127,9 +129,10 @@ namespace MediaBrowser.Providers.MediaInfo throw; } - output = await process.StandardError.ReadToEndAsync(cancellationToken).ConfigureAwait(false); + using var reader = process.StandardError; + var output = await reader.ReadToEndAsync(cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); - MatchCollection split = Regex.Matches(output, @"I:\s+(.*?)\s+LUFS"); + MatchCollection split = LUFSRegex().Matches(output); if (split.Count != 0) { @@ -220,30 +223,39 @@ namespace MediaBrowser.Providers.MediaInfo var albumArtists = tags.AlbumArtists; foreach (var albumArtist in albumArtists) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrEmpty(albumArtist)) { - Name = albumArtist, - Type = PersonKind.AlbumArtist - }); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = albumArtist, + Type = PersonKind.AlbumArtist + }); + } } var performers = tags.Performers; foreach (var performer in performers) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrEmpty(performer)) { - Name = performer, - Type = PersonKind.Artist - }); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = performer, + Type = PersonKind.Artist + }); + } } foreach (var composer in tags.Composers) { - PeopleHelper.AddPerson(people, new PersonInfo + if (!string.IsNullOrEmpty(composer)) { - Name = composer, - Type = PersonKind.Composer - }); + PeopleHelper.AddPerson(people, new PersonInfo + { + Name = composer, + Type = PersonKind.Composer + }); + } } _libraryManager.UpdatePeople(audio, people); diff --git a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs index f58f5f7a3..0bfee07fd 100644 --- a/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/EmbeddedImageProvider.cs @@ -177,9 +177,11 @@ namespace MediaBrowser.Providers.MediaInfo var format = imageStream.Codec switch { + "bmp" => ImageFormat.Bmp, + "gif" => ImageFormat.Gif, "mjpeg" => ImageFormat.Jpg, "png" => ImageFormat.Png, - "gif" => ImageFormat.Gif, + "webp" => ImageFormat.Webp, _ => ImageFormat.Jpg }; @@ -202,16 +204,10 @@ namespace MediaBrowser.Providers.MediaInfo ? Path.GetExtension(attachmentStream.FileName) : MimeTypes.ToExtension(attachmentStream.MimeType); - if (string.IsNullOrEmpty(extension)) - { - extension = ".jpg"; - } - ImageFormat format = extension switch { ".bmp" => ImageFormat.Bmp, ".gif" => ImageFormat.Gif, - ".jpg" => ImageFormat.Jpg, ".png" => ImageFormat.Png, ".webp" => ImageFormat.Webp, _ => ImageFormat.Jpg diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 213639371..35ea04d21 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -1,11 +1,8 @@ -#nullable disable - #pragma warning disable CA1068, CS1591 using System; using System.Collections.Generic; using System.Globalization; -using System.IO; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -83,9 +80,9 @@ namespace MediaBrowser.Providers.MediaInfo CancellationToken cancellationToken) where T : Video { - BlurayDiscInfo blurayDiscInfo = null; + BlurayDiscInfo? blurayDiscInfo = null; - Model.MediaInfo.MediaInfo mediaInfoResult = null; + Model.MediaInfo.MediaInfo? mediaInfoResult = null; if (!item.IsShortcut || options.EnableRemoteContentProbe) { @@ -131,7 +128,7 @@ namespace MediaBrowser.Providers.MediaInfo var m2ts = _mediaEncoder.GetPrimaryPlaylistM2tsFiles(item.Path); // Return if no playable .m2ts files are found - if (blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0) + if (blurayDiscInfo is null || blurayDiscInfo.Files.Length == 0 || m2ts.Count == 0) { _logger.LogError("No playable .m2ts files found in Blu-ray structure, skipping FFprobe."); return ItemUpdateType.MetadataImport; @@ -192,16 +189,14 @@ namespace MediaBrowser.Providers.MediaInfo protected async Task Fetch( Video video, CancellationToken cancellationToken, - Model.MediaInfo.MediaInfo mediaInfo, - BlurayDiscInfo blurayInfo, + Model.MediaInfo.MediaInfo? mediaInfo, + BlurayDiscInfo? blurayInfo, MetadataRefreshOptions options) { - List<MediaStream> mediaStreams; + List<MediaStream> mediaStreams = new List<MediaStream>(); IReadOnlyList<MediaAttachment> mediaAttachments; ChapterInfo[] chapters; - mediaStreams = new List<MediaStream>(); - // Add external streams before adding the streams from the file to preserve stream IDs on remote videos await AddExternalSubtitlesAsync(video, mediaStreams, options, cancellationToken).ConfigureAwait(false); @@ -221,18 +216,6 @@ namespace MediaBrowser.Providers.MediaInfo video.TotalBitrate = mediaInfo.Bitrate; video.RunTimeTicks = mediaInfo.RunTimeTicks; video.Size = mediaInfo.Size; - - if (video.VideoType == VideoType.VideoFile) - { - var extension = (Path.GetExtension(video.Path) ?? string.Empty).TrimStart('.'); - - video.Container = extension; - } - else - { - video.Container = null; - } - video.Container = mediaInfo.Container; chapters = mediaInfo.Chapters ?? Array.Empty<ChapterInfo>(); @@ -243,8 +226,7 @@ namespace MediaBrowser.Providers.MediaInfo } else { - var currentMediaStreams = video.GetMediaStreams(); - foreach (var mediaStream in currentMediaStreams) + foreach (var mediaStream in video.GetMediaStreams()) { if (!mediaStream.IsExternal) { @@ -295,8 +277,8 @@ namespace MediaBrowser.Providers.MediaInfo _itemRepo.SaveMediaAttachments(video.Id, mediaAttachments, cancellationToken); } - if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh || - options.MetadataRefreshMode == MetadataRefreshMode.Default) + if (options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh + || options.MetadataRefreshMode == MetadataRefreshMode.Default) { if (_config.Configuration.DummyChapterDuration > 0 && chapters.Length == 0 && mediaStreams.Any(i => i.Type == MediaStreamType.Video)) { @@ -321,11 +303,11 @@ namespace MediaBrowser.Providers.MediaInfo { for (int i = 0; i < chapters.Length; i++) { - string name = chapters[i].Name; + string? name = chapters[i].Name; // Check if the name is empty and/or if the name is a time // Some ripping programs do that. - if (string.IsNullOrWhiteSpace(name) || - TimeSpan.TryParse(name, out _)) + if (string.IsNullOrWhiteSpace(name) + || TimeSpan.TryParse(name, out _)) { chapters[i].Name = string.Format( CultureInfo.InvariantCulture, @@ -384,23 +366,18 @@ namespace MediaBrowser.Providers.MediaInfo // Use the ffprobe values if these are empty if (videoStream is not null) { - videoStream.BitRate = IsEmpty(videoStream.BitRate) ? currentBitRate : videoStream.BitRate; - videoStream.Width = IsEmpty(videoStream.Width) ? currentWidth : videoStream.Width; - videoStream.Height = IsEmpty(videoStream.Height) ? currentHeight : videoStream.Height; + videoStream.BitRate = videoStream.BitRate.GetValueOrDefault() == 0 ? currentBitRate : videoStream.BitRate; + videoStream.Width = videoStream.Width.GetValueOrDefault() == 0 ? currentWidth : videoStream.Width; + videoStream.Height = videoStream.Height.GetValueOrDefault() == 0 ? currentHeight : videoStream.Height; } } - private bool IsEmpty(int? num) - { - return !num.HasValue || num.Value == 0; - } - /// <summary> /// Gets information about the longest playlist on a bdrom. /// </summary> /// <param name="path">The path.</param> /// <returns>VideoStream.</returns> - private BlurayDiscInfo GetBDInfo(string path) + private BlurayDiscInfo? GetBDInfo(string path) { ArgumentException.ThrowIfNullOrEmpty(path); @@ -527,32 +504,29 @@ namespace MediaBrowser.Providers.MediaInfo private void FetchPeople(Video video, Model.MediaInfo.MediaInfo data, MetadataRefreshOptions options) { - var replaceData = options.ReplaceAllMetadata; + if (video.IsLocked + || video.LockedFields.Contains(MetadataField.Cast) + || data.People.Length == 0) + { + return; + } - if (!video.IsLocked && !video.LockedFields.Contains(MetadataField.Cast)) + if (options.ReplaceAllMetadata || _libraryManager.GetPeople(video).Count == 0) { - if (replaceData || _libraryManager.GetPeople(video).Count == 0) - { - var people = new List<PersonInfo>(); + var people = new List<PersonInfo>(); - foreach (var person in data.People) + foreach (var person in data.People) + { + PeopleHelper.AddPerson(people, new PersonInfo { - PeopleHelper.AddPerson(people, new PersonInfo - { - Name = person.Name, - Type = person.Type, - Role = person.Role - }); - } - - _libraryManager.UpdatePeople(video, people); + Name = person.Name, + Type = person.Type, + Role = person.Role + }); } - } - } - private SubtitleOptions GetOptions() - { - return _config.GetConfiguration<SubtitleOptions>("subtitles"); + _libraryManager.UpdatePeople(video, people); + } } /// <summary> @@ -575,7 +549,7 @@ namespace MediaBrowser.Providers.MediaInfo var enableSubtitleDownloading = options.MetadataRefreshMode == MetadataRefreshMode.Default || options.MetadataRefreshMode == MetadataRefreshMode.FullRefresh; - var subtitleOptions = GetOptions(); + var subtitleOptions = _config.GetConfiguration<SubtitleOptions>("subtitles"); var libraryOptions = _libraryManager.GetLibraryOptions(video); @@ -659,9 +633,9 @@ namespace MediaBrowser.Providers.MediaInfo /// </summary> /// <param name="video">The video.</param> /// <returns>An array of dummy chapters.</returns> - private ChapterInfo[] CreateDummyChapters(Video video) + internal ChapterInfo[] CreateDummyChapters(Video video) { - var runtime = video.RunTimeTicks ?? 0; + var runtime = video.RunTimeTicks.GetValueOrDefault(); // Only process files with a runtime higher than 0 and lower than 12h. The latter are likely corrupted. if (runtime < 0 || runtime > TimeSpan.FromHours(12).Ticks) @@ -671,30 +645,30 @@ namespace MediaBrowser.Providers.MediaInfo CultureInfo.InvariantCulture, "{0} has an invalid runtime of {1} minutes", video.Name, - TimeSpan.FromTicks(runtime).Minutes)); + TimeSpan.FromTicks(runtime).TotalMinutes)); } long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks; - if (runtime > dummyChapterDuration) + if (runtime <= dummyChapterDuration) { - int chapterCount = (int)(runtime / dummyChapterDuration); - var chapters = new ChapterInfo[chapterCount]; + return Array.Empty<ChapterInfo>(); + } - long currentChapterTicks = 0; - for (int i = 0; i < chapterCount; i++) - { - chapters[i] = new ChapterInfo - { - StartPositionTicks = currentChapterTicks - }; + int chapterCount = (int)(runtime / dummyChapterDuration); + var chapters = new ChapterInfo[chapterCount]; - currentChapterTicks += dummyChapterDuration; - } + long currentChapterTicks = 0; + for (int i = 0; i < chapterCount; i++) + { + chapters[i] = new ChapterInfo + { + StartPositionTicks = currentChapterTicks + }; - return chapters; + currentChapterTicks += dummyChapterDuration; } - return Array.Empty<ChapterInfo>(); + return chapters; } } } diff --git a/MediaBrowser.Providers/Movies/ImdbExternalId.cs b/MediaBrowser.Providers/Movies/ImdbExternalId.cs index d00f37db5..a8d74aa0b 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 1bb5e1ea8..8151ab471 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/Plugins/AudioDb/AudioDbAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumExternalId.cs index 3a400575b..138cfef19 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/AudioDbAlbumProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs index 55e2474a5..daad9706c 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbAlbumProvider.cs @@ -176,17 +176,12 @@ namespace MediaBrowser.Providers.Plugins.AudioDb Directory.CreateDirectory(Path.GetDirectoryName(path)); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + var fs = new FileStream(path, fileStreamOptions); + await using (fs.ConfigureAwait(false)) { - var fileStreamOptions = AsyncFile.WriteOptions; - fileStreamOptions.Mode = FileMode.Create; - fileStreamOptions.PreallocationSize = stream.Length; - var xmlFileStream = new FileStream(path, fileStreamOptions); - await using (xmlFileStream.ConfigureAwait(false)) - { - await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); - } + await response.Content.CopyToAsync(fs, cancellationToken).ConfigureAwait(false); } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistExternalId.cs index b9e57eb26..8aceb48c0 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/AudioDbArtistProvider.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs index f3385b3a9..92742b1aa 100644 --- a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbArtistProvider.cs @@ -154,20 +154,15 @@ namespace MediaBrowser.Providers.Plugins.AudioDb using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); response.EnsureSuccessStatusCode(); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); + Directory.CreateDirectory(Path.GetDirectoryName(path)); + + var fileStreamOptions = AsyncFile.WriteOptions; + fileStreamOptions.Mode = FileMode.Create; + var xmlFileStream = new FileStream(path, fileStreamOptions); + await using (xmlFileStream.ConfigureAwait(false)) { - var path = GetArtistInfoPath(_config.ApplicationPaths, musicBrainzId); - Directory.CreateDirectory(Path.GetDirectoryName(path)); - - var fileStreamOptions = AsyncFile.WriteOptions; - fileStreamOptions.Mode = FileMode.Create; - fileStreamOptions.PreallocationSize = stream.Length; - var xmlFileStream = new FileStream(path, fileStreamOptions); - await using (xmlFileStream.ConfigureAwait(false)) - { - await stream.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); - } + await response.Content.CopyToAsync(xmlFileStream, cancellationToken).ConfigureAwait(false); } } diff --git a/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/AudioDb/AudioDbOtherAlbumExternalId.cs index f8f6253ff..014481da2 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 fd598c918..787539104 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/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs index f7850781e..825fe32fa 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzAlbumArtistExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs index a9d4472e7..b7d53984c 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzAlbumExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Album; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs index b89e67270..b3f001618 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzArtistExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is MusicArtist; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs index fdaa5574f..a0a922293 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzOtherArtistExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs index 0baab9955..47b6d6963 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -20,7 +20,7 @@ public class MusicBrainzReleaseGroupExternalId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio or MusicAlbum; diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs index 5c974c411..cb4345660 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -20,7 +20,7 @@ public class MusicBrainzTrackId : IExternalId public ExternalIdMediaType? Type => ExternalIdMediaType.Track; /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; + public string UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Audio; diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs index e4bb4eaea..e84f1359b 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbItemProvider.cs @@ -8,6 +8,7 @@ using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Json; using System.Text; using System.Text.Json; using System.Threading; @@ -137,31 +138,27 @@ namespace MediaBrowser.Providers.Plugins.Omdb var url = OmdbProvider.GetOmdbUrl(urlQuery.ToString()); using var response = await _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken).ConfigureAwait(false); - var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - await using (stream.ConfigureAwait(false)) + if (isSearch) { - if (isSearch) + var searchResultList = await response.Content.ReadFromJsonAsync<SearchResultList>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (searchResultList?.Search is not null) { - var searchResultList = await JsonSerializer.DeserializeAsync<SearchResultList>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (searchResultList?.Search is not null) + var resultCount = searchResultList.Search.Count; + var result = new RemoteSearchResult[resultCount]; + for (var i = 0; i < resultCount; i++) { - var resultCount = searchResultList.Search.Count; - var result = new RemoteSearchResult[resultCount]; - for (var i = 0; i < resultCount; i++) - { - result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd); - } - - return result; + result[i] = ResultToMetadataResult(searchResultList.Search[i], searchInfo, indexNumberEnd); } + + return result; } - else + } + else + { + var result = await response.Content.ReadFromJsonAsync<SearchResult>(_jsonOptions, cancellationToken).ConfigureAwait(false); + if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase)) { - var result = await JsonSerializer.DeserializeAsync<SearchResult>(stream, _jsonOptions, cancellationToken).ConfigureAwait(false); - if (string.Equals(result?.Response, "true", StringComparison.OrdinalIgnoreCase)) - { - return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) }; - } + return new[] { ResultToMetadataResult(result, searchInfo, indexNumberEnd) }; } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs index 0e768bb83..d453a4ff4 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/Movies/TmdbMovieExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs index 38d2c5c69..6d6032e8f 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; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "movie/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs index 027399aec..d26a70028 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; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "person/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index df04cb2e7..5f2d7909a 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; /// <inheritdoc /> - public string? UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; + public string UrlFormatString => TmdbUtils.BaseTmdbUrl + "tv/{0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 516eee758..a7c93ac4c 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -11,10 +11,8 @@ namespace MediaBrowser.Providers.Plugins.Tmdb /// <summary> /// Utilities for the TMDb provider. /// </summary> - public static class TmdbUtils + public static partial class TmdbUtils { - private static readonly Regex _nonWords = new(@"[\W_]+", RegexOptions.Compiled); - /// <summary> /// URL of the TMDb instance to use. /// </summary> @@ -50,6 +48,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb PersonKind.Producer }; + [GeneratedRegex(@"[\W_]+")] + private static partial Regex NonWordRegex(); + /// <summary> /// Cleans the name according to TMDb requirements. /// </summary> @@ -58,7 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb public static string CleanName(string name) { // TMDb expects a space separated list of words make sure that is the case - return _nonWords.Replace(name, " "); + return NonWordRegex().Replace(name, " "); } /// <summary> diff --git a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs index 0c01c5031..87fd2a3cd 100644 --- a/MediaBrowser.Providers/Subtitles/SubtitleManager.cs +++ b/MediaBrowser.Providers/Subtitles/SubtitleManager.cs @@ -200,6 +200,11 @@ namespace MediaBrowser.Providers.Subtitles saveFileName += ".forced"; } + if (response.IsHearingImpaired) + { + saveFileName += ".sdh"; + } + saveFileName += "." + response.Format.ToLowerInvariant(); if (saveInMediaFolder) diff --git a/MediaBrowser.Providers/TV/SeriesMetadataService.cs b/MediaBrowser.Providers/TV/SeriesMetadataService.cs index 9016e5de0..e01c0f483 100644 --- a/MediaBrowser.Providers/TV/SeriesMetadataService.cs +++ b/MediaBrowser.Providers/TV/SeriesMetadataService.cs @@ -1,5 +1,6 @@ #pragma warning disable CS1591 +using System; using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -213,11 +214,10 @@ namespace MediaBrowser.Providers.TV { // Null season numbers will have a 'dummy' season created because seasons are always required. var existingSeason = seasons.FirstOrDefault(i => i.IndexNumber == seasonNumber); - string? seasonName = null; - if (seasonNumber.HasValue && seasonNames.TryGetValue(seasonNumber.Value, out var tmp)) + if (!seasonNumber.HasValue || !seasonNames.TryGetValue(seasonNumber.Value, out var seasonName)) { - seasonName = tmp; + seasonName = GetValidSeasonNameForSeries(series, null, seasonNumber); } if (existingSeason is null) @@ -225,9 +225,9 @@ namespace MediaBrowser.Providers.TV var season = await CreateSeasonAsync(series, seasonName, seasonNumber, cancellationToken).ConfigureAwait(false); series.AddChild(season); } - else + else if (!string.Equals(existingSeason.Name, seasonName, StringComparison.Ordinal)) { - existingSeason.Name = GetValidSeasonNameForSeries(series, seasonName, seasonNumber); + existingSeason.Name = seasonName; await existingSeason.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, cancellationToken).ConfigureAwait(false); } } @@ -247,7 +247,6 @@ namespace MediaBrowser.Providers.TV int? seasonNumber, CancellationToken cancellationToken) { - seasonName = GetValidSeasonNameForSeries(series, seasonName, seasonNumber); Logger.LogInformation("Creating Season {SeasonName} entry for {SeriesName}", seasonName, series.Name); var season = new Season diff --git a/MediaBrowser.Providers/TV/Zap2ItExternalId.cs b/MediaBrowser.Providers/TV/Zap2ItExternalId.cs index 087e4036a..3cb18e424 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; /// <inheritdoc /> - public string? UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; + public string UrlFormatString => "http://tvlistings.zap2it.com/overview.html?programSeriesId={0}"; /// <inheritdoc /> public bool Supports(IHasProviderIds item) => item is Series; diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index 5b68924ac..70e5b66c1 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -13,6 +13,7 @@ using MediaBrowser.Common.Providers; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -159,7 +160,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers // Find last closing Tag // Need to do this in two steps to account for random > characters after the closing xml - var index = xml.LastIndexOf(@"</", StringComparison.Ordinal); + var index = xml.LastIndexOf("</", StringComparison.Ordinal); // If closing tag exists, move to end of Tag if (index != -1) @@ -261,158 +262,84 @@ namespace MediaBrowser.XbmcMetadata.Parsers protected virtual void FetchDataFromXmlNode(XmlReader reader, MetadataResult<T> itemResult) { var item = itemResult.Item; - var nfoConfiguration = _config.GetNfoConfiguration(); - UserItemData? userData = null; + UserItemData? userData; switch (reader.Name) { - // DateCreated case "dateadded": + if (reader.TryReadDateTime(out var dateCreated)) { - var val = reader.ReadElementContentAsString(); - - if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) - { - item.DateCreated = added; - } - else - { - Logger.LogWarning("Invalid Added value found: {Value}", val); - } - - break; + item.DateCreated = dateCreated; } + break; case "originaltitle": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrEmpty(val)) - { - item.OriginalTitle = val; - } - - break; - } - + item.OriginalTitle = reader.ReadNormalizedString(); + break; case "name": case "title": case "localtitle": - item.Name = reader.ReadElementContentAsString(); + item.Name = reader.ReadNormalizedString(); break; - case "sortname": - item.SortName = reader.ReadElementContentAsString(); + item.SortName = reader.ReadNormalizedString(); break; - case "criticrating": + var criticRatingText = reader.ReadElementContentAsString(); + if (float.TryParse(criticRatingText, CultureInfo.InvariantCulture, out var value)) { - var text = reader.ReadElementContentAsString(); - - if (float.TryParse(text, CultureInfo.InvariantCulture, out var value)) - { - item.CriticRating = value; - } - - break; + item.CriticRating = value; } + break; case "sorttitle": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.ForcedSortName = val; - } - - break; - } - + item.ForcedSortName = reader.ReadNormalizedString(); + break; case "biography": case "plot": case "review": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.Overview = val; - } - - break; - } - + item.Overview = reader.ReadNormalizedString(); + break; case "language": - { - var val = reader.ReadElementContentAsString(); - - item.PreferredMetadataLanguage = val; - - break; - } - + item.PreferredMetadataLanguage = reader.ReadNormalizedString(); + break; case "watched": + var played = reader.ReadElementContentAsBoolean(); + if (!string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) { - var val = reader.ReadElementContentAsBoolean(); - - if (!string.IsNullOrWhiteSpace(nfoConfiguration.UserId)) - { - var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); - userData = _userDataManager.GetUserData(user, item); - userData.Played = val; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); - } - - break; + var user = _userManager.GetUserById(Guid.Parse(nfoConfiguration.UserId)); + userData = _userDataManager.GetUserData(user, item); + userData.Played = played; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); } + break; case "playcount": + if (reader.TryReadInt(out var count) + && Guid.TryParse(nfoConfiguration.UserId, out var playCountUserId)) { - var val = reader.ReadElementContentAsString(); - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var count) - && Guid.TryParse(nfoConfiguration.UserId, out var guid)) - { - var user = _userManager.GetUserById(guid); - userData = _userDataManager.GetUserData(user, item); - userData.PlayCount = count; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); - } - - break; + var user = _userManager.GetUserById(playCountUserId); + userData = _userDataManager.GetUserData(user, item); + userData.PlayCount = count; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); } + break; case "lastplayed": + if (reader.TryReadDateTime(out var lastPlayed) + && Guid.TryParse(nfoConfiguration.UserId, out var lastPlayedUserId)) { - var val = reader.ReadElementContentAsString(); - if (Guid.TryParse(nfoConfiguration.UserId, out var guid)) - { - if (DateTime.TryParse(val, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var added)) - { - var user = _userManager.GetUserById(guid); - userData = _userDataManager.GetUserData(user, item); - userData.LastPlayedDate = added; - _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); - } - else - { - Logger.LogWarning("Invalid lastplayed value found: {Value}", val); - } - } - - break; + var user = _userManager.GetUserById(lastPlayedUserId); + userData = _userDataManager.GetUserData(user, item); + userData.LastPlayedDate = lastPlayed; + _userDataManager.SaveUserData(user, item, userData, UserDataSaveReason.Import, CancellationToken.None); } + break; case "countrycode": - { - var val = reader.ReadElementContentAsString(); - - item.PreferredMetadataCountryCode = val; - - break; - } - + item.PreferredMetadataCountryCode = reader.ReadNormalizedString(); + break; case "lockedfields": { var val = reader.ReadElementContentAsString(); @@ -434,9 +361,8 @@ namespace MediaBrowser.XbmcMetadata.Parsers } case "tagline": - item.Tagline = reader.ReadElementContentAsString(); + item.Tagline = reader.ReadNormalizedString(); break; - case "country": { var val = reader.ReadElementContentAsString(); @@ -453,94 +379,45 @@ namespace MediaBrowser.XbmcMetadata.Parsers } case "mpaa": - { - var rating = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(rating)) - { - item.OfficialRating = rating; - } - - break; - } - + item.OfficialRating = reader.ReadNormalizedString(); + break; case "customrating": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.CustomRating = val; - } - - break; - } - + item.CustomRating = reader.ReadNormalizedString(); + break; case "runtime": + var runtimeText = reader.ReadElementContentAsString(); + if (int.TryParse(runtimeText.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) { - var text = reader.ReadElementContentAsString(); - - if (int.TryParse(text.AsSpan().LeftPart(' '), NumberStyles.Integer, CultureInfo.InvariantCulture, out var runtime)) - { - item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; - } - - break; + item.RunTimeTicks = TimeSpan.FromMinutes(runtime).Ticks; } + break; case "aspectratio": + var aspectRatio = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(aspectRatio) && item is IHasAspectRatio hasAspectRatio) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val) - && item is IHasAspectRatio hasAspectRatio) - { - hasAspectRatio.AspectRatio = val; - } - - break; + hasAspectRatio.AspectRatio = aspectRatio; } + break; case "lockdata": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.IsLocked = string.Equals("true", val, StringComparison.OrdinalIgnoreCase); - } - - break; - } - + item.IsLocked = string.Equals(reader.ReadElementContentAsString(), "true", StringComparison.OrdinalIgnoreCase); + break; case "studio": + var studio = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(studio)) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.AddStudio(val); - } - - break; + item.AddStudio(studio); } + break; case "director": + foreach (var director in reader.GetPersonArray(PersonKind.Director)) { - var val = reader.ReadElementContentAsString(); - foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Director })) - { - if (string.IsNullOrWhiteSpace(p.Name)) - { - continue; - } - - itemResult.AddPerson(p); - } - - break; + itemResult.AddPerson(director); } + break; case "credits": { var val = reader.ReadElementContentAsString(); @@ -565,141 +442,76 @@ namespace MediaBrowser.XbmcMetadata.Parsers } case "writer": + foreach (var writer in reader.GetPersonArray(PersonKind.Writer)) { - var val = reader.ReadElementContentAsString(); - foreach (var p in SplitNames(val).Select(v => new PersonInfo { Name = v.Trim(), Type = PersonKind.Writer })) - { - if (string.IsNullOrWhiteSpace(p.Name)) - { - continue; - } - - itemResult.AddPerson(p); - } - - break; + itemResult.AddPerson(writer); } + break; case "actor": + var person = reader.GetPersonFromXmlNode(); + if (person is not null) { - if (!reader.IsEmptyElement) - { - using (var subtree = reader.ReadSubtree()) - { - var person = GetPersonFromXmlNode(subtree); - - if (!string.IsNullOrWhiteSpace(person.Name)) - { - itemResult.AddPerson(person); - } - } - } - else - { - reader.Read(); - } - - break; + itemResult.AddPerson(person); } + break; case "trailer": + var trailer = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(trailer)) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - val = val.Replace("plugin://plugin.video.youtube/?action=play_video&videoid=", BaseNfoSaver.YouTubeWatchUrl, StringComparison.OrdinalIgnoreCase); - - item.AddTrailerUrl(val); - } - - break; + item.AddTrailerUrl(trailer.Replace( + "plugin://plugin.video.youtube/?action=play_video&videoid=", + BaseNfoSaver.YouTubeWatchUrl, + StringComparison.OrdinalIgnoreCase)); } + break; case "displayorder": + var displayOrder = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(displayOrder) && item is IHasDisplayOrder hasDisplayOrder) { - var val = reader.ReadElementContentAsString(); - - if (item is IHasDisplayOrder hasDisplayOrder && !string.IsNullOrWhiteSpace(val)) - { - hasDisplayOrder.DisplayOrder = val; - } - - break; + hasDisplayOrder.DisplayOrder = displayOrder; } + break; case "year": + if (reader.TryReadInt(out var productionYear) && productionYear > 1850) { - var val = reader.ReadElementContentAsString(); - - if (int.TryParse(val, out var productionYear) && productionYear > 1850) - { - item.ProductionYear = productionYear; - } - - break; + item.ProductionYear = productionYear; } + break; case "rating": + var rating = reader.ReadElementContentAsString().Replace(',', '.'); + // All external meta is saving this as '.' for decimal I believe...but just to be sure + if (float.TryParse(rating, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var communityRating)) { - var rating = reader.ReadElementContentAsString(); - - // All external meta is saving this as '.' for decimal I believe...but just to be sure - if (float.TryParse(rating.Replace(',', '.'), NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var val)) - { - item.CommunityRating = val; - } - - break; + item.CommunityRating = communityRating; } + break; case "ratings": - { - if (!reader.IsEmptyElement) - { - using var subtree = reader.ReadSubtree(); - FetchFromRatingsNode(subtree, item); - } - else - { - reader.Read(); - } - - break; - } - + FetchFromRatingsNode(reader, item); + break; case "aired": case "formed": case "premiered": case "releasedate": + if (reader.TryReadDateTimeExact(nfoConfiguration.ReleaseDateFormat, out var releaseDate)) { - var formatString = nfoConfiguration.ReleaseDateFormat; - - var val = reader.ReadElementContentAsString(); - - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) - { - item.PremiereDate = date; - item.ProductionYear = date.Year; - } - - break; + item.PremiereDate = releaseDate; + item.ProductionYear = releaseDate.Year; } + break; case "enddate": + if (reader.TryReadDateTimeExact(nfoConfiguration.ReleaseDateFormat, out var endDate)) { - var formatString = nfoConfiguration.ReleaseDateFormat; - - var val = reader.ReadElementContentAsString(); - - if (DateTime.TryParseExact(val, formatString, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal | DateTimeStyles.AdjustToUniversal, out var date) && date.Year > 1850) - { - item.EndDate = date; - } - - break; + item.EndDate = endDate; } + break; case "genre": { var val = reader.ReadElementContentAsString(); @@ -721,57 +533,34 @@ namespace MediaBrowser.XbmcMetadata.Parsers case "style": case "tag": + var tag = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(tag)) { - var val = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(val)) - { - item.AddTag(val); - } - - break; + item.AddTag(tag); } + break; case "fileinfo": - { - if (!reader.IsEmptyElement) - { - using (var subtree = reader.ReadSubtree()) - { - FetchFromFileInfoNode(subtree, item); - } - } - else - { - reader.Read(); - } - - break; - } - + FetchFromFileInfoNode(reader, item); + break; case "uniqueid": + if (reader.IsEmptyElement) { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - var provider = reader.GetAttribute("type"); - var id = reader.ReadElementContentAsString(); - if (!string.IsNullOrWhiteSpace(provider) && !string.IsNullOrWhiteSpace(id)) - { - item.SetProviderId(provider, id); - } - + reader.Read(); break; } - case "thumb": + var provider = reader.GetAttribute("type"); + var providerId = reader.ReadElementContentAsString(); + if (!string.IsNullOrWhiteSpace(provider) && !string.IsNullOrWhiteSpace(providerId)) { - FetchThumbNode(reader, itemResult, "thumb"); - break; + item.SetProviderId(provider, providerId); } + break; + case "thumb": + FetchThumbNode(reader, itemResult, "thumb"); + break; case "fanart": { if (reader.IsEmptyElement) @@ -876,242 +665,188 @@ namespace MediaBrowser.XbmcMetadata.Parsers } } - private void FetchFromFileInfoNode(XmlReader reader, T item) + private void FetchFromFileInfoNode(XmlReader parentReader, T item) { + if (parentReader.IsEmptyElement) + { + parentReader.Read(); + return; + } + + using var reader = parentReader.ReadSubtree(); reader.MoveToContent(); reader.Read(); // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - if (reader.NodeType == XmlNodeType.Element) + if (reader.NodeType != XmlNodeType.Element) { - switch (reader.Name) - { - case "streamdetails": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subtree = reader.ReadSubtree()) - { - FetchFromStreamDetailsNode(subtree, item); - } - - break; - } - - default: - reader.Skip(); - break; - } + reader.Read(); + continue; } - else + + switch (reader.Name) { - reader.Read(); + case "streamdetails": + FetchFromStreamDetailsNode(reader, item); + break; + default: + reader.Skip(); + break; } } } - private void FetchFromStreamDetailsNode(XmlReader reader, T item) + private void FetchFromStreamDetailsNode(XmlReader parentReader, T item) { + if (parentReader.IsEmptyElement) + { + parentReader.Read(); + return; + } + + using var reader = parentReader.ReadSubtree(); reader.MoveToContent(); reader.Read(); // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - if (reader.NodeType == XmlNodeType.Element) + if (reader.NodeType != XmlNodeType.Element) { - switch (reader.Name) - { - case "video": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subtree = reader.ReadSubtree()) - { - FetchFromVideoNode(subtree, item); - } - - break; - } - - case "subtitle": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using (var subtree = reader.ReadSubtree()) - { - FetchFromSubtitleNode(subtree, item); - } - - break; - } - - default: - reader.Skip(); - break; - } + reader.Read(); + continue; } - else + + switch (reader.Name) { - reader.Read(); + case "video": + FetchFromVideoNode(reader, item); + break; + case "subtitle": + FetchFromSubtitleNode(reader, item); + break; + default: + reader.Skip(); + break; } } } - private void FetchFromVideoNode(XmlReader reader, T item) + private void FetchFromVideoNode(XmlReader parentReader, T item) { + if (parentReader.IsEmptyElement) + { + parentReader.Read(); + return; + } + + using var reader = parentReader.ReadSubtree(); reader.MoveToContent(); reader.Read(); // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - if (reader.NodeType == XmlNodeType.Element) + if (reader.NodeType != XmlNodeType.Element || item is not Video video) { - switch (reader.Name) - { - case "format3d": - { - var val = reader.ReadElementContentAsString(); - - var video = item as Video; - - if (video is not null) - { - if (string.Equals("HSBS", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.HalfSideBySide; - } - else if (string.Equals("HTAB", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.HalfTopAndBottom; - } - else if (string.Equals("FTAB", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.FullTopAndBottom; - } - else if (string.Equals("FSBS", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.FullSideBySide; - } - else if (string.Equals("MVC", val, StringComparison.OrdinalIgnoreCase)) - { - video.Video3DFormat = Video3DFormat.MVC; - } - } - - break; - } - - case "aspect": - { - var val = reader.ReadElementContentAsString(); - - if (item is Video video) - { - video.AspectRatio = val; - } - - break; - } - - case "width": - { - var val = reader.ReadElementContentAsInt(); - - if (item is Video video) - { - video.Width = val; - } - - break; - } - - case "height": - { - var val = reader.ReadElementContentAsInt(); - - if (item is Video video) - { - video.Height = val; - } - - break; - } - - case "durationinseconds": - { - var val = reader.ReadElementContentAsInt(); - - if (item is Video video) - { - video.RunTimeTicks = new TimeSpan(0, 0, val).Ticks; - } - - break; - } - - default: - reader.Skip(); - break; - } + reader.Read(); + continue; } - else + + switch (reader.Name) { - reader.Read(); + case "format3d": + var format = reader.ReadElementContentAsString(); + if (string.Equals("HSBS", format, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.HalfSideBySide; + } + else if (string.Equals("HTAB", format, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.HalfTopAndBottom; + } + else if (string.Equals("FTAB", format, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.FullTopAndBottom; + } + else if (string.Equals("FSBS", format, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.FullSideBySide; + } + else if (string.Equals("MVC", format, StringComparison.OrdinalIgnoreCase)) + { + video.Video3DFormat = Video3DFormat.MVC; + } + + break; + case "aspect": + video.AspectRatio = reader.ReadNormalizedString(); + break; + case "width": + video.Width = reader.ReadElementContentAsInt(); + break; + case "height": + video.Height = reader.ReadElementContentAsInt(); + break; + case "durationinseconds": + video.RunTimeTicks = new TimeSpan(0, 0, reader.ReadElementContentAsInt()).Ticks; + break; + default: + reader.Skip(); + break; } } } - private void FetchFromSubtitleNode(XmlReader reader, T item) + private void FetchFromSubtitleNode(XmlReader parentReader, T item) { + if (parentReader.IsEmptyElement) + { + parentReader.Read(); + return; + } + + using var reader = parentReader.ReadSubtree(); reader.MoveToContent(); reader.Read(); // Loop through each element while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - if (reader.NodeType == XmlNodeType.Element) + if (reader.NodeType != XmlNodeType.Element) { - switch (reader.Name) - { - case "language": - _ = reader.ReadElementContentAsString(); - if (item is Video video) - { - video.HasSubtitles = true; - } - - break; - - default: - reader.Skip(); - break; - } + reader.Read(); + continue; } - else + + switch (reader.Name) { - reader.Read(); + case "language": + _ = reader.ReadElementContentAsString(); + if (item is Video video) + { + video.HasSubtitles = true; + } + + break; + default: + reader.Skip(); + break; } } } - private void FetchFromRatingsNode(XmlReader reader, T item) + private void FetchFromRatingsNode(XmlReader parentReader, T item) { + if (parentReader.IsEmptyElement) + { + parentReader.Read(); + return; + } + + using var reader = parentReader.ReadSubtree(); reader.MoveToContent(); reader.Read(); @@ -1196,102 +931,6 @@ namespace MediaBrowser.XbmcMetadata.Parsers } } - /// <summary> - /// Gets the persons from a XML node. - /// </summary> - /// <param name="reader">The <see cref="XmlReader"/>.</param> - /// <returns>IEnumerable{PersonInfo}.</returns> - private PersonInfo GetPersonFromXmlNode(XmlReader reader) - { - var name = string.Empty; - var type = PersonKind.Actor; // If type is not specified assume actor - var role = string.Empty; - int? sortOrder = null; - string? imageUrl = null; - - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name": - name = reader.ReadElementContentAsString(); - break; - - case "role": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - role = val; - } - - break; - } - - case "type": - { - var val = reader.ReadElementContentAsString(); - if (!Enum.TryParse(val, true, out type)) - { - type = PersonKind.Actor; - } - - break; - } - - case "order": - case "sortorder": - { - var val = reader.ReadElementContentAsString(); - - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var intVal)) - { - sortOrder = intVal; - } - - break; - } - - case "thumb": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - imageUrl = val; - } - - break; - } - - default: - reader.Skip(); - break; - } - } - else - { - reader.Read(); - } - } - - return new PersonInfo - { - Name = name.Trim(), - Role = role, - Type = type, - SortOrder = sortOrder, - ImageUrl = imageUrl - }; - } - internal XmlReaderSettings GetXmlReaderSettings() => new XmlReaderSettings() { @@ -1302,24 +941,6 @@ namespace MediaBrowser.XbmcMetadata.Parsers }; /// <summary> - /// Used to split names of comma or pipe delimited genres and people. - /// </summary> - /// <param name="value">The value.</param> - /// <returns>IEnumerable{System.String}.</returns> - private IEnumerable<string> SplitNames(string value) - { - // 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) && !value.Contains(';', StringComparison.Ordinal) - ? new[] { ',' } - : new[] { '|', ';' }; - - value = value.Trim().Trim(separator); - - return string.IsNullOrWhiteSpace(value) ? Array.Empty<string>() : value.Split(separator, StringSplitOptions.RemoveEmptyEntries); - } - - /// <summary> /// Parses the <see cref="ImageType"/> from the NFO aspect property. /// </summary> /// <param name="aspect">The NFO aspect property.</param> diff --git a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs index d2f349ad7..044efb51e 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/EpisodeNfoParser.cs @@ -1,10 +1,11 @@ using System; -using System.Globalization; using System.IO; +using System.Text; using System.Threading; using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -81,7 +82,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers } // Extract the last episode number from nfo + // Retrieves all title and plot tags from the rest of the nfo and concatenates them with the first episode // This is needed because XBMC metadata uses multiple episodedetails blocks instead of episodenumberend tag + var name = new StringBuilder(item.Item.Name); + var overview = new StringBuilder(item.Item.Overview); while ((index = xmlFile.IndexOf(srch, StringComparison.OrdinalIgnoreCase)) != -1) { xml = xmlFile.Substring(0, index + srch.Length); @@ -92,12 +96,44 @@ namespace MediaBrowser.XbmcMetadata.Parsers { reader.MoveToContent(); - if (reader.ReadToDescendant("episode") && int.TryParse(reader.ReadElementContentAsString(), out var num)) + while (!reader.EOF && reader.ReadState == ReadState.Interactive) { - item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num); + cancellationToken.ThrowIfCancellationRequested(); + + if (reader.NodeType == XmlNodeType.Element) + { + switch (reader.Name) + { + case "name": + case "title": + case "localtitle": + name.Append(" / ").Append(reader.ReadElementContentAsString()); + break; + case "episode": + { + if (int.TryParse(reader.ReadElementContentAsString(), out var num)) + { + item.Item.IndexNumberEnd = Math.Max(num, item.Item.IndexNumberEnd ?? num); + } + + break; + } + + case "biography": + case "plot": + case "review": + overview.Append(" / ").Append(reader.ReadElementContentAsString()); + break; + } + } + + reader.Read(); } } } + + item.Item.Name = name.ToString(); + item.Item.Overview = overview.ToString(); } catch (XmlException) { @@ -112,142 +148,53 @@ namespace MediaBrowser.XbmcMetadata.Parsers switch (reader.Name) { case "season": + if (reader.TryReadInt(out var seasonNumber)) { - var number = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(number)) - { - if (int.TryParse(number, out var num)) - { - item.ParentIndexNumber = num; - } - } - - break; + item.ParentIndexNumber = seasonNumber; } + break; case "episode": + if (reader.TryReadInt(out var episodeNumber)) { - var number = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(number)) - { - if (int.TryParse(number, out var num)) - { - item.IndexNumber = num; - } - } - - break; + item.IndexNumber = episodeNumber; } + break; case "episodenumberend": + if (reader.TryReadInt(out var episodeNumberEnd)) { - var number = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(number)) - { - if (int.TryParse(number, out var num)) - { - item.IndexNumberEnd = num; - } - } - - break; + item.IndexNumberEnd = episodeNumberEnd; } + break; case "airsbefore_episode": + case "displayepisode": + if (reader.TryReadInt(out var airsBeforeEpisode)) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) - { - item.AirsBeforeEpisodeNumber = rval; - } - } - - break; + item.AirsBeforeEpisodeNumber = airsBeforeEpisode; } + break; case "airsafter_season": + case "displayafterseason": + if (reader.TryReadInt(out var airsAfterSeason)) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) - { - item.AirsAfterSeasonNumber = rval; - } - } - - break; + item.AirsAfterSeasonNumber = airsAfterSeason; } + break; case "airsbefore_season": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) - { - item.AirsBeforeSeasonNumber = rval; - } - } - - break; - } - case "displayseason": + if (reader.TryReadInt(out var airsBeforeSeason)) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) - { - item.AirsBeforeSeasonNumber = rval; - } - } - - break; - } - - case "displayepisode": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - // int.TryParse is local aware, so it can be problematic, force us culture - if (int.TryParse(val, NumberStyles.Integer, CultureInfo.InvariantCulture, out var rval)) - { - item.AirsBeforeEpisodeNumber = rval; - } - } - - break; + item.AirsBeforeSeasonNumber = airsBeforeSeason; } + break; case "showtitle": - { - var showtitle = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(showtitle)) - { - item.SeriesName = showtitle; - } - - break; - } - + item.SeriesName = reader.ReadNormalizedString(); + break; default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs index ecfed6873..16ea5e3ea 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/MovieNfoParser.cs @@ -5,6 +5,7 @@ using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities; using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -113,31 +114,23 @@ namespace MediaBrowser.XbmcMetadata.Parsers } case "artist": + var artist = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(artist) && item is MusicVideo artistVideo) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val) && item is MusicVideo movie) - { - var list = movie.Artists.ToList(); - list.Add(val); - movie.Artists = list.ToArray(); - } - - break; + var list = artistVideo.Artists.ToList(); + list.Add(artist); + artistVideo.Artists = list.ToArray(); } + break; case "album": + var album = reader.ReadNormalizedString(); + if (!string.IsNullOrEmpty(album) && item is MusicVideo albumVideo) { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val) && item is MusicVideo movie) - { - movie.Album = val; - } - - break; + albumVideo.Album = album; } + break; default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs index 51d5f932b..e13f0d997 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeasonNfoParser.cs @@ -1,7 +1,7 @@ -using System.Globalization; using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using Microsoft.Extensions.Logging; @@ -41,32 +41,15 @@ namespace MediaBrowser.XbmcMetadata.Parsers switch (reader.Name) { case "seasonnumber": + if (reader.TryReadInt(out var seasonNumber)) { - var number = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(number)) - { - if (int.TryParse(number, NumberStyles.Integer, CultureInfo.InvariantCulture, out var num)) - { - item.IndexNumber = num; - } - } - - break; + item.IndexNumber = seasonNumber; } + break; case "seasonname": - { - var name = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(name)) - { - item.Name = name; - } - - break; - } - + item.Name = reader.ReadNormalizedString(); + break; default: base.FetchDataFromXmlNode(reader, itemResult); break; diff --git a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs index f22b861eb..dbcfe7997 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/SeriesNfoParser.cs @@ -1,9 +1,9 @@ using System; -using System.Collections.Generic; using System.Globalization; using System.Xml; using MediaBrowser.Common.Configuration; using MediaBrowser.Controller.Entities.TV; +using MediaBrowser.Controller.Extensions; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; @@ -76,23 +76,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers } case "airs_dayofweek": - { - item.AirDays = TVUtils.GetAirDays(reader.ReadElementContentAsString()); - break; - } - + item.AirDays = TVUtils.GetAirDays(reader.ReadElementContentAsString()); + break; case "airs_time": - { - var val = reader.ReadElementContentAsString(); - - if (!string.IsNullOrWhiteSpace(val)) - { - item.AirTime = val; - } - - break; - } - + item.AirTime = reader.ReadNormalizedString(); + break; case "status": { var status = reader.ReadElementContentAsString(); diff --git a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs index af581fc5d..9b4e1731d 100644 --- a/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs +++ b/MediaBrowser.XbmcMetadata/Providers/BaseNfoProvider.cs @@ -13,7 +13,7 @@ namespace MediaBrowser.XbmcMetadata.Providers public abstract class BaseNfoProvider<T> : ILocalMetadataProvider<T>, IHasItemChangeMonitor where T : BaseItem, new() { - private IFileSystem _fileSystem; + private readonly IFileSystem _fileSystem; protected BaseNfoProvider(IFileSystem fileSystem) { diff --git a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs index 4f8f869ac..bf66a3145 100644 --- a/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/BaseNfoSaver.cs @@ -27,15 +27,12 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.XbmcMetadata.Savers { - public abstract class BaseNfoSaver : IMetadataFileSaver + public abstract partial class BaseNfoSaver : IMetadataFileSaver { public const string DateAddedFormat = "yyyy-MM-dd HH:mm:ss"; public const string YouTubeWatchUrl = "https://www.youtube.com/watch?v="; - // filters control characters but allows only properly-formed surrogate sequences - private const string _invalidXMLCharsRegex = @"(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFEFF\uFFFE\uFFFF]"; - private static readonly HashSet<string> _commonTags = new HashSet<string>(StringComparer.OrdinalIgnoreCase) { "plot", @@ -148,6 +145,12 @@ namespace MediaBrowser.XbmcMetadata.Savers public static string SaverName => "Nfo"; + // filters control characters but allows only properly-formed surrogate sequences + // http://web.archive.org/web/20181230211547/https://emby.media/community/index.php?/topic/49071-nfo-not-generated-on-actualize-or-rescan-or-identify + // Web Archive version of link since it's not really explained in the thread. + [GeneratedRegex(@"(?<![\uD800-\uDBFF])[\uDC00-\uDFFF]|[\uD800-\uDBFF](?![\uDC00-\uDFFF])|[\x00-\x08\x0B\x0C\x0E-\x1F\x7F-\x9F\uFEFF\uFFFE\uFFFF]")] + private static partial Regex InvalidXMLCharsRegexRegex(); + /// <inheritdoc /> public string GetSavePath(BaseItem item) => GetLocalSavePath(item); @@ -354,9 +357,7 @@ namespace MediaBrowser.XbmcMetadata.Savers if (!string.IsNullOrEmpty(stream.Language)) { - // http://web.archive.org/web/20181230211547/https://emby.media/community/index.php?/topic/49071-nfo-not-generated-on-actualize-or-rescan-or-identify - // Web Archive version of link since it's not really explained in the thread. - writer.WriteElementString("language", Regex.Replace(stream.Language, _invalidXMLCharsRegex, string.Empty)); + writer.WriteElementString("language", InvalidXMLCharsRegexRegex().Replace(stream.Language, string.Empty)); } var scanType = stream.IsInterlaced ? "interlaced" : "progressive"; diff --git a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs index 82e1dc860..8fa22fad9 100644 --- a/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs +++ b/MediaBrowser.XbmcMetadata/Savers/MovieNfoSaver.cs @@ -60,13 +60,13 @@ namespace MediaBrowser.XbmcMetadata.Savers } else { - yield return Path.ChangeExtension(item.Path, ".nfo"); - // only allow movie object to read movie.nfo, not owned videos (which will be itemtype video, not movie) if (!item.IsInMixedFolder && item.ItemType == typeof(Movie)) { yield return Path.Combine(item.ContainingFolderPath, "movie.nfo"); } + + yield return Path.ChangeExtension(item.Path, ".nfo"); } } diff --git a/RSSDP/DeviceAvailableEventArgs.cs b/RSSDP/DeviceAvailableEventArgs.cs index 9d477ea9f..f933f258b 100644 --- a/RSSDP/DeviceAvailableEventArgs.cs +++ b/RSSDP/DeviceAvailableEventArgs.cs @@ -8,7 +8,7 @@ namespace Rssdp /// </summary> public sealed class DeviceAvailableEventArgs : EventArgs { - public IPAddress RemoteIpAddress { get; set; } + public IPAddress RemoteIPAddress { get; set; } private readonly DiscoveredSsdpDevice _DiscoveredDevice; diff --git a/RSSDP/HttpRequestParser.cs b/RSSDP/HttpRequestParser.cs index a1b4627a9..fab70eae2 100644 --- a/RSSDP/HttpRequestParser.cs +++ b/RSSDP/HttpRequestParser.cs @@ -33,10 +33,7 @@ namespace Rssdp.Infrastructure } finally { - if (retVal != null) - { - retVal.Dispose(); - } + retVal?.Dispose(); } } diff --git a/RSSDP/HttpResponseParser.cs b/RSSDP/HttpResponseParser.cs index 71b7a7b99..c570c84cb 100644 --- a/RSSDP/HttpResponseParser.cs +++ b/RSSDP/HttpResponseParser.cs @@ -33,10 +33,7 @@ namespace Rssdp.Infrastructure } catch { - if (retVal != null) - { - retVal.Dispose(); - } + retVal?.Dispose(); throw; } diff --git a/RSSDP/ISsdpCommunicationsServer.cs b/RSSDP/ISsdpCommunicationsServer.cs index 3cbc991d6..95b0a1c70 100644 --- a/RSSDP/ISsdpCommunicationsServer.cs +++ b/RSSDP/ISsdpCommunicationsServer.cs @@ -23,23 +23,23 @@ namespace Rssdp.Infrastructure /// <summary> /// Causes the server to begin listening for multicast messages, being SSDP search requests and notifications. /// </summary> - void BeginListeningForBroadcasts(); + void BeginListeningForMulticast(); /// <summary> /// Causes the server to stop listening for multicast messages, being SSDP search requests and notifications. /// </summary> - void StopListeningForBroadcasts(); + void StopListeningForMulticast(); /// <summary> /// Sends a message to a particular address (uni or multicast) and port. /// </summary> - Task SendMessage(byte[] messageData, IPEndPoint destination, IPAddress fromLocalIpAddress, CancellationToken cancellationToken); + Task SendMessage(byte[] messageData, IPEndPoint destination, IPAddress fromLocalIPAddress, CancellationToken cancellationToken); /// <summary> /// Sends a message to the SSDP multicast address and port. /// </summary> - Task SendMulticastMessage(string message, IPAddress fromLocalIpAddress, CancellationToken cancellationToken); - Task SendMulticastMessage(string message, int sendCount, IPAddress fromLocalIpAddress, CancellationToken cancellationToken); + Task SendMulticastMessage(string message, IPAddress fromLocalIPAddress, CancellationToken cancellationToken); + Task SendMulticastMessage(string message, int sendCount, IPAddress fromLocalIPAddress, CancellationToken cancellationToken); /// <summary> /// Gets or sets a boolean value indicating whether or not this instance is shared amongst multiple <see cref="SsdpDeviceLocator"/> and/or <see cref="ISsdpDevicePublisher"/> instances. diff --git a/RSSDP/RequestReceivedEventArgs.cs b/RSSDP/RequestReceivedEventArgs.cs index 5cf74bd75..b8b2249e4 100644 --- a/RSSDP/RequestReceivedEventArgs.cs +++ b/RSSDP/RequestReceivedEventArgs.cs @@ -13,16 +13,16 @@ namespace Rssdp.Infrastructure private readonly IPEndPoint _ReceivedFrom; - public IPAddress LocalIpAddress { get; private set; } + public IPAddress LocalIPAddress { get; private set; } /// <summary> /// Full constructor. /// </summary> - public RequestReceivedEventArgs(HttpRequestMessage message, IPEndPoint receivedFrom, IPAddress localIpAddress) + public RequestReceivedEventArgs(HttpRequestMessage message, IPEndPoint receivedFrom, IPAddress localIPAddress) { _Message = message; _ReceivedFrom = receivedFrom; - LocalIpAddress = localIpAddress; + LocalIPAddress = localIPAddress; } /// <summary> diff --git a/RSSDP/ResponseReceivedEventArgs.cs b/RSSDP/ResponseReceivedEventArgs.cs index 93262a460..e87ba1452 100644 --- a/RSSDP/ResponseReceivedEventArgs.cs +++ b/RSSDP/ResponseReceivedEventArgs.cs @@ -9,7 +9,7 @@ namespace Rssdp.Infrastructure /// </summary> public sealed class ResponseReceivedEventArgs : EventArgs { - public IPAddress LocalIpAddress { get; set; } + public IPAddress LocalIPAddress { get; set; } private readonly HttpResponseMessage _Message; diff --git a/RSSDP/SsdpCommunicationsServer.cs b/RSSDP/SsdpCommunicationsServer.cs index 6e4f5634d..42563e2ed 100644 --- a/RSSDP/SsdpCommunicationsServer.cs +++ b/RSSDP/SsdpCommunicationsServer.cs @@ -25,18 +25,18 @@ namespace Rssdp.Infrastructure * Since stopping the service would be a bad idea (might not be allowed security wise and might * break other apps running on the system) the only other work around is to use two sockets. * - * We use one socket to listen for/receive notifications and search requests (_BroadcastListenSocket). - * We use a second socket, bound to a different local port, to send search requests and listen for - * responses (_SendSocket). The responses are sent to the local port this socket is bound to, - * which isn't port 1900 so the MS service doesn't steal them. While the caller can specify a local + * We use one group of sockets to listen for/receive notifications and search requests (_MulticastListenSockets). + * We use a second group, bound to a different local port, to send search requests and listen for + * responses (_SendSockets). The responses are sent to the local ports these sockets are bound to, + * which aren't port 1900 so the MS service doesn't steal them. While the caller can specify a local * port to use, we will default to 0 which allows the underlying system to auto-assign a free port. */ - private object _BroadcastListenSocketSynchroniser = new object(); - private ISocket _BroadcastListenSocket; + private object _BroadcastListenSocketSynchroniser = new(); + private List<Socket> _MulticastListenSockets; - private object _SendSocketSynchroniser = new object(); - private List<ISocket> _sendSockets; + private object _SendSocketSynchroniser = new(); + private List<Socket> _sendSockets; private HttpRequestParser _RequestParser; private HttpResponseParser _ResponseParser; @@ -48,7 +48,6 @@ namespace Rssdp.Infrastructure private int _MulticastTtl; private bool _IsShared; - private readonly bool _enableMultiSocketBinding; /// <summary> /// Raised when a HTTPU request message is received by a socket (unicast or multicast). @@ -64,9 +63,11 @@ namespace Rssdp.Infrastructure /// Minimum constructor. /// </summary> /// <exception cref="ArgumentNullException">The <paramref name="socketFactory"/> argument is null.</exception> - public SsdpCommunicationsServer(ISocketFactory socketFactory, - INetworkManager networkManager, ILogger logger, bool enableMultiSocketBinding) - : this(socketFactory, 0, SsdpConstants.SsdpDefaultMulticastTimeToLive, networkManager, logger, enableMultiSocketBinding) + public SsdpCommunicationsServer( + ISocketFactory socketFactory, + INetworkManager networkManager, + ILogger logger) + : this(socketFactory, 0, SsdpConstants.SsdpDefaultMulticastTimeToLive, networkManager, logger) { } @@ -76,9 +77,14 @@ namespace Rssdp.Infrastructure /// </summary> /// <exception cref="ArgumentNullException">The <paramref name="socketFactory"/> argument is null.</exception> /// <exception cref="ArgumentOutOfRangeException">The <paramref name="multicastTimeToLive"/> argument is less than or equal to zero.</exception> - public SsdpCommunicationsServer(ISocketFactory socketFactory, int localPort, int multicastTimeToLive, INetworkManager networkManager, ILogger logger, bool enableMultiSocketBinding) + public SsdpCommunicationsServer( + ISocketFactory socketFactory, + int localPort, + int multicastTimeToLive, + INetworkManager networkManager, + ILogger logger) { - if (socketFactory == null) + if (socketFactory is null) { throw new ArgumentNullException(nameof(socketFactory)); } @@ -88,47 +94,43 @@ namespace Rssdp.Infrastructure throw new ArgumentOutOfRangeException(nameof(multicastTimeToLive), "multicastTimeToLive must be greater than zero."); } - _BroadcastListenSocketSynchroniser = new object(); - _SendSocketSynchroniser = new object(); + _BroadcastListenSocketSynchroniser = new(); + _SendSocketSynchroniser = new(); _LocalPort = localPort; _SocketFactory = socketFactory; - _RequestParser = new HttpRequestParser(); - _ResponseParser = new HttpResponseParser(); + _RequestParser = new(); + _ResponseParser = new(); _MulticastTtl = multicastTimeToLive; _networkManager = networkManager; _logger = logger; - _enableMultiSocketBinding = enableMultiSocketBinding; } /// <summary> /// Causes the server to begin listening for multicast messages, being SSDP search requests and notifications. /// </summary> /// <exception cref="ObjectDisposedException">Thrown if the <see cref="DisposableManagedObjectBase.IsDisposed"/> property is true (because <seealso cref="DisposableManagedObjectBase.Dispose()" /> has been called previously).</exception> - public void BeginListeningForBroadcasts() + public void BeginListeningForMulticast() { ThrowIfDisposed(); - if (_BroadcastListenSocket == null) + lock (_BroadcastListenSocketSynchroniser) { - lock (_BroadcastListenSocketSynchroniser) + if (_MulticastListenSockets is null) { - if (_BroadcastListenSocket == null) + try + { + _MulticastListenSockets = CreateMulticastSocketsAndListen(); + } + catch (SocketException ex) { - try - { - _BroadcastListenSocket = ListenForBroadcastsAsync(); - } - catch (SocketException ex) - { - _logger.LogError("Failed to bind to port 1900: {Message}. DLNA will be unavailable", ex.Message); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in BeginListeningForBroadcasts"); - } + _logger.LogError("Failed to bind to multicast address: {Message}. DLNA will be unavailable", ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Error in BeginListeningForMulticast"); } } } @@ -138,15 +140,19 @@ namespace Rssdp.Infrastructure /// Causes the server to stop listening for multicast messages, being SSDP search requests and notifications. /// </summary> /// <exception cref="ObjectDisposedException">Thrown if the <see cref="DisposableManagedObjectBase.IsDisposed"/> property is true (because <seealso cref="DisposableManagedObjectBase.Dispose()" /> has been called previously).</exception> - public void StopListeningForBroadcasts() + public void StopListeningForMulticast() { lock (_BroadcastListenSocketSynchroniser) { - if (_BroadcastListenSocket != null) + if (_MulticastListenSockets is not null) { _logger.LogInformation("{0} disposing _BroadcastListenSocket", GetType().Name); - _BroadcastListenSocket.Dispose(); - _BroadcastListenSocket = null; + foreach (var socket in _MulticastListenSockets) + { + socket.Dispose(); + } + + _MulticastListenSockets = null; } } } @@ -154,16 +160,16 @@ namespace Rssdp.Infrastructure /// <summary> /// Sends a message to a particular address (uni or multicast) and port. /// </summary> - public async Task SendMessage(byte[] messageData, IPEndPoint destination, IPAddress fromLocalIpAddress, CancellationToken cancellationToken) + public async Task SendMessage(byte[] messageData, IPEndPoint destination, IPAddress fromlocalIPAddress, CancellationToken cancellationToken) { - if (messageData == null) + if (messageData is null) { throw new ArgumentNullException(nameof(messageData)); } ThrowIfDisposed(); - var sockets = GetSendSockets(fromLocalIpAddress, destination); + var sockets = GetSendSockets(fromlocalIPAddress, destination); if (sockets.Count == 0) { @@ -180,11 +186,11 @@ namespace Rssdp.Infrastructure } } - private async Task SendFromSocket(ISocket socket, byte[] messageData, IPEndPoint destination, CancellationToken cancellationToken) + private async Task SendFromSocket(Socket socket, byte[] messageData, IPEndPoint destination, CancellationToken cancellationToken) { try { - await socket.SendToAsync(messageData, 0, messageData.Length, destination, cancellationToken).ConfigureAwait(false); + await socket.SendToAsync(messageData, destination, cancellationToken).ConfigureAwait(false); } catch (ObjectDisposedException) { @@ -194,37 +200,42 @@ namespace Rssdp.Infrastructure } catch (Exception ex) { - _logger.LogError(ex, "Error sending socket message from {0} to {1}", socket.LocalIPAddress.ToString(), destination.ToString()); + var localIP = ((IPEndPoint)socket.LocalEndPoint).Address; + _logger.LogError(ex, "Error sending socket message from {0} to {1}", localIP.ToString(), destination.ToString()); } } - private List<ISocket> GetSendSockets(IPAddress fromLocalIpAddress, IPEndPoint destination) + private List<Socket> GetSendSockets(IPAddress fromlocalIPAddress, IPEndPoint destination) { EnsureSendSocketCreated(); lock (_SendSocketSynchroniser) { - var sockets = _sendSockets.Where(i => i.LocalIPAddress.AddressFamily == fromLocalIpAddress.AddressFamily); + var sockets = _sendSockets.Where(s => s.AddressFamily == fromlocalIPAddress.AddressFamily); // Send from the Any socket and the socket with the matching address - if (fromLocalIpAddress.AddressFamily == AddressFamily.InterNetwork) + if (fromlocalIPAddress.AddressFamily == AddressFamily.InterNetwork) { - sockets = sockets.Where(i => i.LocalIPAddress.Equals(IPAddress.Any) || fromLocalIpAddress.Equals(i.LocalIPAddress)); + sockets = sockets.Where(s => ((IPEndPoint)s.LocalEndPoint).Address.Equals(IPAddress.Any) + || ((IPEndPoint)s.LocalEndPoint).Address.Equals(fromlocalIPAddress)); // If sending to the loopback address, filter the socket list as well if (destination.Address.Equals(IPAddress.Loopback)) { - sockets = sockets.Where(i => i.LocalIPAddress.Equals(IPAddress.Any) || i.LocalIPAddress.Equals(IPAddress.Loopback)); + sockets = sockets.Where(s => ((IPEndPoint)s.LocalEndPoint).Address.Equals(IPAddress.Any) + || ((IPEndPoint)s.LocalEndPoint).Address.Equals(IPAddress.Loopback)); } } - else if (fromLocalIpAddress.AddressFamily == AddressFamily.InterNetworkV6) + else if (fromlocalIPAddress.AddressFamily == AddressFamily.InterNetworkV6) { - sockets = sockets.Where(i => i.LocalIPAddress.Equals(IPAddress.IPv6Any) || fromLocalIpAddress.Equals(i.LocalIPAddress)); + sockets = sockets.Where(s => ((IPEndPoint)s.LocalEndPoint).Address.Equals(IPAddress.IPv6Any) + || ((IPEndPoint)s.LocalEndPoint).Address.Equals(fromlocalIPAddress)); // If sending to the loopback address, filter the socket list as well if (destination.Address.Equals(IPAddress.IPv6Loopback)) { - sockets = sockets.Where(i => i.LocalIPAddress.Equals(IPAddress.IPv6Any) || i.LocalIPAddress.Equals(IPAddress.IPv6Loopback)); + sockets = sockets.Where(s => ((IPEndPoint)s.LocalEndPoint).Address.Equals(IPAddress.IPv6Any) + || ((IPEndPoint)s.LocalEndPoint).Address.Equals(IPAddress.IPv6Loopback)); } } @@ -232,17 +243,17 @@ namespace Rssdp.Infrastructure } } - public Task SendMulticastMessage(string message, IPAddress fromLocalIpAddress, CancellationToken cancellationToken) + public Task SendMulticastMessage(string message, IPAddress fromlocalIPAddress, CancellationToken cancellationToken) { - return SendMulticastMessage(message, SsdpConstants.UdpResendCount, fromLocalIpAddress, cancellationToken); + return SendMulticastMessage(message, SsdpConstants.UdpResendCount, fromlocalIPAddress, cancellationToken); } /// <summary> /// Sends a message to the SSDP multicast address and port. /// </summary> - public async Task SendMulticastMessage(string message, int sendCount, IPAddress fromLocalIpAddress, CancellationToken cancellationToken) + public async Task SendMulticastMessage(string message, int sendCount, IPAddress fromlocalIPAddress, CancellationToken cancellationToken) { - if (message == null) + if (message is null) { throw new ArgumentNullException(nameof(message)); } @@ -263,7 +274,7 @@ namespace Rssdp.Infrastructure new IPEndPoint( IPAddress.Parse(SsdpConstants.MulticastLocalAdminAddress), SsdpConstants.MulticastPort), - fromLocalIpAddress, + fromlocalIPAddress, cancellationToken).ConfigureAwait(false); await Task.Delay(100, cancellationToken).ConfigureAwait(false); @@ -278,7 +289,7 @@ namespace Rssdp.Infrastructure { lock (_SendSocketSynchroniser) { - if (_sendSockets != null) + if (_sendSockets is not null) { var sockets = _sendSockets.ToList(); _sendSockets = null; @@ -287,7 +298,8 @@ namespace Rssdp.Infrastructure foreach (var socket in sockets) { - _logger.LogInformation("{0} disposing sendSocket from {1}", GetType().Name, socket.LocalIPAddress); + var socketAddress = ((IPEndPoint)socket.LocalEndPoint).Address; + _logger.LogInformation("{0} disposing sendSocket from {1}", GetType().Name, socketAddress); socket.Dispose(); } } @@ -315,20 +327,20 @@ namespace Rssdp.Infrastructure { if (disposing) { - StopListeningForBroadcasts(); + StopListeningForMulticast(); StopListeningForResponses(); } } - private Task SendMessageIfSocketNotDisposed(byte[] messageData, IPEndPoint destination, IPAddress fromLocalIpAddress, CancellationToken cancellationToken) + private Task SendMessageIfSocketNotDisposed(byte[] messageData, IPEndPoint destination, IPAddress fromlocalIPAddress, CancellationToken cancellationToken) { var sockets = _sendSockets; - if (sockets != null) + if (sockets is not null) { sockets = sockets.ToList(); - var tasks = sockets.Where(s => (fromLocalIpAddress == null || fromLocalIpAddress.Equals(s.LocalIPAddress))) + var tasks = sockets.Where(s => fromlocalIPAddress is null || fromlocalIPAddress.Equals(((IPEndPoint)s.LocalEndPoint).Address)) .Select(s => SendFromSocket(s, messageData, destination, cancellationToken)); return Task.WhenAll(tasks); } @@ -336,50 +348,69 @@ namespace Rssdp.Infrastructure return Task.CompletedTask; } - private ISocket ListenForBroadcastsAsync() + private List<Socket> CreateMulticastSocketsAndListen() { - var socket = _SocketFactory.CreateUdpMulticastSocket(IPAddress.Parse(SsdpConstants.MulticastLocalAdminAddress), _MulticastTtl, SsdpConstants.MulticastPort); - _ = ListenToSocketInternal(socket); + var sockets = new List<Socket>(); + var multicastGroupAddress = IPAddress.Parse(SsdpConstants.MulticastLocalAdminAddress); - return socket; + // IPv6 is currently unsupported + var validInterfaces = _networkManager.GetInternalBindAddresses() + .Where(x => x.Address is not null) + .Where(x => x.SupportsMulticast) + .Where(x => x.AddressFamily == AddressFamily.InterNetwork) + .DistinctBy(x => x.Index); + + foreach (var intf in validInterfaces) + { + try + { + var socket = _SocketFactory.CreateUdpMulticastSocket(multicastGroupAddress, intf, _MulticastTtl, SsdpConstants.MulticastPort); + _ = ListenToSocketInternal(socket); + sockets.Add(socket); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create SSDP UDP multicast socket for {0} on interface {1} (index {2})", intf.Address, intf.Name, intf.Index); + } + } + + return sockets; } - private List<ISocket> CreateSocketAndListenForResponsesAsync() + private List<Socket> CreateSendSockets() { - var sockets = new List<ISocket>(); + var sockets = new List<Socket>(); - sockets.Add(_SocketFactory.CreateSsdpUdpSocket(IPAddress.Any, _LocalPort)); + // IPv6 is currently unsupported + var validInterfaces = _networkManager.GetInternalBindAddresses() + .Where(x => x.Address is not null) + .Where(x => x.SupportsMulticast) + .Where(x => x.AddressFamily == AddressFamily.InterNetwork); - if (_enableMultiSocketBinding) + if (OperatingSystem.IsMacOS()) { - foreach (var address in _networkManager.GetInternalBindAddresses()) - { - if (address.AddressFamily == AddressFamily.InterNetworkV6) - { - // Not support IPv6 right now - continue; - } - - try - { - sockets.Add(_SocketFactory.CreateSsdpUdpSocket(address.Address, _LocalPort)); - } - catch (Exception ex) - { - _logger.LogError(ex, "Error in CreateSsdpUdpSocket. IPAddress: {0}", address); - } - } + // Manually remove loopback on macOS due to https://github.com/dotnet/runtime/issues/24340 + validInterfaces = validInterfaces.Where(x => !x.Address.Equals(IPAddress.Loopback)); } - foreach (var socket in sockets) + foreach (var intf in validInterfaces) { - _ = ListenToSocketInternal(socket); + try + { + var socket = _SocketFactory.CreateSsdpUdpSocket(intf, _LocalPort); + _ = ListenToSocketInternal(socket); + sockets.Add(socket); + } + catch (Exception ex) + { + _logger.LogError(ex, "Failed to create SSDP UDP sender socket for {0} on interface {1} (index {2})", intf.Address, intf.Name, intf.Index); + } } return sockets; } - private async Task ListenToSocketInternal(ISocket socket) + private async Task ListenToSocketInternal(Socket socket) { var cancelled = false; var receiveBuffer = new byte[8192]; @@ -388,14 +419,17 @@ namespace Rssdp.Infrastructure { try { - var result = await socket.ReceiveAsync(receiveBuffer, 0, receiveBuffer.Length, CancellationToken.None).ConfigureAwait(false); + var result = await socket.ReceiveMessageFromAsync(receiveBuffer, new IPEndPoint(IPAddress.Any, _LocalPort), CancellationToken.None).ConfigureAwait(false); if (result.ReceivedBytes > 0) { - // 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(UTF8Encoding.UTF8.GetString(result.Buffer, 0, result.ReceivedBytes), result.RemoteEndPoint, result.LocalIPAddress); + var remoteEndpoint = (IPEndPoint)result.RemoteEndPoint; + var localEndpointAdapter = _networkManager.GetAllBindInterfaces().First(a => a.Index == result.PacketInformation.Interface); + + ProcessMessage( + Encoding.UTF8.GetString(receiveBuffer, 0, result.ReceivedBytes), + remoteEndpoint, + localEndpointAdapter.Address); } } catch (ObjectDisposedException) @@ -411,21 +445,22 @@ namespace Rssdp.Infrastructure private void EnsureSendSocketCreated() { - if (_sendSockets == null) + if (_sendSockets is null) { lock (_SendSocketSynchroniser) { - _sendSockets ??= CreateSocketAndListenForResponsesAsync(); + _sendSockets ??= CreateSendSockets(); } } } - private void ProcessMessage(string data, IPEndPoint endPoint, IPAddress receivedOnLocalIpAddress) + private void ProcessMessage(string data, IPEndPoint endPoint, IPAddress receivedOnlocalIPAddress) { // Responses start with the HTTP version, prefixed with HTTP/ while // requests start with a method which can vary and might be one we haven't // seen/don't know. We'll check if this message is a request or a response // by checking for the HTTP/ prefix on the start of the message. + _logger.LogDebug("Received data from {From} on {Port} at {Address}:\n{Data}", endPoint.Address, endPoint.Port, receivedOnlocalIPAddress, data); if (data.StartsWith("HTTP/", StringComparison.OrdinalIgnoreCase)) { HttpResponseMessage responseMessage = null; @@ -438,9 +473,9 @@ namespace Rssdp.Infrastructure // Ignore invalid packets. } - if (responseMessage != null) + if (responseMessage is not null) { - OnResponseReceived(responseMessage, endPoint, receivedOnLocalIpAddress); + OnResponseReceived(responseMessage, endPoint, receivedOnlocalIPAddress); } } else @@ -455,14 +490,14 @@ namespace Rssdp.Infrastructure // Ignore invalid packets. } - if (requestMessage != null) + if (requestMessage is not null) { - OnRequestReceived(requestMessage, endPoint, receivedOnLocalIpAddress); + OnRequestReceived(requestMessage, endPoint, receivedOnlocalIPAddress); } } } - private void OnRequestReceived(HttpRequestMessage data, IPEndPoint remoteEndPoint, IPAddress receivedOnLocalIpAddress) + private void OnRequestReceived(HttpRequestMessage data, IPEndPoint remoteEndPoint, IPAddress receivedOnlocalIPAddress) { // SSDP specification says only * is currently used but other uri's might // be implemented in the future and should be ignored unless understood. @@ -472,23 +507,17 @@ namespace Rssdp.Infrastructure return; } - var handlers = this.RequestReceived; - if (handlers != null) - { - handlers(this, new RequestReceivedEventArgs(data, remoteEndPoint, receivedOnLocalIpAddress)); - } + var handlers = RequestReceived; + handlers?.Invoke(this, new RequestReceivedEventArgs(data, remoteEndPoint, receivedOnlocalIPAddress)); } - private void OnResponseReceived(HttpResponseMessage data, IPEndPoint endPoint, IPAddress localIpAddress) + private void OnResponseReceived(HttpResponseMessage data, IPEndPoint endPoint, IPAddress localIPAddress) { - var handlers = this.ResponseReceived; - if (handlers != null) + var handlers = ResponseReceived; + handlers?.Invoke(this, new ResponseReceivedEventArgs(data, endPoint) { - handlers(this, new ResponseReceivedEventArgs(data, endPoint) - { - LocalIpAddress = localIpAddress - }); - } + LocalIPAddress = localIPAddress + }); } } } diff --git a/RSSDP/SsdpConstants.cs b/RSSDP/SsdpConstants.cs index 798f050e1..442f2b8f8 100644 --- a/RSSDP/SsdpConstants.cs +++ b/RSSDP/SsdpConstants.cs @@ -26,6 +26,8 @@ namespace Rssdp.Infrastructure internal const string SsdpDeviceDescriptionXmlNamespace = "urn:schemas-upnp-org:device-1-0"; + internal const string ServerVersion = "1.0"; + /// <summary> /// Default buffer size for receiving SSDP broadcasts. Value is 8192 (bytes). /// </summary> diff --git a/RSSDP/SsdpDevice.cs b/RSSDP/SsdpDevice.cs index 3e4261b6a..569d733ea 100644 --- a/RSSDP/SsdpDevice.cs +++ b/RSSDP/SsdpDevice.cs @@ -337,10 +337,7 @@ namespace Rssdp protected virtual void OnDeviceAdded(SsdpEmbeddedDevice device) { var handlers = this.DeviceAdded; - if (handlers != null) - { - handlers(this, new DeviceEventArgs(device)); - } + handlers?.Invoke(this, new DeviceEventArgs(device)); } /// <summary> @@ -352,10 +349,7 @@ namespace Rssdp protected virtual void OnDeviceRemoved(SsdpEmbeddedDevice device) { var handlers = this.DeviceRemoved; - if (handlers != null) - { - handlers(this, new DeviceEventArgs(device)); - } + handlers?.Invoke(this, new DeviceEventArgs(device)); } } } diff --git a/RSSDP/SsdpDeviceLocator.cs b/RSSDP/SsdpDeviceLocator.cs index 7afd32581..d6fad4b9d 100644 --- a/RSSDP/SsdpDeviceLocator.cs +++ b/RSSDP/SsdpDeviceLocator.cs @@ -1,11 +1,11 @@ using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; using System.Net; using System.Net.Http; using System.Threading; using System.Threading.Tasks; - namespace Rssdp.Infrastructure { /// <summary> @@ -17,7 +17,11 @@ namespace Rssdp.Infrastructure private ISsdpCommunicationsServer _CommunicationsServer; private Timer _BroadcastTimer; - private object _timerLock = new object(); + private object _timerLock = new(); + + private string _OSName; + + private string _OSVersion; private readonly TimeSpan DefaultSearchWaitTime = TimeSpan.FromSeconds(4); private readonly TimeSpan OneSecond = TimeSpan.FromSeconds(1); @@ -25,13 +29,17 @@ namespace Rssdp.Infrastructure /// <summary> /// Default constructor. /// </summary> - public SsdpDeviceLocator(ISsdpCommunicationsServer communicationsServer) + public SsdpDeviceLocator( + ISsdpCommunicationsServer communicationsServer, + string osName, + string osVersion) { - if (communicationsServer == null) - { - throw new ArgumentNullException(nameof(communicationsServer)); - } + ArgumentNullException.ThrowIfNull(communicationsServer); + ArgumentNullException.ThrowIfNullOrEmpty(osName); + ArgumentNullException.ThrowIfNullOrEmpty(osVersion); + _OSName = osName; + _OSVersion = osVersion; _CommunicationsServer = communicationsServer; _CommunicationsServer.ResponseReceived += CommsServer_ResponseReceived; @@ -72,7 +80,7 @@ namespace Rssdp.Infrastructure { lock (_timerLock) { - if (_BroadcastTimer == null) + if (_BroadcastTimer is null) { _BroadcastTimer = new Timer(OnBroadcastTimerCallback, null, dueTime, period); } @@ -87,7 +95,7 @@ namespace Rssdp.Infrastructure { lock (_timerLock) { - if (_BroadcastTimer != null) + if (_BroadcastTimer is not null) { _BroadcastTimer.Dispose(); _BroadcastTimer = null; @@ -148,7 +156,7 @@ namespace Rssdp.Infrastructure private Task SearchAsync(string searchTarget, TimeSpan searchWaitTime, CancellationToken cancellationToken) { - if (searchTarget == null) + if (searchTarget is null) { throw new ArgumentNullException(nameof(searchTarget)); } @@ -187,7 +195,7 @@ namespace Rssdp.Infrastructure { _CommunicationsServer.RequestReceived -= CommsServer_RequestReceived; _CommunicationsServer.RequestReceived += CommsServer_RequestReceived; - _CommunicationsServer.BeginListeningForBroadcasts(); + _CommunicationsServer.BeginListeningForMulticast(); } /// <summary> @@ -211,21 +219,18 @@ namespace Rssdp.Infrastructure /// Raises the <see cref="DeviceAvailable"/> event. /// </summary> /// <seealso cref="DeviceAvailable"/> - protected virtual void OnDeviceAvailable(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IpAddress) + protected virtual void OnDeviceAvailable(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IPAddress) { - if (this.IsDisposed) + if (IsDisposed) { return; } - var handlers = this.DeviceAvailable; - if (handlers != null) + var handlers = DeviceAvailable; + handlers?.Invoke(this, new DeviceAvailableEventArgs(device, isNewDevice) { - handlers(this, new DeviceAvailableEventArgs(device, isNewDevice) - { - RemoteIpAddress = IpAddress - }); - } + RemoteIPAddress = IPAddress + }); } /// <summary> @@ -236,16 +241,13 @@ namespace Rssdp.Infrastructure /// <seealso cref="DeviceUnavailable"/> protected virtual void OnDeviceUnavailable(DiscoveredSsdpDevice device, bool expired) { - if (this.IsDisposed) + if (IsDisposed) { return; } - var handlers = this.DeviceUnavailable; - if (handlers != null) - { - handlers(this, new DeviceUnavailableEventArgs(device, expired)); - } + var handlers = DeviceUnavailable; + handlers?.Invoke(this, new DeviceUnavailableEventArgs(device, expired)); } /// <summary> @@ -281,21 +283,21 @@ namespace Rssdp.Infrastructure var commsServer = _CommunicationsServer; _CommunicationsServer = null; - if (commsServer != null) + if (commsServer is not null) { - commsServer.ResponseReceived -= this.CommsServer_ResponseReceived; - commsServer.RequestReceived -= this.CommsServer_RequestReceived; + commsServer.ResponseReceived -= CommsServer_ResponseReceived; + commsServer.RequestReceived -= CommsServer_RequestReceived; } } } - private void AddOrUpdateDiscoveredDevice(DiscoveredSsdpDevice device, IPAddress IpAddress) + private void AddOrUpdateDiscoveredDevice(DiscoveredSsdpDevice device, IPAddress IPAddress) { bool isNewDevice = false; lock (_Devices) { var existingDevice = FindExistingDeviceNotification(_Devices, device.NotificationType, device.Usn); - if (existingDevice == null) + if (existingDevice is null) { _Devices.Add(device); isNewDevice = true; @@ -307,17 +309,17 @@ namespace Rssdp.Infrastructure } } - DeviceFound(device, isNewDevice, IpAddress); + DeviceFound(device, isNewDevice, IPAddress); } - private void DeviceFound(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IpAddress) + private void DeviceFound(DiscoveredSsdpDevice device, bool isNewDevice, IPAddress IPAddress) { if (!NotificationTypeMatchesFilter(device)) { return; } - OnDeviceAvailable(device, isNewDevice, IpAddress); + OnDeviceAvailable(device, isNewDevice, IPAddress); } private bool NotificationTypeMatchesFilter(DiscoveredSsdpDevice device) @@ -329,12 +331,12 @@ namespace Rssdp.Infrastructure private Task BroadcastDiscoverMessage(string serviceType, TimeSpan mxValue, CancellationToken cancellationToken) { - var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + const string header = "M-SEARCH * HTTP/1.1"; - values["HOST"] = "239.255.255.250:1900"; - values["USER-AGENT"] = "UPnP/1.0 DLNADOC/1.50 Platinum/1.0.4.2"; - // values["X-EMBY-SERVERID"] = _appHost.SystemId; + var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + values["HOST"] = string.Format(CultureInfo.InvariantCulture, "{0}:{1}", SsdpConstants.MulticastLocalAdminAddress, SsdpConstants.MulticastPort); + values["USER-AGENT"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, SsdpConstants.ServerVersion); values["MAN"] = "\"ssdp:discover\""; // Search target @@ -343,14 +345,12 @@ namespace Rssdp.Infrastructure // Seconds to delay response values["MX"] = "3"; - var header = "M-SEARCH * HTTP/1.1"; - var message = BuildMessage(header, values); return _CommunicationsServer.SendMulticastMessage(message, null, cancellationToken); } - private void ProcessSearchResponseMessage(HttpResponseMessage message, IPAddress IpAddress) + private void ProcessSearchResponseMessage(HttpResponseMessage message, IPAddress IPAddress) { if (!message.IsSuccessStatusCode) { @@ -358,7 +358,7 @@ namespace Rssdp.Infrastructure } var location = GetFirstHeaderUriValue("Location", message); - if (location != null) + if (location is not null) { var device = new DiscoveredSsdpDevice() { @@ -370,32 +370,32 @@ namespace Rssdp.Infrastructure ResponseHeaders = message.Headers }; - AddOrUpdateDiscoveredDevice(device, IpAddress); + AddOrUpdateDiscoveredDevice(device, IPAddress); } } - private void ProcessNotificationMessage(HttpRequestMessage message, IPAddress IpAddress) + private void ProcessNotificationMessage(HttpRequestMessage message, IPAddress IPAddress) { - if (String.Compare(message.Method.Method, "Notify", StringComparison.OrdinalIgnoreCase) != 0) + if (string.Compare(message.Method.Method, "Notify", StringComparison.OrdinalIgnoreCase) != 0) { return; } var notificationType = GetFirstHeaderStringValue("NTS", message); - if (String.Compare(notificationType, SsdpConstants.SsdpKeepAliveNotification, StringComparison.OrdinalIgnoreCase) == 0) + if (string.Compare(notificationType, SsdpConstants.SsdpKeepAliveNotification, StringComparison.OrdinalIgnoreCase) == 0) { - ProcessAliveNotification(message, IpAddress); + ProcessAliveNotification(message, IPAddress); } - else if (String.Compare(notificationType, SsdpConstants.SsdpByeByeNotification, StringComparison.OrdinalIgnoreCase) == 0) + else if (string.Compare(notificationType, SsdpConstants.SsdpByeByeNotification, StringComparison.OrdinalIgnoreCase) == 0) { ProcessByeByeNotification(message); } } - private void ProcessAliveNotification(HttpRequestMessage message, IPAddress IpAddress) + private void ProcessAliveNotification(HttpRequestMessage message, IPAddress IPAddress) { var location = GetFirstHeaderUriValue("Location", message); - if (location != null) + if (location is not null) { var device = new DiscoveredSsdpDevice() { @@ -407,14 +407,14 @@ namespace Rssdp.Infrastructure ResponseHeaders = message.Headers }; - AddOrUpdateDiscoveredDevice(device, IpAddress); + AddOrUpdateDiscoveredDevice(device, IPAddress); } } private void ProcessByeByeNotification(HttpRequestMessage message) { var notficationType = GetFirstHeaderStringValue("NT", message); - if (!String.IsNullOrEmpty(notficationType)) + if (!string.IsNullOrEmpty(notficationType)) { var usn = GetFirstHeaderStringValue("USN", message); @@ -441,11 +441,10 @@ namespace Rssdp.Infrastructure private string GetFirstHeaderStringValue(string headerName, HttpResponseMessage message) { string retVal = null; - IEnumerable<string> values; if (message.Headers.Contains(headerName)) { - message.Headers.TryGetValues(headerName, out values); - if (values != null) + message.Headers.TryGetValues(headerName, out var values); + if (values is not null) { retVal = values.FirstOrDefault(); } @@ -457,11 +456,10 @@ namespace Rssdp.Infrastructure private string GetFirstHeaderStringValue(string headerName, HttpRequestMessage message) { string retVal = null; - IEnumerable<string> values; if (message.Headers.Contains(headerName)) { - message.Headers.TryGetValues(headerName, out values); - if (values != null) + message.Headers.TryGetValues(headerName, out var values); + if (values is not null) { retVal = values.FirstOrDefault(); } @@ -473,11 +471,10 @@ namespace Rssdp.Infrastructure private Uri GetFirstHeaderUriValue(string headerName, HttpRequestMessage request) { string value = null; - IEnumerable<string> values; if (request.Headers.Contains(headerName)) { - request.Headers.TryGetValues(headerName, out values); - if (values != null) + request.Headers.TryGetValues(headerName, out var values); + if (values is not null) { value = values.FirstOrDefault(); } @@ -490,11 +487,10 @@ namespace Rssdp.Infrastructure private Uri GetFirstHeaderUriValue(string headerName, HttpResponseMessage response) { string value = null; - IEnumerable<string> values; if (response.Headers.Contains(headerName)) { - response.Headers.TryGetValues(headerName, out values); - if (values != null) + response.Headers.TryGetValues(headerName, out var values); + if (values is not null) { value = values.FirstOrDefault(); } @@ -506,7 +502,7 @@ namespace Rssdp.Infrastructure private TimeSpan CacheAgeFromHeader(System.Net.Http.Headers.CacheControlHeaderValue headerValue) { - if (headerValue == null) + if (headerValue is null) { return TimeSpan.Zero; } @@ -523,7 +519,7 @@ namespace Rssdp.Infrastructure foreach (var device in expiredDevices) { - if (this.IsDisposed) + if (IsDisposed) { return; } @@ -537,7 +533,7 @@ namespace Rssdp.Infrastructure // problems. foreach (var expiredUsn in (from expiredDevice in expiredDevices select expiredDevice.Usn).Distinct()) { - if (this.IsDisposed) + if (IsDisposed) { return; } @@ -554,7 +550,7 @@ namespace Rssdp.Infrastructure existingDevices = FindExistingDeviceNotifications(_Devices, deviceUsn); foreach (var existingDevice in existingDevices) { - if (this.IsDisposed) + if (IsDisposed) { return true; } @@ -563,7 +559,7 @@ namespace Rssdp.Infrastructure } } - if (existingDevices != null && existingDevices.Count > 0) + if (existingDevices is not null && existingDevices.Count > 0) { foreach (var removedDevice in existingDevices) { @@ -619,7 +615,7 @@ namespace Rssdp.Infrastructure private void CommsServer_ResponseReceived(object sender, ResponseReceivedEventArgs e) { - ProcessSearchResponseMessage(e.Message, e.LocalIpAddress); + ProcessSearchResponseMessage(e.Message, e.LocalIPAddress); } private void CommsServer_RequestReceived(object sender, RequestReceivedEventArgs e) diff --git a/RSSDP/SsdpDevicePublisher.cs b/RSSDP/SsdpDevicePublisher.cs index be66f5947..0ac9cc9a1 100644 --- a/RSSDP/SsdpDevicePublisher.cs +++ b/RSSDP/SsdpDevicePublisher.cs @@ -4,9 +4,9 @@ using System.Collections.ObjectModel; using System.Globalization; using System.Linq; using System.Net; +using System.Text; using System.Threading; using System.Threading.Tasks; -using MediaBrowser.Common.Net; namespace Rssdp.Infrastructure { @@ -31,8 +31,6 @@ namespace Rssdp.Infrastructure private Random _Random; - private const string ServerVersion = "1.0"; - /// <summary> /// Default constructor. /// </summary> @@ -42,30 +40,9 @@ namespace Rssdp.Infrastructure string osVersion, bool sendOnlyMatchedHost) { - if (communicationsServer == null) - { - throw new ArgumentNullException(nameof(communicationsServer)); - } - - if (osName == null) - { - throw new ArgumentNullException(nameof(osName)); - } - - if (osName.Length == 0) - { - throw new ArgumentException("osName cannot be an empty string.", nameof(osName)); - } - - if (osVersion == null) - { - throw new ArgumentNullException(nameof(osVersion)); - } - - if (osVersion.Length == 0) - { - throw new ArgumentException("osVersion cannot be an empty string.", nameof(osName)); - } + ArgumentNullException.ThrowIfNull(communicationsServer); + ArgumentNullException.ThrowIfNullOrEmpty(osName); + ArgumentNullException.ThrowIfNullOrEmpty(osVersion); _SupportPnpRootDevice = true; _Devices = new List<SsdpRootDevice>(); @@ -79,10 +56,13 @@ namespace Rssdp.Infrastructure _OSVersion = osVersion; _sendOnlyMatchedHost = sendOnlyMatchedHost; - _CommsServer.BeginListeningForBroadcasts(); + _CommsServer.BeginListeningForMulticast(); + + // Send alive notification once on creation + SendAllAliveNotifications(null); } - public void StartBroadcastingAliveMessages(TimeSpan interval) + public void StartSendingAliveNotifications(TimeSpan interval) { _RebroadcastAliveNotificationsTimer = new Timer(SendAllAliveNotifications, null, TimeSpan.FromSeconds(5), interval); } @@ -98,10 +78,9 @@ namespace Rssdp.Infrastructure /// <param name="device">The <see cref="SsdpDevice"/> instance to add.</param> /// <exception cref="ArgumentNullException">Thrown if the <paramref name="device"/> argument is null.</exception> /// <exception cref="InvalidOperationException">Thrown if the <paramref name="device"/> contains property values that are not acceptable to the UPnP 1.0 specification.</exception> - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Performance", "CA1804:RemoveUnusedLocals", MessageId = "t", Justification = "Capture task to local variable suppresses compiler warning, but task is not really needed.")] public void AddDevice(SsdpRootDevice device) { - if (device == null) + if (device is null) { throw new ArgumentNullException(nameof(device)); } @@ -137,7 +116,7 @@ namespace Rssdp.Infrastructure /// <exception cref="ArgumentNullException">Thrown if the <paramref name="device"/> argument is null.</exception> public async Task RemoveDevice(SsdpRootDevice device) { - if (device == null) + if (device is null) { throw new ArgumentNullException(nameof(device)); } @@ -199,7 +178,7 @@ namespace Rssdp.Infrastructure DisposeRebroadcastTimer(); var commsServer = _CommsServer; - if (commsServer != null) + if (commsServer is not null) { commsServer.RequestReceived -= this.CommsServer_RequestReceived; } @@ -208,7 +187,7 @@ namespace Rssdp.Infrastructure Task.WaitAll(tasks); _CommsServer = null; - if (commsServer != null) + if (commsServer is not null) { if (!commsServer.IsShared) { @@ -224,12 +203,12 @@ namespace Rssdp.Infrastructure string mx, string searchTarget, IPEndPoint remoteEndPoint, - IPAddress receivedOnlocalIpAddress, + IPAddress receivedOnlocalIPAddress, CancellationToken cancellationToken) { - if (String.IsNullOrEmpty(searchTarget)) + if (string.IsNullOrEmpty(searchTarget)) { - WriteTrace(String.Format(CultureInfo.InvariantCulture, "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; } @@ -253,7 +232,7 @@ namespace Rssdp.Infrastructure // return; } - if (!Int32.TryParse(mx, out var maxWaitInterval) || maxWaitInterval <= 0) + if (!int.TryParse(mx, out var maxWaitInterval) || maxWaitInterval <= 0) { return; } @@ -264,47 +243,45 @@ namespace Rssdp.Infrastructure } // Do not block synchronously as that may tie up a threadpool thread for several seconds. - Task.Delay(_Random.Next(16, (maxWaitInterval * 1000))).ContinueWith((parentTask) => + Task.Delay(_Random.Next(16, maxWaitInterval * 1000), cancellationToken).ContinueWith((parentTask) => { // Copying devices to local array here to avoid threading issues/enumerator exceptions. IEnumerable<SsdpDevice> devices = null; lock (_Devices) { - if (String.Compare(SsdpConstants.SsdpDiscoverAllSTHeader, searchTarget, StringComparison.OrdinalIgnoreCase) == 0) + if (string.Compare(SsdpConstants.SsdpDiscoverAllSTHeader, searchTarget, StringComparison.OrdinalIgnoreCase) == 0) { devices = GetAllDevicesAsFlatEnumerable().ToArray(); } - else if (String.Compare(SsdpConstants.UpnpDeviceTypeRootDevice, searchTarget, StringComparison.OrdinalIgnoreCase) == 0 || (this.SupportPnpRootDevice && String.Compare(SsdpConstants.PnpDeviceTypeRootDevice, searchTarget, StringComparison.OrdinalIgnoreCase) == 0)) + else if (string.Compare(SsdpConstants.UpnpDeviceTypeRootDevice, searchTarget, StringComparison.OrdinalIgnoreCase) == 0 || (SupportPnpRootDevice && String.Compare(SsdpConstants.PnpDeviceTypeRootDevice, searchTarget, StringComparison.OrdinalIgnoreCase) == 0)) { devices = _Devices.ToArray(); } else if (searchTarget.Trim().StartsWith("uuid:", StringComparison.OrdinalIgnoreCase)) { - devices = (from device in GetAllDevicesAsFlatEnumerable() where String.Compare(device.Uuid, searchTarget.Substring(5), StringComparison.OrdinalIgnoreCase) == 0 select device).ToArray(); + devices = GetAllDevicesAsFlatEnumerable().Where(d => string.Compare(d.Uuid, searchTarget.Substring(5), StringComparison.OrdinalIgnoreCase) == 0).ToArray(); } else if (searchTarget.StartsWith("urn:", StringComparison.OrdinalIgnoreCase)) { - devices = (from device in GetAllDevicesAsFlatEnumerable() where String.Compare(device.FullDeviceType, searchTarget, StringComparison.OrdinalIgnoreCase) == 0 select device).ToArray(); + devices = GetAllDevicesAsFlatEnumerable().Where(d => string.Compare(d.FullDeviceType, searchTarget, StringComparison.OrdinalIgnoreCase) == 0).ToArray(); } } - if (devices != null) + if (devices is not null) { - var deviceList = devices.ToList(); // WriteTrace(String.Format("Sending {0} search responses", deviceList.Count)); - foreach (var device in deviceList) + foreach (var device in devices) { var root = device.ToRootDevice(); - var source = new IPNetAddress(root.Address, root.PrefixLength); - var destination = new IPNetAddress(remoteEndPoint.Address, root.PrefixLength); - if (!_sendOnlyMatchedHost || source.NetworkAddress.Equals(destination.NetworkAddress)) + + if (!_sendOnlyMatchedHost || root.Address.Equals(receivedOnlocalIPAddress)) { - SendDeviceSearchResponses(device, remoteEndPoint, receivedOnlocalIpAddress, cancellationToken); + SendDeviceSearchResponses(device, remoteEndPoint, receivedOnlocalIPAddress, cancellationToken); } } } - }); + }, cancellationToken); } private IEnumerable<SsdpDevice> GetAllDevicesAsFlatEnumerable() @@ -315,27 +292,27 @@ namespace Rssdp.Infrastructure private void SendDeviceSearchResponses( SsdpDevice device, IPEndPoint endPoint, - IPAddress receivedOnlocalIpAddress, + IPAddress receivedOnlocalIPAddress, CancellationToken cancellationToken) { - bool isRootDevice = (device as SsdpRootDevice) != null; + bool isRootDevice = (device as SsdpRootDevice) is not null; if (isRootDevice) { - SendSearchResponse(SsdpConstants.UpnpDeviceTypeRootDevice, device, GetUsn(device.Udn, SsdpConstants.UpnpDeviceTypeRootDevice), endPoint, receivedOnlocalIpAddress, cancellationToken); - if (this.SupportPnpRootDevice) + SendSearchResponse(SsdpConstants.UpnpDeviceTypeRootDevice, device, GetUsn(device.Udn, SsdpConstants.UpnpDeviceTypeRootDevice), endPoint, receivedOnlocalIPAddress, cancellationToken); + if (SupportPnpRootDevice) { - SendSearchResponse(SsdpConstants.PnpDeviceTypeRootDevice, device, GetUsn(device.Udn, SsdpConstants.PnpDeviceTypeRootDevice), endPoint, receivedOnlocalIpAddress, cancellationToken); + SendSearchResponse(SsdpConstants.PnpDeviceTypeRootDevice, device, GetUsn(device.Udn, SsdpConstants.PnpDeviceTypeRootDevice), endPoint, receivedOnlocalIPAddress, cancellationToken); } } - SendSearchResponse(device.Udn, device, device.Udn, endPoint, receivedOnlocalIpAddress, cancellationToken); + SendSearchResponse(device.Udn, device, device.Udn, endPoint, receivedOnlocalIPAddress, cancellationToken); - SendSearchResponse(device.FullDeviceType, device, GetUsn(device.Udn, device.FullDeviceType), endPoint, receivedOnlocalIpAddress, cancellationToken); + SendSearchResponse(device.FullDeviceType, device, GetUsn(device.Udn, device.FullDeviceType), endPoint, receivedOnlocalIPAddress, cancellationToken); } private string GetUsn(string udn, string fullDeviceType) { - return String.Format(CultureInfo.InvariantCulture, "{0}::{1}", udn, fullDeviceType); + return string.Format(CultureInfo.InvariantCulture, "{0}::{1}", udn, fullDeviceType); } private async void SendSearchResponse( @@ -343,33 +320,32 @@ namespace Rssdp.Infrastructure SsdpDevice device, string uniqueServiceName, IPEndPoint endPoint, - IPAddress receivedOnlocalIpAddress, + IPAddress receivedOnlocalIPAddress, CancellationToken cancellationToken) { - var rootDevice = device.ToRootDevice(); - - // var additionalheaders = FormatCustomHeadersForResponse(device); - const string header = "HTTP/1.1 200 OK"; - var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - values["EXT"] = ""; - values["DATE"] = DateTime.UtcNow.ToString("r"); - values["CACHE-CONTROL"] = "max-age = " + rootDevice.CacheLifetime.TotalSeconds; - values["ST"] = searchTarget; - values["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); - values["USN"] = uniqueServiceName; - values["LOCATION"] = rootDevice.Location.ToString(); + var rootDevice = device.ToRootDevice(); + var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + ["EXT"] = "", + ["DATE"] = DateTime.UtcNow.ToString("r"), + ["HOST"] = string.Format(CultureInfo.InvariantCulture, "{0}:{1}", SsdpConstants.MulticastLocalAdminAddress, SsdpConstants.MulticastPort), + ["CACHE-CONTROL"] = "max-age = " + rootDevice.CacheLifetime.TotalSeconds, + ["ST"] = searchTarget, + ["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, SsdpConstants.ServerVersion), + ["USN"] = uniqueServiceName, + ["LOCATION"] = rootDevice.Location.ToString() + }; var message = BuildMessage(header, values); try { await _CommsServer.SendMessage( - System.Text.Encoding.UTF8.GetBytes(message), + Encoding.UTF8.GetBytes(message), endPoint, - receivedOnlocalIpAddress, + receivedOnlocalIPAddress, cancellationToken) .ConfigureAwait(false); } @@ -464,7 +440,7 @@ namespace Rssdp.Infrastructure if (isRoot) { SendAliveNotification(device, SsdpConstants.UpnpDeviceTypeRootDevice, GetUsn(device.Udn, SsdpConstants.UpnpDeviceTypeRootDevice), cancellationToken); - if (this.SupportPnpRootDevice) + if (SupportPnpRootDevice) { SendAliveNotification(device, SsdpConstants.PnpDeviceTypeRootDevice, GetUsn(device.Udn, SsdpConstants.PnpDeviceTypeRootDevice), cancellationToken); } @@ -485,17 +461,18 @@ namespace Rssdp.Infrastructure const string header = "NOTIFY * HTTP/1.1"; - var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - // 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["CACHE-CONTROL"] = "max-age = " + rootDevice.CacheLifetime.TotalSeconds; - values["LOCATION"] = rootDevice.Location.ToString(); - 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; + var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + // If needed later for non-server devices, these headers will need to be dynamic + ["HOST"] = string.Format(CultureInfo.InvariantCulture, "{0}:{1}", SsdpConstants.MulticastLocalAdminAddress, SsdpConstants.MulticastPort), + ["DATE"] = DateTime.UtcNow.ToString("r"), + ["CACHE-CONTROL"] = "max-age = " + rootDevice.CacheLifetime.TotalSeconds, + ["LOCATION"] = rootDevice.Location.ToString(), + ["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, SsdpConstants.ServerVersion), + ["NTS"] = "ssdp:alive", + ["NT"] = notificationType, + ["USN"] = uniqueServiceName + }; var message = BuildMessage(header, values); @@ -510,7 +487,7 @@ namespace Rssdp.Infrastructure if (isRoot) { tasks.Add(SendByeByeNotification(device, SsdpConstants.UpnpDeviceTypeRootDevice, GetUsn(device.Udn, SsdpConstants.UpnpDeviceTypeRootDevice), cancellationToken)); - if (this.SupportPnpRootDevice) + if (SupportPnpRootDevice) { tasks.Add(SendByeByeNotification(device, "pnp:rootdevice", GetUsn(device.Udn, "pnp:rootdevice"), cancellationToken)); } @@ -527,25 +504,25 @@ namespace Rssdp.Infrastructure return Task.WhenAll(tasks); } - [System.Diagnostics.CodeAnalysis.SuppressMessage("Microsoft.Naming", "CA2204:Literals should be spelled correctly", MessageId = "byebye", Justification = "Correct value for this type of notification in SSDP.")] private Task SendByeByeNotification(SsdpDevice device, string notificationType, string uniqueServiceName, CancellationToken cancellationToken) { const string header = "NOTIFY * HTTP/1.1"; - var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); - - // 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(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, ServerVersion); - values["NTS"] = "ssdp:byebye"; - values["NT"] = notificationType; - values["USN"] = uniqueServiceName; + var values = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase) + { + // If needed later for non-server devices, these headers will need to be dynamic + ["HOST"] = string.Format(CultureInfo.InvariantCulture, "{0}:{1}", SsdpConstants.MulticastLocalAdminAddress, SsdpConstants.MulticastPort), + ["DATE"] = DateTime.UtcNow.ToString("r"), + ["SERVER"] = string.Format(CultureInfo.InvariantCulture, "{0}/{1} UPnP/1.0 RSSDP/{2}", _OSName, _OSVersion, SsdpConstants.ServerVersion), + ["NTS"] = "ssdp:byebye", + ["NT"] = notificationType, + ["USN"] = uniqueServiceName + }; var message = BuildMessage(header, values); var sendCount = IsDisposed ? 1 : 3; - WriteTrace(String.Format(CultureInfo.InvariantCulture, "Sent byebye notification"), device); + WriteTrace(string.Format(CultureInfo.InvariantCulture, "Sent byebye notification"), device); return _CommsServer.SendMulticastMessage(message, sendCount, _sendOnlyMatchedHost ? device.ToRootDevice().Address : null, cancellationToken); } @@ -553,10 +530,7 @@ namespace Rssdp.Infrastructure { var timer = _RebroadcastAliveNotificationsTimer; _RebroadcastAliveNotificationsTimer = null; - if (timer != null) - { - timer.Dispose(); - } + timer?.Dispose(); } private TimeSpan GetMinimumNonZeroCacheLifetime() @@ -578,7 +552,7 @@ namespace Rssdp.Infrastructure private string GetFirstHeaderValue(System.Net.Http.Headers.HttpRequestHeaders httpRequestHeaders, string headerName) { string retVal = null; - if (httpRequestHeaders.TryGetValues(headerName, out var values) && values != null) + if (httpRequestHeaders.TryGetValues(headerName, out var values) && values is not null) { retVal = values.FirstOrDefault(); } @@ -590,17 +564,14 @@ namespace Rssdp.Infrastructure private void WriteTrace(string text) { - if (LogFunction != null) - { - LogFunction(text); - } + LogFunction?.Invoke(text); // System.Diagnostics.Debug.WriteLine(text, "SSDP Publisher"); } private void WriteTrace(string text, SsdpDevice device) { var rootDevice = device as SsdpRootDevice; - if (rootDevice != null) + if (rootDevice is not null) { WriteTrace(text + " " + device.DeviceType + " - " + device.Uuid + " - " + rootDevice.Location); } @@ -626,7 +597,7 @@ namespace Rssdp.Infrastructure // else if (!e.Message.Headers.Contains("MAN")) // WriteTrace("Ignoring search request - missing MAN header."); // else - ProcessSearchRequest(GetFirstHeaderValue(e.Message.Headers, "MX"), GetFirstHeaderValue(e.Message.Headers, "ST"), e.ReceivedFrom, e.LocalIpAddress, CancellationToken.None); + ProcessSearchRequest(GetFirstHeaderValue(e.Message.Headers, "MX"), GetFirstHeaderValue(e.Message.Headers, "ST"), e.ReceivedFrom, e.LocalIPAddress, CancellationToken.None); } } diff --git a/debian/conf/jellyfin b/debian/conf/jellyfin index 912996755..aec1d4d10 100644 --- a/debian/conf/jellyfin +++ b/debian/conf/jellyfin @@ -47,4 +47,4 @@ JELLYFIN_ADDITIONAL_OPTS="" # Application username JELLYFIN_USER="jellyfin" # Full application command -JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS" +JELLYFIN_ARGS="$JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLFIN_ADDITIONAL_OPTS --datadir $JELLYFIN_DATA_DIR --configdir $JELLYFIN_CONFIG_DIR --logdir $JELLYFIN_LOG_DIR --cachedir $JELLYFIN_CACHE_DIR" diff --git a/debian/jellyfin.init b/debian/jellyfin.init index 7f5642bac..784536d87 100644 --- a/debian/jellyfin.init +++ b/debian/jellyfin.init @@ -1,3 +1,4 @@ +#!/bin/sh ### BEGIN INIT INFO # Provides: Jellyfin Media Server # Required-Start: $local_fs $network diff --git a/debian/rules b/debian/rules index f55b1807e..069d48aad 100755 --- a/debian/rules +++ b/debian/rules @@ -25,6 +25,10 @@ ifeq ($(HOST_ARCH),arm64) # Building ARM DOTNETRUNTIME := debian-arm64 endif +ifeq ($(HOST_ARCH),aarch64) + # Building ARM + DOTNETRUNTIME := debian-arm64 +endif export DH_VERBOSE=1 export DOTNET_CLI_TELEMETRY_OPTOUT=1 diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 771675519..6c2086bee 100644 --- a/deployment/Dockerfile.centos.amd64 +++ b/deployment/Dockerfile.centos.amd64 @@ -13,7 +13,7 @@ RUN yum update -yq \ && 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 wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index c552f06b0..16002f790 100644 --- a/deployment/Dockerfile.fedora.amd64 +++ b/deployment/Dockerfile.fedora.amd64 @@ -12,7 +12,7 @@ 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 make # Install DotNET SDK -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index 30100d20d..2befb4b55 100644 --- a/deployment/Dockerfile.ubuntu.amd64 +++ b/deployment/Dockerfile.ubuntu.amd64 @@ -17,7 +17,7 @@ RUN apt-get update -yqq \ libfreetype6-dev libssl-dev libssl1.1 liblttng-ust0 # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index bac2adfaf..497a7a3dd 100644 --- a/deployment/Dockerfile.ubuntu.arm64 +++ b/deployment/Dockerfile.ubuntu.arm64 @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index 37a1ed5ff..fae7b209f 100644 --- a/deployment/Dockerfile.ubuntu.armhf +++ b/deployment/Dockerfile.ubuntu.armhf @@ -16,7 +16,7 @@ RUN apt-get update -yqq \ mmv build-essential lsb-release # Install dotnet repository -RUN wget -q https://download.visualstudio.microsoft.com/download/pr/87a55ae3-917d-449e-a4e8-776f82976e91/03380e598c326c2f9465d262c6a88c45/dotnet-sdk-7.0.305-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/c4b5aad8-a416-436b-927c-3ebd5a9793ad/38efd1b64c8edc7c5f13699dd0be54e1/dotnet-sdk-7.0.402-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/fedora/jellyfin-selinux-launcher.sh b/fedora/jellyfin-selinux-launcher.sh new file mode 100644 index 000000000..e07a351d9 --- /dev/null +++ b/fedora/jellyfin-selinux-launcher.sh @@ -0,0 +1,3 @@ +#!/bin/sh + +exec /usr/lib64/jellyfin/jellyfin "${@}" diff --git a/fedora/jellyfin.service b/fedora/jellyfin.service index 1b3f8032c..01accdc0c 100644 --- a/fedora/jellyfin.service +++ b/fedora/jellyfin.service @@ -8,7 +8,7 @@ EnvironmentFile = /etc/sysconfig/jellyfin User = jellyfin Group = jellyfin WorkingDirectory = /var/lib/jellyfin -ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS +ExecStart = /usr/bin/jellyfin $JELLYFIN_WEB_OPT $JELLYFIN_FFMPEG_OPT $JELLYFIN_SERVICE_OPT $JELLYFIN_NOWEBAPP_OPT $JELLYFIN_ADDITIONAL_OPTS Restart = on-failure TimeoutSec = 15 SuccessExitStatus=0 143 diff --git a/fedora/jellyfin.spec b/fedora/jellyfin.spec index a759b29b1..e78368906 100644 --- a/fedora/jellyfin.spec +++ b/fedora/jellyfin.spec @@ -14,6 +14,7 @@ License: GPLv2 URL: https://jellyfin.org # Jellyfin Server tarball created by `make -f .copr/Makefile srpm`, real URL ends with `v%%{version}.tar.gz` Source0: jellyfin-server-%{version}.tar.gz +Source10: jellyfin-selinux-launcher.sh Source11: jellyfin.service Source12: jellyfin.env Source13: jellyfin.override.conf @@ -73,7 +74,8 @@ dotnet publish --configuration Release --self-contained --runtime %{dotnet_runti # Jellyfin files %{__mkdir} -p %{buildroot}%{_libdir}/jellyfin %{buildroot}%{_bindir} %{__cp} -r Jellyfin.Server/bin/Release/net7.0/%{dotnet_runtime}/publish/* %{buildroot}%{_libdir}/jellyfin -ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin +%{__install} -D %{SOURCE10} %{buildroot}%{_bindir}/jellyfin +sed -i -e 's|/usr/lib64|%{_libdir}|g' %{buildroot}%{_bindir}/jellyfin # Jellyfin config %{__install} -D Jellyfin.Server/Resources/Configuration/logging.json %{buildroot}%{_sysconfdir}/jellyfin/logging.json @@ -106,6 +108,7 @@ ln -srf %{_libdir}/jellyfin/jellyfin %{buildroot}%{_bindir}/jellyfin %attr(755,root,root) %{_libdir}/jellyfin/createdump %attr(755,root,root) %{_libdir}/jellyfin/jellyfin %{_libdir}/jellyfin/* +%attr(755,root,root) %{_bindir}/jellyfin # Jellyfin config %config(noreplace) %attr(644,jellyfin,jellyfin) %{_sysconfdir}/jellyfin/logging.json diff --git a/global.json b/global.json new file mode 100644 index 000000000..24335d7a0 --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "7.0.0", + "rollForward": "latestMinor" + } +} diff --git a/jellyfin.ruleset b/jellyfin.ruleset index b611caa11..870cf253f 100644 --- a/jellyfin.ruleset +++ b/jellyfin.ruleset @@ -52,6 +52,8 @@ <Rule Id="SA1204" Action="None" /> <!-- disable warning SA1309: Fields must not begin with an underscore --> <Rule Id="SA1309" Action="None" /> + <!-- disable warning SA1311: Static readonly fields should begin with upper-case letter --> + <Rule Id="SA1311" Action="None" /> <!-- disable warning SA1413: Use trailing comma in multi-line initializers --> <Rule Id="SA1413" Action="None" /> <!-- disable warning SA1512: Single-line comments must not be followed by blank line --> @@ -60,6 +62,8 @@ <Rule Id="SA1515" Action="None" /> <!-- disable warning SA1600: Elements should be documented --> <Rule Id="SA1600" Action="None" /> + <!-- disable warning SA1601: Partial elements should be documented --> + <Rule Id="SA1601" Action="None" /> <!-- disable warning SA1602: Enumeration items should be documented --> <Rule Id="SA1602" Action="None" /> <!-- disable warning SA1633: The file header is missing or not located at the top of the file --> @@ -87,6 +91,8 @@ <Rule Id="CA1727" Action="Error" /> <!-- error on CA1813: Avoid unsealed attributes --> <Rule Id="CA1813" Action="Error" /> + <!-- error on CA1834: Use 'StringBuilder.Append(char)' instead of 'StringBuilder.Append(string)' when the input is a constant unit string --> + <Rule Id="CA1834" Action="Error" /> <!-- error on CA1843: Do not use 'WaitAll' with a single task --> <Rule Id="CA1843" Action="Error" /> <!-- error on CA1845: Use span-based 'string.Concat' --> @@ -167,4 +173,18 @@ <!-- error on RS0030: Do not used banned APIs --> <Rule Id="RS0030" Action="Error" /> </Rules> + + <Rules AnalyzerId="IDisposableAnalyzers" RuleNamespace="IDisposableAnalyzers.Correctness"> + <!-- disable warning IDISP001: Dispose created --> + <Rule Id="IDISP001" Action="Info" /> + <!-- TODO: Enable when false positives are fixed --> + <!-- disable warning IDISP003: Dispose previous before re-assigning --> + <Rule Id="IDISP003" Action="Info" /> + <!-- disable warning IDISP004: Don't ignore created IDisposable --> + <Rule Id="IDISP004" Action="Info" /> + <!-- disable warning IDISP007: Don't dispose injected --> + <Rule Id="IDISP007" Action="Info" /> + <!-- disable warning IDISP008: Don't assign member with injected and created disposables --> + <Rule Id="IDISP008" Action="Info" /> + </Rules> </RuleSet> diff --git a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj index 034691322..c465c4ad0 100644 --- a/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj +++ b/src/Jellyfin.Drawing.Skia/Jellyfin.Drawing.Skia.csproj @@ -31,8 +31,12 @@ <ProjectReference Include="..\..\MediaBrowser.Common\MediaBrowser.Common.csproj" /> </ItemGroup> - <!-- Code analysers--> + <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="IDisposableAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> diff --git a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs index b387c437b..554fbe941 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaEncoder.cs @@ -126,8 +126,8 @@ public class SkiaEncoder : IImageEncoder var svg = new SKSvg(); try { - svg.Load(path); - return new ImageDimensions(Convert.ToInt32(svg.Picture.CullRect.Width), Convert.ToInt32(svg.Picture.CullRect.Height)); + using var picture = svg.Load(path); + return new ImageDimensions(Convert.ToInt32(picture.CullRect.Width), Convert.ToInt32(picture.CullRect.Height)); } catch (FormatException skiaColorException) { @@ -192,7 +192,7 @@ public class SkiaEncoder : IImageEncoder return path; } - var tempPath = Path.Combine(_appPaths.TempDirectory, Guid.NewGuid() + Path.GetExtension(path)); + var tempPath = Path.Combine(_appPaths.TempDirectory, string.Concat(Guid.NewGuid().ToString(), Path.GetExtension(path.AsSpan()))); var directory = Path.GetDirectoryName(tempPath) ?? throw new ResourceNotFoundException($"Provided path ({tempPath}) is not valid."); Directory.CreateDirectory(directory); File.Copy(path, tempPath, true); @@ -204,20 +204,10 @@ public class SkiaEncoder : IImageEncoder { if (!orientation.HasValue) { - return SKEncodedOrigin.TopLeft; + return SKEncodedOrigin.Default; } - return orientation.Value switch - { - ImageOrientation.TopRight => SKEncodedOrigin.TopRight, - ImageOrientation.RightTop => SKEncodedOrigin.RightTop, - ImageOrientation.RightBottom => SKEncodedOrigin.RightBottom, - ImageOrientation.LeftTop => SKEncodedOrigin.LeftTop, - ImageOrientation.LeftBottom => SKEncodedOrigin.LeftBottom, - ImageOrientation.BottomRight => SKEncodedOrigin.BottomRight, - ImageOrientation.BottomLeft => SKEncodedOrigin.BottomLeft, - _ => SKEncodedOrigin.TopLeft - }; + return (SKEncodedOrigin)orientation.Value; } /// <summary> @@ -436,7 +426,8 @@ public class SkiaEncoder : IImageEncoder // scale image (the FromImage creates a copy) var imageInfo = new SKImageInfo(width, height, bitmap.ColorType, bitmap.AlphaType, bitmap.ColorSpace); - using var resizedBitmap = SKBitmap.FromImage(ResizeImage(bitmap, imageInfo)); + using var resizedImage = ResizeImage(bitmap, imageInfo); + using var resizedBitmap = SKBitmap.FromImage(resizedImage); // If all we're doing is resizing then we can stop now if (!hasBackgroundColor && !hasForegroundColor && blur == 0 && !hasIndicator) @@ -493,10 +484,8 @@ public class SkiaEncoder : IImageEncoder Directory.CreateDirectory(directory); using (var outputStream = new SKFileWStream(outputPath)) { - using (var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels())) - { - pixmap.Encode(outputStream, skiaOutputFormat, quality); - } + using var pixmap = new SKPixmap(new SKImageInfo(width, height), saveBitmap.GetPixels()); + pixmap.Encode(outputStream, skiaOutputFormat, quality); } return outputPath; diff --git a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs index 00d224da9..bd1b2b0da 100644 --- a/src/Jellyfin.Drawing.Skia/SkiaHelper.cs +++ b/src/Jellyfin.Drawing.Skia/SkiaHelper.cs @@ -19,7 +19,6 @@ public static class SkiaHelper public static SKBitmap? GetNextValidImage(SkiaEncoder skiaEncoder, IReadOnlyList<string> paths, int currentIndex, out int newIndex) { var imagesTested = new Dictionary<int, int>(); - SKBitmap? bitmap = null; while (imagesTested.Count < paths.Count) { @@ -28,7 +27,7 @@ public static class SkiaHelper currentIndex = 0; } - bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _); + SKBitmap? bitmap = skiaEncoder.Decode(paths[currentIndex], false, null, out _); imagesTested[currentIndex] = 0; @@ -36,11 +35,12 @@ public static class SkiaHelper if (bitmap is not null) { - break; + newIndex = currentIndex; + return bitmap; } } newIndex = currentIndex; - return bitmap; + return null; } } diff --git a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs index a7a3338df..4aff26c16 100644 --- a/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs +++ b/src/Jellyfin.Drawing.Skia/StripCollageBuilder.cs @@ -23,6 +23,9 @@ public partial class StripCollageBuilder _skiaEncoder = skiaEncoder; } + [GeneratedRegex(@"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]")] + private static partial Regex NonCjkPatternRegex(); + [GeneratedRegex(@"\p{IsArabic}|\p{IsArmenian}|\p{IsHebrew}|\p{IsSyriac}|\p{IsThaana}")] private static partial Regex IsRtlTextRegex(); @@ -35,25 +38,25 @@ public partial class StripCollageBuilder { ArgumentNullException.ThrowIfNull(outputPath); - var ext = Path.GetExtension(outputPath); + var ext = Path.GetExtension(outputPath.AsSpan()); - if (string.Equals(ext, ".jpg", StringComparison.OrdinalIgnoreCase) - || string.Equals(ext, ".jpeg", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".jpg", StringComparison.OrdinalIgnoreCase) + || ext.Equals(".jpeg", StringComparison.OrdinalIgnoreCase)) { return SKEncodedImageFormat.Jpeg; } - if (string.Equals(ext, ".webp", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".webp", StringComparison.OrdinalIgnoreCase)) { return SKEncodedImageFormat.Webp; } - if (string.Equals(ext, ".gif", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".gif", StringComparison.OrdinalIgnoreCase)) { return SKEncodedImageFormat.Gif; } - if (string.Equals(ext, ".bmp", StringComparison.OrdinalIgnoreCase)) + if (ext.Equals(".bmp", StringComparison.OrdinalIgnoreCase)) { return SKEncodedImageFormat.Bmp; } @@ -123,8 +126,7 @@ public partial class StripCollageBuilder var typeFace = SKTypeface.FromFamilyName("sans-serif", SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright); // use the system fallback to find a typeface for the given CJK character - var nonCjkPattern = @"[^\p{IsCJKUnifiedIdeographs}\p{IsCJKUnifiedIdeographsExtensionA}\p{IsKatakana}\p{IsHiragana}\p{IsHangulSyllables}\p{IsHangulJamo}]"; - var filteredName = Regex.Replace(libraryName ?? string.Empty, nonCjkPattern, string.Empty); + var filteredName = NonCjkPatternRegex().Replace(libraryName ?? string.Empty, string.Empty); if (!string.IsNullOrEmpty(filteredName)) { typeFace = SKFontManager.Default.MatchCharacter(null, SKFontStyleWeight.Bold, SKFontStyleWidth.Normal, SKFontStyleSlant.Upright, null, filteredName[0]); @@ -187,12 +189,12 @@ public partial class StripCollageBuilder // Scale image. The FromBitmap creates a copy var imageInfo = new SKImageInfo(cellWidth, cellHeight, currentBitmap.ColorType, currentBitmap.AlphaType, currentBitmap.ColorSpace); - using var resizedBitmap = SKBitmap.FromImage(SkiaEncoder.ResizeImage(currentBitmap, imageInfo)); + using var resizeImage = SkiaEncoder.ResizeImage(currentBitmap, imageInfo); // draw this image into the strip at the next position var xPos = x * cellWidth; var yPos = y * cellHeight; - canvas.DrawBitmap(resizedBitmap, xPos, yPos); + canvas.DrawImage(resizeImage, xPos, yPos); } } diff --git a/src/Jellyfin.Drawing/ImageProcessor.cs b/src/Jellyfin.Drawing/ImageProcessor.cs index 4e5d3b4d5..65a8f4e83 100644 --- a/src/Jellyfin.Drawing/ImageProcessor.cs +++ b/src/Jellyfin.Drawing/ImageProcessor.cs @@ -13,7 +13,6 @@ using MediaBrowser.Controller; using MediaBrowser.Controller.Configuration; using MediaBrowser.Controller.Drawing; using MediaBrowser.Controller.Entities; -using MediaBrowser.Controller.MediaEncoding; using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Entities; using MediaBrowser.Model.IO; @@ -109,24 +108,10 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable public bool SupportsImageCollageCreation => _imageEncoder.SupportsImageCollageCreation; /// <inheritdoc /> - public async Task ProcessImage(ImageProcessingOptions options, Stream toStream) - { - var file = await ProcessImage(options).ConfigureAwait(false); - using (var fileStream = AsyncFile.OpenRead(file.Path)) - { - await fileStream.CopyToAsync(toStream).ConfigureAwait(false); - } - } - - /// <inheritdoc /> public IReadOnlyCollection<ImageFormat> GetSupportedImageOutputFormats() => _imageEncoder.SupportedOutputFormats; /// <inheritdoc /> - public bool SupportsTransparency(string path) - => _transparentImageTypes.Contains(Path.GetExtension(path)); - - /// <inheritdoc /> public async Task<(string Path, string? MimeType, DateTime DateModified)> ProcessImage(ImageProcessingOptions options) { ItemImageInfo originalImage = options.Image; @@ -227,7 +212,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable } } - return (cacheFilePath, GetMimeType(outputFormat, cacheFilePath), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); + return (cacheFilePath, outputFormat.GetMimeType(), _fileSystem.GetLastWriteTimeUtc(cacheFilePath)); } catch (Exception ex) { @@ -265,17 +250,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable return ImageFormat.Jpg; } - private string GetMimeType(ImageFormat format, string path) - => format switch - { - ImageFormat.Bmp => MimeTypes.GetMimeType("i.bmp"), - ImageFormat.Gif => MimeTypes.GetMimeType("i.gif"), - ImageFormat.Jpg => MimeTypes.GetMimeType("i.jpg"), - ImageFormat.Png => MimeTypes.GetMimeType("i.png"), - ImageFormat.Webp => MimeTypes.GetMimeType("i.webp"), - _ => MimeTypes.GetMimeType(path) - }; - /// <summary> /// Gets the cache file path based on a set of parameters. /// </summary> @@ -377,7 +351,7 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable filename.Append(",v="); filename.Append(Version); - return GetCachePath(ResizedImageCachePath, filename.ToString(), "." + format.ToString().ToLowerInvariant()); + return GetCachePath(ResizedImageCachePath, filename.ToString(), format.GetExtension()); } /// <inheritdoc /> @@ -437,8 +411,13 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable => (item.Path + image.DateModified.Ticks).GetMD5().ToString("N", CultureInfo.InvariantCulture); /// <inheritdoc /> - public string GetImageCacheTag(BaseItem item, ChapterInfo chapter) + public string? GetImageCacheTag(BaseItem item, ChapterInfo chapter) { + if (chapter.ImagePath is null) + { + return null; + } + return GetImageCacheTag(item, new ItemImageInfo { Path = chapter.ImagePath, @@ -469,35 +448,6 @@ public sealed class ImageProcessor : IImageProcessor, IDisposable return Task.FromResult((originalImagePath, dateModified)); } - // TODO _mediaEncoder.ConvertImage is not implemented - // if (!_imageEncoder.SupportedInputFormats.Contains(inputFormat)) - // { - // try - // { - // string filename = (originalImagePath + dateModified.Ticks.ToString(CultureInfo.InvariantCulture)).GetMD5().ToString("N", CultureInfo.InvariantCulture); - // - // string cacheExtension = _mediaEncoder.SupportsEncoder("libwebp") ? ".webp" : ".png"; - // var outputPath = Path.Combine(_appPaths.ImageCachePath, "converted-images", filename + cacheExtension); - // - // var file = _fileSystem.GetFileInfo(outputPath); - // if (!file.Exists) - // { - // await _mediaEncoder.ConvertImage(originalImagePath, outputPath).ConfigureAwait(false); - // dateModified = _fileSystem.GetLastWriteTimeUtc(outputPath); - // } - // else - // { - // dateModified = file.LastWriteTimeUtc; - // } - // - // originalImagePath = outputPath; - // } - // catch (Exception ex) - // { - // _logger.LogError(ex, "Image conversion failed for {Path}", originalImagePath); - // } - // } - return Task.FromResult((originalImagePath, dateModified)); } diff --git a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj index e0963ac34..2a5e24a44 100644 --- a/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj +++ b/src/Jellyfin.Drawing/Jellyfin.Drawing.csproj @@ -21,8 +21,12 @@ <Compile Include="..\..\SharedVersion.cs" /> </ItemGroup> - <!-- Code analysers--> + <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="IDisposableAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> diff --git a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj index 4f80aa941..36ae55ed2 100644 --- a/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj +++ b/src/Jellyfin.Extensions/Jellyfin.Extensions.csproj @@ -34,6 +34,10 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="IDisposableAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> diff --git a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs index 321cfa502..17096c017 100644 --- a/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs +++ b/src/Jellyfin.Extensions/Json/Converters/JsonDelimitedArrayConverter.cs @@ -59,7 +59,7 @@ namespace Jellyfin.Extensions.Json.Converters var typedValueIndex = 0; for (var i = 0; i < stringEntries.Length; i++) { - if (parsedValues[i] != null) + if (parsedValues[i] is not null) { typedValues.SetValue(parsedValues[i], typedValueIndex); typedValueIndex++; diff --git a/src/Jellyfin.Extensions/StreamExtensions.cs b/src/Jellyfin.Extensions/StreamExtensions.cs index 9751d9d42..182996852 100644 --- a/src/Jellyfin.Extensions/StreamExtensions.cs +++ b/src/Jellyfin.Extensions/StreamExtensions.cs @@ -26,10 +26,8 @@ namespace Jellyfin.Extensions /// <returns>All lines in the stream.</returns> public static string[] ReadAllLines(this Stream stream, Encoding encoding) { - using (StreamReader reader = new StreamReader(stream, encoding)) - { - return ReadAllLines(reader).ToArray(); - } + using StreamReader reader = new StreamReader(stream, encoding); + return ReadAllLines(reader).ToArray(); } /// <summary> @@ -40,7 +38,7 @@ namespace Jellyfin.Extensions public static IEnumerable<string> ReadAllLines(this TextReader reader) { string? line; - while ((line = reader.ReadLine()) != null) + while ((line = reader.ReadLine()) is not null) { yield return line; } @@ -54,7 +52,7 @@ namespace Jellyfin.Extensions public static async IAsyncEnumerable<string> ReadAllLinesAsync(this TextReader reader) { string? line; - while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) != null) + while ((line = await reader.ReadLineAsync().ConfigureAwait(false)) is not null) { yield return line; } diff --git a/src/Jellyfin.Extensions/StringExtensions.cs b/src/Jellyfin.Extensions/StringExtensions.cs index b22eb7c4e..fd8f7e59a 100644 --- a/src/Jellyfin.Extensions/StringExtensions.cs +++ b/src/Jellyfin.Extensions/StringExtensions.cs @@ -6,11 +6,13 @@ namespace Jellyfin.Extensions /// <summary> /// Provides extensions methods for <see cref="string" />. /// </summary> - public static class StringExtensions + public static partial class StringExtensions { // Matches non-conforming unicode chars // https://mnaoumov.wordpress.com/2014/06/14/stripping-invalid-characters-from-utf-16-strings/ - private static readonly Regex _nonConformingUnicode = new Regex("([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])|(\ufffd)", RegexOptions.Compiled); + + [GeneratedRegex("([\ud800-\udbff](?![\udc00-\udfff]))|((?<![\ud800-\udbff])[\udc00-\udfff])|(�)")] + private static partial Regex NonConformingUnicodeRegex(); /// <summary> /// Removes the diacritics character from the strings. @@ -19,7 +21,7 @@ namespace Jellyfin.Extensions /// <returns>The string without diacritics character.</returns> public static string RemoveDiacritics(this string text) => Diacritics.Extensions.StringExtensions.RemoveDiacritics( - _nonConformingUnicode.Replace(text, string.Empty)); + NonConformingUnicodeRegex().Replace(text, string.Empty)); /// <summary> /// Checks whether or not the specified string has diacritics in it. @@ -28,7 +30,7 @@ namespace Jellyfin.Extensions /// <returns>True if the string has diacritics, false otherwise.</returns> public static bool HasDiacritics(this string text) => Diacritics.Extensions.StringExtensions.HasDiacritics(text) - || _nonConformingUnicode.IsMatch(text); + || NonConformingUnicodeRegex().IsMatch(text); /// <summary> /// Counts the number of occurrences of [needle] in the string. diff --git a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj index 3f4f55ee4..b792e7ec6 100644 --- a/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj +++ b/src/Jellyfin.MediaEncoding.Hls/Jellyfin.MediaEncoding.Hls.csproj @@ -7,6 +7,10 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="IDisposableAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index febe9516a..479e6ffdc 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -68,51 +68,54 @@ public static class FfProbeKeyframeExtractor double streamDuration = 0; double formatDuration = 0; - while (!reader.EndOfStream) + using (reader) { - var line = reader.ReadLine().AsSpan(); - if (line.IsEmpty) + while (!reader.EndOfStream) { - continue; - } + var line = reader.ReadLine().AsSpan(); + if (line.IsEmpty) + { + continue; + } - var firstComma = line.IndexOf(','); - var lineType = line[..firstComma]; - var rest = line[(firstComma + 1)..]; - if (lineType.Equals("packet", StringComparison.OrdinalIgnoreCase)) - { - // Split time and flags from the packet line. Example line: packet,7169.079000,K_ - var secondComma = rest.IndexOf(','); - var ptsTime = rest[..secondComma]; - var flags = rest[(secondComma + 1)..]; - if (flags.StartsWith("K_")) + var firstComma = line.IndexOf(','); + var lineType = line[..firstComma]; + var rest = line[(firstComma + 1)..]; + if (lineType.Equals("packet", StringComparison.OrdinalIgnoreCase)) { - if (double.TryParse(ptsTime, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var keyframe)) + // Split time and flags from the packet line. Example line: packet,7169.079000,K_ + var secondComma = rest.IndexOf(','); + var ptsTime = rest[..secondComma]; + var flags = rest[(secondComma + 1)..]; + if (flags.StartsWith("K_")) { - // Have to manually convert to ticks to avoid rounding errors as TimeSpan is only precise down to 1 ms when converting double. - keyframes.Add(Convert.ToInt64(keyframe * TimeSpan.TicksPerSecond)); + if (double.TryParse(ptsTime, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var keyframe)) + { + // Have to manually convert to ticks to avoid rounding errors as TimeSpan is only precise down to 1 ms when converting double. + keyframes.Add(Convert.ToInt64(keyframe * TimeSpan.TicksPerSecond)); + } } } - } - else if (lineType.Equals("stream", StringComparison.OrdinalIgnoreCase)) - { - if (double.TryParse(rest, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var streamDurationResult)) + else if (lineType.Equals("stream", StringComparison.OrdinalIgnoreCase)) { - streamDuration = streamDurationResult; + if (double.TryParse(rest, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var streamDurationResult)) + { + streamDuration = streamDurationResult; + } } - } - else if (lineType.Equals("format", StringComparison.OrdinalIgnoreCase)) - { - if (double.TryParse(rest, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var formatDurationResult)) + else if (lineType.Equals("format", StringComparison.OrdinalIgnoreCase)) { - formatDuration = formatDurationResult; + if (double.TryParse(rest, NumberStyles.AllowDecimalPoint, CultureInfo.InvariantCulture, out var formatDurationResult)) + { + formatDuration = formatDurationResult; + } } } - } - // Prefer the stream duration as it should be more accurate - var duration = streamDuration > 0 ? streamDuration : formatDuration; + // Prefer the stream duration as it should be more accurate + var duration = streamDuration > 0 ? streamDuration : formatDuration; - return new KeyframeData(TimeSpan.FromSeconds(duration).Ticks, keyframes); + return new KeyframeData(TimeSpan.FromSeconds(duration).Ticks, keyframes); + } } } diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index 71572bcf6..09b1f8faa 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -11,6 +11,10 @@ <!-- Code Analyzers--> <ItemGroup Condition=" '$(Configuration)' == 'Debug' "> + <PackageReference Include="IDisposableAnalyzers"> + <PrivateAssets>all</PrivateAssets> + <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> + </PackageReference> <PackageReference Include="Microsoft.CodeAnalysis.BannedApiAnalyzers"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers</IncludeAssets> diff --git a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs index ad8a051fd..f4f661147 100644 --- a/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs +++ b/tests/Jellyfin.Api.Tests/Auth/DefaultAuthorizationPolicy/DefaultAuthorizationHandlerTests.cs @@ -66,7 +66,7 @@ namespace Jellyfin.Api.Tests.Auth.DefaultAuthorizationPolicy _userManagerMock .Setup(u => u.GetUserById(It.IsAny<Guid>())) - .Returns<User>(null); + .Returns<User?>(null); var claims = new[] { diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs index fe0d7fc90..1f908d7e0 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/AssParserTests.cs @@ -12,17 +12,16 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests [Fact] public void Parse_Valid_Success() { - using (var stream = File.OpenRead("Test Data/example.ass")) - { - var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass"); - Assert.Single(parsed.TrackEvents); - var trackEvent = parsed.TrackEvents[0]; + using var stream = File.OpenRead("Test Data/example.ass"); - Assert.Equal("1", trackEvent.Id); - Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); - Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text); - } + var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "ass"); + Assert.Single(parsed.TrackEvents); + var trackEvent = parsed.TrackEvents[0]; + + Assert.Equal("1", trackEvent.Id); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); + Assert.Equal("{\\pos(400,570)}Like an Angel with pity on nobody" + Environment.NewLine + "The second line in subtitle", trackEvent.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs index 2aebee556..b7152961c 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SrtParserTests.cs @@ -12,45 +12,43 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests [Fact] public void Parse_Valid_Success() { - using (var stream = File.OpenRead("Test Data/example.srt")) - { - var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); - Assert.Equal(2, parsed.TrackEvents.Count); - - var trackEvent1 = parsed.TrackEvents[0]; - Assert.Equal("1", trackEvent1.Id); - Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); - Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text); - - var trackEvent2 = parsed.TrackEvents[1]; - Assert.Equal("2", trackEvent2.Id); - Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); - Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); - } + using var stream = File.OpenRead("Test Data/example.srt"); + + var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); + Assert.Equal(2, parsed.TrackEvents.Count); + + var trackEvent1 = parsed.TrackEvents[0]; + Assert.Equal("1", trackEvent1.Id); + Assert.Equal(TimeSpan.Parse("00:02:17.440", CultureInfo.InvariantCulture).Ticks, trackEvent1.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:02:20.375", CultureInfo.InvariantCulture).Ticks, trackEvent1.EndPositionTicks); + Assert.Equal("Senator, we're making" + Environment.NewLine + "our final approach into Coruscant.", trackEvent1.Text); + + var trackEvent2 = parsed.TrackEvents[1]; + Assert.Equal("2", trackEvent2.Id); + Assert.Equal(TimeSpan.Parse("00:02:20.476", CultureInfo.InvariantCulture).Ticks, trackEvent2.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:02:22.501", CultureInfo.InvariantCulture).Ticks, trackEvent2.EndPositionTicks); + Assert.Equal("Very good, Lieutenant.", trackEvent2.Text); } [Fact] public void Parse_EmptyNewlineBetweenText_Success() { - using (var stream = File.OpenRead("Test Data/example2.srt")) - { - var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); - 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); - } + using var stream = File.OpenRead("Test Data/example2.srt"); + + var parsed = new SubtitleEditParser(new NullLogger<SubtitleEditParser>()).Parse(stream, "srt"); + 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 6abf2d26c..5b7aa7eaa 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SsaParserTests.cs @@ -18,22 +18,21 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests [MemberData(nameof(Parse_MultipleDialogues_TestData))] public void Parse_MultipleDialogues_Success(string ssa, IReadOnlyList<SubtitleTrackEvent> expectedSubtitleTrackEvents) { - using (Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa))) - { - SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa"); + using Stream stream = new MemoryStream(Encoding.UTF8.GetBytes(ssa)); - Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count); + SubtitleTrackInfo subtitleTrackInfo = _parser.Parse(stream, "ssa"); - for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i) - { - SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i]; - SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i]; + Assert.Equal(expectedSubtitleTrackEvents.Count, subtitleTrackInfo.TrackEvents.Count); + + for (int i = 0; i < expectedSubtitleTrackEvents.Count; ++i) + { + SubtitleTrackEvent expected = expectedSubtitleTrackEvents[i]; + SubtitleTrackEvent actual = subtitleTrackInfo.TrackEvents[i]; - Assert.Equal(expected.Id, actual.Id); - Assert.Equal(expected.Text, actual.Text); - Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks); - Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks); - } + Assert.Equal(expected.Id, actual.Id); + Assert.Equal(expected.Text, actual.Text); + Assert.Equal(expected.StartPositionTicks, actual.StartPositionTicks); + Assert.Equal(expected.EndPositionTicks, actual.EndPositionTicks); } } @@ -73,17 +72,16 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests [Fact] public void Parse_Valid_Success() { - using (var stream = File.OpenRead("Test Data/example.ssa")) - { - var parsed = _parser.Parse(stream, "ssa"); - Assert.Single(parsed.TrackEvents); - var trackEvent = parsed.TrackEvents[0]; + using var stream = File.OpenRead("Test Data/example.ssa"); - Assert.Equal("1", trackEvent.Id); - Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); - Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); - Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text); - } + var parsed = _parser.Parse(stream, "ssa"); + Assert.Single(parsed.TrackEvents); + var trackEvent = parsed.TrackEvents[0]; + + Assert.Equal("1", trackEvent.Id); + Assert.Equal(TimeSpan.Parse("00:00:01.18", CultureInfo.InvariantCulture).Ticks, trackEvent.StartPositionTicks); + Assert.Equal(TimeSpan.Parse("00:00:06.85", CultureInfo.InvariantCulture).Ticks, trackEvent.EndPositionTicks); + Assert.Equal("{\\pos(400,570)}Like an angel with pity on nobody", trackEvent.Text); } } } diff --git a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs index 9ace80bbd..ce1f005f4 100644 --- a/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs +++ b/tests/Jellyfin.MediaEncoding.Tests/Subtitles/SubtitleEncoderTests.cs @@ -97,7 +97,7 @@ namespace Jellyfin.MediaEncoding.Subtitles.Tests { var fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); var subtitleEncoder = fixture.Create<SubtitleEncoder>(); - var result = await subtitleEncoder.GetReadableFile(mediaSource, subtitleStream, CancellationToken.None).ConfigureAwait(false); + var result = await subtitleEncoder.GetReadableFile(mediaSource, subtitleStream, CancellationToken.None); Assert.Equal(subtitleInfo.Path, result.Path); Assert.Equal(subtitleInfo.Protocol, result.Protocol); Assert.Equal(subtitleInfo.Format, result.Format); diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index c30dad6f9..210ce4a47 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -351,11 +351,11 @@ namespace Jellyfin.Model.Tests // Assert.Contains(uri.Extension, containers); // Check expected video codec (1) - Assert.Contains(targetVideoStream.Codec, streamInfo.TargetVideoCodec); + Assert.Contains(targetVideoStream?.Codec, streamInfo.TargetVideoCodec); Assert.Single(streamInfo.TargetVideoCodec); // Check expected audio codecs (1) - Assert.Contains(targetAudioStream.Codec, streamInfo.TargetAudioCodec); + Assert.Contains(targetAudioStream?.Codec, streamInfo.TargetAudioCodec); Assert.Single(streamInfo.TargetAudioCodec); // Assert.Single(val.AudioCodecs); @@ -410,13 +410,13 @@ namespace Jellyfin.Model.Tests else { // Check expected video codec (1) - Assert.Contains(targetVideoStream.Codec, streamInfo.TargetVideoCodec); + Assert.Contains(targetVideoStream?.Codec, streamInfo.TargetVideoCodec); Assert.Single(streamInfo.TargetVideoCodec); if (transcodeMode.Equals("DirectStream", StringComparison.Ordinal)) { // Check expected audio codecs (1) - if (!targetAudioStream.IsExternal) + if (targetAudioStream?.IsExternal == false) { // Check expected audio codecs (1) if (streamInfo.TranscodeReasons.HasFlag(TranscodeReason.ContainerNotSupported)) @@ -432,7 +432,7 @@ namespace Jellyfin.Model.Tests else if (transcodeMode.Equals("Remux", StringComparison.Ordinal)) { // Check expected audio codecs (1) - Assert.Contains(targetAudioStream.Codec, streamInfo.AudioCodecs); + Assert.Contains(targetAudioStream?.Codec, streamInfo.AudioCodecs); Assert.Single(streamInfo.AudioCodecs); } @@ -440,10 +440,10 @@ namespace Jellyfin.Model.Tests var videoStream = targetVideoStream; Assert.False(streamInfo.EstimateContentLength); Assert.Equal(TranscodeSeekInfo.Auto, streamInfo.TranscodeSeekInfo); - Assert.Contains(videoStream.Profile?.ToLowerInvariant() ?? string.Empty, streamInfo.TargetVideoProfile?.Split(",").Select(s => s.ToLowerInvariant()) ?? Array.Empty<string>()); - Assert.Equal(videoStream.Level, streamInfo.TargetVideoLevel); - Assert.Equal(videoStream.BitDepth, streamInfo.TargetVideoBitDepth); - Assert.InRange(streamInfo.VideoBitrate.GetValueOrDefault(), videoStream.BitRate.GetValueOrDefault(), int.MaxValue); + Assert.Contains(videoStream?.Profile?.ToLowerInvariant() ?? string.Empty, streamInfo.TargetVideoProfile?.Split(",").Select(s => s.ToLowerInvariant()) ?? Array.Empty<string>()); + Assert.Equal(videoStream?.Level, streamInfo.TargetVideoLevel); + Assert.Equal(videoStream?.BitDepth, streamInfo.TargetVideoBitDepth); + Assert.InRange(streamInfo.VideoBitrate.GetValueOrDefault(), videoStream?.BitRate.GetValueOrDefault() ?? 0, int.MaxValue); // Audio codec not supported if ((why & TranscodeReason.AudioCodecNotSupported) != 0) @@ -452,7 +452,7 @@ namespace Jellyfin.Model.Tests if (options.AudioStreamIndex >= 0) { // TODO:fixme - if (!targetAudioStream.IsExternal) + if (targetAudioStream?.IsExternal == false) { Assert.DoesNotContain(targetAudioStream.Codec, streamInfo.AudioCodecs); } @@ -488,16 +488,16 @@ namespace Jellyfin.Model.Tests private static async ValueTask<T> TestData<T>(string name) { var path = Path.Join("Test Data", typeof(T).Name + "-" + name + ".json"); - using (var stream = File.OpenRead(path)) - { - var value = await JsonSerializer.DeserializeAsync<T>(stream, JsonDefaults.Options); - if (value is not null) - { - return value; - } - throw new SerializationException("Invalid test data: " + name); + using var stream = File.OpenRead(path); + + var value = await JsonSerializer.DeserializeAsync<T>(stream, JsonDefaults.Options); + if (value is not null) + { + return value; } + + throw new SerializationException("Invalid test data: " + name); } private StreamBuilder GetStreamBuilder() diff --git a/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs b/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs index a5bdb42d8..198ad5a27 100644 --- a/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs +++ b/tests/Jellyfin.Model.Tests/Drawing/ImageFormatExtensionsTests.cs @@ -30,4 +30,17 @@ public static class ImageFormatExtensionsTests [InlineData((ImageFormat)5)] public static void GetMimeType_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format) => Assert.Throws<InvalidEnumArgumentException>(() => format.GetMimeType()); + + [Theory] + [MemberData(nameof(GetAllImageFormats))] + public static void GetExtension_Valid_Valid(ImageFormat format) + => Assert.Null(Record.Exception(() => format.GetExtension())); + + [Theory] + [InlineData((ImageFormat)int.MinValue)] + [InlineData((ImageFormat)int.MaxValue)] + [InlineData((ImageFormat)(-1))] + [InlineData((ImageFormat)5)] + public static void GetExtension_Valid_ThrowsInvalidEnumArgumentException(ImageFormat format) + => Assert.Throws<InvalidEnumArgumentException>(() => format.GetExtension()); } diff --git a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs index c72a3315e..9b9c1ec34 100644 --- a/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/AudioBook/AudioBookResolverTests.cs @@ -14,18 +14,18 @@ namespace Jellyfin.Naming.Tests.AudioBook data.Add( new AudioBookFileInfo( - @"/server/AudioBooks/Larry Potter/Larry Potter.mp3", + "/server/AudioBooks/Larry Potter/Larry Potter.mp3", "mp3")); data.Add( new AudioBookFileInfo( - @"/server/AudioBooks/Berry Potter/Chapter 1 .ogg", + "/server/AudioBooks/Berry Potter/Chapter 1 .ogg", "ogg", chapterNumber: 1)); data.Add( new AudioBookFileInfo( - @"/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", + "/server/AudioBooks/Nerry Potter/Part 3 - Chapter 2.mp3", "mp3", chapterNumber: 2, partNumber: 3)); @@ -49,7 +49,7 @@ namespace Jellyfin.Naming.Tests.AudioBook [Fact] public void Resolve_InvalidExtension() { - var result = new AudioBookResolver(_namingOptions).Resolve(@"/server/AudioBooks/Larry Potter/Larry Potter.mp9"); + var result = new AudioBookResolver(_namingOptions).Resolve("/server/AudioBooks/Larry Potter/Larry Potter.mp9"); Assert.Null(result); } diff --git a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs index 97949adff..ba602b5d2 100644 --- a/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs +++ b/tests/Jellyfin.Naming.Tests/ExternalFiles/ExternalPathParserTests.cs @@ -20,11 +20,11 @@ public class ExternalPathParserTests var hindiCultureDto = new CultureDto("Hindi", "Hindi", "hi", new[] { "hin" }); var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("en.*", RegexOptions.IgnoreCase))) .Returns(englishCultureDto); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"fr.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("fr.*", RegexOptions.IgnoreCase))) .Returns(frenchCultureDto); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"hi.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("hi.*", RegexOptions.IgnoreCase))) .Returns(hindiCultureDto); _audioPathParser = new ExternalPathParser(new NamingOptions(), localizationManager.Object, DlnaProfileType.Audio); diff --git a/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs b/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs index c9a295a4c..471616797 100644 --- a/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs +++ b/tests/Jellyfin.Naming.Tests/Music/MultiDiscAlbumTests.cs @@ -12,34 +12,34 @@ namespace Jellyfin.Naming.Tests.Music [InlineData("", false)] [InlineData("C:/", false)] [InlineData("/home/", false)] - [InlineData(@"blah blah", false)] - [InlineData(@"D:/music/weezer/03 Pinkerton", false)] - [InlineData(@"D:/music/michael jackson/Bad (2012 Remaster)", false)] - [InlineData(@"cd1", true)] - [InlineData(@"disc18", true)] - [InlineData(@"disk10", true)] - [InlineData(@"vol7", true)] - [InlineData(@"volume1", true)] - [InlineData(@"cd 1", true)] - [InlineData(@"disc 1", true)] - [InlineData(@"disk 1", true)] - [InlineData(@"disk", false)] - [InlineData(@"disk ·", false)] - [InlineData(@"disk a", false)] - [InlineData(@"disk volume", false)] - [InlineData(@"disc disc", false)] - [InlineData(@"disk disc 6", false)] - [InlineData(@"cd - 1", true)] - [InlineData(@"disc- 1", true)] - [InlineData(@"disk - 1", true)] - [InlineData(@"Disc 01 (Hugo Wolf · 24 Lieder)", true)] - [InlineData(@"Disc 04 (Encores and Folk Songs)", true)] - [InlineData(@"Disc04 (Encores and Folk Songs)", true)] - [InlineData(@"Disc 04(Encores and Folk Songs)", true)] - [InlineData(@"Disc04(Encores and Folk Songs)", true)] - [InlineData(@"D:/Video/MBTestLibrary/VideoTest/music/.38 special/anth/Disc 2", true)] - [InlineData(@"[1985] Opportunities (Let's make lots of money) (1985)", false)] - [InlineData(@"Blah 04(Encores and Folk Songs)", false)] + [InlineData("blah blah", false)] + [InlineData("D:/music/weezer/03 Pinkerton", false)] + [InlineData("D:/music/michael jackson/Bad (2012 Remaster)", false)] + [InlineData("cd1", true)] + [InlineData("disc18", true)] + [InlineData("disk10", true)] + [InlineData("vol7", true)] + [InlineData("volume1", true)] + [InlineData("cd 1", true)] + [InlineData("disc 1", true)] + [InlineData("disk 1", true)] + [InlineData("disk", false)] + [InlineData("disk ·", false)] + [InlineData("disk a", false)] + [InlineData("disk volume", false)] + [InlineData("disc disc", false)] + [InlineData("disk disc 6", false)] + [InlineData("cd - 1", true)] + [InlineData("disc- 1", true)] + [InlineData("disk - 1", true)] + [InlineData("Disc 01 (Hugo Wolf · 24 Lieder)", true)] + [InlineData("Disc 04 (Encores and Folk Songs)", true)] + [InlineData("Disc04 (Encores and Folk Songs)", true)] + [InlineData("Disc 04(Encores and Folk Songs)", true)] + [InlineData("Disc04(Encores and Folk Songs)", true)] + [InlineData("D:/Video/MBTestLibrary/VideoTest/music/.38 special/anth/Disc 2", true)] + [InlineData("[1985] Opportunities (Let's make lots of money) (1985)", false)] + [InlineData("Blah 04(Encores and Folk Songs)", false)] public void AlbumParser_MultidiscPath_Identifies(string path, bool result) { var parser = new AlbumParser(_namingOptions); diff --git a/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs index 72052a23c..f2cd360e5 100644 --- a/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/DailyEpisodeTests.cs @@ -9,10 +9,11 @@ namespace Jellyfin.Naming.Tests.TV private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); [Theory] - [InlineData(@"/server/anything_1996.11.14.mp4", "anything", 1996, 11, 14)] - [InlineData(@"/server/anything_1996-11-14.mp4", "anything", 1996, 11, 14)] - [InlineData(@"/server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv", "james.corden", 2017, 04, 20)] - [InlineData(@"/server/ABC News 2018_03_24_19_00_00.mkv", "ABC News", 2018, 03, 24)] + [InlineData("/server/anything_1996.11.14.mp4", "anything", 1996, 11, 14)] + [InlineData("/server/anything_1996-11-14.mp4", "anything", 1996, 11, 14)] + [InlineData("/server/james.corden.2017.04.20.anne.hathaway.720p.hdtv.x264-crooks.mkv", "james.corden", 2017, 04, 20)] + [InlineData("/server/ABC News 2018_03_24_19_00_00.mkv", "ABC News", 2018, 03, 24)] + [InlineData("/server/Jeopardy 2023 07 14 HDTV x264 AC3.mkv", "Jeopardy", 2023, 07, 14)] // TODO: [InlineData(@"/server/anything_14.11.1996.mp4", "anything", 1996, 11, 14)] // TODO: [InlineData(@"/server/A Daily Show - (2015-01-15) - Episode Name - [720p].mkv", "A Daily Show", 2015, 01, 15)] // TODO: [InlineData(@"/server/Last Man Standing_KTLADT_2018_05_25_01_28_00.wtv", "Last Man Standing", 2018, 05, 25)] diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs index 1da5a30a8..1727b2247 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodeNumberWithoutSeasonTests.cs @@ -9,16 +9,16 @@ namespace Jellyfin.Naming.Tests.TV private readonly EpisodeResolver _resolver = new EpisodeResolver(new NamingOptions()); [Theory] - [InlineData(8, @"The Simpsons/The Simpsons.S25E08.Steal this episode.mp4")] - [InlineData(2, @"The Simpsons/The Simpsons - 02 - Ep Name.avi")] - [InlineData(2, @"The Simpsons/02.avi")] - [InlineData(2, @"The Simpsons/02 - Ep Name.avi")] - [InlineData(2, @"The Simpsons/02-Ep Name.avi")] - [InlineData(2, @"The Simpsons/02.EpName.avi")] - [InlineData(2, @"The Simpsons/The Simpsons - 02.avi")] - [InlineData(2, @"The Simpsons/The Simpsons - 02 Ep Name.avi")] - [InlineData(7, @"GJ Club (2013)/GJ Club - 07.mkv")] - [InlineData(17, @"Case Closed (1996-2007)/Case Closed - 317.mkv")] + [InlineData(8, "The Simpsons/The Simpsons.S25E08.Steal this episode.mp4")] + [InlineData(2, "The Simpsons/The Simpsons - 02 - Ep Name.avi")] + [InlineData(2, "The Simpsons/02.avi")] + [InlineData(2, "The Simpsons/02 - Ep Name.avi")] + [InlineData(2, "The Simpsons/02-Ep Name.avi")] + [InlineData(2, "The Simpsons/02.EpName.avi")] + [InlineData(2, "The Simpsons/The Simpsons - 02.avi")] + [InlineData(2, "The Simpsons/The Simpsons - 02 Ep Name.avi")] + [InlineData(7, "GJ Club (2013)/GJ Club - 07.mkv")] + [InlineData(17, "Case Closed (1996-2007)/Case Closed - 317.mkv")] // TODO: [InlineData(2, @"The Simpsons/The Simpsons 5 - 02 - Ep Name.avi")] // TODO: [InlineData(2, @"The Simpsons/The Simpsons 5 - 02 Ep Name.avi")] // TODO: [InlineData(7, @"Seinfeld/Seinfeld 0807 The Checks.avi")] diff --git a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs index 7604ddc80..5397f1371 100644 --- a/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs +++ b/tests/Jellyfin.Naming.Tests/TV/EpisodePathParserTest.cs @@ -13,10 +13,10 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("/media/Foo - S04E011", true, "Foo", 4, 11)] [InlineData("/media/Foo/Foo s01x01", true, "Foo", 1, 1)] [InlineData("/media/Foo (2019)/Season 4/Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] - [InlineData("D:\\media\\Foo\\Foo-S01E01", true, "Foo", 1, 1)] - [InlineData("D:\\media\\Foo - S04E011", true, "Foo", 4, 11)] - [InlineData("D:\\media\\Foo\\Foo s01x01", true, "Foo", 1, 1)] - [InlineData("D:\\media\\Foo (2019)\\Season 4\\Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] + [InlineData(@"D:\media\Foo\Foo-S01E01", true, "Foo", 1, 1)] + [InlineData(@"D:\media\Foo - S04E011", true, "Foo", 4, 11)] + [InlineData(@"D:\media\Foo\Foo s01x01", true, "Foo", 1, 1)] + [InlineData(@"D:\media\Foo (2019)\Season 4\Foo (2019).S04E03", true, "Foo (2019)", 4, 3)] [InlineData("/Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", false, "Elementary", 2, 3)] [InlineData("/Season 1/seriesname S01E02 blah.avi", false, "seriesname", 1, 2)] [InlineData("/Running Man/Running Man S2017E368.mkv", false, "Running Man", 2017, 368)] diff --git a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs index ffaa64c3f..6d6591abf 100644 --- a/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/MultiEpisodeTests.cs @@ -9,66 +9,66 @@ namespace Jellyfin.Naming.Tests.TV private readonly EpisodePathParser _episodePathParser = new EpisodePathParser(new NamingOptions()); [Theory] - [InlineData(@"Season 1/4x01 – 20 Hours in America (1).mkv", null)] - [InlineData(@"Season 1/01x02 blah.avi", null)] - [InlineData(@"Season 1/S01x02 blah.avi", null)] - [InlineData(@"Season 1/S01E02 blah.avi", null)] - [InlineData(@"Season 1/S01xE02 blah.avi", null)] - [InlineData(@"Season 1/seriesname 01x02 blah.avi", null)] - [InlineData(@"Season 1/seriesname S01x02 blah.avi", null)] - [InlineData(@"Season 1/seriesname S01E02 blah.avi", null)] - [InlineData(@"Season 1/seriesname S01xE02 blah.avi", null)] - [InlineData(@"Season 2/02x03 - 04 Ep Name.mp4", null)] - [InlineData(@"Season 2/My show name 02x03 - 04 Ep Name.mp4", null)] - [InlineData(@"Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2/02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2/02x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/02x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/Elementary - 02x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/02x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/02x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", 26)] - [InlineData(@"Season 1/S01E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 1/4x01 – 20 Hours in America (1).mkv", null)] + [InlineData("Season 1/01x02 blah.avi", null)] + [InlineData("Season 1/S01x02 blah.avi", null)] + [InlineData("Season 1/S01E02 blah.avi", null)] + [InlineData("Season 1/S01xE02 blah.avi", null)] + [InlineData("Season 1/seriesname 01x02 blah.avi", null)] + [InlineData("Season 1/seriesname S01x02 blah.avi", null)] + [InlineData("Season 1/seriesname S01E02 blah.avi", null)] + [InlineData("Season 1/seriesname S01xE02 blah.avi", null)] + [InlineData("Season 2/02x03 - 04 Ep Name.mp4", null)] + [InlineData("Season 2/My show name 02x03 - 04 Ep Name.mp4", null)] + [InlineData("Season 2/Elementary - 02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] + [InlineData("Season 2/02x03 - 02x04 - 02x15 - Ep Name.mp4", 15)] + [InlineData("Season 2/02x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 2/Elementary - 02x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 02/02x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 02/Elementary - 02x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 02/02x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 02/Elementary - 02x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 02/02x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 02/Elementary - 02x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 1/Elementary - S01E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 1/S01E23-E24-E26 - The Woman.mp4", 26)] // Four Digits seasons - [InlineData(@"Season 2009/2009x02 blah.avi", null)] - [InlineData(@"Season 2009/S2009x02 blah.avi", null)] - [InlineData(@"Season 2009/S2009E02 blah.avi", null)] - [InlineData(@"Season 2009/S2009xE02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname 2009x02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname S2009x02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname S2009E02 blah.avi", null)] - [InlineData(@"Season 2009/seriesname S2009xE02 blah.avi", null)] - [InlineData(@"Season 2009/Elementary - 2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03-04-15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03-E15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03 - x04 - x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/2009x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - 2009x03x04x15 - Ep Name.mp4", 15)] - [InlineData(@"Season 2009/Elementary - S2009E23-E24-E26 - The Woman.mp4", 26)] - [InlineData(@"Season 2009/S2009E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 2009/2009x02 blah.avi", null)] + [InlineData("Season 2009/S2009x02 blah.avi", null)] + [InlineData("Season 2009/S2009E02 blah.avi", null)] + [InlineData("Season 2009/S2009xE02 blah.avi", null)] + [InlineData("Season 2009/seriesname 2009x02 blah.avi", null)] + [InlineData("Season 2009/seriesname S2009x02 blah.avi", null)] + [InlineData("Season 2009/seriesname S2009E02 blah.avi", null)] + [InlineData("Season 2009/seriesname S2009xE02 blah.avi", null)] + [InlineData("Season 2009/Elementary - 2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03 - 2009x04 - 2009x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03-04-15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03-E15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03 - x04 - x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/2009x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - 2009x03x04x15 - Ep Name.mp4", 15)] + [InlineData("Season 2009/Elementary - S2009E23-E24-E26 - The Woman.mp4", 26)] + [InlineData("Season 2009/S2009E23-E24-E26 - The Woman.mp4", 26)] // Without season number - [InlineData(@"Season 1/02 - blah.avi", null)] - [InlineData(@"Season 2/02 - blah 14 blah.avi", null)] - [InlineData(@"Season 1/02 - blah-02 a.avi", null)] - [InlineData(@"Season 2/02.avi", null)] - [InlineData(@"Season 1/02-03 - blah.avi", 3)] - [InlineData(@"Season 2/02-04 - blah 14 blah.avi", 4)] - [InlineData(@"Season 1/02-05 - blah-02 a.avi", 5)] - [InlineData(@"Season 2/02-04.avi", 4)] - [InlineData(@"Season 2 /[HorribleSubs] Hunter X Hunter - 136[720p].mkv", null)] + [InlineData("Season 1/02 - blah.avi", null)] + [InlineData("Season 2/02 - blah 14 blah.avi", null)] + [InlineData("Season 1/02 - blah-02 a.avi", null)] + [InlineData("Season 2/02.avi", null)] + [InlineData("Season 1/02-03 - blah.avi", 3)] + [InlineData("Season 2/02-04 - blah 14 blah.avi", 4)] + [InlineData("Season 1/02-05 - blah-02 a.avi", 5)] + [InlineData("Season 2/02-04.avi", 4)] + [InlineData("Season 2 /[HorribleSubs] Hunter X Hunter - 136[720p].mkv", null)] // With format specification that must not be detected as ending episode number - [InlineData(@"Season 1/series-s09e14-1080p.mkv", null)] - [InlineData(@"Season 1/series-s09e14-720p.mkv", null)] - [InlineData(@"Season 1/series-s09e14-720i.mkv", null)] - [InlineData(@"Season 1/MOONLIGHTING_s01e01-e04.mkv", 4)] - [InlineData(@"Season 1/MOONLIGHTING_s01e01-e04", 4)] + [InlineData("Season 1/series-s09e14-1080p.mkv", null)] + [InlineData("Season 1/series-s09e14-720p.mkv", null)] + [InlineData("Season 1/series-s09e14-720i.mkv", null)] + [InlineData("Season 1/MOONLIGHTING_s01e01-e04.mkv", 4)] + [InlineData("Season 1/MOONLIGHTING_s01e01-e04", 4)] public void TestGetEndingEpisodeNumberFromFile(string filename, int? endingEpisodeNumber) { var result = _episodePathParser.Parse(filename, false); diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs index 55af33836..6773bbeb1 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonFolderTests.cs @@ -6,23 +6,23 @@ namespace Jellyfin.Naming.Tests.TV public class SeasonFolderTests { [Theory] - [InlineData(@"/Drive/Season 1", 1, true)] - [InlineData(@"/Drive/Season 2", 2, true)] - [InlineData(@"/Drive/Season 02", 2, true)] - [InlineData(@"/Drive/Seinfeld/S02", 2, true)] - [InlineData(@"/Drive/Seinfeld/2", 2, true)] - [InlineData(@"/Drive/Season 2009", 2009, true)] - [InlineData(@"/Drive/Season1", 1, true)] - [InlineData(@"The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] - [InlineData(@"/Drive/Season 7 (2016)", 7, false)] - [InlineData(@"/Drive/Staffel 7 (2016)", 7, false)] - [InlineData(@"/Drive/Stagione 7 (2016)", 7, false)] - [InlineData(@"/Drive/Season (8)", null, false)] - [InlineData(@"/Drive/3.Staffel", 3, false)] - [InlineData(@"/Drive/s06e05", null, false)] - [InlineData(@"/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] - [InlineData(@"/Drive/extras", 0, true)] - [InlineData(@"/Drive/specials", 0, true)] + [InlineData("/Drive/Season 1", 1, true)] + [InlineData("/Drive/Season 2", 2, true)] + [InlineData("/Drive/Season 02", 2, true)] + [InlineData("/Drive/Seinfeld/S02", 2, true)] + [InlineData("/Drive/Seinfeld/2", 2, true)] + [InlineData("/Drive/Season 2009", 2009, true)] + [InlineData("/Drive/Season1", 1, true)] + [InlineData("The Wonder Years/The.Wonder.Years.S04.PDTV.x264-JCH", 4, true)] + [InlineData("/Drive/Season 7 (2016)", 7, false)] + [InlineData("/Drive/Staffel 7 (2016)", 7, false)] + [InlineData("/Drive/Stagione 7 (2016)", 7, false)] + [InlineData("/Drive/Season (8)", null, false)] + [InlineData("/Drive/3.Staffel", 3, false)] + [InlineData("/Drive/s06e05", null, false)] + [InlineData("/Drive/The.Legend.of.Condor.Heroes.2017.V2.web-dl.1080p.h264.aac-hdctv", null, false)] + [InlineData("/Drive/extras", 0, true)] + [InlineData("/Drive/specials", 0, true)] public void GetSeasonNumberFromPathTest(string path, int? seasonNumber, bool isSeasonDirectory) { var result = SeasonPathParser.Parse(path, true, true); diff --git a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs index 58ec1b5d2..94a953de3 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SeasonNumberTests.cs @@ -51,8 +51,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Season 2009/Elementary - S2009E23-E24-E26 - The Woman.mp4", 2009)] [InlineData("Season 2009/S2009E23-E24-E26 - The Woman.mp4", 2009)] [InlineData("Series/1-12 - The Woman.mp4", 1)] - [InlineData(@"Running Man/Running Man S2017E368.mkv", 2017)] - [InlineData(@"Case Closed (1996-2007)/Case Closed - 317.mkv", 3)] + [InlineData("Running Man/Running Man S2017E368.mkv", 2017)] + [InlineData("Case Closed (1996-2007)/Case Closed - 317.mkv", 3)] // TODO: [InlineData(@"Seinfeld/Seinfeld 0807 The Checks.avi", 8)] public void GetSeasonNumberFromEpisodeFileTest(string path, int? expected) { diff --git a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs index fa46ecc3a..3721cd28c 100644 --- a/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs +++ b/tests/Jellyfin.Naming.Tests/TV/SimpleEpisodeTests.cs @@ -21,8 +21,8 @@ namespace Jellyfin.Naming.Tests.TV [InlineData("Series/4x12 - The Woman.mp4", "", 4, 12)] [InlineData("Series/LA X, Pt. 1_s06e32.mp4", "LA X, Pt. 1", 6, 32)] [InlineData("[Baz-Bar]Foo - [1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] - [InlineData(@"/Foo/The.Series.Name.S01E04.WEBRip.x264-Baz[Bar]/the.series.name.s01e04.webrip.x264-Baz[Bar].mkv", "The.Series.Name", 1, 4)] - [InlineData(@"Love.Death.and.Robots.S01.1080p.NF.WEB-DL.DDP5.1.x264-NTG/Love.Death.and.Robots.S01E01.Sonnies.Edge.1080p.NF.WEB-DL.DDP5.1.x264-NTG.mkv", "Love.Death.and.Robots", 1, 1)] + [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)] [InlineData("[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken/[YuiSubs] Tensura Nikki - Tensei Shitara Slime Datta Ken - 12 (NVENC H.265 1080p).mkv", "Tensura Nikki - Tensei Shitara Slime Datta Ken", null, 12)] [InlineData("[Baz-Bar]Foo - 01 - 12[1080p][Multiple Subtitle]/[Baz-Bar] Foo - 05 [1080p][Multiple Subtitle].mkv", "Foo", null, 5)] [InlineData("Series/4-12 - The Woman.mp4", "", 4, 12, 12)] diff --git a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs index b1141df47..62d60e5a4 100644 --- a/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/CleanDateTimeTests.cs @@ -10,34 +10,34 @@ namespace Jellyfin.Naming.Tests.Video private readonly NamingOptions _namingOptions = new NamingOptions(); [Theory] - [InlineData(@"The Wolf of Wall Street (2013).mkv", "The Wolf of Wall Street", 2013)] - [InlineData(@"The Wolf of Wall Street 2 (2013).mkv", "The Wolf of Wall Street 2", 2013)] - [InlineData(@"The Wolf of Wall Street - 2 (2013).mkv", "The Wolf of Wall Street - 2", 2013)] - [InlineData(@"The Wolf of Wall Street 2001 (2013).mkv", "The Wolf of Wall Street 2001", 2013)] - [InlineData(@"300 (2006).mkv", "300", 2006)] - [InlineData(@"d:/movies/300 (2006).mkv", "300", 2006)] - [InlineData(@"300 2 (2006).mkv", "300 2", 2006)] - [InlineData(@"300 - 2 (2006).mkv", "300 - 2", 2006)] - [InlineData(@"300 2001 (2006).mkv", "300 2001", 2006)] - [InlineData(@"curse.of.chucky.2013.stv.unrated.multi.1080p.bluray.x264-rough", "curse.of.chucky", 2013)] - [InlineData(@"curse.of.chucky.2013.stv.unrated.multi.2160p.bluray.x264-rough", "curse.of.chucky", 2013)] - [InlineData(@"/server/Movies/300 (2007)/300 (2006).bluray.disc", "300", 2006)] - [InlineData(@"Arrival.2016.2160p.Blu-Ray.HEVC.mkv", "Arrival", 2016)] - [InlineData(@"The Wolf of Wall Street (2013)", "The Wolf of Wall Street", 2013)] - [InlineData(@"The Wolf of Wall Street 2 (2013)", "The Wolf of Wall Street 2", 2013)] - [InlineData(@"The Wolf of Wall Street - 2 (2013)", "The Wolf of Wall Street - 2", 2013)] - [InlineData(@"The Wolf of Wall Street 2001 (2013)", "The Wolf of Wall Street 2001", 2013)] - [InlineData(@"300 (2006)", "300", 2006)] - [InlineData(@"d:/movies/300 (2006)", "300", 2006)] - [InlineData(@"300 2 (2006)", "300 2", 2006)] - [InlineData(@"300 - 2 (2006)", "300 - 2", 2006)] - [InlineData(@"300 2001 (2006)", "300 2001", 2006)] - [InlineData(@"/server/Movies/300 (2007)/300 (2006)", "300", 2006)] - [InlineData(@"/server/Movies/300 (2007)/300 (2006).mkv", "300", 2006)] - [InlineData(@"American.Psycho.mkv", "American.Psycho.mkv", null)] - [InlineData(@"American Psycho.mkv", "American Psycho.mkv", null)] - [InlineData(@"[rec].mkv", "[rec].mkv", null)] - [InlineData(@"St. Vincent (2014)", "St. Vincent", 2014)] + [InlineData("The Wolf of Wall Street (2013).mkv", "The Wolf of Wall Street", 2013)] + [InlineData("The Wolf of Wall Street 2 (2013).mkv", "The Wolf of Wall Street 2", 2013)] + [InlineData("The Wolf of Wall Street - 2 (2013).mkv", "The Wolf of Wall Street - 2", 2013)] + [InlineData("The Wolf of Wall Street 2001 (2013).mkv", "The Wolf of Wall Street 2001", 2013)] + [InlineData("300 (2006).mkv", "300", 2006)] + [InlineData("d:/movies/300 (2006).mkv", "300", 2006)] + [InlineData("300 2 (2006).mkv", "300 2", 2006)] + [InlineData("300 - 2 (2006).mkv", "300 - 2", 2006)] + [InlineData("300 2001 (2006).mkv", "300 2001", 2006)] + [InlineData("curse.of.chucky.2013.stv.unrated.multi.1080p.bluray.x264-rough", "curse.of.chucky", 2013)] + [InlineData("curse.of.chucky.2013.stv.unrated.multi.2160p.bluray.x264-rough", "curse.of.chucky", 2013)] + [InlineData("/server/Movies/300 (2007)/300 (2006).bluray.disc", "300", 2006)] + [InlineData("Arrival.2016.2160p.Blu-Ray.HEVC.mkv", "Arrival", 2016)] + [InlineData("The Wolf of Wall Street (2013)", "The Wolf of Wall Street", 2013)] + [InlineData("The Wolf of Wall Street 2 (2013)", "The Wolf of Wall Street 2", 2013)] + [InlineData("The Wolf of Wall Street - 2 (2013)", "The Wolf of Wall Street - 2", 2013)] + [InlineData("The Wolf of Wall Street 2001 (2013)", "The Wolf of Wall Street 2001", 2013)] + [InlineData("300 (2006)", "300", 2006)] + [InlineData("d:/movies/300 (2006)", "300", 2006)] + [InlineData("300 2 (2006)", "300 2", 2006)] + [InlineData("300 - 2 (2006)", "300 - 2", 2006)] + [InlineData("300 2001 (2006)", "300 2001", 2006)] + [InlineData("/server/Movies/300 (2007)/300 (2006)", "300", 2006)] + [InlineData("/server/Movies/300 (2007)/300 (2006).mkv", "300", 2006)] + [InlineData("American.Psycho.mkv", "American.Psycho.mkv", null)] + [InlineData("American Psycho.mkv", "American Psycho.mkv", null)] + [InlineData("[rec].mkv", "[rec].mkv", null)] + [InlineData("St. Vincent (2014)", "St. Vincent", 2014)] [InlineData("Super movie(2009).mp4", "Super movie", 2009)] [InlineData("Drug War 2013.mp4", "Drug War", 2013)] [InlineData("My Movie (1997) - GreatestReleaseGroup 2019.mp4", "My Movie", 1997)] @@ -45,9 +45,9 @@ namespace Jellyfin.Naming.Tests.Video [InlineData("First Man (2018) 1080p.mkv", "First Man", 2018)] [InlineData("Maximum Ride - 2016 - WEBDL-1080p - x264 AC3.mkv", "Maximum Ride", 2016)] // FIXME: [InlineData("Robin Hood [Multi-Subs] [2018].mkv", "Robin Hood", 2018)] - [InlineData(@"3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again + [InlineData("3.Days.to.Kill.2014.720p.BluRay.x264.YIFY.mkv", "3.Days.to.Kill", 2014)] // In this test case, running CleanDateTime first produces no date, so it will attempt to run CleanString first and then CleanDateTime again [InlineData("3 days to kill (2005).mkv", "3 days to kill", 2005)] - [InlineData(@"Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)] + [InlineData("Rain Man 1988 REMASTERED 1080p BluRay x264 AAC - Ozlem.mp4", "Rain Man", 1988)] [InlineData("My Movie 2013.12.09", "My Movie 2013.12.09", null)] [InlineData("My Movie 2013-12-09", "My Movie 2013-12-09", null)] [InlineData("My Movie 20131209", "My Movie 20131209", null)] diff --git a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs index 511a014a6..fccce5bff 100644 --- a/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/Format3DTests.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void Test3DName() { - var result = VideoResolver.ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); + var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.3d.hsbs.mkv", _namingOptions); Assert.Equal("hsbs", result?.Format3D); Assert.Equal("Oblivion", result?.Name); diff --git a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs index 294f11ee7..183ec8984 100644 --- a/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/MultiVersionTests.cs @@ -15,10 +15,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - 1080p.mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - [hsbs].mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - 1080p.mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - [hsbs].mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past [hsbs].mkv" }; var result = VideoListResolver.Resolve( @@ -34,10 +34,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - apple.mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past - banana.mkv", - @"/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - apple.mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past-trailer.mp4", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past - banana.mkv", + "/movies/X-Men Days of Future Past/X-Men Days of Future Past [banana].mp4" }; var result = VideoListResolver.Resolve( @@ -54,8 +54,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1925 version.mkv", - @"/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" + "/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1925 version.mkv", + "/movies/The Phantom of the Opera (1925)/The Phantom of the Opera (1925) - 1929 version.mkv" }; var result = VideoListResolver.Resolve( @@ -71,13 +71,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/M/Movie 1.mkv", - @"/movies/M/Movie 2.mkv", - @"/movies/M/Movie 3.mkv", - @"/movies/M/Movie 4.mkv", - @"/movies/M/Movie 5.mkv", - @"/movies/M/Movie 6.mkv", - @"/movies/M/Movie 7.mkv" + "/movies/M/Movie 1.mkv", + "/movies/M/Movie 2.mkv", + "/movies/M/Movie 3.mkv", + "/movies/M/Movie 4.mkv", + "/movies/M/Movie 5.mkv", + "/movies/M/Movie 6.mkv", + "/movies/M/Movie 7.mkv" }; var result = VideoListResolver.Resolve( @@ -93,14 +93,14 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Movie/Movie.mkv", - @"/movies/Movie/Movie-2.mkv", - @"/movies/Movie/Movie-3.mkv", - @"/movies/Movie/Movie-4.mkv", - @"/movies/Movie/Movie-5.mkv", - @"/movies/Movie/Movie-6.mkv", - @"/movies/Movie/Movie-7.mkv", - @"/movies/Movie/Movie-8.mkv" + "/movies/Movie/Movie.mkv", + "/movies/Movie/Movie-2.mkv", + "/movies/Movie/Movie-3.mkv", + "/movies/Movie/Movie-4.mkv", + "/movies/Movie/Movie-5.mkv", + "/movies/Movie/Movie-6.mkv", + "/movies/Movie/Movie-7.mkv", + "/movies/Movie/Movie-8.mkv" }; var result = VideoListResolver.Resolve( @@ -116,15 +116,15 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Mo/Movie 1.mkv", - @"/movies/Mo/Movie 2.mkv", - @"/movies/Mo/Movie 3.mkv", - @"/movies/Mo/Movie 4.mkv", - @"/movies/Mo/Movie 5.mkv", - @"/movies/Mo/Movie 6.mkv", - @"/movies/Mo/Movie 7.mkv", - @"/movies/Mo/Movie 8.mkv", - @"/movies/Mo/Movie 9.mkv" + "/movies/Mo/Movie 1.mkv", + "/movies/Mo/Movie 2.mkv", + "/movies/Mo/Movie 3.mkv", + "/movies/Mo/Movie 4.mkv", + "/movies/Mo/Movie 5.mkv", + "/movies/Mo/Movie 6.mkv", + "/movies/Mo/Movie 7.mkv", + "/movies/Mo/Movie 8.mkv", + "/movies/Mo/Movie 9.mkv" }; var result = VideoListResolver.Resolve( @@ -140,11 +140,11 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Movie/Movie 1.mkv", - @"/movies/Movie/Movie 2.mkv", - @"/movies/Movie/Movie 3.mkv", - @"/movies/Movie/Movie 4.mkv", - @"/movies/Movie/Movie 5.mkv" + "/movies/Movie/Movie 1.mkv", + "/movies/Movie/Movie 2.mkv", + "/movies/Movie/Movie 3.mkv", + "/movies/Movie/Movie 4.mkv", + "/movies/Movie/Movie 5.mkv" }; var result = VideoListResolver.Resolve( @@ -162,11 +162,11 @@ namespace Jellyfin.Naming.Tests.Video var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man (2008).mkv", - @"/movies/Iron Man/Iron Man (2009).mkv", - @"/movies/Iron Man/Iron Man (2010).mkv", - @"/movies/Iron Man/Iron Man (2011).mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man (2008).mkv", + "/movies/Iron Man/Iron Man (2009).mkv", + "/movies/Iron Man/Iron Man (2010).mkv", + "/movies/Iron Man/Iron Man (2011).mkv" }; var result = VideoListResolver.Resolve( @@ -182,13 +182,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man-720p.mkv", - @"/movies/Iron Man/Iron Man-test.mkv", - @"/movies/Iron Man/Iron Man-bluray.mkv", - @"/movies/Iron Man/Iron Man-3d.mkv", - @"/movies/Iron Man/Iron Man-3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man[test].mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man-720p.mkv", + "/movies/Iron Man/Iron Man-test.mkv", + "/movies/Iron Man/Iron Man-bluray.mkv", + "/movies/Iron Man/Iron Man-3d.mkv", + "/movies/Iron Man/Iron Man-3d-hsbs.mkv", + "/movies/Iron Man/Iron Man[test].mkv" }; var result = VideoListResolver.Resolve( @@ -211,13 +211,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man - 720p.mkv", - @"/movies/Iron Man/Iron Man - test.mkv", - @"/movies/Iron Man/Iron Man - bluray.mkv", - @"/movies/Iron Man/Iron Man - 3d.mkv", - @"/movies/Iron Man/Iron Man - 3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man [test].mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man - 720p.mkv", + "/movies/Iron Man/Iron Man - test.mkv", + "/movies/Iron Man/Iron Man - bluray.mkv", + "/movies/Iron Man/Iron Man - 3d.mkv", + "/movies/Iron Man/Iron Man - 3d-hsbs.mkv", + "/movies/Iron Man/Iron Man [test].mkv" }; var result = VideoListResolver.Resolve( @@ -240,8 +240,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man - B (2006).mkv", - @"/movies/Iron Man/Iron Man - C (2007).mkv" + "/movies/Iron Man/Iron Man - B (2006).mkv", + "/movies/Iron Man/Iron Man - C (2007).mkv" }; var result = VideoListResolver.Resolve( @@ -256,13 +256,13 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Iron Man/Iron Man.mkv", - @"/movies/Iron Man/Iron Man_720p.mkv", - @"/movies/Iron Man/Iron Man_test.mkv", - @"/movies/Iron Man/Iron Man_bluray.mkv", - @"/movies/Iron Man/Iron Man_3d.mkv", - @"/movies/Iron Man/Iron Man_3d-hsbs.mkv", - @"/movies/Iron Man/Iron Man_3d.hsbs.mkv" + "/movies/Iron Man/Iron Man.mkv", + "/movies/Iron Man/Iron Man_720p.mkv", + "/movies/Iron Man/Iron Man_test.mkv", + "/movies/Iron Man/Iron Man_bluray.mkv", + "/movies/Iron Man/Iron Man_3d.mkv", + "/movies/Iron Man/Iron Man_3d-hsbs.mkv", + "/movies/Iron Man/Iron Man_3d.hsbs.mkv" }; var result = VideoListResolver.Resolve( @@ -280,11 +280,11 @@ namespace Jellyfin.Naming.Tests.Video var files = new[] { - @"/movies/Iron Man/Iron Man (2007).mkv", - @"/movies/Iron Man/Iron Man (2008).mkv", - @"/movies/Iron Man/Iron Man (2009).mkv", - @"/movies/Iron Man/Iron Man (2010).mkv", - @"/movies/Iron Man/Iron Man (2011).mkv" + "/movies/Iron Man/Iron Man (2007).mkv", + "/movies/Iron Man/Iron Man (2008).mkv", + "/movies/Iron Man/Iron Man (2009).mkv", + "/movies/Iron Man/Iron Man (2010).mkv", + "/movies/Iron Man/Iron Man (2011).mkv" }; var result = VideoListResolver.Resolve( @@ -300,8 +300,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/Blade Runner (1982)/Blade Runner (1982) [Final Cut] [1080p HEVC AAC].mkv", - @"/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" + "/movies/Blade Runner (1982)/Blade Runner (1982) [Final Cut] [1080p HEVC AAC].mkv", + "/movies/Blade Runner (1982)/Blade Runner (1982) [EE by ADM] [480p HEVC AAC,AAC,AAC].mkv" }; var result = VideoListResolver.Resolve( @@ -317,8 +317,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [1080p] Blu-ray.x264.DTS.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [1080p] Blu-ray.x264.DTS.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) [2160p] Blu-ray.x265.AAC.mkv" }; var result = VideoListResolver.Resolve( @@ -334,12 +334,12 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", - @"/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Theatrical Release.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - Directors Cut.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 1080p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 2160p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016) - 720p.mkv", + "/movies/X-Men Apocalypse (2016)/X-Men Apocalypse (2016).mkv", }; var result = VideoListResolver.Resolve( @@ -361,8 +361,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 1.mkv", - @"/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" + "/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 1.mkv", + "/movies/John Wick - Kapitel 3 (2019) [imdbid=tt6146586]/John Wick - Kapitel 3 (2019) [imdbid=tt6146586] - Version 2.mkv" }; var result = VideoListResolver.Resolve( @@ -378,8 +378,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 1].mkv", - @"/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" + "/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 1].mkv", + "/movies/John Wick - Chapter 3 (2019)/John Wick - Chapter 3 (2019) [Version 2.mkv" }; var result = VideoListResolver.Resolve( diff --git a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs index 97b52f749..c95703f53 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StackTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StackTests.cs @@ -384,8 +384,8 @@ namespace Jellyfin.Naming.Tests.Video // No stacking here because there is no part/disc/etc var files = new[] { - @"M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 01)", - @"M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 02)" + "M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 01)", + "M:/Movies (DVD)/Movies (Musical)/The Sound of Music/The Sound of Music (1965) (Disc 02)" }; var result = StackResolver.ResolveDirectories(files, _namingOptions).ToList(); diff --git a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs index 1d50df7a6..fc852ae85 100644 --- a/tests/Jellyfin.Naming.Tests/Video/StubTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/StubTests.cs @@ -29,7 +29,7 @@ namespace Jellyfin.Naming.Tests.Video [Fact] public void TestStubName() { - var result = VideoResolver.ResolveFile(@"C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc", _namingOptions); + var result = VideoResolver.ResolveFile("C:/Users/media/Desktop/Video Test/Movies/Oblivion/Oblivion.dvd.disc", _namingOptions); Assert.Equal("Oblivion", result?.Name); } diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs index 0316377d4..377f82eac 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoListResolverTests.cs @@ -200,8 +200,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 1", - @"M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" + "M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 1", + "M:/Movies (DVD)/Movies (Musical)/Sound of Music (1965)/Sound of Music Disc 2" }; var result = VideoListResolver.Resolve( @@ -217,8 +217,8 @@ namespace Jellyfin.Naming.Tests.Video // These should be considered separate, unrelated videos var files = new[] { - @"My movie #1.mp4", - @"My movie #2.mp4" + "My movie #1.mp4", + "My movie #2.mp4" }; var result = VideoListResolver.Resolve( @@ -233,10 +233,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"No (2012) part1.mp4", - @"No (2012) part2.mp4", - @"No (2012) part1-trailer.mp4", - @"No (2012)-trailer.mp4" + "No (2012) part1.mp4", + "No (2012) part2.mp4", + "No (2012) part1-trailer.mp4", + "No (2012)-trailer.mp4" }; var result = VideoListResolver.Resolve( @@ -254,10 +254,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Movies/Top Gun (1984)/movie.mp4", - @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer.mp4", - @"/Movies/Top Gun (1984)/Top Gun (1984)-trailer2.mp4", - @"/Movies/trailer.mp4" + "/Movies/Top Gun (1984)/movie.mp4", + "/Movies/Top Gun (1984)/Top Gun (1984)-trailer.mp4", + "/Movies/Top Gun (1984)/Top Gun (1984)-trailer2.mp4", + "/Movies/trailer.mp4" }; var result = VideoListResolver.Resolve( @@ -276,10 +276,10 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd1.avi", - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd2.avi", - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd1.avi", - @"/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd1.avi", + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Counterfeit Racks (2011) Disc 1 cd2.avi", + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd1.avi", + "/MCFAMILY-PC/Private3$/Heterosexual/Breast In Class 2 Counterfeit Racks (2011)/Breast In Class 2 Disc 2 cd2.avi" }; var result = VideoListResolver.Resolve( @@ -294,7 +294,7 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" + "/nas-markrobbo78/Videos/INDEX HTPC/Movies/Watched/3 - ACTION/Argo (2012)/movie.mkv" }; var result = VideoListResolver.Resolve( @@ -309,7 +309,7 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"The Colony.mkv" + "The Colony.mkv" }; var result = VideoListResolver.Resolve( @@ -324,8 +324,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"Four Sisters and a Wedding - A.avi", - @"Four Sisters and a Wedding - B.avi" + "Four Sisters and a Wedding - A.avi", + "Four Sisters and a Wedding - B.avi" }; var result = VideoListResolver.Resolve( @@ -342,8 +342,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"Four Rooms - A.avi", - @"Four Rooms - A.mp4" + "Four Rooms - A.avi", + "Four Rooms - A.mp4" }; var result = VideoListResolver.Resolve( @@ -358,8 +358,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Server/Despicable Me/Despicable Me (2010).mkv", - @"/Server/Despicable Me/trailer.mkv" + "/Server/Despicable Me/Despicable Me (2010).mkv", + "/Server/Despicable Me/trailer.mkv" }; var result = VideoListResolver.Resolve( @@ -376,8 +376,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Server/Despicable Me/Despicable Me (2010).mkv", - @"/Server/Despicable Me/trailers/some title.mkv" + "/Server/Despicable Me/Despicable Me (2010).mkv", + "/Server/Despicable Me/trailers/some title.mkv" }; var result = VideoListResolver.Resolve( @@ -394,8 +394,8 @@ namespace Jellyfin.Naming.Tests.Video { var files = new[] { - @"/Movies/Despicable Me/Despicable Me.mkv", - @"/Movies/Despicable Me/trailers/trailer.mkv" + "/Movies/Despicable Me/Despicable Me.mkv", + "/Movies/Despicable Me/trailers/trailer.mkv" }; var result = VideoListResolver.Resolve( diff --git a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs index 33a99e107..8455a56a1 100644 --- a/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/VideoResolverTests.cs @@ -15,26 +15,26 @@ namespace Jellyfin.Naming.Tests.Video var data = new TheoryData<VideoFileInfo>(); data.Add( new VideoFileInfo( - path: @"/server/Movies/7 Psychos.mkv/7 Psychos.mkv", + path: "/server/Movies/7 Psychos.mkv/7 Psychos.mkv", container: "mkv", name: "7 Psychos")); data.Add( new VideoFileInfo( - path: @"/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", + path: "/server/Movies/3 days to kill (2005)/3 days to kill (2005).mkv", container: "mkv", name: "3 days to kill", year: 2005)); data.Add( new VideoFileInfo( - path: @"/server/Movies/American Psycho/American.Psycho.mkv", + path: "/server/Movies/American Psycho/American.Psycho.mkv", container: "mkv", name: "American.Psycho")); data.Add( new VideoFileInfo( - path: @"/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", + path: "/server/Movies/brave (2007)/brave (2006).3d.sbs.mkv", container: "mkv", name: "brave", year: 2006, @@ -43,14 +43,14 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", + path: "/server/Movies/300 (2007)/300 (2006).3d1.sbas.mkv", container: "mkv", name: "300", year: 2006)); data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", + path: "/server/Movies/300 (2007)/300 (2006).3d.sbs.mkv", container: "mkv", name: "300", year: 2006, @@ -59,7 +59,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", + path: "/server/Movies/brave (2007)/brave (2006)-trailer.bluray.disc", container: "disc", name: "brave", year: 2006, @@ -68,7 +68,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", + path: "/server/Movies/300 (2007)/300 (2006)-trailer.bluray.disc", container: "disc", name: "300", year: 2006, @@ -77,7 +77,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/Brave (2007)/Brave (2006).bluray.disc", + path: "/server/Movies/Brave (2007)/Brave (2006).bluray.disc", container: "disc", name: "Brave", year: 2006, @@ -86,7 +86,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).bluray.disc", + path: "/server/Movies/300 (2007)/300 (2006).bluray.disc", container: "disc", name: "300", year: 2006, @@ -95,7 +95,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006)-trailer.mkv", + path: "/server/Movies/300 (2007)/300 (2006)-trailer.mkv", container: "mkv", name: "300", year: 2006, @@ -103,7 +103,7 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", + path: "/server/Movies/Brave (2007)/Brave (2006)-trailer.mkv", container: "mkv", name: "Brave", year: 2006, @@ -111,28 +111,28 @@ namespace Jellyfin.Naming.Tests.Video data.Add( new VideoFileInfo( - path: @"/server/Movies/300 (2007)/300 (2006).mkv", + path: "/server/Movies/300 (2007)/300 (2006).mkv", container: "mkv", name: "300", year: 2006)); data.Add( new VideoFileInfo( - path: @"/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", + path: "/server/Movies/Bad Boys (1995)/Bad Boys (1995).mkv", container: "mkv", name: "Bad Boys", year: 1995)); data.Add( new VideoFileInfo( - path: @"/server/Movies/Brave (2007)/Brave (2006).mkv", + path: "/server/Movies/Brave (2007)/Brave (2006).mkv", container: "mkv", name: "Brave", 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", + 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)); @@ -174,8 +174,8 @@ namespace Jellyfin.Naming.Tests.Video { var paths = new[] { - @"/Server/Iron Man", - @"Batman", + "/Server/Iron Man", + "Batman", string.Empty }; diff --git a/tests/Jellyfin.Networking.Tests/IPNetAddressTests.cs b/tests/Jellyfin.Networking.Tests/IPNetAddressTests.cs deleted file mode 100644 index aa2dbc57a..000000000 --- a/tests/Jellyfin.Networking.Tests/IPNetAddressTests.cs +++ /dev/null @@ -1,49 +0,0 @@ -using FsCheck; -using FsCheck.Xunit; -using MediaBrowser.Common.Net; -using Xunit; - -namespace Jellyfin.Networking.Tests -{ - public static class IPNetAddressTests - { - /// <summary> - /// Checks IP address formats. - /// </summary> - /// <param name="address">IP Address.</param> - [Theory] - [InlineData("127.0.0.1")] - [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")] - [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")] - [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]")] - [InlineData("fe80::7add:12ff:febb:c67b%16")] - [InlineData("[fe80::7add:12ff:febb:c67b%16]:123")] - [InlineData("fe80::7add:12ff:febb:c67b%16:123")] - [InlineData("[fe80::7add:12ff:febb:c67b%16]")] - [InlineData("192.168.1.2/255.255.255.0")] - [InlineData("192.168.1.2/24")] - public static void TryParse_ValidIPStrings_True(string address) - => Assert.True(IPNetAddress.TryParse(address, out _)); - - [Property] - public static Property TryParse_IPv4Address_True(IPv4Address address) - => IPNetAddress.TryParse(address.Item.ToString(), out _).ToProperty(); - - [Property] - public static Property TryParse_IPv6Address_True(IPv6Address address) - => IPNetAddress.TryParse(address.Item.ToString(), out _).ToProperty(); - - /// <summary> - /// All should be invalid address strings. - /// </summary> - /// <param name="address">Invalid address strings.</param> - [Theory] - [InlineData("256.128.0.0.0.1")] - [InlineData("127.0.0.1#")] - [InlineData("localhost!")] - [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")] - [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")] - public static void TryParse_InvalidAddressString_False(string address) - => Assert.False(IPNetAddress.TryParse(address, out _)); - } -} diff --git a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj index 4b4bdd2a5..3747db3bb 100644 --- a/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj +++ b/tests/Jellyfin.Networking.Tests/Jellyfin.Networking.Tests.csproj @@ -18,12 +18,7 @@ </ItemGroup> <ItemGroup> - <ProjectReference Include="../../Emby.Server.Implementations/Emby.Server.Implementations.csproj" /> - <ProjectReference Include="../../MediaBrowser.Common/MediaBrowser.Common.csproj" /> + <ProjectReference Include="../../Jellyfin.Networking/Jellyfin.Networking.csproj" /> </ItemGroup> - <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> - <DefineConstants>DEBUG</DefineConstants> - </PropertyGroup> - </Project> diff --git a/tests/Jellyfin.Networking.Tests/IPHostTests.cs b/tests/Jellyfin.Networking.Tests/NetworkExtensionsTests.cs index ec3a1300c..072e0a8c5 100644 --- a/tests/Jellyfin.Networking.Tests/IPHostTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkExtensionsTests.cs @@ -1,11 +1,11 @@ using FsCheck; using FsCheck.Xunit; -using MediaBrowser.Common.Net; +using Jellyfin.Networking.Extensions; using Xunit; namespace Jellyfin.Networking.Tests { - public static class IPHostTests + public static class NetworkExtensionsTests { /// <summary> /// Checks IP address formats. @@ -16,7 +16,6 @@ namespace Jellyfin.Networking.Tests [InlineData("127.0.0.1:123")] [InlineData("localhost")] [InlineData("localhost:1345")] - [InlineData("www.google.co.uk")] [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")] [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")] [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]:124")] @@ -27,15 +26,15 @@ namespace Jellyfin.Networking.Tests [InlineData("192.168.1.2/255.255.255.0")] [InlineData("192.168.1.2/24")] public static void TryParse_ValidHostStrings_True(string address) - => Assert.True(IPHost.TryParse(address, out _)); + => Assert.True(NetworkExtensions.TryParseHost(address, out _, true, true)); [Property] public static Property TryParse_IPv4Address_True(IPv4Address address) - => IPHost.TryParse(address.Item.ToString(), out _).ToProperty(); + => NetworkExtensions.TryParseHost(address.Item.ToString(), out _, true, true).ToProperty(); [Property] public static Property TryParse_IPv6Address_True(IPv6Address address) - => IPHost.TryParse(address.Item.ToString(), out _).ToProperty(); + => NetworkExtensions.TryParseHost(address.Item.ToString(), out _, true, true).ToProperty(); /// <summary> /// All should be invalid address strings. @@ -48,6 +47,6 @@ namespace Jellyfin.Networking.Tests [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")] [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")] public static void TryParse_InvalidAddressString_False(string address) - => Assert.False(IPHost.TryParse(address, out _)); + => Assert.False(NetworkExtensions.TryParseHost(address, out _, true, true)); } } diff --git a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs index df2a2ca70..2302f90b8 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkManagerTests.cs @@ -1,7 +1,9 @@ using System.Net; using Jellyfin.Networking.Configuration; using Jellyfin.Networking.Manager; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; +using Moq; using Xunit; namespace Jellyfin.Networking.Tests @@ -23,12 +25,13 @@ namespace Jellyfin.Networking.Tests var ip = IPAddress.Parse(value); var conf = new NetworkConfiguration() { - EnableIPV6 = true, - EnableIPV4 = true, + EnableIPv6 = true, + EnableIPv4 = true, LocalNetworkSubnets = network.Split(',') }; - using var networkManager = new NetworkManager(NetworkParseTests.GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var networkManager = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); Assert.True(networkManager.IsInLocalNetwork(ip)); } @@ -51,14 +54,15 @@ namespace Jellyfin.Networking.Tests var ip = IPAddress.Parse(value); var conf = new NetworkConfiguration() { - EnableIPV6 = true, - EnableIPV4 = true, + EnableIPv6 = true, + EnableIPv4 = true, LocalNetworkSubnets = network.Split(',') }; - using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var networkManager = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); - Assert.False(nm.IsInLocalNetwork(ip)); + Assert.False(networkManager.IsInLocalNetwork(ip)); } } } diff --git a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs index 8174632bb..022b8a3d0 100644 --- a/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs +++ b/tests/Jellyfin.Networking.Tests/NetworkParseTests.cs @@ -1,10 +1,13 @@ using System; -using System.Collections.ObjectModel; +using System.Collections.Generic; +using System.Linq; using System.Net; using Jellyfin.Networking.Configuration; +using Jellyfin.Networking.Extensions; using Jellyfin.Networking.Manager; using MediaBrowser.Common.Configuration; -using MediaBrowser.Common.Net; +using MediaBrowser.Model.Net; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -34,6 +37,8 @@ namespace Jellyfin.Networking.Tests [InlineData("192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11", "192.168.1.0/24;200.200.200.0/24", "[192.168.1.208/24,200.200.200.200/24]")] // eth16 only [InlineData("192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11", "192.168.1.0/24", "[192.168.1.208/24]")] + // eth16 only without mask + [InlineData("192.168.1.208,-16,eth16|200.200.200.200,11,eth11", "192.168.1.0/24", "[192.168.1.208/32]")] // All interfaces excluded. (including loopbacks) [InlineData("192.168.1.208/24,-16,vEthernet1|192.168.2.208/24,-16,vEthernet212|200.200.200.200/24,11,eth11", "192.168.1.0/24", "[]")] // vEthernet1 and vEthernet212 should be excluded. @@ -44,171 +49,107 @@ namespace Jellyfin.Networking.Tests { var conf = new NetworkConfiguration() { - EnableIPV6 = true, - EnableIPV4 = true, + EnableIPv6 = true, + EnableIPv4 = true, LocalNetworkSubnets = lan?.Split(';') ?? throw new ArgumentNullException(nameof(lan)) }; NetworkManager.MockNetworkSettings = interfaces; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); NetworkManager.MockNetworkSettings = string.Empty; - Assert.Equal(nm.GetInternalBindAddresses().AsString(), value); + Assert.Equal(value, "[" + string.Join(",", nm.GetInternalBindAddresses().Select(x => x.Address + "/" + x.Subnet.PrefixLength)) + "]"); } /// <summary> - /// Test collection parsing. + /// Checks valid IP address formats. /// </summary> - /// <param name="settings">Collection to parse.</param> - /// <param name="result1">Included addresses from the collection.</param> - /// <param name="result2">Included IP4 addresses from the collection.</param> - /// <param name="result3">Excluded addresses from the collection.</param> - /// <param name="result4">Excluded IP4 addresses from the collection.</param> - /// <param name="result5">Network addresses of the collection.</param> + /// <param name="address">IP Address.</param> [Theory] - [InlineData( - "127.0.0.1#", - "[]", - "[]", - "[]", - "[]", - "[]")] - [InlineData( - "!127.0.0.1", - "[]", - "[]", - "[127.0.0.1/32]", - "[127.0.0.1/32]", - "[]")] - [InlineData( - "", - "[]", - "[]", - "[]", - "[]", - "[]")] - [InlineData( - "192.158.1.2/16, localhost, fd23:184f:2029:0:3139:7386:67d7:d517, !10.10.10.10", - "[192.158.1.2/16,[127.0.0.1/32,::1/128],fd23:184f:2029:0:3139:7386:67d7:d517/128]", - "[192.158.1.2/16,127.0.0.1/32]", - "[10.10.10.10/32]", - "[10.10.10.10/32]", - "[192.158.0.0/16,127.0.0.1/32,::1/128,fd23:184f:2029:0:3139:7386:67d7:d517/128]")] - [InlineData( - "192.158.1.2/255.255.0.0,192.169.1.2/8", - "[192.158.1.2/16,192.169.1.2/8]", - "[192.158.1.2/16,192.169.1.2/8]", - "[]", - "[]", - "[192.158.0.0/16,192.0.0.0/8]")] - public void TestCollections(string settings, string result1, string result2, string result3, string result4, string result5) - { - ArgumentNullException.ThrowIfNull(settings); - - var conf = new NetworkConfiguration() - { - EnableIPV6 = true, - EnableIPV4 = true, - }; - - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); - - // Test included. - Collection<IPObject> nc = nm.CreateIPCollection(settings.Split(','), false); - Assert.Equal(nc.AsString(), result1); - - // Test excluded. - nc = nm.CreateIPCollection(settings.Split(','), true); - Assert.Equal(nc.AsString(), result3); - - conf.EnableIPV6 = false; - nm.UpdateSettings(conf); - - // Test IP4 included. - nc = nm.CreateIPCollection(settings.Split(','), false); - Assert.Equal(nc.AsString(), result2); - - // Test IP4 excluded. - nc = nm.CreateIPCollection(settings.Split(','), true); - Assert.Equal(nc.AsString(), result4); - - conf.EnableIPV6 = true; - nm.UpdateSettings(conf); - - // Test network addresses of collection. - nc = nm.CreateIPCollection(settings.Split(','), false); - nc = nc.AsNetworks(); - Assert.Equal(nc.AsString(), result5); - } + [InlineData("127.0.0.1")] + [InlineData("127.0.0.1/8")] + [InlineData("192.168.1.2")] + [InlineData("192.168.1.2/24")] + [InlineData("192.168.1.2/255.255.255.0")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517")] + [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517]")] + [InlineData("fe80::7add:12ff:febb:c67b%16")] + [InlineData("[fe80::7add:12ff:febb:c67b%16]:123")] + [InlineData("fe80::7add:12ff:febb:c67b%16:123")] + [InlineData("[fe80::7add:12ff:febb:c67b%16]")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517/56")] + public static void TryParseValidIPStringsTrue(string address) + => Assert.True(NetworkExtensions.TryParseToSubnet(address, out _)); /// <summary> - /// Union two collections. + /// Checks invalid IP address formats. /// </summary> - /// <param name="settings">Source.</param> - /// <param name="compare">Destination.</param> - /// <param name="result">Result.</param> + /// <param name="address">IP Address.</param> [Theory] - [InlineData("127.0.0.1", "fd23:184f:2029:0:3139:7386:67d7:d517/64,fd23:184f:2029:0:c0f0:8a8a:7605:fffa/128,fe80::3139:7386:67d7:d517%16/64,192.168.1.208/24,::1/128,127.0.0.1/8", "[127.0.0.1/32]")] - [InlineData("127.0.0.1", "127.0.0.1/8", "[127.0.0.1/32]")] - public void UnionCheck(string settings, string compare, string result) - { - ArgumentNullException.ThrowIfNull(settings); - - ArgumentNullException.ThrowIfNull(compare); - - ArgumentNullException.ThrowIfNull(result); - - var conf = new NetworkConfiguration() - { - EnableIPV6 = true, - EnableIPV4 = true, - }; - - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); - - Collection<IPObject> nc1 = nm.CreateIPCollection(settings.Split(','), false); - Collection<IPObject> nc2 = nm.CreateIPCollection(compare.Split(','), false); - - Assert.Equal(nc1.ThatAreContainedInNetworks(nc2).AsString(), result); - } + [InlineData("127.0.0.1#")] + [InlineData("localhost!")] + [InlineData("256.128.0.0.0.1")] + [InlineData("fd23:184f:2029:0:3139:7386:67d7:d517:1231")] + [InlineData("[fd23:184f:2029:0:3139:7386:67d7:d517:1231]")] + public static void TryParseInvalidIPStringsFalse(string address) + => Assert.False(NetworkExtensions.TryParseToSubnet(address, out _)); + /// <summary> + /// Checks if IPv4 address is within a defined subnet. + /// </summary> + /// <param name="netMask">Network mask.</param> + /// <param name="ipAddress">IP Address.</param> [Theory] [InlineData("192.168.5.85/24", "192.168.5.1")] [InlineData("192.168.5.85/24", "192.168.5.254")] + [InlineData("192.168.5.85/255.255.255.0", "192.168.5.254")] [InlineData("10.128.240.50/30", "10.128.240.48")] [InlineData("10.128.240.50/30", "10.128.240.49")] [InlineData("10.128.240.50/30", "10.128.240.50")] [InlineData("10.128.240.50/30", "10.128.240.51")] + [InlineData("10.128.240.50/255.255.255.252", "10.128.240.51")] [InlineData("127.0.0.1/8", "127.0.0.1")] - public void IpV4SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress) + public void IPv4SubnetMaskMatchesValidIPAddress(string netMask, string ipAddress) { - var ipAddressObj = IPNetAddress.Parse(netMask); - Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + var ipa = IPAddress.Parse(ipAddress); + Assert.True(NetworkExtensions.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); } + /// <summary> + /// Checks if IPv4 address is not within a defined subnet. + /// </summary> + /// <param name="netMask">Network mask.</param> + /// <param name="ipAddress">IP Address.</param> [Theory] [InlineData("192.168.5.85/24", "192.168.4.254")] [InlineData("192.168.5.85/24", "191.168.5.254")] + [InlineData("192.168.5.85/255.255.255.252", "192.168.4.254")] [InlineData("10.128.240.50/30", "10.128.240.47")] [InlineData("10.128.240.50/30", "10.128.240.52")] [InlineData("10.128.240.50/30", "10.128.239.50")] [InlineData("10.128.240.50/30", "10.127.240.51")] - public void IpV4SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress) + [InlineData("10.128.240.50/255.255.255.252", "10.127.240.51")] + public void IPv4SubnetMaskDoesNotMatchInvalidIPAddress(string netMask, string ipAddress) { - var ipAddressObj = IPNetAddress.Parse(netMask); - Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + var ipa = IPAddress.Parse(ipAddress); + Assert.False(NetworkExtensions.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); } + /// <summary> + /// Checks if IPv6 address is within a defined subnet. + /// </summary> + /// <param name="netMask">Network mask.</param> + /// <param name="ipAddress">IP Address.</param> [Theory] [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")] [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFFF")] [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:0001:0000:0000:0000")] [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0012:FFFF:FFFF:FFFF:FFF0")] [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0000")] - public void IpV6SubnetMaskMatchesValidIpAddress(string netMask, string ipAddress) + public void IPv6SubnetMaskMatchesValidIPAddress(string netMask, string ipAddress) { - var ipAddressObj = IPNetAddress.Parse(netMask); - Assert.True(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); + Assert.True(NetworkExtensions.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); } [Theory] @@ -217,79 +158,16 @@ namespace Jellyfin.Networking.Tests [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0013:0001:0000:0000:0000")] [InlineData("2001:db8:abcd:0012::0/64", "2001:0DB8:ABCD:0011:FFFF:FFFF:FFFF:FFF0")] [InlineData("2001:db8:abcd:0012::0/128", "2001:0DB8:ABCD:0012:0000:0000:0000:0001")] - public void IpV6SubnetMaskDoesNotMatchInvalidIpAddress(string netMask, string ipAddress) - { - var ipAddressObj = IPNetAddress.Parse(netMask); - Assert.False(ipAddressObj.Contains(IPAddress.Parse(ipAddress))); - } - - [Theory] - [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1/32")] - [InlineData("10.0.0.0/8", "10.10.10.1/32")] - [InlineData("10.0.0.0/255.0.0.0", "10.10.10.1")] - - [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1/32")] - [InlineData("10.10.0.0/16", "10.10.10.1/32")] - [InlineData("10.10.0.0/255.255.0.0", "10.10.10.1")] - - [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1/32")] - [InlineData("10.10.10.0/24", "10.10.10.1/32")] - [InlineData("10.10.10.0/255.255.255.0", "10.10.10.1")] - - public void TestSubnetContains(string network, string ip) + public void IPv6SubnetMaskDoesNotMatchInvalidIPAddress(string netMask, string ipAddress) { - Assert.True(IPNetAddress.TryParse(network, out var networkObj)); - Assert.True(IPNetAddress.TryParse(ip, out var ipObj)); - Assert.True(networkObj.Contains(ipObj)); + Assert.False(NetworkExtensions.TryParseToSubnet(netMask, out var subnet) && subnet.Contains(IPAddress.Parse(ipAddress))); } [Theory] - [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24", "172.168.1.2/24")] - [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "172.168.1.2/24, 10.10.10.1", "172.168.1.2/24,10.10.10.1/24")] - [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/255.255.255.0, 10.10.10.1", "192.168.1.2/24,10.10.10.1/24")] - [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "192.168.1.2/24, 100.10.10.1", "192.168.1.2/24")] - [InlineData("192.168.1.2/24,10.10.10.1/24,172.168.1.2/24", "194.168.1.2/24, 100.10.10.1", "")] - - public void TestCollectionEquality(string source, string dest, string result) - { - ArgumentNullException.ThrowIfNull(source); - - ArgumentNullException.ThrowIfNull(dest); - - ArgumentNullException.ThrowIfNull(result); - - var conf = new NetworkConfiguration() - { - EnableIPV6 = true, - EnableIPV4 = true - }; - - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); - - // Test included, IP6. - Collection<IPObject> ncSource = nm.CreateIPCollection(source.Split(',')); - Collection<IPObject> ncDest = nm.CreateIPCollection(dest.Split(',')); - Collection<IPObject> ncResult = ncSource.ThatAreContainedInNetworks(ncDest); - Collection<IPObject> resultCollection = nm.CreateIPCollection(result.Split(',')); - Assert.True(ncResult.Compare(resultCollection)); - } - - [Theory] - [InlineData("10.1.1.1/32", "10.1.1.1")] - [InlineData("192.168.1.254/32", "192.168.1.254/255.255.255.255")] - - public void TestEquals(string source, string dest) - { - Assert.True(IPNetAddress.Parse(source).Equals(IPNetAddress.Parse(dest))); - Assert.True(IPNetAddress.Parse(dest).Equals(IPNetAddress.Parse(source))); - } - - [Theory] - // Testing bind interfaces. // On my system eth16 is internal, eth11 external (Windows defines the indexes). // - // This test is to replicate how DNLA requests work throughout the system. + // This test is to replicate how DLNA requests work throughout the system. // User on internal network, we're bound internal and external - so result is internal. [InlineData("192.168.1.1", "eth16,eth11", false, "eth16")] @@ -319,23 +197,25 @@ namespace Jellyfin.Networking.Tests var conf = new NetworkConfiguration() { LocalNetworkAddresses = bindAddresses.Split(','), - EnableIPV6 = ipv6enabled, - EnableIPV4 = true + EnableIPv6 = ipv6enabled, + EnableIPv4 = true }; NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); NetworkManager.MockNetworkSettings = string.Empty; - _ = nm.TryParseInterface(result, out Collection<IPObject>? resultObj); - - // Check to see if dns resolution is working. If not, skip test. - _ = IPHost.TryParse(source, out var host); + // Check to see if DNS resolution is working. If not, skip test. + if (!NetworkExtensions.TryParseHost(source, out var host)) + { + return; + } - if (resultObj is not null && host?.HasAddress == true) + if (nm.TryParseInterface(result, out var resultObj)) { - result = ((IPNetAddress)resultObj[0]).ToString(true); - var intf = nm.GetBindInterface(source, out _); + result = resultObj[0].Address.ToString(); + var intf = nm.GetBindAddress(source, out _); Assert.Equal(intf, result); } @@ -352,24 +232,24 @@ namespace Jellyfin.Networking.Tests [InlineData("192.168.1.1", "192.168.1.0/24", "eth16,eth11", false, "192.168.1.0/24=internal.jellyfin", "internal.jellyfin")] // User on external network, we're bound internal and external - so result is override. - [InlineData("8.8.8.8", "192.168.1.0/24", "eth16,eth11", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")] + [InlineData("8.8.8.8", "192.168.1.0/24", "eth16,eth11", false, "all=http://helloworld.com", "http://helloworld.com")] // User on internal network, we're bound internal only, but the address isn't in the LAN - so return the override. - [InlineData("10.10.10.10", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://internalButNotDefinedAsLan.com", "http://internalButNotDefinedAsLan.com")] + [InlineData("10.10.10.10", "192.168.1.0/24", "eth16", false, "external=http://internalButNotDefinedAsLan.com", "http://internalButNotDefinedAsLan.com")] // User on internal network, no binding specified - so result is the 1st internal. - [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")] + [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "external=http://helloworld.com", "eth16")] // User on external network, internal binding only - so assumption is a proxy forward, return external override. - [InlineData("jellyfin.org", "192.168.1.0/24", "eth16", false, "0.0.0.0=http://helloworld.com", "http://helloworld.com")] + [InlineData("jellyfin.org", "192.168.1.0/24", "eth16", false, "external=http://helloworld.com", "http://helloworld.com")] - // User on external network, no binding - so result is the 1st external which is overridden. - [InlineData("jellyfin.org", "192.168.1.0/24", "", false, "0.0.0.0 = http://helloworld.com", "http://helloworld.com")] + // User on external network, no binding - so result is the 1st external which is overriden. + [InlineData("jellyfin.org", "192.168.1.0/24", "", false, "external=http://helloworld.com", "http://helloworld.com")] - // User assumed to be internal, no binding - so result is the 1st internal. - [InlineData("", "192.168.1.0/24", "", false, "0.0.0.0=http://helloworld.com", "eth16")] + // User assumed to be internal, no binding - so result is the 1st matching interface. + [InlineData("", "192.168.1.0/24", "", false, "all=http://helloworld.com", "eth16")] - // User is internal, no binding - so result is the 1st internal, which is then overridden. + // User is internal, no binding - so result is the 1st internal interface, which is then overridden. [InlineData("192.168.1.1", "192.168.1.0/24", "", false, "eth16=http://helloworld.com", "http://helloworld.com")] public void TestBindInterfaceOverrides(string source, string lan, string bindAddresses, bool ipv6enabled, string publishedServers, string result) { @@ -381,24 +261,25 @@ namespace Jellyfin.Networking.Tests { LocalNetworkSubnets = lan.Split(','), LocalNetworkAddresses = bindAddresses.Split(','), - EnableIPV6 = ipv6enabled, - EnableIPV4 = true, + EnableIPv6 = ipv6enabled, + EnableIPv4 = true, PublishedServerUriBySubnet = new string[] { publishedServers } }; NetworkManager.MockNetworkSettings = "192.168.1.208/24,-16,eth16|200.200.200.200/24,11,eth11"; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); NetworkManager.MockNetworkSettings = string.Empty; - if (nm.TryParseInterface(result, out Collection<IPObject>? resultObj) && resultObj is not null) + if (nm.TryParseInterface(result, out IReadOnlyList<IPData>? resultObj) && resultObj is not null) { - // Parse out IPAddresses so we can do a string comparison. (Ignore subnet masks). - result = ((IPNetAddress)resultObj[0]).ToString(true); + // Parse out IPAddresses so we can do a string comparison (ignore subnet masks). + result = resultObj[0].Address.ToString(); } - var intf = nm.GetBindInterface(source, out int? _); + var intf = nm.GetBindAddress(source, out int? _); - Assert.Equal(intf, result); + Assert.Equal(result, intf); } [Theory] @@ -406,39 +287,43 @@ namespace Jellyfin.Networking.Tests [InlineData("185.10.10.10", "185.10.10.10", false)] [InlineData("", "100.100.100.100", false)] - public void HasRemoteAccess_GivenWhitelist_AllowsOnlyIpsInWhitelist(string addresses, string remoteIp, bool denied) + public void HasRemoteAccess_GivenWhitelist_AllowsOnlyIPsInWhitelist(string addresses, string remoteIP, bool denied) { // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. // If left blank, all remote addresses will be allowed. var conf = new NetworkConfiguration() { - EnableIPV4 = true, + EnableIPv4 = true, RemoteIPFilter = addresses.Split(','), IsRemoteIPFilterBlacklist = false }; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); - Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIp)), denied); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); + + Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIP)), denied); } [Theory] [InlineData("185.10.10.10", "79.2.3.4", false)] [InlineData("185.10.10.10", "185.10.10.10", true)] [InlineData("", "100.100.100.100", false)] - public void HasRemoteAccess_GivenBlacklist_BlacklistTheIps(string addresses, string remoteIp, bool denied) + + public void HasRemoteAccess_GivenBlacklist_BlacklistTheIPs(string addresses, string remoteIP, bool denied) { // Comma separated list of IP addresses or IP/netmask entries for networks that will be allowed to connect remotely. // If left blank, all remote addresses will be allowed. var conf = new NetworkConfiguration() { - EnableIPV4 = true, + EnableIPv4 = true, RemoteIPFilter = addresses.Split(','), IsRemoteIPFilterBlacklist = true }; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); - Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIp)), denied); + Assert.NotEqual(nm.HasRemoteAccess(IPAddress.Parse(remoteIP)), denied); } [Theory] @@ -450,15 +335,16 @@ namespace Jellyfin.Networking.Tests { var conf = new NetworkConfiguration { - EnableIPV4 = true, + EnableIPv4 = true, LocalNetworkSubnets = lan.Split(','), LocalNetworkAddresses = bind.Split(',') }; NetworkManager.MockNetworkSettings = interfaces; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); - var interfaceToUse = nm.GetBindInterface(string.Empty, out _); + var interfaceToUse = nm.GetBindAddress(string.Empty, out _); Assert.Equal(result, interfaceToUse); } @@ -474,15 +360,16 @@ namespace Jellyfin.Networking.Tests { var conf = new NetworkConfiguration { - EnableIPV4 = true, + EnableIPv4 = true, LocalNetworkSubnets = lan.Split(','), LocalNetworkAddresses = bind.Split(',') }; NetworkManager.MockNetworkSettings = interfaces; - using var nm = new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + using var nm = new NetworkManager(NetworkParseTests.GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); - var interfaceToUse = nm.GetBindInterface(source, out _); + var interfaceToUse = nm.GetBindAddress(source, out _); Assert.Equal(result, interfaceToUse); } diff --git a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj index c12f0cd68..1263043a5 100644 --- a/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj +++ b/tests/Jellyfin.Providers.Tests/Jellyfin.Providers.Tests.csproj @@ -7,6 +7,9 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="AutoFixture" /> + <PackageReference Include="AutoFixture.AutoMoq" /> + <PackageReference Include="AutoFixture.Xunit2" /> <PackageReference Include="Microsoft.NET.Test.Sdk" /> <PackageReference Include="Moq" /> <PackageReference Include="xunit" /> diff --git a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs index 925e8fa19..d5ab6ab4b 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ItemImageProviderTests.cs @@ -25,10 +25,13 @@ using Xunit; namespace Jellyfin.Providers.Tests.Manager { - public class ItemImageProviderTests + public partial class ItemImageProviderTests { private const string TestDataImagePath = "Test Data/Images/blank{0}.jpg"; + [GeneratedRegex("[0-9]+")] + private static partial Regex NumbersRegex(); + [Fact] public void ValidateImages_PhotoEmptyProviders_NoChange() { @@ -349,11 +352,11 @@ namespace Jellyfin.Providers.Tests.Manager { if (forceRefresh) { - Assert.Matches(@"image url [0-9]", image.Path); + Assert.Matches("image url [0-9]", image.Path); } else { - Assert.DoesNotMatch(@"image url [0-9]", image.Path); + Assert.DoesNotMatch("image url [0-9]", image.Path); } } } @@ -463,7 +466,7 @@ namespace Jellyfin.Providers.Tests.Manager // images from the provider manager are sorted by preference (earlier images are higher priority) so we can verify that low url numbers are chosen foreach (var image in actualImages) { - var index = int.Parse(Regex.Match(image.Path, @"[0-9]+").Value, NumberStyles.Integer, CultureInfo.InvariantCulture); + var index = int.Parse(NumbersRegex().Match(image.Path).ValueSpan, NumberStyles.Integer, CultureInfo.InvariantCulture); Assert.True(index < imageCount); } } diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs index 400e30bd6..1e0851993 100644 --- a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -82,7 +82,7 @@ namespace Jellyfin.Providers.Tests.Manager AddParts(providerManager, metadataServices: servicesList.Select(s => s.Object).ToArray()); var refreshOptions = new MetadataRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict)); - var actual = await providerManager.RefreshSingleItem(item, refreshOptions, CancellationToken.None).ConfigureAwait(false); + var actual = await providerManager.RefreshSingleItem(item, refreshOptions, CancellationToken.None); Assert.Equal(ItemUpdateType.MetadataDownload, actual); for (var i = 0; i < servicesList.Length; i++) @@ -105,7 +105,7 @@ namespace Jellyfin.Providers.Tests.Manager AddParts(providerManager, metadataServices: servicesList.Select(s => s.Object).ToArray()); var refreshOptions = new MetadataRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict)); - var actual = await providerManager.RefreshSingleItem(item, refreshOptions, CancellationToken.None).ConfigureAwait(false); + var actual = await providerManager.RefreshSingleItem(item, refreshOptions, CancellationToken.None); var expectedResult = serviceFound ? ItemUpdateType.MetadataDownload : ItemUpdateType.None; Assert.Equal(expectedResult, actual); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs index 6b2d9021c..2bc686a33 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/EmbeddedImageProviderTests.cs @@ -98,9 +98,11 @@ namespace Jellyfin.Providers.Tests.MediaInfo [InlineData(null, null, 1, ImageType.Primary, ImageFormat.Jpg)] // no label, finds primary [InlineData("backdrop", null, 2, ImageType.Backdrop, ImageFormat.Jpg)] // uses label to find index 2, not just pulling first stream [InlineData("cover", null, 2, ImageType.Primary, ImageFormat.Jpg)] // uses label to find index 2, not just pulling first stream + [InlineData(null, "bmp", 1, ImageType.Primary, ImageFormat.Bmp)] + [InlineData(null, "gif", 1, ImageType.Primary, ImageFormat.Gif)] [InlineData(null, "mjpeg", 1, ImageType.Primary, ImageFormat.Jpg)] [InlineData(null, "png", 1, ImageType.Primary, ImageFormat.Png)] - [InlineData(null, "gif", 1, ImageType.Primary, ImageFormat.Gif)] + [InlineData(null, "webp", 1, ImageType.Primary, ImageFormat.Webp)] public async void GetImage_Embedded_ReturnsCorrectSelection(string label, string? codec, int targetIndex, ImageType type, ImageFormat? expectedFormat) { var streams = new List<MediaStream>(); diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs new file mode 100644 index 000000000..76922af8d --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/FFProbeVideoInfoTests.cs @@ -0,0 +1,61 @@ +using System; +using AutoFixture; +using AutoFixture.AutoMoq; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Providers.MediaInfo; +using Moq; +using Xunit; + +namespace Jellyfin.Providers.Tests.MediaInfo; + +public class FFProbeVideoInfoTests +{ + private readonly FFProbeVideoInfo _fFProbeVideoInfo; + + public FFProbeVideoInfoTests() + { + var serverConfiguration = new ServerConfiguration() + { + DummyChapterDuration = (int)TimeSpan.FromMinutes(5).TotalSeconds + }; + var serverConfig = new Mock<IServerConfigurationManager>(); + serverConfig.Setup(c => c.Configuration) + .Returns(serverConfiguration); + + IFixture fixture = new Fixture().Customize(new AutoMoqCustomization { ConfigureMembers = true }); + fixture.Inject(serverConfig); + _fFProbeVideoInfo = fixture.Create<FFProbeVideoInfo>(); + } + + [Theory] + [InlineData(-1L)] + [InlineData(long.MinValue)] + [InlineData(long.MaxValue)] + public void CreateDummyChapters_InvalidRuntime_ThrowsArgumentException(long? runtime) + { + Assert.Throws<ArgumentException>( + () => _fFProbeVideoInfo.CreateDummyChapters(new Video() + { + RunTimeTicks = runtime + })); + } + + [Theory] + [InlineData(null, 0)] + [InlineData(0L, 0)] + [InlineData(1L, 0)] + [InlineData(TimeSpan.TicksPerMinute * 5, 0)] + [InlineData((TimeSpan.TicksPerMinute * 5) + 1, 1)] + [InlineData(TimeSpan.TicksPerMinute * 50, 10)] + public void CreateDummyChapters_ValidRuntime_CorrectChaptersCount(long? runtime, int chaptersCount) + { + var chapters = _fFProbeVideoInfo.CreateDummyChapters(new Video() + { + RunTimeTicks = runtime + }); + + Assert.Equal(chaptersCount, chapters.Length); + } +} diff --git a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs index 6ee4b8ef2..2b3867512 100644 --- a/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs +++ b/tests/Jellyfin.Providers.Tests/MediaInfo/MediaInfoResolverTests.cs @@ -29,7 +29,7 @@ public class MediaInfoResolverTests public const string VideoDirectoryPath = "Test Data/Video"; public const string VideoDirectoryRegex = @"Test Data[/\\]Video"; public const string MetadataDirectoryPath = "library/00/00000000000000000000000000000000"; - public const string MetadataDirectoryRegex = @"library.*"; + public const string MetadataDirectoryRegex = "library.*"; private readonly ILocalizationManager _localizationManager; private readonly MediaInfoResolver _subtitleResolver; @@ -49,7 +49,7 @@ public class MediaInfoResolverTests var englishCultureDto = new CultureDto("English", "English", "en", new[] { "eng" }); var localizationManager = new Mock<ILocalizationManager>(MockBehavior.Loose); - localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex(@"en.*", RegexOptions.IgnoreCase))) + localizationManager.Setup(lm => lm.FindLanguageInfo(It.IsRegex("en.*", RegexOptions.IgnoreCase))) .Returns(englishCultureDto); _localizationManager = localizationManager.Object; @@ -79,7 +79,7 @@ public class MediaInfoResolverTests { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock<IMediaSourceManager>(); - mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex("http.*"))) .Returns(MediaProtocol.Http); BaseItem.MediaSourceManager = mediaSourceManager.Object; @@ -186,7 +186,7 @@ public class MediaInfoResolverTests { // need a media source manager capable of returning something other than file protocol var mediaSourceManager = new Mock<IMediaSourceManager>(); - mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex(@"http.*"))) + mediaSourceManager.Setup(m => m.GetPathProtocol(It.IsRegex("http.*"))) .Returns(MediaProtocol.Http); BaseItem.MediaSourceManager = mediaSourceManager.Object; diff --git a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs index f01611819..22667ee82 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/HttpServer/WebSocketConnectionTests.cs @@ -13,7 +13,7 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer [Fact] public void DeserializeWebSocketMessage_SingleSegment_Success() { - var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!); + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); var bytes = File.ReadAllBytes("Test Data/HttpServer/ForceKeepAlive.json"); con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed); Assert.Equal(109, bytesConsumed); @@ -23,7 +23,7 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer public void DeserializeWebSocketMessage_MultipleSegments_Success() { const int SplitPos = 64; - var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!); + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); var bytes = File.ReadAllBytes("Test Data/HttpServer/ForceKeepAlive.json"); var seg1 = new BufferSegment(new Memory<byte>(bytes, 0, SplitPos)); var seg2 = seg1.Append(new Memory<byte>(bytes, SplitPos, bytes.Length - SplitPos)); @@ -34,7 +34,7 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer [Fact] public void DeserializeWebSocketMessage_ValidPartial_Success() { - var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!); + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); var bytes = File.ReadAllBytes("Test Data/HttpServer/ValidPartial.json"); con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed); Assert.Equal(109, bytesConsumed); @@ -43,7 +43,7 @@ namespace Jellyfin.Server.Implementations.Tests.HttpServer [Fact] public void DeserializeWebSocketMessage_Partial_ThrowJsonException() { - var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!); + var con = new WebSocketConnection(new NullLogger<WebSocketConnection>(), null!, null!, null!); var bytes = File.ReadAllBytes("Test Data/HttpServer/Partial.json"); Assert.Throws<JsonException>(() => con.DeserializeWebSocketMessage(new ReadOnlySequence<byte>(bytes), out var bytesConsumed)); } diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs index 09eb22328..07061cfc7 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/IgnorePatternsTests.cs @@ -31,6 +31,7 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("/media/music/Foo B.A.R./epic.flac", false)] [InlineData("/media/music/Foo B.A.R", false)] [InlineData("/media/music/Foo B.A.R.", false)] + [InlineData("/movies/.zfs/snapshot/AutoM-2023-09", true)] public void PathIgnored(string path, bool expected) { Assert.Equal(expected, IgnorePatterns.ShouldIgnore(path)); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs index c33a957e6..1c35eb3f5 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Library/PathExtensionsTests.cs @@ -48,10 +48,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library [InlineData("C:/Users/jeff/myfile.mkv", "C:/Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] [InlineData("C:/Users/jeff/myfile.mkv", "C:/Users/jeff/", "/home/jeff", "/home/jeff/myfile.mkv")] [InlineData("/home/jeff/music/jeff's band/consistently inconsistent.mp3", "/home/jeff/music/jeff's band", "/home/not jeff", "/home/not jeff/consistently inconsistent.mp3")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff", "/home/jeff/", "/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff/", "/home/jeff/", "/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\jeff\\myfile.mkv", "C:\\Users/jeff/", "/", "/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff", "/home/jeff", "/home/jeff/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff", "/home/jeff/", "/home/jeff/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff/", "/home/jeff/", "/home/jeff/myfile.mkv")] + [InlineData(@"C:\Users\jeff\myfile.mkv", "C:\\Users/jeff/", "/", "/myfile.mkv")] [InlineData("/o", "/o", "/s", "/s")] // regression test for #5977 public void TryReplaceSubPath_ValidArgs_Correct(string path, string subPath, string newSubPath, string? expectedResult) { @@ -78,10 +78,10 @@ namespace Jellyfin.Server.Implementations.Tests.Library [Theory] [InlineData(null, '/', null)] [InlineData(null, '\\', null)] - [InlineData("/home/jeff/myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] - [InlineData("C:\\Users\\Jeff\\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")] - [InlineData("\\home/jeff\\myfile.mkv", '\\', "\\home\\jeff\\myfile.mkv")] - [InlineData("\\home/jeff\\myfile.mkv", '/', "/home/jeff/myfile.mkv")] + [InlineData("/home/jeff/myfile.mkv", '\\', @"\home\jeff\myfile.mkv")] + [InlineData(@"C:\Users\Jeff\myfile.mkv", '/', "C:/Users/Jeff/myfile.mkv")] + [InlineData(@"\home/jeff\myfile.mkv", '\\', @"\home\jeff\myfile.mkv")] + [InlineData(@"\home/jeff\myfile.mkv", '/', "/home/jeff/myfile.mkv")] [InlineData("", '/', "")] public void NormalizePath_SpecifyingSeparator_Normalizes(string path, char separator, string expectedPath) { @@ -90,8 +90,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library [Theory] [InlineData("/home/jeff/myfile.mkv")] - [InlineData("C:\\Users\\Jeff\\myfile.mkv")] - [InlineData("\\home/jeff\\myfile.mkv")] + [InlineData(@"C:\Users\Jeff\myfile.mkv")] + [InlineData(@"\home/jeff\myfile.mkv")] public void NormalizePath_NoArgs_UsesDirectorySeparatorChar(string path) { var separator = Path.DirectorySeparatorChar; @@ -101,8 +101,8 @@ namespace Jellyfin.Server.Implementations.Tests.Library [Theory] [InlineData("/home/jeff/myfile.mkv", '/')] - [InlineData("C:\\Users\\Jeff\\myfile.mkv", '\\')] - [InlineData("\\home/jeff\\myfile.mkv", '/')] + [InlineData(@"C:\Users\Jeff\myfile.mkv", '\\')] + [InlineData(@"\home/jeff\myfile.mkv", '/')] public void NormalizePath_OutVar_Correct(string path, char expectedSeparator) { var result = path.NormalizePath(out var separator); diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs index c859d11c6..13ac3ddb0 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/HdHomerunHostTests.cs @@ -52,7 +52,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv Url = "192.168.1.182" }; - var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None).ConfigureAwait(false); + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); Assert.Equal("HDHomeRun PRIME", modelInfo.FriendlyName); Assert.Equal("HDHR3-CC", modelInfo.ModelNumber); Assert.Equal("hdhomerun3_cablecard", modelInfo.FirmwareName); @@ -72,7 +72,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv Url = "10.10.10.100" }; - var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None).ConfigureAwait(false); + var modelInfo = await _hdHomerunHost.GetModelInfo(host, true, CancellationToken.None); Assert.Equal("HDHomeRun DUAL", modelInfo.FriendlyName); Assert.Equal("HDHR3-US", modelInfo.ModelNumber); Assert.Equal("hdhomerun3_atsc", modelInfo.FirmwareName); @@ -103,7 +103,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv Url = "192.168.1.182" }; - var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None).ConfigureAwait(false); + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); Assert.Equal(6, channels.Count); Assert.Equal("4.1", channels[0].GuideNumber); Assert.Equal("WCMH-DT", channels[0].GuideName); @@ -133,7 +133,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv ImportFavoritesOnly = true }; - var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None).ConfigureAwait(false); + var channels = await _hdHomerunHost.GetLineup(host, CancellationToken.None); Assert.Single(channels); Assert.Equal("4.1", channels[0].GuideNumber); Assert.Equal("WCMH-DT", channels[0].GuideName); @@ -145,7 +145,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv [Fact] public async Task TryGetTunerHostInfo_Valid_Success() { - var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None).ConfigureAwait(false); + var host = await _hdHomerunHost.TryGetTunerHostInfo("192.168.1.182", CancellationToken.None); Assert.Equal(_hdHomerunHost.Type, host.Type); Assert.Equal("192.168.1.182", host.Url); Assert.Equal("HDHomeRun PRIME", host.FriendlyName); diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs index e1d2bb2d5..d4f28f327 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/SchedulesDirect/SchedulesDirectDeserializeTests.cs @@ -96,7 +96,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect var days = JsonSerializer.Deserialize<IReadOnlyList<DayDto>>(bytes, _jsonOptions); Assert.NotNull(days); - Assert.Equal(1, days!.Count); + Assert.Single(days); var dayDto = days[0]; Assert.Equal("20454", dayDto.StationId); @@ -110,7 +110,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect 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.Single(dayDto.Programs[0].VideoProperties); Assert.Equal("hdtv", dayDto.Programs[0].VideoProperties[0]); } @@ -126,13 +126,13 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect Assert.NotNull(programDtos); Assert.Equal(2, programDtos!.Count); Assert.Equal("EP000000060003", programDtos[0].ProgramId); - Assert.Equal(1, programDtos[0].Titles.Count); + Assert.Single(programDtos[0].Titles); 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.Single(programDtos[0].Genres); Assert.Equal("Sitcom", programDtos[0].Genres[0]); Assert.Equal("The Poloceman Cometh", programDtos[0].EpisodeTitle150); Assert.Equal(2, programDtos[0].Metadata[0].Gracenote?.Season); @@ -161,7 +161,7 @@ namespace Jellyfin.Server.Implementations.Tests.LiveTv.SchedulesDirect var showImagesDtos = JsonSerializer.Deserialize<IReadOnlyList<ShowImagesDto>>(bytes, _jsonOptions); Assert.NotNull(showImagesDtos); - Assert.Equal(1, showImagesDtos!.Count); + Assert.Single(showImagesDtos!); Assert.Equal("SH00712240", showImagesDtos[0].ProgramId); Assert.Equal(4, showImagesDtos[0].Data.Count); Assert.Equal("135", showImagesDtos[0].Data[0].Width); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs index 7fabe9904..09e4709da 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Localization/LocalizationManagerTests.cs @@ -100,7 +100,7 @@ namespace Jellyfin.Server.Implementations.Tests.Localization await localizationManager.LoadAll(); var ratings = localizationManager.GetParentalRatings().ToList(); - Assert.Equal(19, ratings.Count); + Assert.Equal(24, ratings.Count); var fsk = ratings.FirstOrDefault(x => x.Name.Equals("FSK-12", StringComparison.Ordinal)); Assert.NotNull(fsk); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs index d4b90dac0..934024826 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Plugins/PluginManagerTests.cs @@ -119,8 +119,8 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins [InlineData("C:\\some.dll")] // Windows root path. [InlineData("test.txt")] // Not a DLL [InlineData(".././.././../some.dll")] // Traversal with current and parent - [InlineData("..\\.\\..\\.\\..\\some.dll")] // Windows traversal with current and parent - [InlineData("\\\\network\\resource.dll")] // UNC Path + [InlineData(@"..\.\..\.\..\some.dll")] // Windows traversal with current and parent + [InlineData(@"\\network\resource.dll")] // UNC Path [InlineData("https://jellyfin.org/some.dll")] // URL [InlineData("~/some.dll")] // Tilde poses a shell expansion risk, but is a valid path character. public void Constructor_DiscoversUnsafePluginAssembly_Status_Malfunctioned(string unsafePath) @@ -191,13 +191,13 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + await File.WriteAllTextAsync(metafilePath, JsonSerializer.Serialize(partial, _options)); var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); @@ -231,7 +231,7 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); var metafilePath = Path.Combine(_pluginPath, "meta.json"); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); @@ -251,13 +251,13 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + await File.WriteAllTextAsync(metafilePath, JsonSerializer.Serialize(partial, _options)); var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); @@ -277,13 +277,13 @@ namespace Jellyfin.Server.Implementations.Tests.Plugins }; var metafilePath = Path.Combine(_pluginPath, "meta.json"); - File.WriteAllText(metafilePath, JsonSerializer.Serialize(partial, _options)); + await File.WriteAllTextAsync(metafilePath, JsonSerializer.Serialize(partial, _options)); var pluginManager = new PluginManager(new NullLogger<PluginManager>(), null!, null!, _tempPath, new Version(1, 0)); await pluginManager.PopulateManifest(packageInfo, new Version(1, 0), _pluginPath, PluginStatus.Active); - var resultBytes = File.ReadAllBytes(metafilePath); + var resultBytes = await File.ReadAllBytesAsync(metafilePath); var result = JsonSerializer.Deserialize<PluginManifest>(resultBytes, _options); Assert.NotNull(result); diff --git a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs index 7abd2e685..5caf7d124 100644 --- a/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs +++ b/tests/Jellyfin.Server.Implementations.Tests/Updates/InstallationManagerTests.cs @@ -90,7 +90,7 @@ namespace Jellyfin.Server.Implementations.Tests.Updates Checksum = "InvalidChecksum" }; - await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)).ConfigureAwait(false); + await Assert.ThrowsAsync<InvalidDataException>(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); } [Fact] @@ -103,7 +103,7 @@ namespace Jellyfin.Server.Implementations.Tests.Updates Checksum = "11b5b2f1a9ebc4f66d6ef19018543361" }; - var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)).ConfigureAwait(false); + var ex = await Record.ExceptionAsync(() => _installationManager.InstallPackage(packageInfo, CancellationToken.None)); Assert.Null(ex); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs index 3737fee0a..4e8aec9f1 100644 --- a/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs +++ b/tests/Jellyfin.Server.Integration.Tests/AuthHelper.cs @@ -15,41 +15,41 @@ namespace Jellyfin.Server.Integration.Tests { public static class AuthHelper { - public const string AuthHeaderName = "X-Emby-Authorization"; - public const string DummyAuthHeader = "MediaBrowser Client=\"Jellyfin.Server Integration Tests\", DeviceId=\"69420\", Device=\"Apple II\", Version=\"10.8.0\""; + public const string AuthHeaderName = "Authorization"; + public const string DummyAuthHeader = "MediaBrowser Client=\"Jellyfin.Server%20Integration%20Tests\", DeviceId=\"69420\", Device=\"Apple%20II\", Version=\"10.8.0\""; public static async Task<string> CompleteStartupAsync(HttpClient client) { var jsonOptions = JsonDefaults.Options; - var userResponse = await client.GetByteArrayAsync("/Startup/User").ConfigureAwait(false); + var userResponse = await client.GetByteArrayAsync("/Startup/User"); var user = JsonSerializer.Deserialize<StartupUserDto>(userResponse, jsonOptions); - using var completeResponse = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>())).ConfigureAwait(false); + using var completeResponse = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>())); Assert.Equal(HttpStatusCode.NoContent, completeResponse.StatusCode); - using var content = JsonContent.Create( + using var httpRequest = new HttpRequestMessage(HttpMethod.Post, "/Users/AuthenticateByName"); + httpRequest.Headers.TryAddWithoutValidation(AuthHeaderName, DummyAuthHeader); + httpRequest.Content = JsonContent.Create( new AuthenticateUserByName() { Username = user!.Name, Pw = user.Password, }, options: jsonOptions); - content.Headers.Add("X-Emby-Authorization", DummyAuthHeader); - using var authResponse = await client.PostAsync("/Users/AuthenticateByName", content).ConfigureAwait(false); - var auth = await JsonSerializer.DeserializeAsync<AuthenticationResultDto>( - await authResponse.Content.ReadAsStreamAsync().ConfigureAwait(false), - jsonOptions).ConfigureAwait(false); + using var authResponse = await client.SendAsync(httpRequest); + authResponse.EnsureSuccessStatusCode(); + + var auth = await authResponse.Content.ReadFromJsonAsync<AuthenticationResultDto>(jsonOptions); return auth!.AccessToken; } public static async Task<UserDto> GetUserDtoAsync(HttpClient client) { - using var response = await client.GetAsync("Users/Me").ConfigureAwait(false); + using var response = await client.GetAsync("Users/Me"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var userDto = await JsonSerializer.DeserializeAsync<UserDto>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), JsonDefaults.Options).ConfigureAwait(false); + var userDto = await response.Content.ReadFromJsonAsync<UserDto>(JsonDefaults.Options); Assert.NotNull(userDto); return userDto; } @@ -58,15 +58,13 @@ namespace Jellyfin.Server.Integration.Tests { if (userId.Equals(default)) { - var userDto = await GetUserDtoAsync(client).ConfigureAwait(false); + var userDto = await GetUserDtoAsync(client); userId = userDto.Id; } - var response = await client.GetAsync($"Users/{userId}/Items/Root").ConfigureAwait(false); + var response = await client.GetAsync($"Users/{userId}/Items/Root"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var rootDto = await JsonSerializer.DeserializeAsync<BaseItemDto>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - JsonDefaults.Options).ConfigureAwait(false); + var rootDto = await response.Content.ReadFromJsonAsync<BaseItemDto>(JsonDefaults.Options); Assert.NotNull(rootDto); return rootDto; } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs index be89fbc9a..96ca96558 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/ActivityLogControllerTests.cs @@ -19,9 +19,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task ActivityLog_GetEntries_Ok() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("System/ActivityLog/Entries").ConfigureAwait(false); + var response = await client.GetAsync("System/ActivityLog/Entries"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs index 87136dfc8..8761cf69b 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/BrandingControllerTests.cs @@ -1,4 +1,5 @@ using System.Net; +using System.Net.Http.Json; using System.Net.Mime; using System.Text; using System.Text.Json; @@ -30,8 +31,7 @@ namespace Jellyfin.Server.Integration.Tests Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); - var responseBody = await response.Content.ReadAsStreamAsync(); - _ = await JsonSerializer.DeserializeAsync<BrandingOptions>(responseBody); + await response.Content.ReadFromJsonAsync<BrandingOptions>(); } [Theory] diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs index 52df1cd60..39d449e27 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/DashboardControllerTests.cs @@ -1,5 +1,6 @@ using System.IO; using System.Net; +using System.Net.Http.Json; using System.Net.Mime; using System.Text; using System.Text.Json; @@ -26,7 +27,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - var response = await client.GetAsync("web/ConfigurationPage?name=ThisPageDoesntExists").ConfigureAwait(false); + var response = await client.GetAsync("web/ConfigurationPage?name=ThisPageDoesntExists"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -36,12 +37,12 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - var response = await client.GetAsync("/web/ConfigurationPage?name=TestPlugin").ConfigureAwait(false); + var response = await client.GetAsync("/web/ConfigurationPage?name=TestPlugin"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Text.Html, response.Content.Headers.ContentType?.MediaType); StreamReader reader = new StreamReader(typeof(TestPlugin).Assembly.GetManifestResourceStream("Jellyfin.Server.Integration.Tests.TestPage.html")!); - Assert.Equal(await response.Content.ReadAsStringAsync().ConfigureAwait(false), await reader.ReadToEndAsync().ConfigureAwait(false)); + Assert.Equal(await response.Content.ReadAsStringAsync(), await reader.ReadToEndAsync()); } [Fact] @@ -49,7 +50,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - var response = await client.GetAsync("/web/ConfigurationPage?name=BrokenPage").ConfigureAwait(false); + var response = await client.GetAsync("/web/ConfigurationPage?name=BrokenPage"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -58,14 +59,13 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task GetConfigurationPages_NoParams_AllConfigurationPages() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("/web/ConfigurationPages").ConfigureAwait(false); + var response = await client.GetAsync("/web/ConfigurationPages"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var res = await response.Content.ReadAsStreamAsync(); - _ = await JsonSerializer.DeserializeAsync<ConfigurationPageInfo[]>(res, _jsonOpions); + _ = await response.Content.ReadFromJsonAsync<ConfigurationPageInfo[]>(_jsonOpions); // TODO: check content } @@ -73,16 +73,15 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task GetConfigurationPages_True_MainMenuConfigurationPages() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("/web/ConfigurationPages?enableInMainMenu=true").ConfigureAwait(false); + var response = await client.GetAsync("/web/ConfigurationPages?enableInMainMenu=true"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); Assert.Equal(Encoding.UTF8.BodyName, response.Content.Headers.ContentType?.CharSet); - var res = await response.Content.ReadAsStreamAsync(); - var data = await JsonSerializer.DeserializeAsync<ConfigurationPageInfo[]>(res, _jsonOpions); + var data = await response.Content.ReadFromJsonAsync<ConfigurationPageInfo[]>(_jsonOpions); Assert.NotNull(data); Assert.Empty(data); } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs index a65f65bb2..e5d5e785c 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/DlnaControllerTests.cs @@ -32,9 +32,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task GetProfile_DoesNotExist_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.GetAsync("/Dlna/Profiles/" + NonExistentProfile).ConfigureAwait(false); + using var response = await client.GetAsync("/Dlna/Profiles/" + NonExistentProfile); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -43,9 +43,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task DeleteProfile_DoesNotExist_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.DeleteAsync("/Dlna/Profiles/" + NonExistentProfile).ConfigureAwait(false); + using var response = await client.DeleteAsync("/Dlna/Profiles/" + NonExistentProfile); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -54,14 +54,14 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task UpdateProfile_DoesNotExist_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); var deviceProfile = new DeviceProfile() { Name = "ThisProfileDoesNotExist" }; - using var response = await client.PostAsJsonAsync("/Dlna/Profiles/" + NonExistentProfile, deviceProfile, _jsonOptions).ConfigureAwait(false); + using var response = await client.PostAsJsonAsync("/Dlna/Profiles/" + NonExistentProfile, deviceProfile, _jsonOptions); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -70,14 +70,14 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task CreateProfile_Valid_NoContent() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); var deviceProfile = new DeviceProfile() { Name = "ThisProfileIsNew" }; - using var response = await client.PostAsJsonAsync("/Dlna/Profiles", deviceProfile, _jsonOptions).ConfigureAwait(false); + using var response = await client.PostAsJsonAsync("/Dlna/Profiles", deviceProfile, _jsonOptions); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } @@ -86,16 +86,14 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task GetProfileInfos_Valid_ContainsThisProfileIsNew() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.GetAsync("/Dlna/ProfileInfos").ConfigureAwait(false); + using var response = await client.GetAsync("/Dlna/ProfileInfos"); 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<DeviceProfileInfo[]>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var profiles = await response.Content.ReadFromJsonAsync<DeviceProfileInfo[]>(_jsonOptions); var newProfile = profiles?.FirstOrDefault(x => string.Equals(x.Name, "ThisProfileIsNew", StringComparison.Ordinal)); Assert.NotNull(newProfile); @@ -107,7 +105,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task UpdateProfile_Valid_NoContent() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); var updatedProfile = new DeviceProfile() { @@ -115,18 +113,16 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Id = _newDeviceProfileId }; - using var postResponse = await client.PostAsJsonAsync("/Dlna/Profiles/" + _newDeviceProfileId, updatedProfile, _jsonOptions).ConfigureAwait(false); + using var postResponse = await client.PostAsJsonAsync("/Dlna/Profiles/" + _newDeviceProfileId, updatedProfile, _jsonOptions); Assert.Equal(HttpStatusCode.NoContent, postResponse.StatusCode); // Verify that the profile got updated - using var response = await client.GetAsync("/Dlna/ProfileInfos").ConfigureAwait(false); + using var response = await client.GetAsync("/Dlna/ProfileInfos"); 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<DeviceProfileInfo[]>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var profiles = await response.Content.ReadFromJsonAsync<DeviceProfileInfo[]>(_jsonOptions); Assert.Null(profiles?.FirstOrDefault(x => string.Equals(x.Name, "ThisProfileIsNew", StringComparison.Ordinal))); var newProfile = profiles?.FirstOrDefault(x => string.Equals(x.Name, "ThisProfileIsUpdated", StringComparison.Ordinal)); @@ -139,20 +135,18 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task DeleteProfile_Valid_NoContent() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var deleteResponse = await client.DeleteAsync("/Dlna/Profiles/" + _newDeviceProfileId).ConfigureAwait(false); + using var deleteResponse = await client.DeleteAsync("/Dlna/Profiles/" + _newDeviceProfileId); Assert.Equal(HttpStatusCode.NoContent, deleteResponse.StatusCode); // Verify that the profile got deleted - using var response = await client.GetAsync("/Dlna/ProfileInfos").ConfigureAwait(false); + using var response = await client.GetAsync("/Dlna/ProfileInfos"); 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<DeviceProfileInfo[]>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var profiles = await response.Content.ReadFromJsonAsync<DeviceProfileInfo[]>(_jsonOptions); Assert.Null(profiles?.FirstOrDefault(x => string.Equals(x.Name, "ThisProfileIsUpdated", StringComparison.Ordinal))); } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs index 078002994..23de2489e 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/ItemsControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Net; +using System.Net.Http.Json; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Extensions.Json; @@ -25,9 +26,9 @@ public sealed class ItemsControllerTests : IClassFixture<JellyfinApplicationFact public async Task GetItems_NoApiKeyOrUserId_Success() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("Items").ConfigureAwait(false); + var response = await client.GetAsync("Items"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); } @@ -37,9 +38,9 @@ public sealed class ItemsControllerTests : IClassFixture<JellyfinApplicationFact public async Task GetUserItems_NonExistentUserId_NotFound(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -50,15 +51,13 @@ public sealed class ItemsControllerTests : IClassFixture<JellyfinApplicationFact public async Task GetItems_UserId_Ok(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); - var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id)).ConfigureAwait(false); + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var items = await JsonSerializer.DeserializeAsync<QueryResult<BaseItemDto>>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var items = await response.Content.ReadFromJsonAsync<QueryResult<BaseItemDto>>(_jsonOptions); Assert.NotNull(items); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs index 8998683a7..06abae14c 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/LibraryControllerTests.cs @@ -32,9 +32,9 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa public async Task Get_NonExistentItemId_NotFound(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -45,7 +45,7 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa { var client = _factory.CreateClient(); - var response = await client.DeleteAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + var response = await client.DeleteAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } @@ -55,9 +55,9 @@ public sealed class LibraryControllerTests : IClassFixture<JellyfinApplicationFa public async Task Delete_NonExistentItemId_NotFound(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.DeleteAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())).ConfigureAwait(false); + var response = await client.DeleteAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid())); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs index 34d26680a..abc8b6009 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaInfoControllerTests.cs @@ -20,9 +20,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task BitrateTest_Default_Ok() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("Playback/BitrateTest").ConfigureAwait(false); + var response = await client.GetAsync("Playback/BitrateTest"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Application.Octet, response.Content.Headers.ContentType?.MediaType); @@ -34,9 +34,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task BitrateTest_WithValidParam_Ok(int size) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Application.Octet, response.Content.Headers.ContentType?.MediaType); @@ -51,9 +51,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task BitrateTest_InvalidValue_BadRequest(int size) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)).ConfigureAwait(false); + var response = await client.GetAsync("Playback/BitrateTest?size=" + size.ToString(CultureInfo.InvariantCulture)); 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 index 24251013c..6699c6834 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaStructureControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MediaStructureControllerTests.cs @@ -26,10 +26,10 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task RenameVirtualFolder_WhiteSpaceName_ReturnsBadRequest() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); using var postContent = new ByteArrayContent(Array.Empty<byte>()); - var response = await client.PostAsync("Library/VirtualFolders/Name?name=+&newName=test", postContent).ConfigureAwait(false); + var response = await client.PostAsync("Library/VirtualFolders/Name?name=+&newName=test", postContent); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -38,10 +38,10 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task RenameVirtualFolder_WhiteSpaceNewName_ReturnsBadRequest() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); using var postContent = new ByteArrayContent(Array.Empty<byte>()); - var response = await client.PostAsync("Library/VirtualFolders/Name?name=test&newName=+", postContent).ConfigureAwait(false); + var response = await client.PostAsync("Library/VirtualFolders/Name?name=test&newName=+", postContent); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -50,10 +50,10 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task RenameVirtualFolder_NameDoesntExist_ReturnsNotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); using var postContent = new ByteArrayContent(Array.Empty<byte>()); - var response = await client.PostAsync("Library/VirtualFolders/Name?name=doesnt+exist&newName=test", postContent).ConfigureAwait(false); + var response = await client.PostAsync("Library/VirtualFolders/Name?name=doesnt+exist&newName=test", postContent); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -62,7 +62,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task AddMediaPath_PathDoesntExist_ReturnsNotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); var data = new MediaPathDto() { @@ -70,7 +70,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Path = "/this/path/doesnt/exist" }; - var response = await client.PostAsJsonAsync("Library/VirtualFolders/Paths", data, _jsonOptions).ConfigureAwait(false); + var response = await client.PostAsJsonAsync("Library/VirtualFolders/Paths", data, _jsonOptions); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -79,7 +79,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task UpdateMediaPath_WhiteSpaceName_ReturnsBadRequest() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); var data = new UpdateMediaPathRequestDto() { @@ -87,7 +87,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers PathInfo = new MediaPathInfo("test") }; - var response = await client.PostAsJsonAsync("Library/VirtualFolders/Paths/Update", data, _jsonOptions).ConfigureAwait(false); + var response = await client.PostAsJsonAsync("Library/VirtualFolders/Paths/Update", data, _jsonOptions); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -96,9 +96,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task RemoveMediaPath_WhiteSpaceName_ReturnsBadRequest() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.DeleteAsync("Library/VirtualFolders/Paths?name=+").ConfigureAwait(false); + var response = await client.DeleteAsync("Library/VirtualFolders/Paths?name=+"); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -107,9 +107,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task RemoveMediaPath_PathDoesntExist_ReturnsNotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.DeleteAsync("Library/VirtualFolders/Paths?name=none&path=%2Fthis%2Fpath%2Fdoesnt%2Fexist").ConfigureAwait(false); + var response = await client.DeleteAsync("Library/VirtualFolders/Paths?name=none&path=%2Fthis%2Fpath%2Fdoesnt%2Fexist"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs index 17f3dc99f..f9982cf12 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/MusicGenreControllerTests.cs @@ -18,9 +18,9 @@ public sealed class MusicGenreControllerTests : IClassFixture<JellyfinApplicatio public async Task MusicGenres_FakeMusicGenre_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync("MusicGenres/Fake-MusicGenre").ConfigureAwait(false); + var response = await client.GetAsync("MusicGenres/Fake-MusicGenre"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/PersonsControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/PersonsControllerTests.cs new file mode 100644 index 000000000..38c64547c --- /dev/null +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/PersonsControllerTests.cs @@ -0,0 +1,26 @@ +using System.Net; +using System.Threading.Tasks; +using Xunit; + +namespace Jellyfin.Server.Integration.Tests.Controllers; + +public class PersonsControllerTests : IClassFixture<JellyfinApplicationFactory> +{ + private readonly JellyfinApplicationFactory _factory; + private static string? _accessToken; + + public PersonsControllerTests(JellyfinApplicationFactory factory) + { + _factory = factory; + } + + [Fact] + public async Task GetPerson_DoesntExist_NotFound() + { + var client = _factory.CreateClient(); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); + + using var response = await client.GetAsync($"Persons/DoesntExist"); + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } +} diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs index 868ecd53f..9554d3ebc 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/PlaystateControllerTests.cs @@ -19,9 +19,9 @@ public class PlaystateControllerTests : IClassFixture<JellyfinApplicationFactory public async Task DeleteMarkUnplayedItem_NonExistentUserId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.DeleteAsync($"Users/{Guid.NewGuid()}/PlayedItems/{Guid.NewGuid()}").ConfigureAwait(false); + using var response = await client.DeleteAsync($"Users/{Guid.NewGuid()}/PlayedItems/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -29,9 +29,9 @@ public class PlaystateControllerTests : IClassFixture<JellyfinApplicationFactory public async Task PostMarkPlayedItem_NonExistentUserId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.PostAsync($"Users/{Guid.NewGuid()}/PlayedItems/{Guid.NewGuid()}", null).ConfigureAwait(false); + using var response = await client.PostAsync($"Users/{Guid.NewGuid()}/PlayedItems/{Guid.NewGuid()}", null); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -39,11 +39,11 @@ public class PlaystateControllerTests : IClassFixture<JellyfinApplicationFactory public async Task DeleteMarkUnplayedItem_NonExistentItemId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); - using var response = await client.DeleteAsync($"Users/{userDto.Id}/PlayedItems/{Guid.NewGuid()}").ConfigureAwait(false); + using var response = await client.DeleteAsync($"Users/{userDto.Id}/PlayedItems/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -51,11 +51,11 @@ public class PlaystateControllerTests : IClassFixture<JellyfinApplicationFactory public async Task PostMarkPlayedItem_NonExistentItemId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); - using var response = await client.PostAsync($"Users/{userDto.Id}/PlayedItems/{Guid.NewGuid()}", null).ConfigureAwait(false); + using var response = await client.PostAsync($"Users/{userDto.Id}/PlayedItems/{Guid.NewGuid()}", null); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs index cb0a829e8..b9def13f8 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/SessionControllerTests.cs @@ -19,9 +19,9 @@ public class SessionControllerTests : IClassFixture<JellyfinApplicationFactory> public async Task GetSessions_NonExistentUserId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.GetAsync($"Session/Sessions?userId={Guid.NewGuid()}").ConfigureAwait(false); + using var response = await client.GetAsync($"Session/Sessions?userId={Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs index 0dd22644a..36861294b 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/StartupControllerTests.cs @@ -36,15 +36,14 @@ namespace Jellyfin.Server.Integration.Tests.Controllers PreferredMetadataLanguage = "nl" }; - using var postResponse = await client.PostAsJsonAsync("/Startup/Configuration", config, _jsonOptions).ConfigureAwait(false); + using var postResponse = await client.PostAsJsonAsync("/Startup/Configuration", config, _jsonOptions); Assert.Equal(HttpStatusCode.NoContent, postResponse.StatusCode); - using var getResponse = await client.GetAsync("/Startup/Configuration").ConfigureAwait(false); + using var getResponse = await client.GetAsync("/Startup/Configuration"); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); Assert.Equal(MediaTypeNames.Application.Json, getResponse.Content.Headers.ContentType?.MediaType); - using var responseStream = await getResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); - var newConfig = await JsonSerializer.DeserializeAsync<StartupConfigurationDto>(responseStream, _jsonOptions).ConfigureAwait(false); + var newConfig = await getResponse.Content.ReadFromJsonAsync<StartupConfigurationDto>(_jsonOptions); Assert.Equal(config.UICulture, newConfig!.UICulture); Assert.Equal(config.MetadataCountryCode, newConfig.MetadataCountryCode); Assert.Equal(config.PreferredMetadataLanguage, newConfig.PreferredMetadataLanguage); @@ -56,12 +55,11 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - using var response = await client.GetAsync("/Startup/User").ConfigureAwait(false); + using var response = await client.GetAsync("/Startup/User"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); Assert.Equal(MediaTypeNames.Application.Json, response.Content.Headers.ContentType?.MediaType); - using var contentStream = await response.Content.ReadAsStreamAsync().ConfigureAwait(false); - var user = await JsonSerializer.DeserializeAsync<StartupUserDto>(contentStream, _jsonOptions).ConfigureAwait(false); + var user = await response.Content.ReadFromJsonAsync<StartupUserDto>(_jsonOptions); Assert.NotNull(user); Assert.NotNull(user.Name); Assert.NotEmpty(user.Name); @@ -80,15 +78,14 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Password = "NewPassword" }; - var postResponse = await client.PostAsJsonAsync("/Startup/User", user, _jsonOptions).ConfigureAwait(false); + var postResponse = await client.PostAsJsonAsync("/Startup/User", user, _jsonOptions); Assert.Equal(HttpStatusCode.NoContent, postResponse.StatusCode); - var getResponse = await client.GetAsync("/Startup/User").ConfigureAwait(false); + var getResponse = await client.GetAsync("/Startup/User"); Assert.Equal(HttpStatusCode.OK, getResponse.StatusCode); Assert.Equal(MediaTypeNames.Application.Json, getResponse.Content.Headers.ContentType?.MediaType); - var contentStream = await getResponse.Content.ReadAsStreamAsync().ConfigureAwait(false); - var newUser = await JsonSerializer.DeserializeAsync<StartupUserDto>(contentStream, _jsonOptions).ConfigureAwait(false); + var newUser = await getResponse.Content.ReadFromJsonAsync<StartupUserDto>(_jsonOptions); Assert.NotNull(newUser); Assert.Equal(user.Name, newUser.Name); Assert.NotNull(newUser.Password); @@ -102,7 +99,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - var response = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>())).ConfigureAwait(false); + var response = await client.PostAsync("/Startup/Complete", new ByteArrayContent(Array.Empty<byte>())); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); } @@ -112,7 +109,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - using var response = await client.GetAsync("/Startup/User").ConfigureAwait(false); + using var response = await client.GetAsync("/Startup/User"); Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs index 2a3c53dbe..4fcacd2ca 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserControllerTests.cs @@ -41,10 +41,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers { var client = _factory.CreateClient(); - using var response = await client.GetAsync("Users/Public").ConfigureAwait(false); + using var response = await client.GetAsync("Users/Public"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var users = await JsonSerializer.DeserializeAsync<UserDto[]>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + var users = await response.Content.ReadFromJsonAsync<UserDto[]>(_jsonOpions); // User are hidden by default Assert.NotNull(users); Assert.Empty(users); @@ -55,12 +54,11 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task GetUsers_Valid_Success() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - using var response = await client.GetAsync("Users").ConfigureAwait(false); + using var response = await client.GetAsync("Users"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var users = await JsonSerializer.DeserializeAsync<UserDto[]>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + var users = await response.Content.ReadFromJsonAsync<UserDto[]>(_jsonOpions); Assert.NotNull(users); Assert.Single(users); Assert.False(users![0].HasConfiguredPassword); @@ -71,9 +69,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers public async Task Me_Valid_Success() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - _ = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + _ = await AuthHelper.GetUserDtoAsync(client); } [Fact] @@ -90,10 +88,9 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Name = TestUsername }; - using var response = await CreateUserByName(client, createRequest).ConfigureAwait(false); + using var response = await CreateUserByName(client, createRequest); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var user = await JsonSerializer.DeserializeAsync<UserDto>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + var user = await response.Content.ReadFromJsonAsync<UserDto>(_jsonOpions); Assert.Equal(TestUsername, user!.Name); Assert.False(user.HasPassword); Assert.False(user.HasConfiguredPassword); @@ -121,7 +118,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers Name = username! }; - using var response = await CreateUserByName(client, createRequest).ConfigureAwait(false); + using var response = await CreateUserByName(client, createRequest); Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); } @@ -134,7 +131,7 @@ namespace Jellyfin.Server.Integration.Tests.Controllers // access token can't be null here as the previous test populated it client.DefaultRequestHeaders.AddAuthHeader(_accessToken!); - using var response = await client.DeleteAsync($"User/{Guid.NewGuid()}").ConfigureAwait(false); + using var response = await client.DeleteAsync($"User/{Guid.NewGuid()}"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -150,11 +147,11 @@ namespace Jellyfin.Server.Integration.Tests.Controllers NewPw = "4randomPa$$word" }; - using var response = await UpdateUserPassword(client, _testUserId, createRequest).ConfigureAwait(false); + using var response = await UpdateUserPassword(client, _testUserId, createRequest); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); var users = await JsonSerializer.DeserializeAsync<UserDto[]>( - await client.GetStreamAsync("Users").ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + await client.GetStreamAsync("Users"), _jsonOpions); var user = users!.First(x => x.Id.Equals(_testUserId)); Assert.True(user.HasPassword); Assert.True(user.HasConfiguredPassword); @@ -173,11 +170,11 @@ namespace Jellyfin.Server.Integration.Tests.Controllers CurrentPw = "4randomPa$$word", }; - using var response = await UpdateUserPassword(client, _testUserId, createRequest).ConfigureAwait(false); + using var response = await UpdateUserPassword(client, _testUserId, createRequest); Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); var users = await JsonSerializer.DeserializeAsync<UserDto[]>( - await client.GetStreamAsync("Users").ConfigureAwait(false), _jsonOpions).ConfigureAwait(false); + await client.GetStreamAsync("Users"), _jsonOpions); var user = users!.First(x => x.Id.Equals(_testUserId)); Assert.False(user.HasPassword); Assert.False(user.HasConfiguredPassword); diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs index 69f2ccf33..130281c6d 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/UserLibraryControllerTests.cs @@ -1,6 +1,7 @@ using System; using System.Globalization; using System.Net; +using System.Net.Http.Json; using System.Text.Json; using System.Threading.Tasks; using Jellyfin.Extensions.Json; @@ -25,9 +26,9 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task GetRootFolder_NonExistenUserId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.GetAsync($"Users/{Guid.NewGuid()}/Items/Root").ConfigureAwait(false); + var response = await client.GetAsync($"Users/{Guid.NewGuid()}/Items/Root"); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -35,9 +36,9 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task GetRootFolder_UserId_Valid() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - _ = await AuthHelper.GetRootFolderDtoAsync(client).ConfigureAwait(false); + _ = await AuthHelper.GetRootFolderDtoAsync(client); } [Theory] @@ -49,11 +50,11 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task GetItem_NonExistenUserId_NotFound(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client).ConfigureAwait(false); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client); - var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid(), rootFolderDto.Id)).ConfigureAwait(false); + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, Guid.NewGuid(), rootFolderDto.Id)); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -66,11 +67,11 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task GetItem_NonExistentItemId_NotFound(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); - var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id, Guid.NewGuid())).ConfigureAwait(false); + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id, Guid.NewGuid())); Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); } @@ -78,16 +79,14 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task GetItem_UserIdAndItemId_Valid() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); - var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id); - var response = await client.GetAsync($"Users/{userDto.Id}/Items/{rootFolderDto.Id}").ConfigureAwait(false); + var response = await client.GetAsync($"Users/{userDto.Id}/Items/{rootFolderDto.Id}"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var rootDto = await JsonSerializer.DeserializeAsync<BaseItemDto>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var rootDto = await response.Content.ReadFromJsonAsync<BaseItemDto>(_jsonOptions); Assert.NotNull(rootDto); } @@ -95,16 +94,14 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task GetIntros_UserIdAndItemId_Valid() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); - var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id); - var response = await client.GetAsync($"Users/{userDto.Id}/Items/{rootFolderDto.Id}/Intros").ConfigureAwait(false); + var response = await client.GetAsync($"Users/{userDto.Id}/Items/{rootFolderDto.Id}/Intros"); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var rootDto = await JsonSerializer.DeserializeAsync<QueryResult<BaseItemDto>>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var rootDto = await response.Content.ReadFromJsonAsync<QueryResult<BaseItemDto>>(_jsonOptions); Assert.NotNull(rootDto); } @@ -114,16 +111,14 @@ public sealed class UserLibraryControllerTests : IClassFixture<JellyfinApplicati public async Task LocalTrailersAndSpecialFeatures_UserIdAndItemId_Valid(string format) { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var userDto = await AuthHelper.GetUserDtoAsync(client).ConfigureAwait(false); - var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id).ConfigureAwait(false); + var userDto = await AuthHelper.GetUserDtoAsync(client); + var rootFolderDto = await AuthHelper.GetRootFolderDtoAsync(client, userDto.Id); - var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id, rootFolderDto.Id)).ConfigureAwait(false); + var response = await client.GetAsync(string.Format(CultureInfo.InvariantCulture, format, userDto.Id, rootFolderDto.Id)); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - var rootDto = await JsonSerializer.DeserializeAsync<BaseItemDto[]>( - await response.Content.ReadAsStreamAsync().ConfigureAwait(false), - _jsonOptions).ConfigureAwait(false); + var rootDto = await response.Content.ReadFromJsonAsync<BaseItemDto[]>(_jsonOptions); Assert.NotNull(rootDto); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs b/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs index 0f9a2e90a..47bec5d79 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Controllers/VideosControllerTests.cs @@ -19,9 +19,9 @@ public sealed class VideosControllerTests : IClassFixture<JellyfinApplicationFac public async Task DeleteAlternateSources_NonExistentItemId_NotFound() { var client = _factory.CreateClient(); - client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client).ConfigureAwait(false)); + client.DefaultRequestHeaders.AddAuthHeader(_accessToken ??= await AuthHelper.CompleteStartupAsync(client)); - var response = await client.DeleteAsync($"Videos/{Guid.NewGuid()}").ConfigureAwait(false); + var response = await client.DeleteAsync($"Videos/{Guid.NewGuid()}"); 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 2361e4aa4..d2249cdc3 100644 --- a/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs +++ b/tests/Jellyfin.Server.Integration.Tests/EncodedQueryStringTest.cs @@ -27,9 +27,9 @@ namespace Jellyfin.Server.Integration.Tests { var client = _factory.CreateClient(); - var response = await client.GetAsync("Encoder/UrlDecode?" + sourceUrl).ConfigureAwait(false); + var response = await client.GetAsync("Encoder/UrlDecode?" + sourceUrl); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - string reply = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + string reply = await response.Content.ReadAsStringAsync(); Assert.Equal(unencodedUrl, reply); } @@ -40,9 +40,9 @@ namespace Jellyfin.Server.Integration.Tests { var client = _factory.CreateClient(); - var response = await client.GetAsync("Encoder/UrlArrayDecode?" + sourceUrl).ConfigureAwait(false); + var response = await client.GetAsync("Encoder/UrlArrayDecode?" + sourceUrl); Assert.Equal(HttpStatusCode.OK, response.StatusCode); - string reply = await response.Content.ReadAsStringAsync().ConfigureAwait(false); + string reply = await response.Content.ReadAsStringAsync(); Assert.Equal(unencodedUrl, reply); } } diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 55bc43455..1c87d11f1 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -2,7 +2,6 @@ using System; using System.Collections.Concurrent; using System.Globalization; using System.IO; -using System.Threading; using Emby.Server.Implementations; using Jellyfin.Server.Extensions; using Jellyfin.Server.Helpers; @@ -105,7 +104,7 @@ namespace Jellyfin.Server.Integration.Tests var appHost = (TestAppHost)testServer.Services.GetRequiredService<IApplicationHost>(); appHost.ServiceProvider = testServer.Services; appHost.InitializeServices().GetAwaiter().GetResult(); - appHost.RunStartupTasksAsync(CancellationToken.None).GetAwaiter().GetResult(); + appHost.RunStartupTasksAsync().GetAwaiter().GetResult(); return testServer; } diff --git a/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs b/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs index 8c49a2e2b..c8ad9d2a1 100644 --- a/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/Middleware/RobotsRedirectionMiddlewareTests.cs @@ -23,7 +23,7 @@ namespace Jellyfin.Server.Integration.Tests.Middleware AllowAutoRedirect = false }); - var response = await client.GetAsync("robots.txt").ConfigureAwait(false); + var response = await client.GetAsync("robots.txt"); Assert.Equal(HttpStatusCode.Redirect, response.StatusCode); Assert.Equal("web/robots.txt", response.Headers.Location?.ToString()); diff --git a/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs index 0ade345a1..98195a294 100644 --- a/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs +++ b/tests/Jellyfin.Server.Integration.Tests/OpenApiSpecTests.cs @@ -31,10 +31,10 @@ namespace Jellyfin.Server.Integration.Tests Assert.Equal("application/json; charset=utf-8", response.Content.Headers.ContentType?.ToString()); // Write out for publishing - var responseBody = await response.Content.ReadAsStringAsync(); string outputPath = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Assembly.GetExecutingAssembly().Location) ?? ".", "openapi.json")); _outputHelper.WriteLine("Writing OpenAPI Spec JSON to '{0}'.", outputPath); - File.WriteAllText(outputPath, responseBody); + await using var fs = File.Create(outputPath); + await response.Content.CopyToAsync(fs); } } } diff --git a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs index a1bdfa31b..288102037 100644 --- a/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs +++ b/tests/Jellyfin.Server.Tests/ParseNetworkTests.cs @@ -7,6 +7,7 @@ using Jellyfin.Server.Extensions; using MediaBrowser.Common.Configuration; using Microsoft.AspNetCore.Builder; using Microsoft.AspNetCore.HttpOverrides; +using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Logging.Abstractions; using Moq; using Xunit; @@ -21,9 +22,9 @@ namespace Jellyfin.Server.Tests data.Add( true, true, - new string[] { "192.168.t", "127.0.0.1", "1234.1232.12.1234" }, - new IPAddress[] { IPAddress.Loopback.MapToIPv6() }, - Array.Empty<IPNetwork>()); + new string[] { "192.168.t", "127.0.0.1", "::1", "1234.1232.12.1234" }, + new IPAddress[] { IPAddress.Loopback }, + new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); data.Add( true, @@ -64,7 +65,7 @@ namespace Jellyfin.Server.Tests true, true, new string[] { "localhost" }, - new IPAddress[] { IPAddress.Loopback.MapToIPv6() }, + new IPAddress[] { IPAddress.Loopback }, new IPNetwork[] { new IPNetwork(IPAddress.IPv6Loopback, 128) }); return data; } @@ -77,8 +78,8 @@ namespace Jellyfin.Server.Tests var settings = new NetworkConfiguration { - EnableIPV4 = ip4, - EnableIPV6 = ip6 + EnableIPv4 = ip4, + EnableIPv6 = ip6 }; ForwardedHeadersOptions options = new ForwardedHeadersOptions(); @@ -116,11 +117,11 @@ namespace Jellyfin.Server.Tests { var conf = new NetworkConfiguration() { - EnableIPV6 = true, - EnableIPV4 = true, + EnableIPv6 = true, + EnableIPv4 = true, }; - - return new NetworkManager(GetMockConfig(conf), new NullLogger<NetworkManager>()); + var startupConf = new Mock<IConfiguration>(); + return new NetworkManager(GetMockConfig(conf), startupConf.Object, new NullLogger<NetworkManager>()); } } } diff --git a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs index 797fc8f64..93e065685 100644 --- a/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs +++ b/tests/Jellyfin.Server.Tests/UrlDecodeQueryFeatureTests.cs @@ -22,7 +22,7 @@ namespace Jellyfin.Server.Tests Assert.Single(test.Query); var (k, v) = test.Query.First(); Assert.Equal(key, k); - Assert.Empty(v); + Assert.True(StringValues.IsNullOrEmpty(v)); } } } diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs index f63bc0e1b..c0d06116b 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/EpisodeNfoProviderTests.cs @@ -1,4 +1,4 @@ -using System; +using System; using System.Linq; using System.Threading; using Jellyfin.Data.Enums; @@ -114,11 +114,11 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers _parser.Fetch(result, "Test Data/Rising.nfo", CancellationToken.None); var item = result.Item; - Assert.Equal("Rising (1)", item.Name); + Assert.Equal("Rising (1) / Rising (2)", item.Name); Assert.Equal(1, item.IndexNumber); Assert.Equal(2, item.IndexNumberEnd); Assert.Equal(1, item.ParentIndexNumber); - Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy.", item.Overview); + Assert.Equal("A new Stargate team embarks on a dangerous mission to a distant galaxy, where they discover a mythical lost city -- and a deadly new enemy. / Sheppard tries to convince Weir to mount a rescue mission to free Colonel Sumner, Teyla, and the others captured by the Wraith.", item.Overview); Assert.Equal(new DateTime(2004, 7, 16), item.PremiereDate); Assert.Equal(2004, item.ProductionYear); } diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs index f56f58c6f..0a153b9cc 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MovieNfoParserTests.cs @@ -60,7 +60,7 @@ namespace Jellyfin.XbmcMetadata.Tests.Parsers { Exists = true, FullName = OperatingSystem.IsWindows() ? - "C:\\media\\movies\\Justice League (2017).jpg" + @"C:\media\movies\Justice League (2017).jpg" : "/media/movies/Justice League (2017).jpg" }; directoryService.Setup(x => x.GetFile(_localImageFileMetadata.FullName)) diff --git a/tests/jellyfin-tests.ruleset b/tests/jellyfin-tests.ruleset index e2abaf5bb..9d133da56 100644 --- a/tests/jellyfin-tests.ruleset +++ b/tests/jellyfin-tests.ruleset @@ -19,4 +19,10 @@ <!-- CA2234: Pass system uri objects instead of strings --> <Rule Id="CA2234" Action="Info" /> </Rules> + + <!-- xUnit --> + <Rules AnalyzerId="xUnit" RuleNamespace="xUnit"> + <!-- Test methods must have a supported return type. --> + <Rule Id="xUnit1028" Action="None" /> + </Rules> </RuleSet> |
