diff options
| author | Cody Robibero <cody@robibe.ro> | 2022-11-27 08:49:52 -0700 |
|---|---|---|
| committer | Cody Robibero <cody@robibe.ro> | 2022-11-27 08:49:52 -0700 |
| commit | 4e34c428d8ed8753af0fa81a8833ac92a7d2f5ac (patch) | |
| tree | 29fbe5f7355bc7dfe96a7787df34dec8fe75597d | |
| parent | 5787ab15dc297da6d2e1ec984ccf19751ed85db6 (diff) | |
| parent | d4bd72049b4609582fb57f63134bee58327bc0cc (diff) | |
Merge branch 'master' into fix-fmp4-flac-opus
193 files changed, 3891 insertions, 2371 deletions
diff --git a/.github/dependabot.yml b/.github/dependabot.yml deleted file mode 100644 index 70bcd4973..000000000 --- a/.github/dependabot.yml +++ /dev/null @@ -1,15 +0,0 @@ -version: 2 -updates: -- package-ecosystem: nuget - directory: "/" - schedule: - interval: weekly - time: '12:00' - open-pull-requests-limit: 10 - -- package-ecosystem: github-actions - directory: '/' - schedule: - interval: weekly - time: '12:00' - open-pull-requests-limit: 10 diff --git a/.github/renovate.json b/.github/renovate.json new file mode 100644 index 000000000..5ca683876 --- /dev/null +++ b/.github/renovate.json @@ -0,0 +1,6 @@ +{ + "$schema": "https://docs.renovatebot.com/renovate-schema.json", + "extends": [ + "github>jellyfin/.github//renovate-presets/dotnet" + ] +} diff --git a/.github/workflows/automation.yml b/.github/workflows/automation.yml index 20294843d..0989df64b 100644 --- a/.github/workflows/automation.yml +++ b/.github/workflows/automation.yml @@ -14,7 +14,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Apply label - uses: eps1lon/actions-label-merge-conflict@v2.0.1 + uses: eps1lon/actions-label-merge-conflict@fd1f295ee7443d13745804bc49fe158e240f6c6e # tag=v2.1.0 if: ${{ github.event_name == 'push' || github.event_name == 'pull_request_target'}} with: dirtyLabel: 'merge conflict' @@ -26,7 +26,7 @@ jobs: if: ${{ github.repository == 'jellyfin/jellyfin' }} steps: - name: Remove from 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -35,7 +35,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Release Next' project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: (github.event.pull_request || github.event.issue.pull_request) && github.event.action == 'opened' continue-on-error: true with: @@ -44,7 +44,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add to 'Current Release' project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: (github.event.pull_request || github.event.issue.pull_request) && !contains(github.event.*.labels.*.name, 'stable backport') continue-on-error: true with: @@ -58,7 +58,7 @@ jobs: run: echo "::set-output name=number::$(curl -s ${{ github.event.issue.comments_url }} | jq '.[] | select(.author_association == "MEMBER") | .author_association' | wc -l)" - name: Move issue to needs triage - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: github.event.issue.pull_request == '' && github.event.comment.author_association == 'MEMBER' && steps.member_comments.outputs.number <= 1 continue-on-error: true with: @@ -67,7 +67,7 @@ jobs: repo-token: ${{ secrets.JF_BOT_TOKEN }} - name: Add issue to triage project - uses: alex-page/github-project-automation-plus@v0.8.1 + uses: alex-page/github-project-automation-plus@1f8873e97e3c8f58161a323b7c568c1f623a1c4d # tag=v0.8.2 if: github.event.issue.pull_request == '' && github.event.action == 'opened' continue-on-error: true with: diff --git a/.github/workflows/codeql-analysis.yml b/.github/workflows/codeql-analysis.yml index 1dbd7fa36..adca9680f 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@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 with: dotnet-version: '6.0.x' - name: Initialize CodeQL - uses: github/codeql-action/init@v2 + uses: github/codeql-action/init@312e093a1892bd801f026f1090904ee8e460b9b6 # v2 with: languages: ${{ matrix.language }} queries: +security-extended - name: Autobuild - uses: github/codeql-action/autobuild@v2 + uses: github/codeql-action/autobuild@312e093a1892bd801f026f1090904ee8e460b9b6 # v2 - name: Perform CodeQL Analysis - uses: github/codeql-action/analyze@v2 + uses: github/codeql-action/analyze@312e093a1892bd801f026f1090904ee8e460b9b6 # v2 diff --git a/.github/workflows/commands.yml b/.github/workflows/commands.yml index 23873706d..a29519b29 100644 --- a/.github/workflows/commands.yml +++ b/.github/workflows/commands.yml @@ -16,20 +16,20 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 with: token: ${{ secrets.JF_BOT_TOKEN }} comment-id: ${{ github.event.comment.id }} reactions: '+1' - name: Checkout the latest code - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Automatic Rebase - uses: cirrus-actions/rebase@1.7 + uses: cirrus-actions/rebase@6e572f08c244e2f04f9beb85a943eb618218714d # tag=1.7 env: GITHUB_TOKEN: ${{ secrets.JF_BOT_TOKEN }} @@ -39,7 +39,7 @@ jobs: runs-on: ubuntu-latest steps: - name: Notify as seen - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -47,14 +47,14 @@ jobs: reactions: eyes - name: Checkout the latest code - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: token: ${{ secrets.JF_BOT_TOKEN }} fetch-depth: 0 - name: Notify as running id: comment_running - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -89,7 +89,7 @@ jobs: exit ${retcode} - name: Notify with result success - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null && success() }} with: token: ${{ secrets.JF_BOT_TOKEN }} @@ -104,7 +104,7 @@ jobs: reactions: hooray - name: Notify with result failure - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ github.event.comment != null && failure() }} with: token: ${{ secrets.JF_BOT_TOKEN }} diff --git a/.github/workflows/openapi.yml b/.github/workflows/openapi.yml index ceb4e8cdf..ca710fe83 100644 --- a/.github/workflows/openapi.yml +++ b/.github/workflows/openapi.yml @@ -12,18 +12,18 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: ref: ${{ github.event.pull_request.head.sha }} repository: ${{ github.event.pull_request.head.repo.full_name }} - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 with: dotnet-version: '6.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3 with: name: openapi-head retention-days: 14 @@ -37,17 +37,17 @@ jobs: permissions: read-all steps: - name: Checkout repository - uses: actions/checkout@v3 + uses: actions/checkout@93ea575cb5d8a053eaa0ac8fa3b40d7e05a33cc8 # tag=v3 with: ref: ${{ github.base_ref }} - name: Setup .NET Core - uses: actions/setup-dotnet@v3 + uses: actions/setup-dotnet@607fce577a46308457984d59e4954e075820f10a # tag=v3 with: dotnet-version: '6.0.x' - name: Generate openapi.json run: dotnet test tests/Jellyfin.Server.Integration.Tests/Jellyfin.Server.Integration.Tests.csproj -c Release --filter "Jellyfin.Server.Integration.Tests.OpenApiSpecTests" - name: Upload openapi.json - uses: actions/upload-artifact@v3 + uses: actions/upload-artifact@83fd05a356d7e2593de66fc9913b3002723633cb # tag=v3 with: name: openapi-base retention-days: 14 @@ -63,12 +63,12 @@ jobs: - openapi-base steps: - name: Download openapi-head - uses: actions/download-artifact@v3 + uses: actions/download-artifact@9782bd6a9848b53b110e712e20e42d89988822b7 # tag=v3 with: name: openapi-head path: openapi-head - name: Download openapi-base - uses: actions/download-artifact@v3 + uses: actions/download-artifact@9782bd6a9848b53b110e712e20e42d89988822b7 # tag=v3 with: name: openapi-base path: openapi-base @@ -90,14 +90,14 @@ jobs: body="${body//$'\r'/'%0D'}" echo ::set-output name=body::$body - name: Find difference comment - uses: peter-evans/find-comment@v2 + uses: peter-evans/find-comment@b657a70ff16d17651703a84bee1cb9ad9d2be2ea # tag=v2 id: find-comment with: issue-number: ${{ github.event.pull_request.number }} direction: last body-includes: openapi-diff-workflow-comment - name: Reply or edit difference comment (changed) - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ steps.read-diff.outputs.body != '' }} with: issue-number: ${{ github.event.pull_request.number }} @@ -112,7 +112,7 @@ jobs: </details> - name: Edit difference comment (unchanged) - uses: peter-evans/create-or-update-comment@v2 + uses: peter-evans/create-or-update-comment@5adcb0bb0f9fb3f95ef05400558bdb3f329ee808 # tag=v2 if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }} with: issue-number: ${{ github.event.pull_request.number }} diff --git a/.github/workflows/repo-stale.yaml b/.github/workflows/repo-stale.yaml index 2578f82cf..f7a77f02b 100644 --- a/.github/workflows/repo-stale.yaml +++ b/.github/workflows/repo-stale.yaml @@ -10,7 +10,7 @@ jobs: runs-on: ubuntu-latest if: ${{ contains(github.repository, 'jellyfin/') }} steps: - - uses: actions/stale@v6 + - uses: actions/stale@5ebf00ea0e4c1561e9b43a292ed34424fb1d4578 # tag=v6 with: repo-token: ${{ secrets.JF_BOT_TOKEN }} days-before-stale: 120 diff --git a/.gitignore b/.gitignore index c2ae76c1e..fd8e1f314 100644 --- a/.gitignore +++ b/.gitignore @@ -150,8 +150,6 @@ publish/ *.pubxml # NuGet Packages Directory -## TODO: If you have NuGet Package Restore enabled, uncomment the next line -# packages/ dlls/ dllssigned/ @@ -166,7 +164,6 @@ AppPackages/ sql/ *.Cache ClientBin/ -[Ss]tyle[Cc]op.* ~$* *~ *.dbmdl diff --git a/Directory.Build.props b/Directory.Build.props index efcfb7224..f812f4419 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -16,6 +16,7 @@ <ItemGroup> <AdditionalFiles Include="$(SolutionDir)/BannedSymbols.txt" /> + <AdditionalFiles Include="$(SolutionDir)/stylecop.json" /> </ItemGroup> </Project> diff --git a/Dockerfile b/Dockerfile index 219b95893..7b69a186f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -89,4 +89,4 @@ ENTRYPOINT ["./jellyfin/jellyfin", \ "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ - CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 + CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm b/Dockerfile.arm index 8e0ba7af5..84ddf499a 100644 --- a/Dockerfile.arm +++ b/Dockerfile.arm @@ -78,4 +78,4 @@ ENTRYPOINT ["./jellyfin/jellyfin", \ "--ffmpeg", "/usr/lib/jellyfin-ffmpeg/ffmpeg"] HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ - CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 + CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 diff --git a/Dockerfile.arm64 b/Dockerfile.arm64 index 790be1c39..d4ae5802c 100644 --- a/Dockerfile.arm64 +++ b/Dockerfile.arm64 @@ -72,4 +72,4 @@ ENTRYPOINT ["./jellyfin/jellyfin", \ "--ffmpeg", "/usr/bin/ffmpeg"] HEALTHCHECK --interval=30s --timeout=30s --start-period=10s --retries=3 \ - CMD curl -Lk "${HEALTHCHECK_URL}" || exit 1 + CMD curl -Lk -fsS "${HEALTHCHECK_URL}" || exit 1 diff --git a/Emby.Dlna/Eventing/DlnaEventManager.cs b/Emby.Dlna/Eventing/DlnaEventManager.cs index d17e23871..68895a7fe 100644 --- a/Emby.Dlna/Eventing/DlnaEventManager.cs +++ b/Emby.Dlna/Eventing/DlnaEventManager.cs @@ -127,8 +127,7 @@ namespace Emby.Dlna.Eventing public Task TriggerEvent(string notificationType, IDictionary<string, string> stateVariables) { var subs = _subscriptions.Values - .Where(i => !i.IsExpired && string.Equals(notificationType, i.NotificationType, StringComparison.OrdinalIgnoreCase)) - .ToList(); + .Where(i => !i.IsExpired && string.Equals(notificationType, i.NotificationType, StringComparison.OrdinalIgnoreCase)); var tasks = subs.Select(i => TriggerEvent(i, stateVariables)); diff --git a/Emby.Dlna/PlayTo/PlayToController.cs b/Emby.Dlna/PlayTo/PlayToController.cs index b73ce00b6..65367e24f 100644 --- a/Emby.Dlna/PlayTo/PlayToController.cs +++ b/Emby.Dlna/PlayTo/PlayToController.cs @@ -338,7 +338,6 @@ namespace Emby.Dlna.PlayTo SubtitleStreamIndex = info.SubtitleStreamIndex, VolumeLevel = _device.Volume, - // TODO CanSeek = true, PlayMethod = info.IsDirectStream ? PlayMethod.DirectStream : PlayMethod.Transcode diff --git a/Emby.Naming/AudioBook/AudioBookListResolver.cs b/Emby.Naming/AudioBook/AudioBookListResolver.cs index 2efe7d526..6e491185d 100644 --- a/Emby.Naming/AudioBook/AudioBookListResolver.cs +++ b/Emby.Naming/AudioBook/AudioBookListResolver.cs @@ -36,8 +36,7 @@ namespace Emby.Naming.AudioBook // File with empty fullname will be sorted out here. var audiobookFileInfos = files .Select(i => _audioBookResolver.Resolve(i.FullName)) - .OfType<AudioBookFileInfo>() - .ToList(); + .OfType<AudioBookFileInfo>(); var stackResult = StackResolver.ResolveAudioBooks(audiobookFileInfos); diff --git a/Emby.Naming/Common/NamingOptions.cs b/Emby.Naming/Common/NamingOptions.cs index 513733ab5..0119fa38c 100644 --- a/Emby.Naming/Common/NamingOptions.cs +++ b/Emby.Naming/Common/NamingOptions.cs @@ -175,6 +175,7 @@ namespace Emby.Naming.Common AlbumStackingPrefixes = new[] { "cd", + "digital media", "disc", "disk", "vol", @@ -512,13 +513,13 @@ namespace Emby.Naming.Common MediaType.Video), new ExtraRule( - ExtraType.Clip, + ExtraType.Short, ExtraRuleType.DirectoryName, "shorts", MediaType.Video), new ExtraRule( - ExtraType.Clip, + ExtraType.Featurette, ExtraRuleType.DirectoryName, "featurettes", MediaType.Video), @@ -536,6 +537,12 @@ namespace Emby.Naming.Common MediaType.Video), new ExtraRule( + ExtraType.Clip, + ExtraRuleType.DirectoryName, + "clips", + MediaType.Video), + + new ExtraRule( ExtraType.Trailer, ExtraRuleType.Filename, "trailer", @@ -638,13 +645,13 @@ namespace Emby.Naming.Common MediaType.Video), new ExtraRule( - ExtraType.Clip, + ExtraType.Featurette, ExtraRuleType.Suffix, "-featurette", MediaType.Video), new ExtraRule( - ExtraType.Clip, + ExtraType.Short, ExtraRuleType.Suffix, "-short", MediaType.Video), diff --git a/Emby.Notifications/NotificationManager.cs b/Emby.Notifications/NotificationManager.cs index 8b281e487..ac90cc8ec 100644 --- a/Emby.Notifications/NotificationManager.cs +++ b/Emby.Notifications/NotificationManager.cs @@ -88,8 +88,7 @@ namespace Emby.Notifications string description, CancellationToken cancellationToken) { - users = users.Where(i => IsEnabledForUser(service, i)) - .ToList(); + users = users.Where(i => IsEnabledForUser(service, i)); var tasks = users.Select(i => SendNotification(request, service, title, description, i, cancellationToken)); diff --git a/Emby.Server.Implementations/ApplicationHost.cs b/Emby.Server.Implementations/ApplicationHost.cs index 909972469..8e4c13def 100644 --- a/Emby.Server.Implementations/ApplicationHost.cs +++ b/Emby.Server.Implementations/ApplicationHost.cs @@ -48,6 +48,7 @@ using Jellyfin.Api.Helpers; using Jellyfin.MediaEncoding.Hls.Playlist; using Jellyfin.Networking.Configuration; using Jellyfin.Networking.Manager; +using Jellyfin.Server.Implementations; using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Events; @@ -101,6 +102,7 @@ using MediaBrowser.Providers.Subtitles; using MediaBrowser.XbmcMetadata.Providers; using Microsoft.AspNetCore.Http; using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Configuration; using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.Logging; @@ -652,6 +654,17 @@ namespace Emby.Server.Implementations /// <returns>A task representing the service initialization operation.</returns> public async Task InitializeServices() { + var jellyfinDb = await Resolve<IDbContextFactory<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false); + await using (jellyfinDb.ConfigureAwait(false)) + { + if ((await jellyfinDb.Database.GetPendingMigrationsAsync().ConfigureAwait(false)).Any()) + { + Logger.LogInformation("There are pending EFCore migrations in the database. Applying... (This may take a while, do not stop Jellyfin)"); + await jellyfinDb.Database.MigrateAsync().ConfigureAwait(false); + Logger.LogInformation("EFCore migrations applied successfully"); + } + } + var localizationManager = (LocalizationManager)Resolve<ILocalizationManager>(); await localizationManager.LoadAll().ConfigureAwait(false); @@ -1088,15 +1101,7 @@ namespace Emby.Server.Implementations return GetLocalApiUrl(request.Host.Host, request.Scheme, requestPort); } - // Published server ends with a / - if (!string.IsNullOrEmpty(PublishedServerUrl)) - { - // Published server ends with a '/', so we need to remove it. - return PublishedServerUrl.Trim('/'); - } - - string smart = NetManager.GetBindInterface(request, out var port); - return GetLocalApiUrl(smart.Trim('/'), request.Scheme, port); + return GetSmartApiUrl(request.HttpContext.Connection.RemoteIpAddress ?? IPAddress.Loopback); } /// <inheritdoc/> diff --git a/Emby.Server.Implementations/Collections/CollectionManager.cs b/Emby.Server.Implementations/Collections/CollectionManager.cs index 5fc2e39a7..187e0c9b3 100644 --- a/Emby.Server.Implementations/Collections/CollectionManager.cs +++ b/Emby.Server.Implementations/Collections/CollectionManager.cs @@ -232,10 +232,10 @@ namespace Emby.Server.Implementations.Collections if (list.Count > 0) { - var newList = collection.LinkedChildren.ToList(); - newList.AddRange(list); - collection.LinkedChildren = newList.ToArray(); - + LinkedChild[] newChildren = new LinkedChild[collection.LinkedChildren.Length + list.Count]; + collection.LinkedChildren.CopyTo(newChildren, 0); + list.CopyTo(newChildren, collection.LinkedChildren.Length); + collection.LinkedChildren = newChildren; collection.UpdateRatingToItems(linkedChildrenList); await collection.UpdateToRepositoryAsync(ItemUpdateType.MetadataEdit, CancellationToken.None).ConfigureAwait(false); diff --git a/Emby.Server.Implementations/Data/SqliteItemRepository.cs b/Emby.Server.Implementations/Data/SqliteItemRepository.cs index 7622d2fe6..371111dff 100644 --- a/Emby.Server.Implementations/Data/SqliteItemRepository.cs +++ b/Emby.Server.Implementations/Data/SqliteItemRepository.cs @@ -3524,6 +3524,13 @@ namespace Emby.Server.Implementations.Data statement?.TryBind("@MinIndexNumber", query.MinIndexNumber.Value); } + if (query.MinParentAndIndexNumber.HasValue) + { + whereClauses.Add("((ParentIndexNumber=@MinParentAndIndexNumberParent and IndexNumber>=@MinParentAndIndexNumberIndex) or ParentIndexNumber>@MinParentAndIndexNumberParent)"); + statement?.TryBind("@MinParentAndIndexNumberParent", query.MinParentAndIndexNumber.Value.ParentIndexNumber); + statement?.TryBind("@MinParentAndIndexNumberIndex", query.MinParentAndIndexNumber.Value.IndexNumber); + } + if (query.MinDateCreated.HasValue) { whereClauses.Add("DateCreated>=@MinDateCreated"); diff --git a/Emby.Server.Implementations/Emby.Server.Implementations.csproj b/Emby.Server.Implementations/Emby.Server.Implementations.csproj index c4fdf08b9..eeaa3346d 100644 --- a/Emby.Server.Implementations/Emby.Server.Implementations.csproj +++ b/Emby.Server.Implementations/Emby.Server.Implementations.csproj @@ -25,13 +25,13 @@ <ItemGroup> <PackageReference Include="DiscUtils.Udf" Version="0.16.13" /> <PackageReference Include="Jellyfin.XmlTv" Version="10.8.0" /> - <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.DependencyInjection" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Caching.Memory" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Hosting.Abstractions" Version="6.0.0" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.9" /> - <PackageReference Include="Mono.Nat" Version="3.0.3" /> - <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.2.4" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.11" /> + <PackageReference Include="Mono.Nat" Version="3.0.4" /> + <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.4.0" /> <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" /> <PackageReference Include="DotNet.Glob" Version="3.1.3" /> </ItemGroup> diff --git a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs index 9e35d83aa..d5e4a636e 100644 --- a/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs +++ b/Emby.Server.Implementations/EntryPoints/LibraryChangedNotifier.cs @@ -115,7 +115,7 @@ namespace Emby.Server.Implementations.EntryPoints { } - var collectionFolders = _libraryManager.GetCollectionFolders(item).ToList(); + var collectionFolders = _libraryManager.GetCollectionFolders(item); foreach (var collectionFolder in collectionFolders) { diff --git a/Emby.Server.Implementations/IO/LibraryMonitor.cs b/Emby.Server.Implementations/IO/LibraryMonitor.cs index 657daac3f..c1422c43d 100644 --- a/Emby.Server.Implementations/IO/LibraryMonitor.cs +++ b/Emby.Server.Implementations/IO/LibraryMonitor.cs @@ -79,14 +79,6 @@ namespace Emby.Server.Implementations.IO TemporarilyIgnore(path); } - public bool IsPathLocked(string path) - { - // This method is not used by the core but it used by auto-organize - - var lockedPaths = _tempIgnoredPaths.Keys.ToList(); - return lockedPaths.Any(i => _fileSystem.AreEqual(i, path) || _fileSystem.ContainsSubPath(i, path)); - } - public async void ReportFileSystemChangeComplete(string path, bool refreshPath) { if (string.IsNullOrEmpty(path)) @@ -145,8 +137,7 @@ namespace Emby.Server.Implementations.IO .OfType<Folder>() .SelectMany(f => f.PhysicalLocations) .Distinct(StringComparer.OrdinalIgnoreCase) - .OrderBy(i => i) - .ToList(); + .OrderBy(i => i); foreach (var path in paths) { @@ -372,11 +363,8 @@ namespace Emby.Server.Implementations.IO var monitorPath = !IgnorePatterns.ShouldIgnore(path); - // Ignore certain files - var tempIgnorePaths = _tempIgnoredPaths.Keys.ToList(); - - // If the parent of an ignored path has a change event, ignore that too - if (tempIgnorePaths.Any(i => + // Ignore certain files, If the parent of an ignored path has a change event, ignore that too + if (_tempIgnoredPaths.Keys.Any(i => { if (_fileSystem.AreEqual(i, path)) { @@ -491,7 +479,7 @@ namespace Emby.Server.Implementations.IO { lock (_activeRefreshers) { - foreach (var refresher in _activeRefreshers.ToList()) + foreach (var refresher in _activeRefreshers) { refresher.Completed -= OnNewRefresherCompleted; refresher.Dispose(); diff --git a/Emby.Server.Implementations/Library/LibraryManager.cs b/Emby.Server.Implementations/Library/LibraryManager.cs index cef82ebbc..b688af528 100644 --- a/Emby.Server.Implementations/Library/LibraryManager.cs +++ b/Emby.Server.Implementations/Library/LibraryManager.cs @@ -2590,9 +2590,9 @@ namespace Emby.Server.Implementations.Library { /* Anime series don't generally have a season in their file name, however, - tvdb needs a season to correctly get the metadata. + TVDb needs a season to correctly get the metadata. Hence, a null season needs to be filled with something. */ - // FIXME perhaps this would be better for tvdb parser to ask for season 1 if no season is specified + // FIXME perhaps this would be better for TVDb parser to ask for season 1 if no season is specified episode.ParentIndexNumber = 1; } diff --git a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs index 8f9e5f01b..84d4688af 100644 --- a/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/Movies/MovieResolver.cs @@ -376,7 +376,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (!justName.IsEmpty) { - // check for tmdb id + // Check for TMDb id var tmdbid = justName.GetAttributeValue("tmdbid"); if (!string.IsNullOrWhiteSpace(tmdbid)) @@ -387,7 +387,7 @@ namespace Emby.Server.Implementations.Library.Resolvers.Movies if (!string.IsNullOrEmpty(item.Path)) { - // check for imdb id - we use full media path, as we can assume, that this will match in any use case (either id in parent dir or in file name) + // Check for IMDb id - we use full media path, as we can assume that this will match in any use case (whether id in parent dir or in file name) var imdbid = item.Path.AsSpan().GetAttributeValue("imdbid"); if (!string.IsNullOrWhiteSpace(imdbid)) diff --git a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs index 6b0dfe986..7a2b3da3a 100644 --- a/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs +++ b/Emby.Server.Implementations/Library/Resolvers/PlaylistResolver.cs @@ -31,16 +31,18 @@ namespace Emby.Server.Implementations.Library.Resolvers if (args.IsDirectory) { // It's a boxset if the path is a directory with [playlist] in it's the name - // TODO: Should this use Path.GetDirectoryName() instead? - bool isBoxSet = Path.GetFileName(args.Path) - ?.Contains("[playlist]", StringComparison.OrdinalIgnoreCase) - ?? false; - if (isBoxSet) + var filename = Path.GetFileName(Path.TrimEndingDirectorySeparator(args.Path)); + if (string.IsNullOrEmpty(filename)) + { + return null; + } + + if (filename.Contains("[playlist]", StringComparison.OrdinalIgnoreCase)) { return new Playlist { Path = args.Path, - Name = Path.GetFileName(args.Path).Replace("[playlist]", string.Empty, StringComparison.OrdinalIgnoreCase).Trim() + Name = filename.Replace("[playlist]", string.Empty, StringComparison.OrdinalIgnoreCase).Trim() }; } @@ -51,7 +53,7 @@ namespace Emby.Server.Implementations.Library.Resolvers return new Playlist { Path = args.Path, - Name = Path.GetFileName(args.Path) + Name = filename }; } } @@ -60,8 +62,8 @@ namespace Emby.Server.Implementations.Library.Resolvers // It should have the correct collection type and a supported file extension else if (_musicPlaylistCollectionTypes.Contains(args.CollectionType ?? string.Empty, StringComparison.OrdinalIgnoreCase)) { - var extension = Path.GetExtension(args.Path); - if (Playlist.SupportedExtensions.Contains(extension ?? string.Empty, StringComparison.OrdinalIgnoreCase)) + var extension = Path.GetExtension(args.Path.AsSpan()); + if (Playlist.SupportedExtensions.Contains(extension, StringComparison.OrdinalIgnoreCase)) { return new Playlist { diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs index 74321a256..cf9be5a54 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/EmbyTV.cs @@ -2192,16 +2192,15 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV private void HandleDuplicateShowIds(List<TimerInfo> timers) { - foreach (var timer in timers.Skip(1)) + // sort showings by HD channels first, then by startDate, record earliest showing possible + foreach (var timer in timers.OrderByDescending(t => _liveTvManager.GetLiveTvChannel(t, this).IsHD).ThenBy(t => t.StartDate).Skip(1)) { - // TODO: Get smarter, prefer HD, etc - timer.Status = RecordingStatus.Cancelled; _timerProvider.Update(timer); } } - private void SearchForDuplicateShowIds(List<TimerInfo> timers) + private void SearchForDuplicateShowIds(IEnumerable<TimerInfo> timers) { var groups = timers.ToLookup(i => i.ShowId ?? string.Empty).ToList(); @@ -2282,39 +2281,13 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (updateTimerSettings) { - // Only update if not currently active - test both new timer and existing in case Id's are different - // Id's could be different if the timer was created manually prior to series timer creation - if (!_activeRecordings.TryGetValue(timer.Id, out _) && !_activeRecordings.TryGetValue(existingTimer.Id, out _)) - { - UpdateExistingTimerWithNewMetadata(existingTimer, timer); - - // Needed by ShouldCancelTimerForSeriesTimer - timer.IsManual = existingTimer.IsManual; - - if (ShouldCancelTimerForSeriesTimer(seriesTimer, timer)) - { - existingTimer.Status = RecordingStatus.Cancelled; - } - else if (!existingTimer.IsManual) - { - existingTimer.Status = RecordingStatus.New; - } - - if (existingTimer.Status != RecordingStatus.Cancelled) - { - enabledTimersForSeries.Add(existingTimer); - } - - existingTimer.KeepUntil = seriesTimer.KeepUntil; - existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; - existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; - existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; - existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; - existingTimer.Priority = seriesTimer.Priority; - existingTimer.SeriesTimerId = seriesTimer.Id; - - _timerProvider.Update(existingTimer); - } + existingTimer.KeepUntil = seriesTimer.KeepUntil; + existingTimer.IsPostPaddingRequired = seriesTimer.IsPostPaddingRequired; + existingTimer.IsPrePaddingRequired = seriesTimer.IsPrePaddingRequired; + existingTimer.PostPaddingSeconds = seriesTimer.PostPaddingSeconds; + existingTimer.PrePaddingSeconds = seriesTimer.PrePaddingSeconds; + existingTimer.Priority = seriesTimer.Priority; + existingTimer.SeriesTimerId = seriesTimer.Id; } existingTimer.SeriesTimerId = seriesTimer.Id; diff --git a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs index a861e6ae4..f612565d1 100644 --- a/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs +++ b/Emby.Server.Implementations/LiveTv/EmbyTV/TimerManager.cs @@ -122,11 +122,28 @@ namespace Emby.Server.Implementations.LiveTv.EmbyTV if (_timers.TryAdd(item.Id, timer)) { - Logger.LogInformation( - "Creating recording timer for {Id}, {Name}. Timer will fire in {Minutes} minutes", + if (item.IsSeries) + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} {SeasonNumber}x{EpisodeNumber:D2} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", item.Id, item.Name, - dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture)); + item.SeasonNumber, + item.EpisodeNumber, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } + else + { + Logger.LogInformation( + "Creating recording timer for {Id}, {Name} on channel {ChannelId}. Timer will fire in {Minutes} minutes at {StartDate}", + item.Id, + item.Name, + item.ChannelId, + dueTime.TotalMinutes.ToString(CultureInfo.InvariantCulture), + item.StartDate); + } } else { diff --git a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs index 4311db28d..b981ad81a 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/SchedulesDirect.cs @@ -166,12 +166,12 @@ namespace Emby.Server.Implementations.LiveTv.Listings const double DesiredAspect = 2.0 / 3; - programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect) ?? - GetProgramImage(ApiUrl, allImages, DesiredAspect); + programEntry.PrimaryImage = GetProgramImage(ApiUrl, imagesWithText, DesiredAspect, token) ?? + GetProgramImage(ApiUrl, allImages, DesiredAspect, token); const double WideAspect = 16.0 / 9; - programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect); + programEntry.ThumbImage = GetProgramImage(ApiUrl, imagesWithText, WideAspect, token); // Don't supply the same image twice if (string.Equals(programEntry.PrimaryImage, programEntry.ThumbImage, StringComparison.Ordinal)) @@ -179,7 +179,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings programEntry.ThumbImage = null; } - programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect); + programEntry.BackdropImage = GetProgramImage(ApiUrl, imagesWithoutText, WideAspect, token); // programEntry.bannerImage = GetProgramImage(ApiUrl, data, "Banner", false) ?? // GetProgramImage(ApiUrl, data, "Banner-L1", false) ?? @@ -400,7 +400,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings return info; } - private static string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, double desiredAspect) + private static string GetProgramImage(string apiUrl, IEnumerable<ImageDataDto> images, double desiredAspect, string token) { var match = images .OrderBy(i => Math.Abs(desiredAspect - GetAspectRatio(i))) @@ -424,7 +424,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings } else { - return apiUrl + "/image/" + uri; + return apiUrl + "/image/" + uri + "?token=" + token; } } @@ -458,6 +458,8 @@ namespace Emby.Server.Implementations.LiveTv.Listings IReadOnlyList<string> programIds, CancellationToken cancellationToken) { + var token = await GetToken(info, cancellationToken).ConfigureAwait(false); + if (programIds.Count == 0) { return Array.Empty<ShowImagesDto>(); @@ -479,6 +481,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings { Content = new StringContent(str.ToString(), Encoding.UTF8, MediaTypeNames.Application.Json) }; + message.Headers.TryAddWithoutValidation("token", token); try { diff --git a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs index 7570a2bcf..82f0baf32 100644 --- a/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs +++ b/Emby.Server.Implementations/LiveTv/Listings/XmlTvListingsProvider.cs @@ -32,18 +32,15 @@ namespace Emby.Server.Implementations.LiveTv.Listings private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; private readonly ILogger<XmlTvListingsProvider> _logger; - private readonly IFileSystem _fileSystem; public XmlTvListingsProvider( IServerConfigurationManager config, IHttpClientFactory httpClientFactory, - ILogger<XmlTvListingsProvider> logger, - IFileSystem fileSystem) + ILogger<XmlTvListingsProvider> logger) { _config = config; _httpClientFactory = httpClientFactory; _logger = logger; - _fileSystem = fileSystem; } public string Name => "XmlTV"; @@ -165,7 +162,7 @@ namespace Emby.Server.Implementations.LiveTv.Listings HasImage = !string.IsNullOrEmpty(program.Icon?.Source), OfficialRating = string.IsNullOrEmpty(program.Rating?.Value) ? null : program.Rating.Value, CommunityRating = program.StarRating, - SeriesId = program.Episode == null ? null : program.Title.GetMD5().ToString("N", CultureInfo.InvariantCulture) + SeriesId = program.Episode == null ? null : program.Title?.GetMD5().ToString("N", CultureInfo.InvariantCulture) }; if (string.IsNullOrWhiteSpace(program.ProgramId)) diff --git a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs index 48d9e316d..e67b5846a 100644 --- a/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs +++ b/Emby.Server.Implementations/LiveTv/TunerHosts/HdHomerun/HdHomerunManager.cs @@ -67,7 +67,7 @@ namespace Emby.Server.Implementations.LiveTv.TunerHosts.HdHomerun int receivedBytes = await stream.ReadAsync(buffer, cancellationToken).ConfigureAwait(false); - return VerifyReturnValueOfGetSet(buffer.AsSpan(receivedBytes), "none"); + return VerifyReturnValueOfGetSet(buffer.AsSpan(0, receivedBytes), "none"); } finally { diff --git a/Emby.Server.Implementations/Localization/Core/ar.json b/Emby.Server.Implementations/Localization/Core/ar.json index 9dc2fe799..ada3c7730 100644 --- a/Emby.Server.Implementations/Localization/Core/ar.json +++ b/Emby.Server.Implementations/Localization/Core/ar.json @@ -97,7 +97,7 @@ "TasksChannelsCategory": "قنوات الإنترنت", "TasksLibraryCategory": "مكتبة", "TasksMaintenanceCategory": "صيانة", - "TaskRefreshLibraryDescription": "يفصح مكتبة الوسائط الخاصة بك بحثًا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.", + "TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.", "TaskRefreshLibrary": "افحص مكتبة الوسائط", "TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.", "TaskRefreshChapterImages": "استخراج صور الفصل", diff --git a/Emby.Server.Implementations/Localization/Core/ca.json b/Emby.Server.Implementations/Localization/Core/ca.json index 644d2676e..ab04693cc 100644 --- a/Emby.Server.Implementations/Localization/Core/ca.json +++ b/Emby.Server.Implementations/Localization/Core/ca.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimitzar la base de dades", "TaskKeyframeExtractorDescription": "Extreu fotogrames clau dels fitxers de vídeo per crear llistes de reproducció HLS més precises. Aquesta tasca pot durar molt de temps.", "TaskKeyframeExtractor": "Extractor de fotogrames clau", - "External": "Extern" + "External": "Extern", + "HearingImpaired": "Discapacitat Auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/cs.json b/Emby.Server.Implementations/Localization/Core/cs.json index 943fc651f..08db5a30e 100644 --- a/Emby.Server.Implementations/Localization/Core/cs.json +++ b/Emby.Server.Implementations/Localization/Core/cs.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimalizovat databázi", "TaskKeyframeExtractorDescription": "Vytahuje klíčové snímky ze souborů videa za účelem vytváření přesnějších seznamů přehrávání HLS. Tento úkol může trvat velmi dlouho.", "TaskKeyframeExtractor": "Vytahovač klíčových snímků", - "External": "Externí" + "External": "Externí", + "HearingImpaired": "Sluchově postižení" } diff --git a/Emby.Server.Implementations/Localization/Core/da.json b/Emby.Server.Implementations/Localization/Core/da.json index 57455587d..34655ace6 100644 --- a/Emby.Server.Implementations/Localization/Core/da.json +++ b/Emby.Server.Implementations/Localization/Core/da.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimér database", "TaskKeyframeExtractorDescription": "Udtrækker billeder fra videofiler for at lave mere præcise HLS playlister. Denne opgave kan godt tage lang tid.", "TaskKeyframeExtractor": "Billedramme udtrækker", - "External": "Ekstern" + "External": "Ekstern", + "HearingImpaired": "Hørehæmmet" } diff --git a/Emby.Server.Implementations/Localization/Core/de.json b/Emby.Server.Implementations/Localization/Core/de.json index 9c278db4d..e1c3e9de1 100644 --- a/Emby.Server.Implementations/Localization/Core/de.json +++ b/Emby.Server.Implementations/Localization/Core/de.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Datenbank optimieren", "TaskKeyframeExtractorDescription": "Extrahiere Keyframes aus Videodateien, um präzisere HLS-Playlisten zu erzeugen. Dieser Vorgang kann sehr lange dauern.", "TaskKeyframeExtractor": "Keyframe Extraktor", - "External": "Extern" + "External": "Extern", + "HearingImpaired": "Hörgeschädigt" } diff --git a/Emby.Server.Implementations/Localization/Core/el.json b/Emby.Server.Implementations/Localization/Core/el.json index 9e216a166..8e9287af4 100644 --- a/Emby.Server.Implementations/Localization/Core/el.json +++ b/Emby.Server.Implementations/Localization/Core/el.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Βελτιστοποίηση βάσης δεδομένων", "TaskKeyframeExtractorDescription": "Εξάγει καρέ από αρχεία βίντεο για να δημιουργήσει πιο ακριβείς λίστες αναπαραγωγής HLS. Αυτή η διεργασία μπορεί να πάρει χρόνο.", "TaskKeyframeExtractor": "Εξαγωγέας βασικών καρέ βίντεο", - "External": "Εξωτερικό" + "External": "Εξωτερικό", + "HearingImpaired": "Με προβλήματα ακοής" } diff --git a/Emby.Server.Implementations/Localization/Core/en-GB.json b/Emby.Server.Implementations/Localization/Core/en-GB.json index 862410c54..243688388 100644 --- a/Emby.Server.Implementations/Localization/Core/en-GB.json +++ b/Emby.Server.Implementations/Localization/Core/en-GB.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimise database", "TaskKeyframeExtractorDescription": "Extracts keyframes from video files to create more precise HLS playlists. This task may run for a long time.", "TaskKeyframeExtractor": "Keyframe Extractor", - "External": "External" + "External": "External", + "HearingImpaired": "Hearing Impaired" } diff --git a/Emby.Server.Implementations/Localization/Core/es-AR.json b/Emby.Server.Implementations/Localization/Core/es-AR.json index 1289172ba..8ad9e8c71 100644 --- a/Emby.Server.Implementations/Localization/Core/es-AR.json +++ b/Emby.Server.Implementations/Localization/Core/es-AR.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimización de base de datos", "External": "Externo", "TaskKeyframeExtractorDescription": "Extrae Fotogramas Clave de los archivos de vídeo para crear Listas de Reprodución HLS más precisas. Esta tarea puede durar mucho tiempo.", - "TaskKeyframeExtractor": "Extractor de Fotogramas Clave" + "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", + "HearingImpaired": "Personas con discapacidad auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/es-MX.json b/Emby.Server.Implementations/Localization/Core/es-MX.json index a7391cc88..d677cc46c 100644 --- a/Emby.Server.Implementations/Localization/Core/es-MX.json +++ b/Emby.Server.Implementations/Localization/Core/es-MX.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabaseDescription": "Compacta la base de datos y trunca el espacio libre. Puede mejorar el rendimiento si se realiza esta tarea después de escanear la biblioteca o después de realizar otros cambios que impliquen modificar la base de datos.", "TaskKeyframeExtractorDescription": "Extrae los cuadros clave de los archivos de vídeo para crear listas HLS más precisas. Esta tarea puede tardar un buen rato.", "TaskKeyframeExtractor": "Extractor de Cuadros Clave", - "External": "Externo" + "External": "Externo", + "HearingImpaired": "Discapacidad Auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/es.json b/Emby.Server.Implementations/Localization/Core/es.json index db65a0c6d..afffdf3bf 100644 --- a/Emby.Server.Implementations/Localization/Core/es.json +++ b/Emby.Server.Implementations/Localization/Core/es.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabaseDescription": "Optimiza y libera el espacio libre en la base de datos. Ejecutar esta tarea tras escanear la biblioteca o hacer cambios que impliquen modificaciones en la base de datos puede mejorar el rendimiento.", "TaskKeyframeExtractorDescription": "Extrae los fotogramas clave de los archivos de vídeo para crear listas HLS más precisas. Esta tarea puede tardar mucho tiempo.", "TaskKeyframeExtractor": "Extractor de Fotogramas Clave", - "External": "Externo" + "External": "Externo", + "HearingImpaired": "Discapacidad Auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/et.json b/Emby.Server.Implementations/Localization/Core/et.json index da44e53d0..081462407 100644 --- a/Emby.Server.Implementations/Localization/Core/et.json +++ b/Emby.Server.Implementations/Localization/Core/et.json @@ -120,5 +120,8 @@ "UserPolicyUpdatedWithName": "Kasutaja {0} õigusi värskendati", "UserStoppedPlayingItemWithValues": "{0} lõpetas {1} taasesituse seadmes {2}", "UserOnlineFromDevice": "{0} on ühendatud seadmest {1}", - "External": "Väline" + "External": "Väline", + "HearingImpaired": "Kuulmispuudega", + "TaskKeyframeExtractorDescription": "Eraldab videofailidest võtmekaadreid, et luua täpsemaid HLS-i esitusloendeid. See ülesanne võib kesta pikka aega.", + "TaskKeyframeExtractor": "Võtmekaadri ekstraktor" } diff --git a/Emby.Server.Implementations/Localization/Core/eu.json b/Emby.Server.Implementations/Localization/Core/eu.json index dfedce7b3..d657ac7b6 100644 --- a/Emby.Server.Implementations/Localization/Core/eu.json +++ b/Emby.Server.Implementations/Localization/Core/eu.json @@ -116,5 +116,12 @@ "CameraImageUploadedFrom": "{0}-tik kamera irudi berri bat igo da", "AuthenticationSucceededWithUserName": "{0} ongi autentifikatu da", "Application": "Aplikazioa", - "AppDeviceValues": "App: {0}, Gailua: {1}" + "AppDeviceValues": "App: {0}, Gailua: {1}", + "HearingImpaired": "Entzunaldia aldatua", + "ProviderValue": "Hornitzailea: {0}", + "TaskKeyframeExtractorDescription": "Bideo fitxategietako fotograma gakoak ateratzen ditu HLS erreprodukzio-zerrenda zehatzagoak sortzeko. Zeregin honek denbora asko iraun dezake.", + "HeaderRecordingGroups": "Grabaketa taldeak", + "Inherit": "Oinordetu", + "TaskOptimizeDatabaseDescription": "Datu-basea trinkotu eta bertatik espazioa askatzen du. Liburutegia eskaneatu ondoren edo datu-basean aldaketak egin ondoren ataza hau exekutatzeak errendimendua hobetu lezake.", + "TaskKeyframeExtractor": "Fotograma gakoen erauzgailua" } diff --git a/Emby.Server.Implementations/Localization/Core/fi.json b/Emby.Server.Implementations/Localization/Core/fi.json index f0cafd1c0..ec72d58dd 100644 --- a/Emby.Server.Implementations/Localization/Core/fi.json +++ b/Emby.Server.Implementations/Localization/Core/fi.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "Optimoi tietokanta", "TaskKeyframeExtractorDescription": "Purkaa videotiedostojen avainkuvat tarkempien HLS-toistolistojen luomiseksi. Tehtävä saattaa kestää huomattavan pitkään.", "TaskKeyframeExtractor": "Avainkuvien purkain", - "External": "Ulkoinen" + "External": "Ulkoinen", + "HearingImpaired": "Kuulorajoitteinen" } diff --git a/Emby.Server.Implementations/Localization/Core/fr-CA.json b/Emby.Server.Implementations/Localization/Core/fr-CA.json index 24ca8f861..3ee045d89 100644 --- a/Emby.Server.Implementations/Localization/Core/fr-CA.json +++ b/Emby.Server.Implementations/Localization/Core/fr-CA.json @@ -5,7 +5,7 @@ "Artists": "Artistes", "AuthenticationSucceededWithUserName": "{0} authentifié avec succès", "Books": "Livres", - "CameraImageUploadedFrom": "Une nouvelle image de caméra a été téléchargée depuis {0}", + "CameraImageUploadedFrom": "Une nouvelle photo a été téléversée depuis {0}", "Channels": "Chaînes", "ChapterNameValue": "Chapitre {0}", "Collections": "Collections", @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimiser la base de données", "TaskKeyframeExtractorDescription": "Extrait les images clés des fichiers vidéo pour créer des listes de lecture HLS plus précises. Cette tâche peut durer très longtemps.", "TaskKeyframeExtractor": "Extracteur d'image clé", - "External": "Externe" + "External": "Externe", + "HearingImpaired": "Malentendants" } diff --git a/Emby.Server.Implementations/Localization/Core/fr.json b/Emby.Server.Implementations/Localization/Core/fr.json index 648c878e9..768245a09 100644 --- a/Emby.Server.Implementations/Localization/Core/fr.json +++ b/Emby.Server.Implementations/Localization/Core/fr.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimiser la base de données", "TaskKeyframeExtractorDescription": "Extrait les images clés des fichiers vidéo pour créer des listes de lecture HLS plus précises. Cette tâche peut durer très longtemps.", "TaskKeyframeExtractor": "Extracteur d'image clé", - "External": "Externe" + "External": "Externe", + "HearingImpaired": "Malentendants" } diff --git a/Emby.Server.Implementations/Localization/Core/gl.json b/Emby.Server.Implementations/Localization/Core/gl.json index b433c6f68..76a98aa54 100644 --- a/Emby.Server.Implementations/Localization/Core/gl.json +++ b/Emby.Server.Implementations/Localization/Core/gl.json @@ -47,7 +47,7 @@ "HeaderFavoriteEpisodes": "Episodios Favoritos", "HeaderFavoriteArtists": "Artistas Favoritos", "HeaderFavoriteAlbums": "Álbunes Favoritos", - "HeaderContinueWatching": "Seguir mirando", + "HeaderContinueWatching": "Seguir vendo", "HeaderAlbumArtists": "Artistas do Album", "Genres": "Xéneros", "Forced": "Forzado", @@ -119,5 +119,9 @@ "UserOnlineFromDevice": "{0} está en liña desde {1}", "UserOfflineFromDevice": "{0} desconectouse desde {1}", "TaskOptimizeDatabaseDescription": "Compacta e libera o espazo libre da base de datos. Executar esta tarefa logo de realizar mudanzas que impliquen modificacións da base de datos ou despois de escanear a biblioteca pode traer mellorías de desempeño.", - "TaskOptimizeDatabase": "Optimizar base de datos" + "TaskOptimizeDatabase": "Optimizar base de datos", + "TaskKeyframeExtractorDescription": "Extrae fragmentos do vídeo para crear listas de reprodución HLS máis precisas. Podería levarlle bastante tempo.", + "External": "Externo", + "HearingImpaired": "Problemas de audición", + "TaskKeyframeExtractor": "Extractor de fragmentos" } diff --git a/Emby.Server.Implementations/Localization/Core/he.json b/Emby.Server.Implementations/Localization/Core/he.json index c635dab23..694a3d688 100644 --- a/Emby.Server.Implementations/Localization/Core/he.json +++ b/Emby.Server.Implementations/Localization/Core/he.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים ומוריד את שטח האחסון שבשימוש. הרצה של פעולה זו לאחר סריקת הספרייה או שינויים אחרים שמשפיעים על מסד הנתונים יכולה לשפר ביצועים.", "TaskKeyframeExtractorDescription": "חלץ תמונות מפתח מקבצי וידאו בכדי ליצור רשימות השמעה מדויקות יותר של HLS. משימה זו עלולה להימשך זמן רב.", "TaskKeyframeExtractor": "מחלץ תמונות מפתח", - "External": "חיצוני" + "External": "חיצוני", + "HearingImpaired": "לקוי שמיעה" } diff --git a/Emby.Server.Implementations/Localization/Core/hr.json b/Emby.Server.Implementations/Localization/Core/hr.json index c63cd2b94..d01295419 100644 --- a/Emby.Server.Implementations/Localization/Core/hr.json +++ b/Emby.Server.Implementations/Localization/Core/hr.json @@ -123,5 +123,6 @@ "External": "Vanjski", "TaskKeyframeExtractorDescription": "Izvlačenje ključnih okvira iz videozapisa za stvaranje objektivnije HLS liste za reprodukciju. Pokretanje ovog zadatka može potrajati.", "TaskKeyframeExtractor": "Izvoditelj ključnog okvira", - "TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka." + "TaskOptimizeDatabaseDescription": "Sažima bazu podataka i uklanja prazan prostor. Pokretanje ovog zadatka, može poboljšati performanse nakon provođenja indeksiranja biblioteke ili provođenja drugih promjena koje utječu na bazu podataka.", + "HearingImpaired": "Oštećen sluh" } diff --git a/Emby.Server.Implementations/Localization/Core/hu.json b/Emby.Server.Implementations/Localization/Core/hu.json index c7f2f9c85..62d48cebd 100644 --- a/Emby.Server.Implementations/Localization/Core/hu.json +++ b/Emby.Server.Implementations/Localization/Core/hu.json @@ -123,5 +123,6 @@ "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.", - "External": "Külső" + "External": "Külső", + "HearingImpaired": "Hallássérült" } diff --git a/Emby.Server.Implementations/Localization/Core/id.json b/Emby.Server.Implementations/Localization/Core/id.json index 3e05525c8..695c0f404 100644 --- a/Emby.Server.Implementations/Localization/Core/id.json +++ b/Emby.Server.Implementations/Localization/Core/id.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "Optimalkan basis data", "TaskKeyframeExtractorDescription": "Ekstrak bingkai utama dari file video untuk membuat daftar putar HLS yang lebih tepat. Tugas ini dapat berjalan untuk waktu yang lama.", "TaskKeyframeExtractor": "Ekstraktor Bingkai Utama", - "External": "Luar" + "External": "Luar", + "HearingImpaired": "Gangguan Pendengaran" } diff --git a/Emby.Server.Implementations/Localization/Core/it.json b/Emby.Server.Implementations/Localization/Core/it.json index 2aa84c536..3710f03e0 100644 --- a/Emby.Server.Implementations/Localization/Core/it.json +++ b/Emby.Server.Implementations/Localization/Core/it.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Ottimizza Database", "TaskKeyframeExtractor": "Estrattore di Keyframe", "TaskKeyframeExtractorDescription": "Estrae i keyframe dai video per creare migliori playlist HLS. Questa procedura potrebbe richiedere molto tempo.", - "External": "Esterno" + "External": "Esterno", + "HearingImpaired": "con problemi di udito" } diff --git a/Emby.Server.Implementations/Localization/Core/jbo.json b/Emby.Server.Implementations/Localization/Core/jbo.json new file mode 100644 index 000000000..1b47bb2f2 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/jbo.json @@ -0,0 +1,7 @@ +{ + "Albums": "lo albuma", + "Artists": "lo larpra", + "Books": "lo cukta", + "HeaderAlbumArtists": "lo albuma larpra", + "Playlists": "lo zgipor" +} diff --git a/Emby.Server.Implementations/Localization/Core/km.json b/Emby.Server.Implementations/Localization/Core/km.json new file mode 100644 index 000000000..02f9d4443 --- /dev/null +++ b/Emby.Server.Implementations/Localization/Core/km.json @@ -0,0 +1,3 @@ +{ + "Albums": "Albums" +} diff --git a/Emby.Server.Implementations/Localization/Core/lt-LT.json b/Emby.Server.Implementations/Localization/Core/lt-LT.json index 232b3ec93..e1c937b6c 100644 --- a/Emby.Server.Implementations/Localization/Core/lt-LT.json +++ b/Emby.Server.Implementations/Localization/Core/lt-LT.json @@ -123,5 +123,6 @@ "TaskKeyframeExtractorDescription": "Iš vaizdo įrašo paruošia reikšminius kadrus, kad būtų sukuriamas tikslenis HLS grojaraštis. Šios užduoties vykdymas gali ilgai užtrukti.", "TaskKeyframeExtractor": "Pagrindinių kadrų ištraukėjas", "TaskOptimizeDatabaseDescription": "Suspaudžia duomenų bazę ir atlaisvina vietą. Paleidžiant šią užduotį, po bibliotekos skenavimo arba kitų veiksmų kurie galimai modifikuoja duomenų bazė, gali pagerinti greitaveiką.", - "External": "Išorinis" + "External": "Išorinis", + "HearingImpaired": "Su klausos sutrikimais" } diff --git a/Emby.Server.Implementations/Localization/Core/nb.json b/Emby.Server.Implementations/Localization/Core/nb.json index 77ee46a4f..5c7dec7ef 100644 --- a/Emby.Server.Implementations/Localization/Core/nb.json +++ b/Emby.Server.Implementations/Localization/Core/nb.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabaseDescription": "Komprimerer database og frigjør plass. Denne prosessen kan forbedre ytelsen etter skanning av bibliotek eller andre handlinger som fører til databaseendringer.", "TaskKeyframeExtractorDescription": "Trekker ut nøkkelbilder fra videofiler for å skape mere nøyaktige HLS-spillelister. Denne oppgaven kan ta lang tid.", "TaskKeyframeExtractor": "Nøkkelbilde-uttrekker", - "External": "Ekstern" + "External": "Ekstern", + "HearingImpaired": "Hørselshemmet" } diff --git a/Emby.Server.Implementations/Localization/Core/nl.json b/Emby.Server.Implementations/Localization/Core/nl.json index 3f22355d6..c05114f01 100644 --- a/Emby.Server.Implementations/Localization/Core/nl.json +++ b/Emby.Server.Implementations/Localization/Core/nl.json @@ -5,7 +5,7 @@ "Artists": "Artiesten", "AuthenticationSucceededWithUserName": "{0} is succesvol geauthenticeerd", "Books": "Boeken", - "CameraImageUploadedFrom": "Nieuwe camera afbeelding toegevoegd vanaf {0}", + "CameraImageUploadedFrom": "Nieuwe camera-afbeelding toegevoegd vanaf {0}", "Channels": "Kanalen", "ChapterNameValue": "Hoofdstuk {0}", "Collections": "Verzamelingen", @@ -15,7 +15,7 @@ "Favorites": "Favorieten", "Folders": "Mappen", "Genres": "Genres", - "HeaderAlbumArtists": "Album Artiesten", + "HeaderAlbumArtists": "Albumartiesten", "HeaderContinueWatching": "Kijken hervatten", "HeaderFavoriteAlbums": "Favoriete albums", "HeaderFavoriteArtists": "Favoriete artiesten", @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Database optimaliseren", "TaskKeyframeExtractorDescription": "Haalt keyframes uit videobestanden om preciezere HLS afspeellijsten te maken. Dit kan lang duren.", "TaskKeyframeExtractor": "Keyframe Extractor", - "External": "Extern" + "External": "Extern", + "HearingImpaired": "Slechthorend" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-BR.json b/Emby.Server.Implementations/Localization/Core/pt-BR.json index 38a36a7e0..b9b93b7b6 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-BR.json +++ b/Emby.Server.Implementations/Localization/Core/pt-BR.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Otimizar base de dados", "TaskKeyframeExtractor": "Extrator de quadro-chave", "TaskKeyframeExtractorDescription": "Extrai quadros-chave de arquivos de vídeo para criar listas de reprodução HLS mais precisas. Esta tarefa pode ser executada por um longo tempo.", - "External": "Externo" + "External": "Externo", + "HearingImpaired": "Deficiência Auditiva" } diff --git a/Emby.Server.Implementations/Localization/Core/pt-PT.json b/Emby.Server.Implementations/Localization/Core/pt-PT.json index 7047f1c28..a75182f22 100644 --- a/Emby.Server.Implementations/Localization/Core/pt-PT.json +++ b/Emby.Server.Implementations/Localization/Core/pt-PT.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Otimizar base de dados", "TaskKeyframeExtractorDescription": "Extrai quadros-chave de ficheiros de video para criar listas de reprodução HLS mais precisas. Esta tarefa pode demorar algum tempo.", "TaskKeyframeExtractor": "Extrator de Quadros-chave", - "External": "Externo" + "External": "Externo", + "HearingImpaired": "Surdo" } diff --git a/Emby.Server.Implementations/Localization/Core/pt.json b/Emby.Server.Implementations/Localization/Core/pt.json index c2c77ccab..39229f45f 100644 --- a/Emby.Server.Implementations/Localization/Core/pt.json +++ b/Emby.Server.Implementations/Localization/Core/pt.json @@ -120,5 +120,6 @@ "TaskCleanActivityLogDescription": "Apaga itens no registro com idade acima do que é configurado.", "TaskOptimizeDatabase": "Otimizar base de dados", "TaskOptimizeDatabaseDescription": "Base de dados compacta e corta espaço livre. A execução desta tarefa depois de digitalizar a biblioteca ou de fazer outras alterações que impliquem modificações na base de dados pode melhorar o desempenho.", - "External": "Externo" + "External": "Externo", + "HearingImpaired": "Problemas auditivos" } diff --git a/Emby.Server.Implementations/Localization/Core/ro.json b/Emby.Server.Implementations/Localization/Core/ro.json index 53456269a..2c10bb477 100644 --- a/Emby.Server.Implementations/Localization/Core/ro.json +++ b/Emby.Server.Implementations/Localization/Core/ro.json @@ -11,7 +11,7 @@ "UserOfflineFromDevice": "{0} s-a deconectat de la {1}", "UserLockedOutWithName": "Utilizatorul {0} a fost blocat", "UserDownloadingItemWithValues": "{0} descarcă {1}", - "UserDeletedWithName": "Utilizatorul {0} a fost eliminat", + "UserDeletedWithName": "Utilizatorul {0} a fost șters", "UserCreatedWithName": "Utilizatorul {0} a fost creat", "User": "Utilizator", "TvShows": "Seriale TV", @@ -20,33 +20,33 @@ "SubtitleDownloadFailureFromForItem": "Subtitrările nu au putut fi descărcate de la {0} pentru {1}", "StartupEmbyServerIsLoading": "Se încarcă serverul Jellyfin. Încercați din nou în scurt timp.", "Songs": "Melodii", - "Shows": "Spectacole", - "ServerNameNeedsToBeRestarted": "{0} trebuie repornit", + "Shows": "Seriale", + "ServerNameNeedsToBeRestarted": "{0} trebuie să fie repornit", "ScheduledTaskStartedWithName": "{0} pornit/ă", "ScheduledTaskFailedWithName": "{0} eșuat/ă", "ProviderValue": "Furnizor: {0}", "PluginUpdatedWithName": "{0} a fost actualizat/ă", "PluginUninstalledWithName": "{0} a fost dezinstalat", "PluginInstalledWithName": "{0} a fost instalat", - "Plugin": "Plugin", - "Playlists": "Liste redare", + "Plugin": "Extensie", + "Playlists": "Liste de redare", "Photos": "Fotografii", "NotificationOptionVideoPlaybackStopped": "Redarea video oprită", "NotificationOptionVideoPlayback": "Redare video începută", "NotificationOptionUserLockedOut": "Utilizatorul a fost blocat", - "NotificationOptionTaskFailed": "Activitate programata eșuată", + "NotificationOptionTaskFailed": "Activitate programată eșuată", "NotificationOptionServerRestartRequired": "Este necesară repornirea serverului", - "NotificationOptionPluginUpdateInstalled": "Actualizare plugin instalată", - "NotificationOptionPluginUninstalled": "Plugin dezinstalat", - "NotificationOptionPluginInstalled": "Plugin instalat", - "NotificationOptionPluginError": "Plugin-ul a eșuat", - "NotificationOptionNewLibraryContent": "Adăugat conținut nou", - "NotificationOptionInstallationFailed": "Eșec la instalare", - "NotificationOptionCameraImageUploaded": "Încarcată imagine cameră", + "NotificationOptionPluginUpdateInstalled": "Actualizarea extensiei este instalată", + "NotificationOptionPluginUninstalled": "Extensie dezinstalată", + "NotificationOptionPluginInstalled": "Extensie instalată", + "NotificationOptionPluginError": "Eroare de extensie", + "NotificationOptionNewLibraryContent": "A fost adăugat conținut nou", + "NotificationOptionInstallationFailed": "Instalare eșuată", + "NotificationOptionCameraImageUploaded": "Imagine încarcată", "NotificationOptionAudioPlaybackStopped": "Redare audio oprită", "NotificationOptionAudioPlayback": "A început redarea audio", "NotificationOptionApplicationUpdateInstalled": "Actualizarea aplicației a fost instalată", - "NotificationOptionApplicationUpdateAvailable": "Disponibilă o actualizare a aplicației", + "NotificationOptionApplicationUpdateAvailable": "Este disponibilă o actualizare a aplicației", "NewVersionIsAvailable": "O nouă versiune a Jellyfin Server este disponibilă pentru descărcare.", "NameSeasonUnknown": "Sezon Necunoscut", "NameSeasonNumber": "Sezonul {0}", @@ -54,8 +54,8 @@ "MusicVideos": "Videoclipuri muzicale", "Music": "Muzică", "Movies": "Filme", - "MixedContent": "Conținut mixt", - "MessageServerConfigurationUpdated": "Configurația serverului a fost actualizată", + "MixedContent": "Conținut amestecat", + "MessageServerConfigurationUpdated": "Configurarea serverului a fost actualizată", "MessageNamedServerConfigurationUpdatedWithValue": "Secțiunea de configurare a serverului {0} a fost acualizata", "MessageApplicationUpdatedTo": "Jellyfin Server a fost actualizat la {0}", "MessageApplicationUpdated": "Jellyfin Server a fost actualizat", @@ -69,7 +69,7 @@ "HeaderRecordingGroups": "Grupuri de înregistrare", "HeaderLiveTV": "TV în Direct", "HeaderFavoriteSongs": "Melodii Favorite", - "HeaderFavoriteShows": "Spectacole Favorite", + "HeaderFavoriteShows": "Seriale TV Favorite", "HeaderFavoriteEpisodes": "Episoade Favorite", "HeaderFavoriteArtists": "Artiști Favoriți", "HeaderFavoriteAlbums": "Albume Favorite", @@ -97,10 +97,10 @@ "TaskRefreshChannels": "Actualizează canale", "TaskCleanTranscodeDescription": "Șterge fișierele de transcodare mai vechi de o zi.", "TaskCleanTranscode": "Curățați directorul de transcodare", - "TaskUpdatePluginsDescription": "Descarcă și instalează actualizări pentru pluginuri care sunt configurate să se actualizeze automat.", - "TaskUpdatePlugins": "Actualizați plugin-uri", + "TaskUpdatePluginsDescription": "Descarcă și instalează actualizări pentru extensiile care sunt configurate să se actualizeze automat.", + "TaskUpdatePlugins": "Actualizați Extensile", "TaskRefreshPeopleDescription": "Actualizează metadatele pentru actori și regizori din biblioteca media.", - "TaskRefreshPeople": "Actualizează oamenii", + "TaskRefreshPeople": "Actualizează Persoanele", "TaskCleanLogsDescription": "Șterge fișierele jurnal care au mai mult de {0} zile.", "TaskCleanLogs": "Curățare director jurnal", "TaskRefreshLibraryDescription": "Scanează biblioteca media pentru fișiere noi și reîmprospătează metadatele.", @@ -114,13 +114,14 @@ "TasksLibraryCategory": "Librărie", "TasksMaintenanceCategory": "Mentenanță", "TaskCleanActivityLogDescription": "Șterge intrările din jurnalul de activitate mai vechi de data configurată.", - "TaskCleanActivityLog": "Curăță Jurnalul de Activitate", + "TaskCleanActivityLog": "Curăță Jurnalul de Activități", "Undefined": "Nedefinit", "Forced": "Forțat", "Default": "Implicit", - "TaskOptimizeDatabaseDescription": "Compactează baza de date și trunchiază spațiul liber. Rularea acestei sarcini după scanarea bibliotecii sau după efectuarea altor modificări care implică modificări ale bazei de date poate îmbunătăți performanța.", + "TaskOptimizeDatabaseDescription": "Comprimă baza de date și trunchiază spațiul liber. Rularea acestei sarcini după scanarea bibliotecii sau după efectuarea altor modificări care implică modificări ale bazei de date poate îmbunătăți performanța.", "TaskOptimizeDatabase": "Optimizează baza de date", "TaskKeyframeExtractorDescription": "Extrage cadrele cheie din fișierele video pentru a crea liste de redare HLS mai precise. Această sarcină poate rula o perioadă lungă de timp.", "External": "Extern", - "TaskKeyframeExtractor": "Extractor de cadre cheie" + "TaskKeyframeExtractor": "Extractor de cadre cheie", + "HearingImpaired": "Ascultare Impară" } diff --git a/Emby.Server.Implementations/Localization/Core/ru.json b/Emby.Server.Implementations/Localization/Core/ru.json index ea9a82d2b..dc45a8264 100644 --- a/Emby.Server.Implementations/Localization/Core/ru.json +++ b/Emby.Server.Implementations/Localization/Core/ru.json @@ -75,7 +75,7 @@ "StartupEmbyServerIsLoading": "Jellyfin Server загружается. Повторите попытку в ближайшее время.", "SubtitleDownloadFailureForItem": "Субтитры к {0} не удалось загрузить", "SubtitleDownloadFailureFromForItem": "Субтитры к {1} не удалось загрузить с {0}", - "Sync": "Синхро", + "Sync": "Синхронизация", "System": "Система", "TvShows": "ТВ", "User": "Пользователь", @@ -117,11 +117,12 @@ "TaskCleanActivityLogDescription": "Удаляет записи журнала активности старше установленного возраста.", "TaskCleanActivityLog": "Очистка журнала активности", "Undefined": "Не определено", - "Forced": "Форсир-ые", + "Forced": "Принудительно", "Default": "По умолчанию", "TaskOptimizeDatabaseDescription": "Сжимает базу данных и вырезает свободные места. Выполнение этой задачи после сканирования библиотеки или внесения других изменений, предполагающих модификации базы данных, может повысить производительность.", "TaskOptimizeDatabase": "Оптимизация базы данных", "TaskKeyframeExtractorDescription": "Извлекаются ключевые кадры из видеофайлов для создания более точных списков плей-листов HLS. Эта задача может выполняться в течение длительного времени.", "TaskKeyframeExtractor": "Извлечение ключевых кадров", - "External": "Внешние" + "External": "Внешние", + "HearingImpaired": "Для слабослышащих" } diff --git a/Emby.Server.Implementations/Localization/Core/sk.json b/Emby.Server.Implementations/Localization/Core/sk.json index 7502969a6..858cc40dd 100644 --- a/Emby.Server.Implementations/Localization/Core/sk.json +++ b/Emby.Server.Implementations/Localization/Core/sk.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "Optimalizovať databázu", "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é" + "External": "Externé", + "HearingImpaired": "Sluchovo Postihnutý" } diff --git a/Emby.Server.Implementations/Localization/Core/sq.json b/Emby.Server.Implementations/Localization/Core/sq.json index 2766dab06..d1b73a3eb 100644 --- a/Emby.Server.Implementations/Localization/Core/sq.json +++ b/Emby.Server.Implementations/Localization/Core/sq.json @@ -119,5 +119,9 @@ "Forced": "I detyruar", "Default": "Parazgjedhur", "TaskOptimizeDatabaseDescription": "Kompakton bazën e të dhënave dhe shkurton hapësirën e lirë. Drejtimi i kësaj detyre pasi skanoni bibliotekën ose bëni ndryshime të tjera që nënkuptojnë modifikime të bazës së të dhënave mund të përmirësojë performancën.", - "TaskOptimizeDatabase": "Optimizo databazën" + "TaskOptimizeDatabase": "Optimizo databazën", + "TaskKeyframeExtractorDescription": "Nxjerrë kornizat kryesore nga skedarët video për të krijuar lista luajtjeje më të sakta HLS. Ky veprim mund të dojë një kohë të gjatë për tu kompletuar.", + "TaskKeyframeExtractor": "Nxjerrës i kornizës kryesore", + "External": "Jashtem", + "HearingImpaired": "Dëgjimi i dëmtuar" } diff --git a/Emby.Server.Implementations/Localization/Core/uk.json b/Emby.Server.Implementations/Localization/Core/uk.json index 3e0fd11c8..92ce616f2 100644 --- a/Emby.Server.Implementations/Localization/Core/uk.json +++ b/Emby.Server.Implementations/Localization/Core/uk.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabaseDescription": "Стискає базу даних та збільшує вільний простір. Виконання цього завдання після сканування медіатеки або внесення інших змін, які передбачають модифікацію бази даних може покращити продуктивність.", "TaskKeyframeExtractorDescription": "Витягує ключові кадри з відеофайлів для створення більш точних списків відтворення HLS. Це завдання може виконуватися протягом тривалого часу.", "TaskKeyframeExtractor": "Екстрактор ключових кадрів", - "External": "Зовнішній" + "External": "Зовнішній", + "HearingImpaired": "З порушеннями слуху" } diff --git a/Emby.Server.Implementations/Localization/Core/ur_PK.json b/Emby.Server.Implementations/Localization/Core/ur_PK.json index e7f3e492c..5413346d4 100644 --- a/Emby.Server.Implementations/Localization/Core/ur_PK.json +++ b/Emby.Server.Implementations/Localization/Core/ur_PK.json @@ -5,18 +5,18 @@ "HeaderAlbumArtists": "البم کے فنکار", "Movies": "فلمیں", "HeaderFavoriteEpisodes": "پسندیدہ اقساط", - "Collections": "مجموعہ", + "Collections": "مجموعے", "Folders": "فولڈرز", "HeaderLiveTV": "براہ راست ٹی وی", "Channels": "چینلز", "HeaderContinueWatching": "دیکھنا جاری رکھیں", "Playlists": "پلے لسٹس", - "ValueSpecialEpisodeName": "خاص - {0}", - "Shows": "شوز", + "ValueSpecialEpisodeName": "خصوصی - {0}", + "Shows": "دکھاتا ہے۔", "Genres": "انواع", "Artists": "فنکار", - "Sync": "مطابقت", - "Photos": "تصوریں", + "Sync": "مطابقت پذیری", + "Photos": "تصاویر", "Albums": "البمز", "Favorites": "پسندیدہ", "Songs": "گانے", diff --git a/Emby.Server.Implementations/Localization/Core/vi.json b/Emby.Server.Implementations/Localization/Core/vi.json index b9e2f1e6c..44ce4ac5b 100644 --- a/Emby.Server.Implementations/Localization/Core/vi.json +++ b/Emby.Server.Implementations/Localization/Core/vi.json @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "Tối ưu hóa cơ sở dữ liệu", "TaskKeyframeExtractor": "Trích Xuất Khung Hình", "TaskKeyframeExtractorDescription": "Trích xuất khung hình chính từ các tệp video để tạo danh sách phát HLS chính xác hơn. Tác vụ này có thể chạy trong một thời gian dài.", - "External": "Bên ngoài" + "External": "Bên ngoài", + "HearingImpaired": "Khiếm Thính" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-CN.json b/Emby.Server.Implementations/Localization/Core/zh-CN.json index a121fc376..ccfbeef0c 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-CN.json +++ b/Emby.Server.Implementations/Localization/Core/zh-CN.json @@ -123,5 +123,6 @@ "TaskOptimizeDatabase": "优化数据库", "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的HLS播放列表。这项任务可能需要很长时间。", "TaskKeyframeExtractor": "关键帧提取器", - "External": "外部" + "External": "外部", + "HearingImpaired": "听力障碍" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-HK.json b/Emby.Server.Implementations/Localization/Core/zh-HK.json index 6c8bf7627..baa9ecc1c 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-HK.json +++ b/Emby.Server.Implementations/Localization/Core/zh-HK.json @@ -123,5 +123,6 @@ "TaskCleanActivityLogDescription": "刪除早於設定時間的日誌記錄。", "TaskKeyframeExtractorDescription": "提取關鍵格以創建更準確的HLS播放列表。次指示可能用時很長。", "TaskKeyframeExtractor": "關鍵幀提取器", - "External": "外部" + "External": "外部", + "HearingImpaired": "聽力障礙" } diff --git a/Emby.Server.Implementations/Localization/Core/zh-TW.json b/Emby.Server.Implementations/Localization/Core/zh-TW.json index 102a266f8..4949c5ab6 100644 --- a/Emby.Server.Implementations/Localization/Core/zh-TW.json +++ b/Emby.Server.Implementations/Localization/Core/zh-TW.json @@ -37,7 +37,7 @@ "MixedContent": "混合內容", "Movies": "電影", "Music": "音樂", - "MusicVideos": "音樂錄影帶", + "MusicVideos": "MV", "NameInstallFailed": "{0} 安裝失敗", "NameSeasonNumber": "第 {0} 季", "NameSeasonUnknown": "未知季數", @@ -122,5 +122,6 @@ "TaskOptimizeDatabase": "最佳化資料庫", "TaskKeyframeExtractorDescription": "將關鍵幀從影片檔案提取出來並建立更精準的HLS播放清單。這可能需要很長時間。", "TaskKeyframeExtractor": "關鍵幀提取器", - "External": "外部" + "External": "外部", + "HearingImpaired": "聽力障礙" } diff --git a/Emby.Server.Implementations/Localization/LocalizationManager.cs b/Emby.Server.Implementations/Localization/LocalizationManager.cs index 281dbb00b..b77168126 100644 --- a/Emby.Server.Implementations/Localization/LocalizationManager.cs +++ b/Emby.Server.Implementations/Localization/LocalizationManager.cs @@ -386,6 +386,7 @@ namespace Emby.Server.Implementations.Localization yield return new LocalizationOption("Español (Dominicana)", "es_DO"); yield return new LocalizationOption("Español (México)", "es-MX"); yield return new LocalizationOption("Eesti", "et"); + yield return new LocalizationOption("Basque", "eu"); yield return new LocalizationOption("فارسی", "fa"); yield return new LocalizationOption("Suomi", "fi"); yield return new LocalizationOption("Filipino", "fil"); @@ -433,8 +434,8 @@ namespace Emby.Server.Implementations.Localization yield return new LocalizationOption("Українська", "uk"); yield return new LocalizationOption("اُردُو", "ur_PK"); yield return new LocalizationOption("Tiếng Việt", "vi"); - yield return new LocalizationOption("汉语 (简化字)", "zh-CN"); - yield return new LocalizationOption("漢語 (繁体字)", "zh-TW"); + yield return new LocalizationOption("汉语 (简体字)", "zh-CN"); + yield return new LocalizationOption("漢語 (繁體字)", "zh-TW"); yield return new LocalizationOption("廣東話 (香港)", "zh-HK"); } } diff --git a/Emby.Server.Implementations/Plugins/PluginManager.cs b/Emby.Server.Implementations/Plugins/PluginManager.cs index ec4e0dbeb..3f7d46822 100644 --- a/Emby.Server.Implementations/Plugins/PluginManager.cs +++ b/Emby.Server.Implementations/Plugins/PluginManager.cs @@ -715,6 +715,7 @@ namespace Emby.Server.Implementations.Plugins { // This value is memory only - so that the web will show restart required. plugin.Manifest.Status = PluginStatus.Restart; + plugin.Manifest.AutoUpdate = false; return; } @@ -729,6 +730,7 @@ namespace Emby.Server.Implementations.Plugins // This value is memory only - so that the web will show restart required. plugin.Manifest.Status = PluginStatus.Restart; + plugin.Manifest.AutoUpdate = false; } } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs index 0bf0838fa..da7c8732a 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/ChapterImagesTask.cs @@ -16,6 +16,7 @@ using MediaBrowser.Model.Entities; using MediaBrowser.Model.Globalization; using MediaBrowser.Model.IO; using MediaBrowser.Model.Tasks; +using Microsoft.Extensions.Logging; namespace Emby.Server.Implementations.ScheduledTasks.Tasks { @@ -24,15 +25,10 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// </summary> public class ChapterImagesTask : IScheduledTask { - /// <summary> - /// The _library manager. - /// </summary> + private readonly ILogger<ChapterImagesTask> _logger; private readonly ILibraryManager _libraryManager; - private readonly IItemRepository _itemRepo; - private readonly IApplicationPaths _appPaths; - private readonly IEncodingManager _encodingManager; private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; @@ -40,6 +36,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// <summary> /// Initializes a new instance of the <see cref="ChapterImagesTask" /> class. /// </summary> + /// <param name="logger">The logger.</param>. /// <param name="libraryManager">The library manager.</param>. /// <param name="itemRepo">The item repository.</param> /// <param name="appPaths">The application paths.</param> @@ -47,6 +44,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks /// <param name="fileSystem">The filesystem.</param> /// <param name="localization">The localization manager.</param> public ChapterImagesTask( + ILogger<ChapterImagesTask> logger, ILibraryManager libraryManager, IItemRepository itemRepo, IApplicationPaths appPaths, @@ -54,6 +52,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks IFileSystem fileSystem, ILocalizationManager localization) { + _logger = logger; _libraryManager = libraryManager; _itemRepo = itemRepo; _appPaths = appPaths; @@ -167,9 +166,10 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks progress.Report(100 * percent); } - catch (ObjectDisposedException) + catch (ObjectDisposedException ex) { // TODO Investigate and properly fix. + _logger.LogError(ex, "Object Disposed"); break; } } diff --git a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs index 98e45fa46..1efacd856 100644 --- a/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs +++ b/Emby.Server.Implementations/ScheduledTasks/Tasks/OptimizeDatabaseTask.cs @@ -17,7 +17,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks { private readonly ILogger<OptimizeDatabaseTask> _logger; private readonly ILocalizationManager _localization; - private readonly JellyfinDbProvider _provider; + private readonly IDbContextFactory<JellyfinDb> _provider; /// <summary> /// Initializes a new instance of the <see cref="OptimizeDatabaseTask" /> class. @@ -28,7 +28,7 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks public OptimizeDatabaseTask( ILogger<OptimizeDatabaseTask> logger, ILocalizationManager localization, - JellyfinDbProvider provider) + IDbContextFactory<JellyfinDb> provider) { _logger = logger; _localization = localization; @@ -70,30 +70,31 @@ namespace Emby.Server.Implementations.ScheduledTasks.Tasks } /// <inheritdoc /> - public Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) + public async Task ExecuteAsync(IProgress<double> progress, CancellationToken cancellationToken) { _logger.LogInformation("Optimizing and vacuuming jellyfin.db..."); try { - using var context = _provider.CreateContext(); - if (context.Database.IsSqlite()) + var context = await _provider.CreateDbContextAsync(cancellationToken).ConfigureAwait(false); + await using (context.ConfigureAwait(false)) { - context.Database.ExecuteSqlRaw("PRAGMA optimize"); - context.Database.ExecuteSqlRaw("VACUUM"); - _logger.LogInformation("jellyfin.db optimized successfully!"); - } - else - { - _logger.LogInformation("This database doesn't support optimization"); + if (context.Database.IsSqlite()) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA optimize", cancellationToken).ConfigureAwait(false); + await context.Database.ExecuteSqlRawAsync("VACUUM", cancellationToken).ConfigureAwait(false); + _logger.LogInformation("jellyfin.db optimized successfully!"); + } + else + { + _logger.LogInformation("This database doesn't support optimization"); + } } } catch (Exception e) { _logger.LogError(e, "Error while optimizing jellyfin.db"); } - - return Task.CompletedTask; } } } diff --git a/Emby.Server.Implementations/TV/TVSeriesManager.cs b/Emby.Server.Implementations/TV/TVSeriesManager.cs index 6005896ad..5c9b9df15 100644 --- a/Emby.Server.Implementations/TV/TVSeriesManager.cs +++ b/Emby.Server.Implementations/TV/TVSeriesManager.cs @@ -192,7 +192,6 @@ namespace Emby.Server.Implementations.TV AncestorWithPresentationUniqueKey = null, SeriesPresentationUniqueKey = seriesKey, IncludeItemTypes = new[] { BaseItemKind.Episode }, - OrderBy = new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }, IsPlayed = true, Limit = 1, ParentIndexNumberNotEquals = 0, @@ -203,11 +202,10 @@ namespace Emby.Server.Implementations.TV } }; - if (rewatching) - { - // find last watched by date played, not by newest episode watched - lastQuery.OrderBy = new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }; - } + // If rewatching is enabled, sort first by date played and then by season and episode numbers + lastQuery.OrderBy = rewatching + ? new[] { (ItemSortBy.DatePlayed, SortOrder.Descending), (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) } + : new[] { (ItemSortBy.ParentIndexNumber, SortOrder.Descending), (ItemSortBy.IndexNumber, SortOrder.Descending) }; var lastWatchedEpisode = _libraryManager.GetItemList(lastQuery).Cast<Episode>().FirstOrDefault(); @@ -226,18 +224,16 @@ namespace Emby.Server.Implementations.TV DtoOptions = dtoOptions }; - Episode nextEpisode; - if (rewatching) - { - nextQuery.Limit = 2; - // get watched episode after most recently watched - nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().ElementAtOrDefault(1); - } - else + // Locate the next up episode based on the last watched episode's season and episode number + var lastWatchedParentIndexNumber = lastWatchedEpisode?.ParentIndexNumber; + var lastWatchedIndexNumber = lastWatchedEpisode?.IndexNumberEnd ?? lastWatchedEpisode?.IndexNumber; + if (lastWatchedParentIndexNumber.HasValue && lastWatchedIndexNumber.HasValue) { - nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().FirstOrDefault(); + nextQuery.MinParentAndIndexNumber = (lastWatchedParentIndexNumber.Value, lastWatchedIndexNumber.Value + 1); } + var nextEpisode = _libraryManager.GetItemList(nextQuery).Cast<Episode>().FirstOrDefault(); + if (_configurationManager.Configuration.DisplaySpecialsWithinSeasons) { var consideredEpisodes = _libraryManager.GetItemList(new InternalItemsQuery(user) diff --git a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs index 64ee5680c..14fd7eb3c 100644 --- a/Jellyfin.Api/Controllers/DisplayPreferencesController.cs +++ b/Jellyfin.Api/Controllers/DisplayPreferencesController.cs @@ -178,7 +178,7 @@ namespace Jellyfin.Api.Controllers foreach (var key in displayPreferences.CustomPrefs.Keys.Where(key => key.StartsWith("homesection", StringComparison.OrdinalIgnoreCase))) { - var order = int.Parse(key.AsSpan().Slice("homesection".Length)); + var order = int.Parse(key.AsSpan().Slice("homesection".Length), NumberStyles.Any, CultureInfo.InvariantCulture); if (!Enum.TryParse<HomeSectionType>(displayPreferences.CustomPrefs[key], true, out var type)) { type = order < 8 ? defaults[order] : HomeSectionType.None; diff --git a/Jellyfin.Api/Controllers/ItemsController.cs b/Jellyfin.Api/Controllers/ItemsController.cs index 80ae5abcb..3ee5b8d73 100644 --- a/Jellyfin.Api/Controllers/ItemsController.cs +++ b/Jellyfin.Api/Controllers/ItemsController.cs @@ -87,9 +87,9 @@ namespace Jellyfin.Api.Controllers /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> /// <param name="isMovie">Optional filter for live tv movies.</param> /// <param name="isSeries">Optional filter for live tv series.</param> /// <param name="isNews">Optional filter for live tv news.</param> @@ -100,7 +100,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> @@ -282,39 +282,13 @@ namespace Jellyfin.Api.Controllers includeItemTypes = new[] { BaseItemKind.Playlist }; } - var enabledChannels = isApiKey - ? Array.Empty<Guid>() - : user!.GetPreferenceValues<Guid>(PreferenceKind.EnabledChannels); - - // api keys are always enabled for all folders - bool isInEnabledFolder = isApiKey - || Array.IndexOf(user!.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders), item.Id) != -1 - // Assume all folders inside an EnabledChannel are enabled - || Array.IndexOf(enabledChannels, item.Id) != -1 - // Assume all items inside an EnabledChannel are enabled - || Array.IndexOf(enabledChannels, item.ChannelId) != -1; - - if (!isInEnabledFolder) - { - var collectionFolders = _libraryManager.GetCollectionFolders(item); - foreach (var collectionFolder in collectionFolders) - { - // api keys never enter this block, so user is never null - if (user!.GetPreferenceValues<Guid>(PreferenceKind.EnabledFolders).Contains(collectionFolder.Id)) - { - isInEnabledFolder = true; - } - } - } - - // api keys are always enabled for all folders, so user is never null if (item is not UserRootFolder - && !isInEnabledFolder - && !user!.HasPermission(PermissionKind.EnableAllFolders) - && !user.HasPermission(PermissionKind.EnableAllChannels) - && !string.Equals(collectionType, CollectionType.Folders, StringComparison.OrdinalIgnoreCase)) + // api keys can always access all folders + && !isApiKey + // check the item is visible for the user + && !item.IsVisible(user)) { - _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user.Username, item.Name); + _logger.LogWarning("{UserName} is not permitted to access Library {ItemName}", user!.Username, item.Name); return Unauthorized($"{user.Username} is not permitted to access Library {item.Name}."); } @@ -562,9 +536,9 @@ namespace Jellyfin.Api.Controllers /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> /// <param name="isMovie">Optional filter for live tv movies.</param> /// <param name="isSeries">Optional filter for live tv series.</param> /// <param name="isNews">Optional filter for live tv news.</param> @@ -575,7 +549,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> diff --git a/Jellyfin.Api/Controllers/LibraryController.cs b/Jellyfin.Api/Controllers/LibraryController.cs index e9492a6a4..7a57bf1a2 100644 --- a/Jellyfin.Api/Controllers/LibraryController.cs +++ b/Jellyfin.Api/Controllers/LibraryController.cs @@ -485,7 +485,7 @@ namespace Jellyfin.Api.Controllers /// <response code="200">Media folders returned.</response> /// <returns>List of user media folders.</returns> [HttpGet("Library/MediaFolders")] - [Authorize(Policy = Policies.DefaultAuthorization)] + [Authorize(Policy = Policies.RequiresElevation)] [ProducesResponseType(StatusCodes.Status200OK)] public ActionResult<QueryResult<BaseItemDto>> GetMediaFolders([FromQuery] bool? isHidden) { diff --git a/Jellyfin.Api/Controllers/MoviesController.cs b/Jellyfin.Api/Controllers/MoviesController.cs index 8195fc760..03f864b4a 100644 --- a/Jellyfin.Api/Controllers/MoviesController.cs +++ b/Jellyfin.Api/Controllers/MoviesController.cs @@ -193,7 +193,7 @@ namespace Jellyfin.Api.Controllers new InternalItemsQuery(user) { Person = name, - // Account for duplicates by imdb id, since the database doesn't support this yet + // Account for duplicates by IMDb id, since the database doesn't support this yet Limit = itemLimit + 2, PersonTypes = new[] { PersonType.Director }, IncludeItemTypes = itemTypes.ToArray(), @@ -232,15 +232,15 @@ namespace Jellyfin.Api.Controllers foreach (var name in names) { var items = _libraryManager.GetItemList(new InternalItemsQuery(user) - { - Person = name, - // Account for duplicates by imdb id, since the database doesn't support this yet - Limit = itemLimit + 2, - IncludeItemTypes = itemTypes.ToArray(), - IsMovie = true, - EnableGroupByMetadataKey = true, - DtoOptions = dtoOptions - }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) + { + Person = name, + // Account for duplicates by IMDb id, since the database doesn't support this yet + Limit = itemLimit + 2, + IncludeItemTypes = itemTypes.ToArray(), + IsMovie = true, + EnableGroupByMetadataKey = true, + DtoOptions = dtoOptions + }).GroupBy(i => i.GetProviderId(MediaBrowser.Model.Entities.MetadataProvider.Imdb) ?? Guid.NewGuid().ToString("N", CultureInfo.InvariantCulture)) .Select(x => x.First()) .Take(itemLimit) .ToList(); diff --git a/Jellyfin.Api/Controllers/TrailersController.cs b/Jellyfin.Api/Controllers/TrailersController.cs index b296d1c96..53a839e43 100644 --- a/Jellyfin.Api/Controllers/TrailersController.cs +++ b/Jellyfin.Api/Controllers/TrailersController.cs @@ -55,9 +55,9 @@ namespace Jellyfin.Api.Controllers /// <param name="minDateLastSavedForUser">Optional. The minimum last saved date for the current user. Format = ISO.</param> /// <param name="maxPremiereDate">Optional. The maximum premiere date. Format = ISO.</param> /// <param name="hasOverview">Optional filter by items that have an overview or not.</param> - /// <param name="hasImdbId">Optional filter by items that have an imdb id or not.</param> - /// <param name="hasTmdbId">Optional filter by items that have a tmdb id or not.</param> - /// <param name="hasTvdbId">Optional filter by items that have a tvdb id or not.</param> + /// <param name="hasImdbId">Optional filter by items that have an IMDb id or not.</param> + /// <param name="hasTmdbId">Optional filter by items that have a TMDb id or not.</param> + /// <param name="hasTvdbId">Optional filter by items that have a TVDb id or not.</param> /// <param name="isMovie">Optional filter for live tv movies.</param> /// <param name="isSeries">Optional filter for live tv series.</param> /// <param name="isNews">Optional filter for live tv news.</param> @@ -68,7 +68,7 @@ namespace Jellyfin.Api.Controllers /// <param name="limit">Optional. The maximum number of records to return.</param> /// <param name="recursive">When searching within folders, this determines whether or not the search will be recursive. true/false.</param> /// <param name="searchTerm">Optional. Filter based on a search term.</param> - /// <param name="sortOrder">Sort Order - Ascending,Descending.</param> + /// <param name="sortOrder">Sort Order - Ascending, Descending.</param> /// <param name="parentId">Specify this to localize the search to a specific item or folder. Omit to use the root.</param> /// <param name="fields">Optional. Specify additional fields of information to return in the output. This allows multiple, comma delimited. Options: Budget, Chapters, DateCreated, Genres, HomePageUrl, IndexOptions, MediaStreams, Overview, ParentId, Path, People, ProviderIds, PrimaryImageAspectRatio, Revenue, SortName, Studios, Taglines.</param> /// <param name="excludeItemTypes">Optional. If specified, results will be filtered based on item type. This allows multiple, comma delimited.</param> diff --git a/Jellyfin.Api/Helpers/StreamingHelpers.cs b/Jellyfin.Api/Helpers/StreamingHelpers.cs index 370573773..c8e62999c 100644 --- a/Jellyfin.Api/Helpers/StreamingHelpers.cs +++ b/Jellyfin.Api/Helpers/StreamingHelpers.cs @@ -351,7 +351,7 @@ namespace Jellyfin.Api.Helpers try { // Parses npt times in the format of '10:19:25.7' - return TimeSpan.Parse(value).Ticks; + return TimeSpan.Parse(value, CultureInfo.InvariantCulture).Ticks; } catch { diff --git a/Jellyfin.Api/Jellyfin.Api.csproj b/Jellyfin.Api/Jellyfin.Api.csproj index 7e64cf645..a4502b612 100644 --- a/Jellyfin.Api/Jellyfin.Api.csproj +++ b/Jellyfin.Api/Jellyfin.Api.csproj @@ -17,7 +17,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.9" /> + <PackageReference Include="Microsoft.AspNetCore.Authorization" Version="6.0.11" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> <PackageReference Include="Swashbuckle.AspNetCore" Version="6.2.3" /> <PackageReference Include="Swashbuckle.AspNetCore.ReDoc" Version="6.4.0" /> diff --git a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs index 592c53fe5..9d6ca6aab 100644 --- a/Jellyfin.Server.Implementations/Activity/ActivityManager.cs +++ b/Jellyfin.Server.Implementations/Activity/ActivityManager.cs @@ -15,13 +15,13 @@ namespace Jellyfin.Server.Implementations.Activity /// </summary> public class ActivityManager : IActivityManager { - private readonly JellyfinDbProvider _provider; + private readonly IDbContextFactory<JellyfinDb> _provider; /// <summary> /// Initializes a new instance of the <see cref="ActivityManager"/> class. /// </summary> /// <param name="provider">The Jellyfin database provider.</param> - public ActivityManager(JellyfinDbProvider provider) + public ActivityManager(IDbContextFactory<JellyfinDb> provider) { _provider = provider; } @@ -32,10 +32,12 @@ namespace Jellyfin.Server.Implementations.Activity /// <inheritdoc/> public async Task CreateAsync(ActivityLog entry) { - await using var dbContext = _provider.CreateContext(); - - dbContext.ActivityLogs.Add(entry); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.ActivityLogs.Add(entry); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } EntryCreated?.Invoke(this, new GenericEventArgs<ActivityLogEntry>(ConvertToOldModel(entry))); } @@ -43,44 +45,47 @@ namespace Jellyfin.Server.Implementations.Activity /// <inheritdoc/> public async Task<QueryResult<ActivityLogEntry>> GetPagedResultAsync(ActivityLogQuery query) { - await using var dbContext = _provider.CreateContext(); + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + IQueryable<ActivityLog> entries = dbContext.ActivityLogs + .OrderByDescending(entry => entry.DateCreated); - IQueryable<ActivityLog> entries = dbContext.ActivityLogs - .AsQueryable() - .OrderByDescending(entry => entry.DateCreated); + if (query.MinDate.HasValue) + { + entries = entries.Where(entry => entry.DateCreated >= query.MinDate); + } - if (query.MinDate.HasValue) - { - entries = entries.Where(entry => entry.DateCreated >= query.MinDate); - } + if (query.HasUserId.HasValue) + { + entries = entries.Where(entry => (!entry.UserId.Equals(default)) == query.HasUserId.Value); + } - if (query.HasUserId.HasValue) - { - entries = entries.Where(entry => (!entry.UserId.Equals(default)) == query.HasUserId.Value); + return new QueryResult<ActivityLogEntry>( + query.Skip, + await entries.CountAsync().ConfigureAwait(false), + await entries + .Skip(query.Skip ?? 0) + .Take(query.Limit ?? 100) + .AsAsyncEnumerable() + .Select(ConvertToOldModel) + .ToListAsync() + .ConfigureAwait(false)); } - - return new QueryResult<ActivityLogEntry>( - query.Skip, - await entries.CountAsync().ConfigureAwait(false), - await entries - .Skip(query.Skip ?? 0) - .Take(query.Limit ?? 100) - .AsAsyncEnumerable() - .Select(ConvertToOldModel) - .ToListAsync() - .ConfigureAwait(false)); } /// <inheritdoc /> public async Task CleanAsync(DateTime startDate) { - await using var dbContext = _provider.CreateContext(); - var entries = dbContext.ActivityLogs - .AsQueryable() - .Where(entry => entry.DateCreated <= startDate); + var dbContext = await _provider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var entries = dbContext.ActivityLogs + .Where(entry => entry.DateCreated <= startDate); - dbContext.RemoveRange(entries); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.RemoveRange(entries); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } private static ActivityLogEntry ConvertToOldModel(ActivityLog entry) diff --git a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs index 0728f1179..eeb958c62 100644 --- a/Jellyfin.Server.Implementations/Devices/DeviceManager.cs +++ b/Jellyfin.Server.Implementations/Devices/DeviceManager.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using Jellyfin.Data.Entities; @@ -22,7 +23,7 @@ namespace Jellyfin.Server.Implementations.Devices /// </summary> public class DeviceManager : IDeviceManager { - private readonly JellyfinDbProvider _dbProvider; + private readonly IDbContextFactory<JellyfinDb> _dbProvider; private readonly IUserManager _userManager; private readonly ConcurrentDictionary<string, ClientCapabilities> _capabilitiesMap = new(); @@ -31,7 +32,7 @@ namespace Jellyfin.Server.Implementations.Devices /// </summary> /// <param name="dbProvider">The database provider.</param> /// <param name="userManager">The user manager.</param> - public DeviceManager(JellyfinDbProvider dbProvider, IUserManager userManager) + public DeviceManager(IDbContextFactory<JellyfinDb> dbProvider, IUserManager userManager) { _dbProvider = dbProvider; _userManager = userManager; @@ -49,39 +50,50 @@ namespace Jellyfin.Server.Implementations.Devices /// <inheritdoc /> public async Task UpdateDeviceOptions(string deviceId, string deviceName) { - await using var dbContext = _dbProvider.CreateContext(); - var deviceOptions = await dbContext.DeviceOptions.AsQueryable().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); - if (deviceOptions == null) + DeviceOptions? deviceOptions; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - deviceOptions = new DeviceOptions(deviceId); - dbContext.DeviceOptions.Add(deviceOptions); + deviceOptions = await dbContext.DeviceOptions.AsQueryable().FirstOrDefaultAsync(dev => dev.DeviceId == deviceId).ConfigureAwait(false); + if (deviceOptions == null) + { + deviceOptions = new DeviceOptions(deviceId); + dbContext.DeviceOptions.Add(deviceOptions); + } + + deviceOptions.CustomName = deviceName; + await dbContext.SaveChangesAsync().ConfigureAwait(false); } - deviceOptions.CustomName = deviceName; - await dbContext.SaveChangesAsync().ConfigureAwait(false); - DeviceOptionsUpdated?.Invoke(this, new GenericEventArgs<Tuple<string, DeviceOptions>>(new Tuple<string, DeviceOptions>(deviceId, deviceOptions))); } /// <inheritdoc /> public async Task<Device> CreateDevice(Device device) { - await using var dbContext = _dbProvider.CreateContext(); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Devices.Add(device); - dbContext.Devices.Add(device); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } - await dbContext.SaveChangesAsync().ConfigureAwait(false); return device; } /// <inheritdoc /> public async Task<DeviceOptions> GetDeviceOptions(string deviceId) { - await using var dbContext = _dbProvider.CreateContext(); - var deviceOptions = await dbContext.DeviceOptions - .AsQueryable() - .FirstOrDefaultAsync(d => d.DeviceId == deviceId) - .ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + DeviceOptions? deviceOptions; + await using (dbContext.ConfigureAwait(false)) + { + deviceOptions = await dbContext.DeviceOptions + .AsNoTracking() + .FirstOrDefaultAsync(d => d.DeviceId == deviceId) + .ConfigureAwait(false); + } return deviceOptions ?? new DeviceOptions(deviceId); } @@ -97,14 +109,17 @@ namespace Jellyfin.Server.Implementations.Devices /// <inheritdoc /> public async Task<DeviceInfo?> GetDevice(string id) { - await using var dbContext = _dbProvider.CreateContext(); - var device = await dbContext.Devices - .AsQueryable() - .Where(d => d.DeviceId == id) - .OrderByDescending(d => d.DateLastActivity) - .Include(d => d.User) - .FirstOrDefaultAsync() - .ConfigureAwait(false); + Device? device; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + device = await dbContext.Devices + .Where(d => d.DeviceId == id) + .OrderByDescending(d => d.DateLastActivity) + .Include(d => d.User) + .FirstOrDefaultAsync() + .ConfigureAwait(false); + } var deviceInfo = device == null ? null : ToDeviceInfo(device); @@ -114,41 +129,40 @@ namespace Jellyfin.Server.Implementations.Devices /// <inheritdoc /> public async Task<QueryResult<Device>> GetDevices(DeviceQuery query) { - await using var dbContext = _dbProvider.CreateContext(); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var devices = dbContext.Devices.AsQueryable(); - var devices = dbContext.Devices.AsQueryable(); + if (query.UserId.HasValue) + { + devices = devices.Where(device => device.UserId.Equals(query.UserId.Value)); + } - if (query.UserId.HasValue) - { - devices = devices.Where(device => device.UserId.Equals(query.UserId.Value)); - } + if (query.DeviceId != null) + { + devices = devices.Where(device => device.DeviceId == query.DeviceId); + } - if (query.DeviceId != null) - { - devices = devices.Where(device => device.DeviceId == query.DeviceId); - } + if (query.AccessToken != null) + { + devices = devices.Where(device => device.AccessToken == query.AccessToken); + } - if (query.AccessToken != null) - { - devices = devices.Where(device => device.AccessToken == query.AccessToken); - } + var count = await devices.CountAsync().ConfigureAwait(false); - var count = await devices.CountAsync().ConfigureAwait(false); + if (query.Skip.HasValue) + { + devices = devices.Skip(query.Skip.Value); + } - if (query.Skip.HasValue) - { - devices = devices.Skip(query.Skip.Value); - } + if (query.Limit.HasValue) + { + devices = devices.Take(query.Limit.Value); + } - if (query.Limit.HasValue) - { - devices = devices.Take(query.Limit.Value); + return new QueryResult<Device>(query.Skip, count, await devices.ToListAsync().ConfigureAwait(false)); } - - return new QueryResult<Device>( - query.Skip, - count, - await devices.ToListAsync().ConfigureAwait(false)); } /// <inheritdoc /> @@ -165,37 +179,43 @@ namespace Jellyfin.Server.Implementations.Devices /// <inheritdoc /> public async Task<QueryResult<DeviceInfo>> GetDevicesForUser(Guid? userId, bool? supportsSync) { - await using var dbContext = _dbProvider.CreateContext(); - var sessions = dbContext.Devices - .Include(d => d.User) - .AsQueryable() - .OrderByDescending(d => d.DateLastActivity) - .ThenBy(d => d.DeviceId) - .AsAsyncEnumerable(); - - if (supportsSync.HasValue) + IAsyncEnumerable<Device> sessions; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == supportsSync.Value); - } + sessions = dbContext.Devices + .Include(d => d.User) + .OrderByDescending(d => d.DateLastActivity) + .ThenBy(d => d.DeviceId) + .AsAsyncEnumerable(); - if (userId.HasValue) - { - var user = _userManager.GetUserById(userId.Value); + if (supportsSync.HasValue) + { + sessions = sessions.Where(i => GetCapabilities(i.DeviceId).SupportsSync == supportsSync.Value); + } - sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); - } + if (userId.HasValue) + { + var user = _userManager.GetUserById(userId.Value); + + sessions = sessions.Where(i => CanAccessDevice(user, i.DeviceId)); + } - var array = await sessions.Select(device => ToDeviceInfo(device)).ToArrayAsync().ConfigureAwait(false); + var array = await sessions.Select(device => ToDeviceInfo(device)).ToArrayAsync().ConfigureAwait(false); - return new QueryResult<DeviceInfo>(array); + return new QueryResult<DeviceInfo>(array); + } } /// <inheritdoc /> public async Task DeleteDevice(Device device) { - await using var dbContext = _dbProvider.CreateContext(); - dbContext.Devices.Remove(device); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Devices.Remove(device); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } /// <inheritdoc /> diff --git a/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs new file mode 100644 index 000000000..f98a0aede --- /dev/null +++ b/Jellyfin.Server.Implementations/Extensions/ServiceCollectionExtensions.cs @@ -0,0 +1,43 @@ +using System; +using System.IO; +using EFCoreSecondLevelCacheInterceptor; +using MediaBrowser.Common.Configuration; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.Logging; + +namespace Jellyfin.Server.Implementations.Extensions; + +/// <summary> +/// Extensions for the <see cref="IServiceCollection"/> interface. +/// </summary> +public static class ServiceCollectionExtensions +{ + /// <summary> + /// Adds the <see cref="IDbContextFactory{TContext}"/> interface to the service collection with second level caching enabled. + /// </summary> + /// <param name="serviceCollection">An instance of the <see cref="IServiceCollection"/> interface.</param> + /// <returns>The updated service collection.</returns> + public static IServiceCollection AddJellyfinDbContext(this IServiceCollection serviceCollection) + { + serviceCollection.AddEFSecondLevelCache(options => + options.UseMemoryCacheProvider() + .CacheAllQueries(CacheExpirationMode.Sliding, TimeSpan.FromMinutes(10)) + .DisableLogging(true) + .UseCacheKeyPrefix("EF_") + // Don't cache null values. Remove this optional setting if it's not necessary. + .SkipCachingResults(result => + result.Value == null || (result.Value is EFTableRows rows && rows.RowsCount == 0))); + + serviceCollection.AddPooledDbContextFactory<JellyfinDb>((serviceProvider, opt) => + { + var applicationPaths = serviceProvider.GetRequiredService<IApplicationPaths>(); + var loggerFactory = serviceProvider.GetRequiredService<ILoggerFactory>(); + opt.UseSqlite($"Filename={Path.Combine(applicationPaths.DataPath, "jellyfin.db")}") + .AddInterceptors(serviceProvider.GetRequiredService<SecondLevelCacheInterceptor>()) + .UseLoggerFactory(loggerFactory); + }); + + return serviceCollection; + } +} diff --git a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj index 83b226278..8c7eee505 100644 --- a/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj +++ b/Jellyfin.Server.Implementations/Jellyfin.Server.Implementations.csproj @@ -26,14 +26,15 @@ </ItemGroup> <ItemGroup> + <PackageReference Include="EFCoreSecondLevelCacheInterceptor" Version="3.7.5" /> <PackageReference Include="System.Linq.Async" Version="6.0.1" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.9" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.11" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Relational" Version="6.0.11" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.9"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> diff --git a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs b/Jellyfin.Server.Implementations/JellyfinDbProvider.cs deleted file mode 100644 index c2c5198d1..000000000 --- a/Jellyfin.Server.Implementations/JellyfinDbProvider.cs +++ /dev/null @@ -1,51 +0,0 @@ -using System; -using System.IO; -using System.Linq; -using MediaBrowser.Common.Configuration; -using Microsoft.EntityFrameworkCore; -using Microsoft.Extensions.DependencyInjection; -using Microsoft.Extensions.Logging; - -namespace Jellyfin.Server.Implementations -{ - /// <summary> - /// Factory class for generating new <see cref="JellyfinDb"/> instances. - /// </summary> - public class JellyfinDbProvider - { - private readonly IServiceProvider _serviceProvider; - private readonly IApplicationPaths _appPaths; - private readonly ILogger<JellyfinDbProvider> _logger; - - /// <summary> - /// Initializes a new instance of the <see cref="JellyfinDbProvider"/> class. - /// </summary> - /// <param name="serviceProvider">The application's service provider.</param> - /// <param name="appPaths">The application paths.</param> - /// <param name="logger">The logger.</param> - public JellyfinDbProvider(IServiceProvider serviceProvider, IApplicationPaths appPaths, ILogger<JellyfinDbProvider> logger) - { - _serviceProvider = serviceProvider; - _appPaths = appPaths; - _logger = logger; - - using var jellyfinDb = CreateContext(); - if (jellyfinDb.Database.GetPendingMigrations().Any()) - { - _logger.LogInformation("There are pending EFCore migrations in the database. Applying... (This may take a while, do not stop Jellyfin)"); - jellyfinDb.Database.Migrate(); - _logger.LogInformation("EFCore migrations applied successfully"); - } - } - - /// <summary> - /// Creates a new <see cref="JellyfinDb"/> context. - /// </summary> - /// <returns>The newly created context.</returns> - public JellyfinDb CreateContext() - { - var contextOptions = new DbContextOptionsBuilder<JellyfinDb>().UseSqlite($"Filename={Path.Combine(_appPaths.DataPath, "jellyfin.db")}"); - return ActivatorUtilities.CreateInstance<JellyfinDb>(_serviceProvider, contextOptions.Options); - } - } -} diff --git a/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs new file mode 100644 index 000000000..03e3f3c92 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.Designer.cs @@ -0,0 +1,657 @@ +#pragma warning disable CS1591 + +// <auto-generated /> +using System; +using Jellyfin.Server.Implementations; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + [DbContext(typeof(JellyfinDb))] + [Migration("20221022080052_AddIndexActivityLogsDateCreated")] + partial class AddIndexActivityLogsDateCreated + { + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasDefaultSchema("jellyfin") + .HasAnnotation("ProductVersion", "6.0.9"); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + 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", "jellyfin"); + }); + + modelBuilder.Entity("Jellyfin.Data.Entities.User", b => + { + b.Property<Guid>("Id") + .ValueGeneratedOnAdd() + .HasColumnType("TEXT"); + + b.Property<string>("AudioLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<string>("AuthenticationProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("DisplayCollectionsView") + .HasColumnType("INTEGER"); + + b.Property<bool>("DisplayMissingEpisodes") + .HasColumnType("INTEGER"); + + b.Property<string>("EasyPassword") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<bool>("EnableAutoLogin") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableLocalPassword") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableNextEpisodeAutoPlay") + .HasColumnType("INTEGER"); + + b.Property<bool>("EnableUserPreferenceAccess") + .HasColumnType("INTEGER"); + + b.Property<bool>("HidePlayedInLatest") + .HasColumnType("INTEGER"); + + b.Property<long>("InternalId") + .HasColumnType("INTEGER"); + + b.Property<int>("InvalidLoginAttemptCount") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("LastActivityDate") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("LastLoginDate") + .HasColumnType("TEXT"); + + b.Property<int?>("LoginAttemptsBeforeLockout") + .HasColumnType("INTEGER"); + + b.Property<int>("MaxActiveSessions") + .HasColumnType("INTEGER"); + + b.Property<int?>("MaxParentalAgeRating") + .HasColumnType("INTEGER"); + + b.Property<bool>("MustUpdatePassword") + .HasColumnType("INTEGER"); + + b.Property<string>("Password") + .HasMaxLength(65535) + .HasColumnType("TEXT"); + + b.Property<string>("PasswordResetProviderId") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<bool>("PlayDefaultAudioTrack") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberAudioSelections") + .HasColumnType("INTEGER"); + + b.Property<bool>("RememberSubtitleSelections") + .HasColumnType("INTEGER"); + + b.Property<int?>("RemoteClientBitrateLimit") + .HasColumnType("INTEGER"); + + b.Property<uint>("RowVersion") + .IsConcurrencyToken() + .HasColumnType("INTEGER"); + + b.Property<string>("SubtitleLanguagePreference") + .HasMaxLength(255) + .HasColumnType("TEXT"); + + b.Property<int>("SubtitleMode") + .HasColumnType("INTEGER"); + + b.Property<int>("SyncPlayAccess") + .HasColumnType("INTEGER"); + + b.Property<string>("Username") + .IsRequired() + .HasMaxLength(255) + .HasColumnType("TEXT") + .UseCollation("NOCASE"); + + b.HasKey("Id"); + + b.HasIndex("Username") + .IsUnique(); + + b.ToTable("Users", "jellyfin"); + }); + + 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/20221022080052_AddIndexActivityLogsDateCreated.cs b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.cs new file mode 100644 index 000000000..f09ad2709 --- /dev/null +++ b/Jellyfin.Server.Implementations/Migrations/20221022080052_AddIndexActivityLogsDateCreated.cs @@ -0,0 +1,28 @@ +#pragma warning disable CS1591, SA1601 + +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Jellyfin.Server.Implementations.Migrations +{ + public partial class AddIndexActivityLogsDateCreated : Migration + { + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateIndex( + name: "IX_ActivityLogs_DateCreated", + schema: "jellyfin", + table: "ActivityLogs", + column: "DateCreated"); + } + + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropIndex( + name: "IX_ActivityLogs_DateCreated", + schema: "jellyfin", + table: "ActivityLogs"); + } + } +} diff --git a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs index fcc360e26..2dd7b094a 100644 --- a/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs +++ b/Jellyfin.Server.Implementations/Migrations/JellyfinDbModelSnapshot.cs @@ -5,6 +5,8 @@ using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +#nullable disable + namespace Jellyfin.Server.Implementations.Migrations { [DbContext(typeof(JellyfinDb))] @@ -15,7 +17,7 @@ namespace Jellyfin.Server.Implementations.Migrations #pragma warning disable 612, 618 modelBuilder .HasDefaultSchema("jellyfin") - .HasAnnotation("ProductVersion", "5.0.7"); + .HasAnnotation("ProductVersion", "6.0.9"); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => { @@ -39,7 +41,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId"); - b.ToTable("AccessSchedules"); + b.ToTable("AccessSchedules", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.ActivityLog", b => @@ -85,7 +87,9 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasKey("Id"); - b.ToTable("ActivityLogs"); + b.HasIndex("DateCreated"); + + b.ToTable("ActivityLogs", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.CustomItemDisplayPreferences", b => @@ -117,7 +121,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId", "ItemId", "Client", "Key") .IsUnique(); - b.ToTable("CustomItemDisplayPreferences"); + b.ToTable("CustomItemDisplayPreferences", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.DisplayPreferences", b => @@ -174,7 +178,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId", "ItemId", "Client") .IsUnique(); - b.ToTable("DisplayPreferences"); + b.ToTable("DisplayPreferences", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.HomeSection", b => @@ -196,7 +200,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("DisplayPreferencesId"); - b.ToTable("HomeSection"); + b.ToTable("HomeSection", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.ImageInfo", b => @@ -221,7 +225,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId") .IsUnique(); - b.ToTable("ImageInfos"); + b.ToTable("ImageInfos", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.ItemDisplayPreferences", b => @@ -265,7 +269,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId"); - b.ToTable("ItemDisplayPreferences"); + b.ToTable("ItemDisplayPreferences", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Permission", b => @@ -296,7 +300,7 @@ namespace Jellyfin.Server.Implementations.Migrations .IsUnique() .HasFilter("[UserId] IS NOT NULL"); - b.ToTable("Permissions"); + b.ToTable("Permissions", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Preference", b => @@ -329,7 +333,7 @@ namespace Jellyfin.Server.Implementations.Migrations .IsUnique() .HasFilter("[UserId] IS NOT NULL"); - b.ToTable("Preferences"); + b.ToTable("Preferences", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Security.ApiKey", b => @@ -358,7 +362,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("AccessToken") .IsUnique(); - b.ToTable("ApiKeys"); + b.ToTable("ApiKeys", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Security.Device", b => @@ -416,7 +420,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("UserId", "DeviceId"); - b.ToTable("Devices"); + b.ToTable("Devices", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.Security.DeviceOptions", b => @@ -437,7 +441,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("DeviceId") .IsUnique(); - b.ToTable("DeviceOptions"); + b.ToTable("DeviceOptions", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.User", b => @@ -550,7 +554,7 @@ namespace Jellyfin.Server.Implementations.Migrations b.HasIndex("Username") .IsUnique(); - b.ToTable("Users"); + b.ToTable("Users", "jellyfin"); }); modelBuilder.Entity("Jellyfin.Data.Entities.AccessSchedule", b => diff --git a/Jellyfin.Server.Implementations/ModelConfiguration/ActivityLogConfiguration.cs b/Jellyfin.Server.Implementations/ModelConfiguration/ActivityLogConfiguration.cs new file mode 100644 index 000000000..9a63ed9f2 --- /dev/null +++ b/Jellyfin.Server.Implementations/ModelConfiguration/ActivityLogConfiguration.cs @@ -0,0 +1,17 @@ +using Jellyfin.Data.Entities; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace Jellyfin.Server.Implementations.ModelConfiguration; + +/// <summary> +/// FluentAPI configuration for the ActivityLog entity. +/// </summary> +public class ActivityLogConfiguration : IEntityTypeConfiguration<ActivityLog> +{ + /// <inheritdoc/> + public void Configure(EntityTypeBuilder<ActivityLog> builder) + { + builder.HasIndex(entity => entity.DateCreated); + } +} diff --git a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs index b79e46469..33c08c8c2 100644 --- a/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs +++ b/Jellyfin.Server.Implementations/Security/AuthenticationManager.cs @@ -10,13 +10,13 @@ namespace Jellyfin.Server.Implementations.Security /// <inheritdoc /> public class AuthenticationManager : IAuthenticationManager { - private readonly JellyfinDbProvider _dbProvider; + private readonly IDbContextFactory<JellyfinDb> _dbProvider; /// <summary> /// Initializes a new instance of the <see cref="AuthenticationManager"/> class. /// </summary> /// <param name="dbProvider">The database provider.</param> - public AuthenticationManager(JellyfinDbProvider dbProvider) + public AuthenticationManager(IDbContextFactory<JellyfinDb> dbProvider) { _dbProvider = dbProvider; } @@ -24,50 +24,56 @@ namespace Jellyfin.Server.Implementations.Security /// <inheritdoc /> public async Task CreateApiKey(string name) { - await using var dbContext = _dbProvider.CreateContext(); - - dbContext.ApiKeys.Add(new ApiKey(name)); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.ApiKeys.Add(new ApiKey(name)); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } /// <inheritdoc /> public async Task<IReadOnlyList<AuthenticationInfo>> GetApiKeys() { - await using var dbContext = _dbProvider.CreateContext(); - - return await dbContext.ApiKeys - .AsAsyncEnumerable() - .Select(key => new AuthenticationInfo - { - AppName = key.Name, - AccessToken = key.AccessToken, - DateCreated = key.DateCreated, - DeviceId = string.Empty, - DeviceName = string.Empty, - AppVersion = string.Empty - }).ToListAsync().ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + return await dbContext.ApiKeys + .AsAsyncEnumerable() + .Select(key => new AuthenticationInfo + { + AppName = key.Name, + AccessToken = key.AccessToken, + DateCreated = key.DateCreated, + DeviceId = string.Empty, + DeviceName = string.Empty, + AppVersion = string.Empty + }).ToListAsync().ConfigureAwait(false); + } } /// <inheritdoc /> public async Task DeleteApiKey(string accessToken) { - await using var dbContext = _dbProvider.CreateContext(); - - var key = await dbContext.ApiKeys - .AsQueryable() - .Where(apiKey => apiKey.AccessToken == accessToken) - .FirstOrDefaultAsync() - .ConfigureAwait(false); - - if (key == null) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - return; - } + var key = await dbContext.ApiKeys + .AsQueryable() + .Where(apiKey => apiKey.AccessToken == accessToken) + .FirstOrDefaultAsync() + .ConfigureAwait(false); - dbContext.Remove(key); + if (key == null) + { + return; + } + + dbContext.Remove(key); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } } diff --git a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs index 9f813f532..4d1a1b3cf 100644 --- a/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs +++ b/Jellyfin.Server.Implementations/Security/AuthorizationContext.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Net; using System.Threading.Tasks; +using EFCoreSecondLevelCacheInterceptor; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Controller.Net; @@ -15,12 +16,12 @@ namespace Jellyfin.Server.Implementations.Security { public class AuthorizationContext : IAuthorizationContext { - private readonly JellyfinDbProvider _jellyfinDbProvider; + private readonly IDbContextFactory<JellyfinDb> _jellyfinDbProvider; private readonly IUserManager _userManager; private readonly IServerApplicationHost _serverApplicationHost; public AuthorizationContext( - JellyfinDbProvider jellyfinDb, + IDbContextFactory<JellyfinDb> jellyfinDb, IUserManager userManager, IServerApplicationHost serverApplicationHost) { @@ -121,96 +122,99 @@ namespace Jellyfin.Server.Implementations.Security #pragma warning restore CA1508 authInfo.HasToken = true; - await using var dbContext = _jellyfinDbProvider.CreateContext(); - var device = await dbContext.Devices.FirstOrDefaultAsync(d => d.AccessToken == token).ConfigureAwait(false); - - if (device != null) + var dbContext = await _jellyfinDbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - authInfo.IsAuthenticated = true; - var updateToken = false; - - // TODO: Remove these checks for IsNullOrWhiteSpace - if (string.IsNullOrWhiteSpace(authInfo.Client)) - { - authInfo.Client = device.AppName; - } + var device = await dbContext.Devices.FirstOrDefaultAsync(d => d.AccessToken == token).ConfigureAwait(false); - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + if (device != null) { - authInfo.DeviceId = device.DeviceId; - } - - // Temporary. TODO - allow clients to specify that the token has been shared with a casting device - var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); + authInfo.IsAuthenticated = true; + var updateToken = false; - if (string.IsNullOrWhiteSpace(authInfo.Device)) - { - authInfo.Device = device.DeviceName; - } - else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) + // TODO: Remove these checks for IsNullOrWhiteSpace + if (string.IsNullOrWhiteSpace(authInfo.Client)) { - updateToken = true; - device.DeviceName = authInfo.Device; + authInfo.Client = device.AppName; } - } - if (string.IsNullOrWhiteSpace(authInfo.Version)) - { - authInfo.Version = device.AppVersion; - } - else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase)) - { - if (allowTokenInfoUpdate) + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) { - updateToken = true; - device.AppVersion = authInfo.Version; + authInfo.DeviceId = device.DeviceId; } - } - if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3) - { - device.DateLastActivity = DateTime.UtcNow; - updateToken = true; - } + // Temporary. TODO - allow clients to specify that the token has been shared with a casting device + var allowTokenInfoUpdate = !authInfo.Client.Contains("chromecast", StringComparison.OrdinalIgnoreCase); - authInfo.User = _userManager.GetUserById(device.UserId); + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = device.DeviceName; + } + else if (!string.Equals(authInfo.Device, device.DeviceName, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) + { + updateToken = true; + device.DeviceName = authInfo.Device; + } + } - if (updateToken) - { - dbContext.Devices.Update(device); - await dbContext.SaveChangesAsync().ConfigureAwait(false); - } - } - else - { - var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false); - if (key != null) - { - authInfo.IsAuthenticated = true; - authInfo.Client = key.Name; - authInfo.Token = key.AccessToken; - if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + if (string.IsNullOrWhiteSpace(authInfo.Version)) { - authInfo.DeviceId = _serverApplicationHost.SystemId; + authInfo.Version = device.AppVersion; + } + else if (!string.Equals(authInfo.Version, device.AppVersion, StringComparison.OrdinalIgnoreCase)) + { + if (allowTokenInfoUpdate) + { + updateToken = true; + device.AppVersion = authInfo.Version; + } } - if (string.IsNullOrWhiteSpace(authInfo.Device)) + if ((DateTime.UtcNow - device.DateLastActivity).TotalMinutes > 3) { - authInfo.Device = _serverApplicationHost.Name; + device.DateLastActivity = DateTime.UtcNow; + updateToken = true; } - if (string.IsNullOrWhiteSpace(authInfo.Version)) + authInfo.User = _userManager.GetUserById(device.UserId); + + if (updateToken) { - authInfo.Version = _serverApplicationHost.ApplicationVersionString; + dbContext.Devices.Update(device); + await dbContext.SaveChangesAsync().ConfigureAwait(false); } + } + else + { + var key = await dbContext.ApiKeys.FirstOrDefaultAsync(apiKey => apiKey.AccessToken == token).ConfigureAwait(false); + if (key != null) + { + authInfo.IsAuthenticated = true; + authInfo.Client = key.Name; + authInfo.Token = key.AccessToken; + if (string.IsNullOrWhiteSpace(authInfo.DeviceId)) + { + authInfo.DeviceId = _serverApplicationHost.SystemId; + } + + if (string.IsNullOrWhiteSpace(authInfo.Device)) + { + authInfo.Device = _serverApplicationHost.Name; + } + + if (string.IsNullOrWhiteSpace(authInfo.Version)) + { + authInfo.Version = _serverApplicationHost.ApplicationVersionString; + } - authInfo.IsApiKey = true; + authInfo.IsApiKey = true; + } } - } - return authInfo; + return authInfo; + } } /// <summary> diff --git a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs index 65edb30ad..87babc05c 100644 --- a/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs +++ b/Jellyfin.Server.Implementations/Users/DisplayPreferencesManager.cs @@ -20,10 +20,10 @@ namespace Jellyfin.Server.Implementations.Users /// <summary> /// Initializes a new instance of the <see cref="DisplayPreferencesManager"/> class. /// </summary> - /// <param name="dbContext">The database context.</param> - public DisplayPreferencesManager(JellyfinDb dbContext) + /// <param name="dbContextFactory">The database context factory.</param> + public DisplayPreferencesManager(IDbContextFactory<JellyfinDb> dbContextFactory) { - _dbContext = dbContext; + _dbContext = dbContextFactory.CreateDbContext(); } /// <inheritdoc /> diff --git a/Jellyfin.Server.Implementations/Users/UserManager.cs b/Jellyfin.Server.Implementations/Users/UserManager.cs index ed90e81c6..25560707a 100644 --- a/Jellyfin.Server.Implementations/Users/UserManager.cs +++ b/Jellyfin.Server.Implementations/Users/UserManager.cs @@ -33,7 +33,7 @@ namespace Jellyfin.Server.Implementations.Users /// </summary> public class UserManager : IUserManager { - private readonly JellyfinDbProvider _dbProvider; + private readonly IDbContextFactory<JellyfinDb> _dbProvider; private readonly IEventManager _eventManager; private readonly ICryptoProvider _cryptoProvider; private readonly INetworkManager _networkManager; @@ -59,7 +59,7 @@ namespace Jellyfin.Server.Implementations.Users /// <param name="imageProcessor">The image processor.</param> /// <param name="logger">The logger.</param> public UserManager( - JellyfinDbProvider dbProvider, + IDbContextFactory<JellyfinDb> dbProvider, IEventManager eventManager, ICryptoProvider cryptoProvider, INetworkManager networkManager, @@ -83,7 +83,7 @@ namespace Jellyfin.Server.Implementations.Users _defaultPasswordResetProvider = _passwordResetProviders.OfType<DefaultPasswordResetProvider>().First(); _users = new ConcurrentDictionary<Guid, User>(); - using var dbContext = _dbProvider.CreateContext(); + using var dbContext = _dbProvider.CreateDbContext(); foreach (var user in dbContext.Users .Include(user => user.Permissions) .Include(user => user.Preferences) @@ -139,31 +139,35 @@ namespace Jellyfin.Server.Implementations.Users throw new ArgumentException("The new and old names must be different."); } - await using var dbContext = _dbProvider.CreateContext(); - - if (await dbContext.Users - .AsQueryable() - .AnyAsync(u => u.Username == newName && !u.Id.Equals(user.Id)) - .ConfigureAwait(false)) + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) { - throw new ArgumentException(string.Format( - CultureInfo.InvariantCulture, - "A user with the name '{0}' already exists.", - newName)); + if (await dbContext.Users + .AsQueryable() + .AnyAsync(u => u.Username == newName && !u.Id.Equals(user.Id)) + .ConfigureAwait(false)) + { + throw new ArgumentException(string.Format( + CultureInfo.InvariantCulture, + "A user with the name '{0}' already exists.", + newName)); + } + + user.Username = newName; + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); } - user.Username = newName; - await UpdateUserAsync(user).ConfigureAwait(false); OnUserUpdated?.Invoke(this, new GenericEventArgs<User>(user)); } /// <inheritdoc/> public async Task UpdateUserAsync(User user) { - await using var dbContext = _dbProvider.CreateContext(); - dbContext.Users.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + await UpdateUserInternalAsync(dbContext, user).ConfigureAwait(false); + } } internal async Task<User> CreateUserInternalAsync(string name, JellyfinDb dbContext) @@ -202,12 +206,15 @@ namespace Jellyfin.Server.Implementations.Users name)); } - await using var dbContext = _dbProvider.CreateContext(); - - var newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); + User newUser; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + newUser = await CreateUserInternalAsync(name, dbContext).ConfigureAwait(false); - dbContext.Users.Add(newUser); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.Users.Add(newUser); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } await _eventManager.PublishAsync(new UserCreatedEventArgs(newUser)).ConfigureAwait(false); @@ -241,9 +248,13 @@ namespace Jellyfin.Server.Implementations.Users nameof(userId)); } - await using var dbContext = _dbProvider.CreateContext(); - dbContext.Users.Remove(user); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Users.Remove(user); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + _users.Remove(userId); await _eventManager.PublishAsync(new UserDeletedEventArgs(user)).ConfigureAwait(false); @@ -288,7 +299,7 @@ namespace Jellyfin.Server.Implementations.Users user.EasyPassword = newPasswordSha1; await UpdateUserAsync(user).ConfigureAwait(false); - _eventManager.Publish(new UserPasswordChangedEventArgs(user)); + await _eventManager.PublishAsync(new UserPasswordChangedEventArgs(user)).ConfigureAwait(false); } /// <inheritdoc/> @@ -541,14 +552,17 @@ namespace Jellyfin.Server.Implementations.Users _logger.LogWarning("No users, creating one with username {UserName}", defaultName); - await using var dbContext = _dbProvider.CreateContext(); - var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); - newUser.SetPermission(PermissionKind.IsAdministrator, true); - newUser.SetPermission(PermissionKind.EnableContentDeletion, true); - newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var newUser = await CreateUserInternalAsync(defaultName, dbContext).ConfigureAwait(false); + newUser.SetPermission(PermissionKind.IsAdministrator, true); + newUser.SetPermission(PermissionKind.EnableContentDeletion, true); + newUser.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, true); - dbContext.Users.Add(newUser); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + dbContext.Users.Add(newUser); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } /// <inheritdoc/> @@ -584,105 +598,111 @@ namespace Jellyfin.Server.Implementations.Users /// <inheritdoc/> public async Task UpdateConfigurationAsync(Guid userId, UserConfiguration config) { - await using var dbContext = _dbProvider.CreateContext(); - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - user.SubtitleMode = config.SubtitleMode; - user.HidePlayedInLatest = config.HidePlayedInLatest; - user.EnableLocalPassword = config.EnableLocalPassword; - user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; - user.DisplayCollectionsView = config.DisplayCollectionsView; - user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; - user.AudioLanguagePreference = config.AudioLanguagePreference; - user.RememberAudioSelections = config.RememberAudioSelections; - user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; - user.RememberSubtitleSelections = config.RememberSubtitleSelections; - user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; - - user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); - user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); - user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); - user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); - - dbContext.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = dbContext.Users + .Include(u => u.Permissions) + .Include(u => u.Preferences) + .Include(u => u.AccessSchedules) + .Include(u => u.ProfileImage) + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + user.SubtitleMode = config.SubtitleMode; + user.HidePlayedInLatest = config.HidePlayedInLatest; + user.EnableLocalPassword = config.EnableLocalPassword; + user.PlayDefaultAudioTrack = config.PlayDefaultAudioTrack; + user.DisplayCollectionsView = config.DisplayCollectionsView; + user.DisplayMissingEpisodes = config.DisplayMissingEpisodes; + user.AudioLanguagePreference = config.AudioLanguagePreference; + user.RememberAudioSelections = config.RememberAudioSelections; + user.EnableNextEpisodeAutoPlay = config.EnableNextEpisodeAutoPlay; + user.RememberSubtitleSelections = config.RememberSubtitleSelections; + user.SubtitleLanguagePreference = config.SubtitleLanguagePreference; + + user.SetPreference(PreferenceKind.OrderedViews, config.OrderedViews); + user.SetPreference(PreferenceKind.GroupedFolders, config.GroupedFolders); + user.SetPreference(PreferenceKind.MyMediaExcludes, config.MyMediaExcludes); + user.SetPreference(PreferenceKind.LatestItemExcludes, config.LatestItemsExcludes); + + dbContext.Update(user); + _users[user.Id] = user; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } /// <inheritdoc/> public async Task UpdatePolicyAsync(Guid userId, UserPolicy policy) { - await using var dbContext = _dbProvider.CreateContext(); - var user = dbContext.Users - .Include(u => u.Permissions) - .Include(u => u.Preferences) - .Include(u => u.AccessSchedules) - .Include(u => u.ProfileImage) - .FirstOrDefault(u => u.Id.Equals(userId)) - ?? throw new ArgumentException("No user exists with given Id!"); - - // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" - int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch - { - -1 => null, - 0 => 3, - _ => policy.LoginAttemptsBeforeLockout - }; + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + var user = dbContext.Users + .Include(u => u.Permissions) + .Include(u => u.Preferences) + .Include(u => u.AccessSchedules) + .Include(u => u.ProfileImage) + .FirstOrDefault(u => u.Id.Equals(userId)) + ?? throw new ArgumentException("No user exists with given Id!"); + + // The default number of login attempts is 3, but for some god forsaken reason it's sent to the server as "0" + int? maxLoginAttempts = policy.LoginAttemptsBeforeLockout switch + { + -1 => null, + 0 => 3, + _ => policy.LoginAttemptsBeforeLockout + }; - user.MaxParentalAgeRating = policy.MaxParentalRating; - user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; - user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; - user.AuthenticationProviderId = policy.AuthenticationProviderId; - user.PasswordResetProviderId = policy.PasswordResetProviderId; - user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; - user.LoginAttemptsBeforeLockout = maxLoginAttempts; - user.MaxActiveSessions = policy.MaxActiveSessions; - user.SyncPlayAccess = policy.SyncPlayAccess; - user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); - user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); - user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); - user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); - user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); - user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); - user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); - user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); - user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); - user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); - user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); - user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); - user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); - user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); - user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); - user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); - user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); - user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); - user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); - user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); - - user.AccessSchedules.Clear(); - foreach (var policyAccessSchedule in policy.AccessSchedules) - { - user.AccessSchedules.Add(policyAccessSchedule); - } - - // TODO: fix this at some point - user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); - user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); - user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); - user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); - user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); - user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); - - dbContext.Update(user); - _users[user.Id] = user; - await dbContext.SaveChangesAsync().ConfigureAwait(false); + user.MaxParentalAgeRating = policy.MaxParentalRating; + user.EnableUserPreferenceAccess = policy.EnableUserPreferenceAccess; + user.RemoteClientBitrateLimit = policy.RemoteClientBitrateLimit; + user.AuthenticationProviderId = policy.AuthenticationProviderId; + user.PasswordResetProviderId = policy.PasswordResetProviderId; + user.InvalidLoginAttemptCount = policy.InvalidLoginAttemptCount; + user.LoginAttemptsBeforeLockout = maxLoginAttempts; + user.MaxActiveSessions = policy.MaxActiveSessions; + user.SyncPlayAccess = policy.SyncPlayAccess; + user.SetPermission(PermissionKind.IsAdministrator, policy.IsAdministrator); + user.SetPermission(PermissionKind.IsHidden, policy.IsHidden); + user.SetPermission(PermissionKind.IsDisabled, policy.IsDisabled); + user.SetPermission(PermissionKind.EnableSharedDeviceControl, policy.EnableSharedDeviceControl); + user.SetPermission(PermissionKind.EnableRemoteAccess, policy.EnableRemoteAccess); + user.SetPermission(PermissionKind.EnableLiveTvManagement, policy.EnableLiveTvManagement); + user.SetPermission(PermissionKind.EnableLiveTvAccess, policy.EnableLiveTvAccess); + user.SetPermission(PermissionKind.EnableMediaPlayback, policy.EnableMediaPlayback); + user.SetPermission(PermissionKind.EnableAudioPlaybackTranscoding, policy.EnableAudioPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableVideoPlaybackTranscoding, policy.EnableVideoPlaybackTranscoding); + user.SetPermission(PermissionKind.EnableContentDeletion, policy.EnableContentDeletion); + user.SetPermission(PermissionKind.EnableContentDownloading, policy.EnableContentDownloading); + user.SetPermission(PermissionKind.EnableSyncTranscoding, policy.EnableSyncTranscoding); + user.SetPermission(PermissionKind.EnableMediaConversion, policy.EnableMediaConversion); + user.SetPermission(PermissionKind.EnableAllChannels, policy.EnableAllChannels); + user.SetPermission(PermissionKind.EnableAllDevices, policy.EnableAllDevices); + user.SetPermission(PermissionKind.EnableAllFolders, policy.EnableAllFolders); + user.SetPermission(PermissionKind.EnableRemoteControlOfOtherUsers, policy.EnableRemoteControlOfOtherUsers); + user.SetPermission(PermissionKind.EnablePlaybackRemuxing, policy.EnablePlaybackRemuxing); + user.SetPermission(PermissionKind.ForceRemoteSourceTranscoding, policy.ForceRemoteSourceTranscoding); + user.SetPermission(PermissionKind.EnablePublicSharing, policy.EnablePublicSharing); + + user.AccessSchedules.Clear(); + foreach (var policyAccessSchedule in policy.AccessSchedules) + { + user.AccessSchedules.Add(policyAccessSchedule); + } + + // TODO: fix this at some point + user.SetPreference(PreferenceKind.BlockUnratedItems, policy.BlockUnratedItems ?? Array.Empty<UnratedItem>()); + user.SetPreference(PreferenceKind.BlockedTags, policy.BlockedTags); + user.SetPreference(PreferenceKind.EnabledChannels, policy.EnabledChannels); + user.SetPreference(PreferenceKind.EnabledDevices, policy.EnabledDevices); + user.SetPreference(PreferenceKind.EnabledFolders, policy.EnabledFolders); + user.SetPreference(PreferenceKind.EnableContentDeletionFromFolders, policy.EnableContentDeletionFromFolders); + + dbContext.Update(user); + _users[user.Id] = user; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } /// <inheritdoc/> @@ -693,9 +713,13 @@ namespace Jellyfin.Server.Implementations.Users return; } - await using var dbContext = _dbProvider.CreateContext(); - dbContext.Remove(user.ProfileImage); - await dbContext.SaveChangesAsync().ConfigureAwait(false); + var dbContext = await _dbProvider.CreateDbContextAsync().ConfigureAwait(false); + await using (dbContext.ConfigureAwait(false)) + { + dbContext.Remove(user.ProfileImage); + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } + user.ProfileImage = null; _users[user.Id] = user; } @@ -859,5 +883,12 @@ namespace Jellyfin.Server.Implementations.Users await UpdateUserAsync(user).ConfigureAwait(false); } + + private async Task UpdateUserInternalAsync(JellyfinDb dbContext, User user) + { + dbContext.Users.Update(user); + _users[user.Id] = user; + await dbContext.SaveChangesAsync().ConfigureAwait(false); + } } } diff --git a/Jellyfin.Server/CoreAppHost.cs b/Jellyfin.Server/CoreAppHost.cs index 984711dc2..002193baf 100644 --- a/Jellyfin.Server/CoreAppHost.cs +++ b/Jellyfin.Server/CoreAppHost.cs @@ -1,6 +1,5 @@ using System; using System.Collections.Generic; -using System.IO; using System.Reflection; using Emby.Drawing; using Emby.Server.Implementations; @@ -71,19 +70,13 @@ namespace Jellyfin.Server Logger.LogWarning("Skia not available. Will fallback to {ImageEncoder}.", nameof(NullImageEncoder)); } - serviceCollection.AddDbContextPool<JellyfinDb>( - options => options - .UseLoggerFactory(LoggerFactory) - .UseSqlite($"Filename={Path.Combine(ApplicationPaths.DataPath, "jellyfin.db")}")); - serviceCollection.AddEventServices(); serviceCollection.AddSingleton<IBaseItemManager, BaseItemManager>(); serviceCollection.AddSingleton<IEventManager, EventManager>(); - serviceCollection.AddSingleton<JellyfinDbProvider>(); serviceCollection.AddSingleton<IActivityManager, ActivityManager>(); serviceCollection.AddSingleton<IUserManager, UserManager>(); - serviceCollection.AddSingleton<IDisplayPreferencesManager, DisplayPreferencesManager>(); + serviceCollection.AddScoped<IDisplayPreferencesManager, DisplayPreferencesManager>(); serviceCollection.AddSingleton<IDeviceManager, DeviceManager>(); // TODO search the assemblies instead of adding them manually? diff --git a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs index 66fa3bc31..f74152405 100644 --- a/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs +++ b/Jellyfin.Server/Extensions/ApiServiceCollectionExtensions.cs @@ -434,11 +434,15 @@ namespace Jellyfin.Server.Extensions options.MapType<TranscodeReason>(() => new OpenApiSchema { - Type = "string", - Enum = Enum.GetNames<TranscodeReason>() - .Select(e => new OpenApiString(e)) - .Cast<IOpenApiAny>() - .ToArray() + Type = "array", + Items = new OpenApiSchema + { + Reference = new OpenApiReference + { + Id = nameof(TranscodeReason), + Type = ReferenceType.Schema, + } + } }); // Swashbuckle doesn't use JsonOptions to describe responses, so we need to manually describe it. diff --git a/Jellyfin.Server/Filters/AdditionalModelFilter.cs b/Jellyfin.Server/Filters/AdditionalModelFilter.cs index 487948f81..645696e31 100644 --- a/Jellyfin.Server/Filters/AdditionalModelFilter.cs +++ b/Jellyfin.Server/Filters/AdditionalModelFilter.cs @@ -1,4 +1,5 @@ using System; +using System.Linq; using Jellyfin.Extensions; using Jellyfin.Server.Migrations; using MediaBrowser.Common.Plugins; @@ -8,6 +9,7 @@ using MediaBrowser.Model.ApiClient; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Session; using MediaBrowser.Model.SyncPlay; +using Microsoft.OpenApi.Any; using Microsoft.OpenApi.Models; using Swashbuckle.AspNetCore.SwaggerGen; @@ -56,6 +58,15 @@ namespace Jellyfin.Server.Filters context.SchemaGenerator.GenerateSchema(configuration.ConfigurationType, context.SchemaRepository); } + + context.SchemaRepository.AddDefinition(nameof(TranscodeReason), new OpenApiSchema + { + Type = "string", + Enum = Enum.GetNames<TranscodeReason>() + .Select(e => new OpenApiString(e)) + .Cast<IOpenApiAny>() + .ToArray() + }); } } } diff --git a/Jellyfin.Server/Jellyfin.Server.csproj b/Jellyfin.Server/Jellyfin.Server.csproj index b2d79050b..44f92cf83 100644 --- a/Jellyfin.Server/Jellyfin.Server.csproj +++ b/Jellyfin.Server/Jellyfin.Server.csproj @@ -37,10 +37,10 @@ <PackageReference Include="CommandLineParser" Version="2.9.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.EnvironmentVariables" Version="6.0.1" /> <PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.9" /> - <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.9" /> - <PackageReference Include="prometheus-net" Version="6.0.0" /> - <PackageReference Include="prometheus-net.AspNetCore" Version="6.0.0" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks" Version="6.0.11" /> + <PackageReference Include="Microsoft.Extensions.Diagnostics.HealthChecks.EntityFrameworkCore" Version="6.0.11" /> + <PackageReference Include="prometheus-net" Version="7.0.0" /> + <PackageReference Include="prometheus-net.AspNetCore" Version="7.0.0" /> <PackageReference Include="Serilog.AspNetCore" Version="4.1.0" /> <PackageReference Include="Serilog.Enrichers.Thread" Version="3.1.0" /> <PackageReference Include="Serilog.Settings.Configuration" Version="3.4.0" /> diff --git a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs index 9875df310..6ee5bf38a 100644 --- a/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs +++ b/Jellyfin.Server/Middleware/BaseUrlRedirectionMiddleware.cs @@ -65,8 +65,9 @@ namespace Jellyfin.Server.Middleware // Always redirect back to the default path if the base prefix is invalid or missing _logger.LogDebug("Normalizing an URL at {LocalPath}", localPath); - var uri = new Uri(localPath); - var redirectUri = new Uri(baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]); + var port = httpContext.Request.Host.Port ?? -1; + var uri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, localPath).Uri; + var redirectUri = new UriBuilder(httpContext.Request.Scheme, httpContext.Request.Host.Host, port, baseUrlPrefix + "/" + _configuration[DefaultRedirectKey]).Uri; var target = uri.MakeRelativeUri(redirectUri).ToString(); _logger.LogDebug("Redirecting to {Target}", target); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs index 9e22978ae..bf66f75ff 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateActivityLogDb.cs @@ -19,7 +19,7 @@ namespace Jellyfin.Server.Migrations.Routines private const string DbFilename = "activitylog.db"; private readonly ILogger<MigrateActivityLogDb> _logger; - private readonly JellyfinDbProvider _provider; + private readonly IDbContextFactory<JellyfinDb> _provider; private readonly IServerApplicationPaths _paths; /// <summary> @@ -28,7 +28,7 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="logger">The logger.</param> /// <param name="paths">The server application paths.</param> /// <param name="provider">The database provider.</param> - public MigrateActivityLogDb(ILogger<MigrateActivityLogDb> logger, IServerApplicationPaths paths, JellyfinDbProvider provider) + public MigrateActivityLogDb(ILogger<MigrateActivityLogDb> logger, IServerApplicationPaths paths, IDbContextFactory<JellyfinDb> provider) { _logger = logger; _provider = provider; @@ -68,7 +68,7 @@ namespace Jellyfin.Server.Migrations.Routines { using var userDbConnection = SQLite3.Open(Path.Combine(dataPath, "users.db"), ConnectionFlags.ReadOnly, null); _logger.LogWarning("Migrating the activity database may take a while, do not stop Jellyfin."); - using var dbContext = _provider.CreateContext(); + using var dbContext = _provider.CreateDbContext(); var queryResult = connection.Query("SELECT * FROM ActivityLog ORDER BY Id"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs index ba0e33585..bf1ea8233 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateAuthenticationDb.cs @@ -6,6 +6,7 @@ using Jellyfin.Data.Entities.Security; using Jellyfin.Server.Implementations; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -19,7 +20,7 @@ namespace Jellyfin.Server.Migrations.Routines private const string DbFilename = "authentication.db"; private readonly ILogger<MigrateAuthenticationDb> _logger; - private readonly JellyfinDbProvider _dbProvider; + private readonly IDbContextFactory<JellyfinDb> _dbProvider; private readonly IServerApplicationPaths _appPaths; private readonly IUserManager _userManager; @@ -32,7 +33,7 @@ namespace Jellyfin.Server.Migrations.Routines /// <param name="userManager">The user manager.</param> public MigrateAuthenticationDb( ILogger<MigrateAuthenticationDb> logger, - JellyfinDbProvider dbProvider, + IDbContextFactory<JellyfinDb> dbProvider, IServerApplicationPaths appPaths, IUserManager userManager) { @@ -60,7 +61,7 @@ namespace Jellyfin.Server.Migrations.Routines ConnectionFlags.ReadOnly, null)) { - using var dbContext = _dbProvider.CreateContext(); + using var dbContext = _dbProvider.CreateDbContext(); var authenticatedDevices = connection.Query("SELECT * FROM Tokens"); diff --git a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs index 74f2349f5..37716482c 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateDisplayPreferencesDb.cs @@ -10,6 +10,7 @@ using Jellyfin.Server.Implementations; using MediaBrowser.Controller; using MediaBrowser.Controller.Library; using MediaBrowser.Model.Dto; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; @@ -24,7 +25,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateDisplayPreferencesDb> _logger; private readonly IServerApplicationPaths _paths; - private readonly JellyfinDbProvider _provider; + private readonly IDbContextFactory<JellyfinDb> _provider; private readonly JsonSerializerOptions _jsonOptions; private readonly IUserManager _userManager; @@ -38,7 +39,7 @@ namespace Jellyfin.Server.Migrations.Routines public MigrateDisplayPreferencesDb( ILogger<MigrateDisplayPreferencesDb> logger, IServerApplicationPaths paths, - JellyfinDbProvider provider, + IDbContextFactory<JellyfinDb> provider, IUserManager userManager) { _logger = logger; @@ -84,7 +85,7 @@ namespace Jellyfin.Server.Migrations.Routines var dbFilePath = Path.Combine(_paths.DataPath, DbFilename); using (var connection = SQLite3.Open(dbFilePath, ConnectionFlags.ReadOnly, null)) { - using var dbContext = _provider.CreateContext(); + using var dbContext = _provider.CreateDbContext(); var results = connection.Query("SELECT * FROM userdisplaypreferences"); foreach (var result in results) diff --git a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs index 9b2d603c7..0c2cc69a7 100644 --- a/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs +++ b/Jellyfin.Server/Migrations/Routines/MigrateUserDb.cs @@ -11,6 +11,7 @@ using MediaBrowser.Controller.Entities; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Serialization; using MediaBrowser.Model.Users; +using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; using SQLitePCL.pretty; using JsonSerializer = System.Text.Json.JsonSerializer; @@ -26,7 +27,7 @@ namespace Jellyfin.Server.Migrations.Routines private readonly ILogger<MigrateUserDb> _logger; private readonly IServerApplicationPaths _paths; - private readonly JellyfinDbProvider _provider; + private readonly IDbContextFactory<JellyfinDb> _provider; private readonly IXmlSerializer _xmlSerializer; /// <summary> @@ -39,7 +40,7 @@ namespace Jellyfin.Server.Migrations.Routines public MigrateUserDb( ILogger<MigrateUserDb> logger, IServerApplicationPaths paths, - JellyfinDbProvider provider, + IDbContextFactory<JellyfinDb> provider, IXmlSerializer xmlSerializer) { _logger = logger; @@ -65,7 +66,7 @@ namespace Jellyfin.Server.Migrations.Routines using (var connection = SQLite3.Open(Path.Combine(dataPath, DbFilename), ConnectionFlags.ReadOnly, null)) { - var dbContext = _provider.CreateContext(); + var dbContext = _provider.CreateDbContext(); var queryResult = connection.Query("SELECT * FROM LocalUsersv2"); diff --git a/Jellyfin.Server/Program.cs b/Jellyfin.Server/Program.cs index a6f0b705d..7ed838825 100644 --- a/Jellyfin.Server/Program.cs +++ b/Jellyfin.Server/Program.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Globalization; using System.IO; using System.Linq; using System.Net; @@ -192,6 +193,7 @@ namespace Jellyfin.Server // Re-use the web host service provider in the app host since ASP.NET doesn't allow a custom service collection. appHost.ServiceProvider = webHost.Services; + await appHost.InitializeServices().ConfigureAwait(false); Migrations.MigrationRunner.Run(appHost, _loggerFactory); @@ -236,10 +238,13 @@ namespace Jellyfin.Server { _logger.LogInformation("Running query planner optimizations in the database... This might take a while"); // Run before disposing the application - using var context = appHost.Resolve<JellyfinDbProvider>().CreateContext(); - if (context.Database.IsSqlite()) + var context = await appHost.ServiceProvider.GetRequiredService<IDbContextFactory<JellyfinDb>>().CreateDbContextAsync().ConfigureAwait(false); + await using (context.ConfigureAwait(false)) { - context.Database.ExecuteSqlRaw("PRAGMA optimize"); + if (context.Database.IsSqlite()) + { + await context.Database.ExecuteSqlRawAsync("PRAGMA optimize").ConfigureAwait(false); + } } } @@ -608,11 +613,14 @@ namespace Jellyfin.Server catch (Exception ex) { Log.Logger = new LoggerConfiguration() - .WriteTo.Console(outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}") + .WriteTo.Console( + outputTemplate: "[{Timestamp:HH:mm:ss}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message:lj}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture) .WriteTo.Async(x => x.File( Path.Combine(appPaths.LogDirectoryPath, "log_.log"), rollingInterval: RollingInterval.Day, outputTemplate: "[{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz}] [{Level:u3}] [{ThreadId}] {SourceContext}: {Message}{NewLine}{Exception}", + formatProvider: CultureInfo.InvariantCulture, encoding: Encoding.UTF8)) .Enrich.FromLogContext() .Enrich.WithThreadId() diff --git a/Jellyfin.Server/Startup.cs b/Jellyfin.Server/Startup.cs index 1954a5c55..49a57aa68 100644 --- a/Jellyfin.Server/Startup.cs +++ b/Jellyfin.Server/Startup.cs @@ -9,6 +9,7 @@ using Jellyfin.MediaEncoding.Hls.Extensions; using Jellyfin.Networking.Configuration; using Jellyfin.Server.Extensions; using Jellyfin.Server.Implementations; +using Jellyfin.Server.Implementations.Extensions; using Jellyfin.Server.Infrastructure; using Jellyfin.Server.Middleware; using MediaBrowser.Common.Net; @@ -65,7 +66,7 @@ namespace Jellyfin.Server // TODO remove once this is fixed upstream https://github.com/dotnet/aspnetcore/issues/34371 services.AddSingleton<IActionResultExecutor<PhysicalFileResult>, SymlinkFollowingPhysicalFileResultExecutor>(); services.AddJellyfinApi(_serverApplicationHost.GetApiPluginAssemblies(), _serverConfigurationManager.GetNetworkConfiguration()); - + services.AddJellyfinDbContext(); services.AddJellyfinApiSwagger(); // configure custom legacy authentication diff --git a/MediaBrowser.Common/Providers/ProviderIdParsers.cs b/MediaBrowser.Common/Providers/ProviderIdParsers.cs index 487b5a6d2..d569167b1 100644 --- a/MediaBrowser.Common/Providers/ProviderIdParsers.cs +++ b/MediaBrowser.Common/Providers/ProviderIdParsers.cs @@ -20,7 +20,7 @@ namespace MediaBrowser.Common.Providers /// <returns>True if parsing was successful, false otherwise.</returns> public static bool TryFindImdbId(ReadOnlySpan<char> text, out ReadOnlySpan<char> imdbId) { - // imdb id is at least 9 chars (tt + 7 numbers) + // IMDb id is at least 9 chars (tt + 7 numbers) while (text.Length >= 2 + ImdbMinNumbers) { var ttPos = text.IndexOf(ImdbPrefix); @@ -42,7 +42,7 @@ namespace MediaBrowser.Common.Providers } } - // skip if more than 8 digits + 2 chars for tt + // Skip if more than 8 digits + 2 chars for tt if (i <= ImdbMaxNumbers + 2 && i >= ImdbMinNumbers + 2) { imdbId = text.Slice(0, i); diff --git a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs index d273b54fc..61539cae5 100644 --- a/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/BaseItemManager.cs @@ -35,7 +35,7 @@ namespace MediaBrowser.Controller.BaseItemManager public SemaphoreSlim MetadataRefreshThrottler { get; private set; } /// <inheritdoc /> - public bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name) + public bool IsMetadataFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name) { if (baseItem is Channel) { @@ -49,10 +49,9 @@ namespace MediaBrowser.Controller.BaseItemManager return !baseItem.EnableMediaSourceDisplay; } - var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name); - if (typeOptions != null) + if (libraryTypeOptions != null) { - return typeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + return libraryTypeOptions.MetadataFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); } var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase)); @@ -61,7 +60,7 @@ namespace MediaBrowser.Controller.BaseItemManager } /// <inheritdoc /> - public bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name) + public bool IsImageFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name) { if (baseItem is Channel) { @@ -75,10 +74,9 @@ namespace MediaBrowser.Controller.BaseItemManager return !baseItem.EnableMediaSourceDisplay; } - var typeOptions = libraryOptions.GetTypeOptions(baseItem.GetType().Name); - if (typeOptions != null) + if (libraryTypeOptions != null) { - return typeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); + return libraryTypeOptions.ImageFetchers.Contains(name.AsSpan(), StringComparison.OrdinalIgnoreCase); } var itemConfig = _serverConfigurationManager.Configuration.MetadataOptions.FirstOrDefault(i => string.Equals(i.ItemType, baseItem.GetType().Name, StringComparison.OrdinalIgnoreCase)); diff --git a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs index e18994214..b07c80879 100644 --- a/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs +++ b/MediaBrowser.Controller/BaseItemManager/IBaseItemManager.cs @@ -18,18 +18,18 @@ namespace MediaBrowser.Controller.BaseItemManager /// Is metadata fetcher enabled. /// </summary> /// <param name="baseItem">The base item.</param> - /// <param name="libraryOptions">The library options.</param> + /// <param name="libraryTypeOptions">The type options for <c>baseItem</c> from the library (if defined).</param> /// <param name="name">The metadata fetcher name.</param> /// <returns><c>true</c> if metadata fetcher is enabled, else false.</returns> - bool IsMetadataFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); + bool IsMetadataFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name); /// <summary> /// Is image fetcher enabled. /// </summary> /// <param name="baseItem">The base item.</param> - /// <param name="libraryOptions">The library options.</param> + /// <param name="libraryTypeOptions">The type options for <c>baseItem</c> from the library (if defined).</param> /// <param name="name">The image fetcher name.</param> /// <returns><c>true</c> if image fetcher is enabled, else false.</returns> - bool IsImageFetcherEnabled(BaseItem baseItem, LibraryOptions libraryOptions, string name); + bool IsImageFetcherEnabled(BaseItem baseItem, TypeOptions? libraryTypeOptions, string name); } } diff --git a/MediaBrowser.Controller/Entities/BaseItem.cs b/MediaBrowser.Controller/Entities/BaseItem.cs index 24163f1df..7f5f9f74b 100644 --- a/MediaBrowser.Controller/Entities/BaseItem.cs +++ b/MediaBrowser.Controller/Entities/BaseItem.cs @@ -75,7 +75,9 @@ namespace MediaBrowser.Controller.Entities Model.Entities.ExtraType.DeletedScene, Model.Entities.ExtraType.Interview, Model.Entities.ExtraType.Sample, - Model.Entities.ExtraType.Scene + Model.Entities.ExtraType.Scene, + Model.Entities.ExtraType.Featurette, + Model.Entities.ExtraType.Short }; private string _sortName; diff --git a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs index 13bfd07c3..1bf528538 100644 --- a/MediaBrowser.Controller/Entities/InternalItemsQuery.cs +++ b/MediaBrowser.Controller/Entities/InternalItemsQuery.cs @@ -205,6 +205,16 @@ namespace MediaBrowser.Controller.Entities public int? MinIndexNumber { get; set; } + /// <summary> + /// Gets or sets the minimum ParentIndexNumber and IndexNumber. + /// </summary> + /// <remarks> + /// It produces this where clause: + /// <para>(ParentIndexNumber = X and IndexNumber >= Y) or ParentIndexNumber > X. + /// </para> + /// </remarks> + public (int ParentIndexNumber, int IndexNumber)? MinParentAndIndexNumber { get; set; } + public int? AiredDuringSeason { get; set; } public double? MinCriticRating { get; set; } diff --git a/MediaBrowser.Controller/Entities/Movies/Movie.cs b/MediaBrowser.Controller/Entities/Movies/Movie.cs index 77e70f8fb..3c12acd90 100644 --- a/MediaBrowser.Controller/Entities/Movies/Movie.cs +++ b/MediaBrowser.Controller/Entities/Movies/Movie.cs @@ -33,9 +33,9 @@ namespace MediaBrowser.Controller.Entities.Movies .ToArray(); /// <summary> - /// Gets or sets the name of the TMDB collection. + /// Gets or sets the name of the TMDb collection. /// </summary> - /// <value>The name of the TMDB collection.</value> + /// <value>The name of the TMDb collection.</value> public string TmdbCollectionName { get; set; } [JsonIgnore] diff --git a/MediaBrowser.Controller/Library/ILibraryMonitor.cs b/MediaBrowser.Controller/Library/ILibraryMonitor.cs index 455054bd1..de74aa5a1 100644 --- a/MediaBrowser.Controller/Library/ILibraryMonitor.cs +++ b/MediaBrowser.Controller/Library/ILibraryMonitor.cs @@ -34,12 +34,5 @@ namespace MediaBrowser.Controller.Library /// </summary> /// <param name="path">The path.</param> void ReportFileSystemChanged(string path); - - /// <summary> - /// Determines whether [is path locked] [the specified path]. - /// </summary> - /// <param name="path">The path.</param> - /// <returns><c>true</c> if [is path locked] [the specified path]; otherwise, <c>false</c>.</returns> - bool IsPathLocked(string path); } } diff --git a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs index d0362b128..74abb91b2 100644 --- a/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs +++ b/MediaBrowser.Controller/MediaEncoding/EncodingHelper.cs @@ -31,10 +31,13 @@ namespace MediaBrowser.Controller.MediaEncoding private const string VideotoolboxAlias = "vt"; private const string OpenclAlias = "ocl"; private const string CudaAlias = "cu"; + private const string DrmAlias = "dr"; + private const string VulkanAlias = "vk"; private readonly IApplicationPaths _appPaths; private readonly IMediaEncoder _mediaEncoder; private readonly ISubtitleEncoder _subtitleEncoder; private readonly IConfiguration _config; + private readonly Version _minKernelVersionAmdVkFmtModifier = new Version(5, 15); private readonly Version _minKernelVersioni915Hang = new Version(5, 18); private static readonly string[] _videoProfilesH264 = new[] @@ -149,6 +152,14 @@ namespace MediaBrowser.Controller.MediaEncoding && _mediaEncoder.SupportsFilter("hwupload_cuda"); } + private bool IsVulkanFullSupported() + { + return _mediaEncoder.SupportsHwaccel("vulkan") + && _mediaEncoder.SupportsFilter("libplacebo") + && _mediaEncoder.SupportsFilter("scale_vulkan") + && _mediaEncoder.SupportsFilterWithOption(FilterOptionType.OverlayVulkanFrameSync); + } + private bool IsHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream == null @@ -176,6 +187,19 @@ namespace MediaBrowser.Controller.MediaEncoding || string.Equals(state.VideoStream.VideoRangeType, "HLG", StringComparison.OrdinalIgnoreCase)); } + private bool IsVulkanHwTonemapAvailable(EncodingJobInfo state, EncodingOptions options) + { + if (state.VideoStream == null) + { + return false; + } + + // libplacebo has partial Dolby Vision to SDR tonemapping support. + return options.EnableTonemapping + && string.Equals(state.VideoStream.VideoRange, "HDR", StringComparison.OrdinalIgnoreCase) + && GetVideoColorBitDepth(state) == 10; + } + private bool IsVaapiVppTonemapAvailable(EncodingJobInfo state, EncodingOptions options) { if (state.VideoStream == null @@ -756,8 +780,13 @@ namespace MediaBrowser.Controller.MediaEncoding } else if (_mediaEncoder.IsVaapiDeviceAmd) { - args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); - filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + if (!IsVulkanFullSupported() + || !_mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier + || Environment.OSVersion.Version < _minKernelVersionAmdVkFmtModifier) + { + args.Append(GetOpenclDeviceArgs(0, "Advanced Micro Devices", null, OpenclAlias)); + filterDevArgs = GetFilterHwDeviceArgs(OpenclAlias); + } } else { @@ -1147,24 +1176,6 @@ namespace MediaBrowser.Controller.MediaEncoding ":fontsdir='{0}'", _mediaEncoder.EscapeSubtitleFilterPath(fontPath)); - // TODO - // var fallbackFontPath = Path.Combine(_appPaths.ProgramDataPath, "fonts", "DroidSansFallback.ttf"); - // string fallbackFontParam = string.Empty; - - // if (!File.Exists(fallbackFontPath)) - // { - // _fileSystem.CreateDirectory(_fileSystem.GetDirectoryName(fallbackFontPath)); - // using (var stream = _assemblyInfo.GetManifestResourceStream(GetType(), GetType().Namespace + ".DroidSansFallback.ttf")) - // { - // using (var fileStream = new FileStream(fallbackFontPath, FileMode.Create, FileAccess.Write, FileShare.Read)) - // { - // stream.CopyTo(fileStream); - // } - // } - // } - - // fallbackFontParam = string.Format(CultureInfo.InvariantCulture, ":force_style='FontName=Droid Sans Fallback':fontsdir='{0}'", _mediaEncoder.EscapeSubtitleFilterPath(_fileSystem.GetDirectoryName(fallbackFontPath))); - if (state.SubtitleStream.IsExternal) { var charsetParam = string.Empty; @@ -1192,7 +1203,6 @@ namespace MediaBrowser.Controller.MediaEncoding alphaParam, sub2videoParam, fontParam, - // fallbackFontParam, setPtsParam); } @@ -1430,7 +1440,11 @@ namespace MediaBrowser.Controller.MediaEncoding param += " -preset 7"; } - param += " -look_ahead 0"; + // Only h264_qsv has look_ahead option + if (string.Equals(videoEncoder, "h264_qsv", StringComparison.OrdinalIgnoreCase)) + { + param += " -look_ahead 0"; + } } else if (string.Equals(videoEncoder, "h264_nvenc", StringComparison.OrdinalIgnoreCase) // h264 (h264_nvenc) || string.Equals(videoEncoder, "hevc_nvenc", StringComparison.OrdinalIgnoreCase)) // hevc (hevc_nvenc) @@ -1468,7 +1482,7 @@ namespace MediaBrowser.Controller.MediaEncoding break; default: - param += " -preset p4"; + param += " -preset p1"; break; } } @@ -2774,22 +2788,41 @@ namespace MediaBrowser.Controller.MediaEncoding return string.Empty; } - var args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709"; + var args = string.Empty; + var algorithm = options.TonemappingAlgorithm; - if (hwTonemapSuffix.Contains("vaapi", StringComparison.OrdinalIgnoreCase)) + if (string.Equals(hwTonemapSuffix, "vaapi", StringComparison.OrdinalIgnoreCase)) { - args += ",procamp_vaapi=b={2}:c={3}:extra_hw_frames=16"; + args = "tonemap_vaapi=format={0}:p=bt709:t=bt709:m=bt709,procamp_vaapi=b={1}:c={2}:extra_hw_frames=16"; return string.Format( CultureInfo.InvariantCulture, args, - hwTonemapSuffix, videoFormat ?? "nv12", options.VppTonemappingBrightness, options.VppTonemappingContrast); } + else if (string.Equals(hwTonemapSuffix, "vulkan", StringComparison.OrdinalIgnoreCase)) + { + args = "libplacebo=format={1}:tonemapping={2}:color_primaries=bt709:color_trc=bt709:colorspace=bt709:peak_detect=0:upscaler=none:downscaler=none"; + + if (!string.Equals(options.TonemappingRange, "auto", StringComparison.OrdinalIgnoreCase)) + { + args += ":range={6}"; + } + + if (string.Equals(options.TonemappingAlgorithm, "bt2390", StringComparison.OrdinalIgnoreCase)) + { + algorithm = "bt.2390"; + } + + else if (string.Equals(options.TonemappingAlgorithm, "none", StringComparison.OrdinalIgnoreCase)) + { + algorithm = "clip"; + } + } else { - args += ":tonemap={2}:peak={3}:desat={4}"; + args = "tonemap_{0}=format={1}:p=bt709:t=bt709:m=bt709:tonemap={2}:peak={3}:desat={4}"; if (options.TonemappingParam != 0) { @@ -2807,7 +2840,7 @@ namespace MediaBrowser.Controller.MediaEncoding args, hwTonemapSuffix, videoFormat ?? "nv12", - options.TonemappingAlgorithm, + algorithm, options.TonemappingPeak, options.TonemappingDesat, options.TonemappingParam, @@ -3419,6 +3452,12 @@ 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 @@ -3770,7 +3809,9 @@ namespace MediaBrowser.Controller.MediaEncoding var vidDecoder = GetHardwareVideoDecoder(state, options) ?? string.Empty; var isSwDecoder = string.IsNullOrEmpty(vidDecoder); var isSwEncoder = !vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); - var isVaapiOclSupported = isLinux && IsVaapiSupported(state) && IsVaapiFullSupported() && IsOpenclFullSupported(); + var isVaapiFullSupported = isLinux && IsVaapiSupported(state) && IsVaapiFullSupported(); + var isVaapiOclSupported = isVaapiFullSupported && IsOpenclFullSupported(); + var isVaapiVkSupported = isVaapiFullSupported && IsVulkanFullSupported(); // legacy vaapi pipeline(copy-back) if ((isSwDecoder && isSwEncoder) @@ -3798,14 +3839,24 @@ namespace MediaBrowser.Controller.MediaEncoding if (_mediaEncoder.IsVaapiDeviceInteliHD) { // Intel iHD path, with extra vpp tonemap and overlay support. - return GetVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); + return GetIntelVaapiFullVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - // Intel i965 and Amd radeonsi/r600 path, only featuring scale and deinterlace support. + // prefered vaapi + vulkan filters pipeline + if (_mediaEncoder.IsVaapiDeviceAmd + && isVaapiVkSupported + && _mediaEncoder.IsVaapiDeviceSupportVulkanFmtModifier + && Environment.OSVersion.Version >= _minKernelVersionAmdVkFmtModifier) + { + // AMD radeonsi path(Vega/gfx9+, kernel>=5.15), 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. return GetVaapiLimitedVidFiltersPrefered(state, options, vidDecoder, vidEncoder); } - public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiFullVidFiltersPrefered( + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetIntelVaapiFullVidFiltersPrefered( EncodingJobInfo state, EncodingOptions options, string vidDecoder, @@ -4003,6 +4054,203 @@ namespace MediaBrowser.Controller.MediaEncoding return (mainFilters, subFilters, overlayFilters); } + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetAmdVaapiFullVidFiltersPrefered( + EncodingJobInfo state, + EncodingOptions options, + string vidDecoder, + string vidEncoder) + { + var inW = state.VideoStream?.Width; + var inH = state.VideoStream?.Height; + var reqW = state.BaseRequest.Width; + var reqH = state.BaseRequest.Height; + var reqMaxW = state.BaseRequest.MaxWidth; + var reqMaxH = state.BaseRequest.MaxHeight; + var threeDFormat = state.MediaSource.Video3DFormat; + + var isVaapiDecoder = vidDecoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isVaapiEncoder = vidEncoder.Contains("vaapi", StringComparison.OrdinalIgnoreCase); + var isSwDecoder = string.IsNullOrEmpty(vidDecoder); + var isSwEncoder = !isVaapiEncoder; + var isVaInVaOut = isVaapiDecoder && isVaapiEncoder; + + var doDeintH264 = state.DeInterlace("h264", true) || state.DeInterlace("avc", true); + var doDeintHevc = state.DeInterlace("h265", true) || state.DeInterlace("hevc", true); + var doVkTonemap = IsVulkanHwTonemapAvailable(state, options); + var doDeintH2645 = doDeintH264 || doDeintHevc; + + var hasSubs = state.SubtitleStream != null && state.SubtitleDeliveryMethod == SubtitleDeliveryMethod.Encode; + var hasTextSubs = hasSubs && state.SubtitleStream.IsTextSubtitleStream; + var hasGraphicalSubs = hasSubs && !state.SubtitleStream.IsTextSubtitleStream; + var hasAssSubs = hasSubs + && (string.Equals(state.SubtitleStream.Codec, "ass", StringComparison.OrdinalIgnoreCase) + || string.Equals(state.SubtitleStream.Codec, "ssa", StringComparison.OrdinalIgnoreCase)); + + /* Make main filters for video stream */ + var mainFilters = new List<string>(); + + mainFilters.Add(GetOverwriteColorPropertiesParam(state, doVkTonemap)); + + if (isSwDecoder) + { + // INPUT sw surface(memory) + // sw deint + if (doDeintH2645) + { + var swDeintFilter = GetSwDeinterlaceFilter(state, options); + mainFilters.Add(swDeintFilter); + } + + var outFormat = doVkTonemap ? "yuv420p10le" : "nv12"; + var swScaleFilter = GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH); + // sw scale + mainFilters.Add(swScaleFilter); + mainFilters.Add("format=" + outFormat); + + // keep video at memory except vk tonemap, + // since the overhead caused by hwupload >>> using sw filter. + // sw => hw + if (doVkTonemap) + { + mainFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + } + } + else if (isVaapiDecoder) + { + // INPUT vaapi surface(vram) + // hw deint + if (doDeintH2645) + { + var deintFilter = GetHwDeinterlaceFilter(state, options, "vaapi"); + mainFilters.Add(deintFilter); + } + + var outFormat = doVkTonemap ? string.Empty : (hasSubs && isVaInVaOut ? "bgra" : "nv12"); + var hwScaleFilter = GetHwScaleFilter("vaapi", outFormat, inW, inH, reqW, reqH, reqMaxW, reqMaxH); + + // allocate extra pool sizes for overlay_vulkan + if (!string.IsNullOrEmpty(hwScaleFilter) && isVaInVaOut && hasSubs) + { + hwScaleFilter += ":extra_hw_frames=32"; + } + + // hw scale + mainFilters.Add(hwScaleFilter); + } + + if ((isVaapiDecoder && doVkTonemap) || (isVaInVaOut && (doVkTonemap || hasSubs))) + { + // map from vaapi to vulkan via vaapi-vulkan interop (Vega/gfx9+). + mainFilters.Add("hwmap=derive_device=vulkan"); + } + + // vk tonemap + if (doVkTonemap) + { + var outFormat = isVaInVaOut && hasSubs ? "bgra" : "nv12"; + var tonemapFilter = GetHwTonemapFilter(options, "vulkan", outFormat); + mainFilters.Add(tonemapFilter); + } + + if (doVkTonemap && isVaInVaOut && !hasSubs) + { + // OUTPUT vaapi(nv12/bgra) surface(vram) + // reverse-mapping via vaapi-vulkan interop. + mainFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + mainFilters.Add("format=vaapi"); + } + + var memoryOutput = false; + var isUploadForVkTonemap = isSwDecoder && doVkTonemap; + if ((isVaapiDecoder && isSwEncoder) || isUploadForVkTonemap) + { + memoryOutput = true; + + // OUTPUT nv12 surface(memory) + mainFilters.Add("hwdownload"); + mainFilters.Add("format=nv12"); + } + + // OUTPUT nv12 surface(memory) + if (isSwDecoder && isVaapiEncoder) + { + memoryOutput = true; + } + + if (memoryOutput) + { + // text subtitles + if (hasTextSubs) + { + var textSubtitlesFilter = GetTextSubtitlesFilter(state, false, false); + mainFilters.Add(textSubtitlesFilter); + } + } + + if (memoryOutput && isVaapiEncoder) + { + if (!hasGraphicalSubs) + { + mainFilters.Add("hwupload_vaapi"); + } + } + + /* Make sub and overlay filters for subtitle stream */ + var subFilters = new List<string>(); + var overlayFilters = new List<string>(); + if (isVaInVaOut) + { + if (hasSubs) + { + if (hasGraphicalSubs) + { + // scale=s=1280x720,format=bgra,hwupload + var subSwScaleFilter = GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + subFilters.Add("format=bgra"); + } + else if (hasTextSubs) + { + var alphaSrcFilter = GetAlphaSrcFilter(state, inW, inH, reqW, reqH, reqMaxW, reqMaxH, hasAssSubs ? 10 : 5); + var subTextSubtitlesFilter = GetTextSubtitlesFilter(state, true, true); + subFilters.Add(alphaSrcFilter); + subFilters.Add("format=bgra"); + subFilters.Add(subTextSubtitlesFilter); + } + + subFilters.Add("hwupload=derive_device=vulkan:extra_hw_frames=16"); + + overlayFilters.Add("overlay_vulkan=eof_action=endall:shortest=1:repeatlast=0"); + + // explicitly sync using libplacebo. + overlayFilters.Add("libplacebo=format=nv12:upscaler=none:downscaler=none"); + + // OUTPUT vaapi(nv12/bgra) surface(vram) + // reverse-mapping via vaapi-vulkan interop. + overlayFilters.Add("hwmap=derive_device=vaapi:reverse=1"); + overlayFilters.Add("format=vaapi"); + } + } + else if (memoryOutput) + { + if (hasGraphicalSubs) + { + var subSwScaleFilter = isSwDecoder + ? GetSwScaleFilter(state, options, vidEncoder, inW, inH, threeDFormat, reqW, reqH, reqMaxW, reqMaxH) + : GetCustomSwScaleFilter(inW, inH, reqW, reqH, reqMaxW, reqMaxH); + subFilters.Add(subSwScaleFilter); + overlayFilters.Add("overlay=eof_action=pass:shortest=1:repeatlast=0"); + + if (isVaapiEncoder) + { + overlayFilters.Add("hwupload_vaapi"); + } + } + } + + return (mainFilters, subFilters, overlayFilters); + } + public (List<string> MainFilters, List<string> SubFilters, List<string> OverlayFilters) GetVaapiLimitedVidFiltersPrefered( EncodingJobInfo state, EncodingOptions options, diff --git a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs index a4869cb67..b1d319d21 100644 --- a/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs +++ b/MediaBrowser.Controller/MediaEncoding/FilterOptionType.cs @@ -28,6 +28,11 @@ namespace MediaBrowser.Controller.MediaEncoding /// <summary> /// The overlay_vaapi_framesync. /// </summary> - OverlayVaapiFrameSync = 4 + OverlayVaapiFrameSync = 4, + + /// <summary> + /// The overlay_vulkan_framesync. + /// </summary> + OverlayVulkanFrameSync = 5 } } diff --git a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs index 69d0bf45c..52c57b906 100644 --- a/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs +++ b/MediaBrowser.Controller/MediaEncoding/IMediaEncoder.cs @@ -62,6 +62,12 @@ namespace MediaBrowser.Controller.MediaEncoding bool IsVaapiDeviceInteli965 { get; } /// <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; } + + /// <summary> /// Whether given encoder codec is supported. /// </summary> /// <param name="encoder">The encoder.</param> diff --git a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs index 647de5003..2fe3a5472 100644 --- a/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs +++ b/MediaBrowser.Controller/Net/BasePeriodicWebSocketListener.cs @@ -227,9 +227,10 @@ namespace MediaBrowser.Controller.Net connection.Item2.Cancel(); connection.Item2.Dispose(); } - catch (ObjectDisposedException) + catch (ObjectDisposedException ex) { // TODO Investigate and properly fix. + Logger.LogError(ex, "Object Disposed"); } lock (_activeConnections) diff --git a/MediaBrowser.Controller/Providers/IHasOrder.cs b/MediaBrowser.Controller/Providers/IHasOrder.cs index 9fde0e695..77b0407a2 100644 --- a/MediaBrowser.Controller/Providers/IHasOrder.cs +++ b/MediaBrowser.Controller/Providers/IHasOrder.cs @@ -1,9 +1,14 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Interface IHasOrder. + /// </summary> public interface IHasOrder { + /// <summary> + /// Gets the order. + /// </summary> + /// <value>The order.</value> int Order { get; } } } diff --git a/MediaBrowser.Controller/Providers/IMetadataService.cs b/MediaBrowser.Controller/Providers/IMetadataService.cs index 05fbb18ee..f0f1d1862 100644 --- a/MediaBrowser.Controller/Providers/IMetadataService.cs +++ b/MediaBrowser.Controller/Providers/IMetadataService.cs @@ -23,6 +23,11 @@ namespace MediaBrowser.Controller.Providers /// <returns><c>true</c> if this instance can refresh the specified item.</returns> bool CanRefresh(BaseItem item); + /// <summary> + /// Determines whether this instance primarily targets the specified type. + /// </summary> + /// <param name="type">The type.</param> + /// <returns><c>true</c> if this instance primarily targets the specified type.</returns> bool CanRefreshPrimary(Type type); /// <summary> diff --git a/MediaBrowser.Controller/Providers/IProviderManager.cs b/MediaBrowser.Controller/Providers/IProviderManager.cs index 44bc4a50c..32a7951f6 100644 --- a/MediaBrowser.Controller/Providers/IProviderManager.cs +++ b/MediaBrowser.Controller/Providers/IProviderManager.cs @@ -132,6 +132,24 @@ namespace MediaBrowser.Controller.Providers IEnumerable<ImageProviderInfo> GetRemoteImageProviderInfo(BaseItem item); /// <summary> + /// Gets the image providers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="refreshOptions">The image refresh options.</param> + /// <returns>The image providers for the item.</returns> + IEnumerable<IImageProvider> GetImageProviders(BaseItem item, ImageRefreshOptions refreshOptions); + + /// <summary> + /// Gets the metadata providers for the provided item. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="libraryOptions">The library options.</param> + /// <typeparam name="T">The type of metadata provider.</typeparam> + /// <returns>The metadata providers.</returns> + IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions) + where T : BaseItem; + + /// <summary> /// Gets all metadata plugins. /// </summary> /// <returns>IEnumerable{MetadataPlugin}.</returns> diff --git a/MediaBrowser.Controller/Providers/IRemoteMetadataProvider.cs b/MediaBrowser.Controller/Providers/IRemoteMetadataProvider.cs index f146decb6..888ca6c72 100644 --- a/MediaBrowser.Controller/Providers/IRemoteMetadataProvider.cs +++ b/MediaBrowser.Controller/Providers/IRemoteMetadataProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; @@ -8,20 +6,41 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Controller.Providers { + /// <summary> + /// Interface IRemoteMetadataProvider. + /// </summary> public interface IRemoteMetadataProvider : IMetadataProvider { } + /// <summary> + /// Interface IRemoteMetadataProvider. + /// </summary> public interface IRemoteMetadataProvider<TItemType, in TLookupInfoType> : IMetadataProvider<TItemType>, IRemoteMetadataProvider, IRemoteSearchProvider<TLookupInfoType> where TItemType : BaseItem, IHasLookupInfo<TLookupInfoType> where TLookupInfoType : ItemLookupInfo, new() { + /// <summary> + /// Gets the metadata for a specific LookupInfoType. + /// </summary> + /// <param name="info">The LookupInfoType to get metadata for.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A task returning a MetadataResult for the specific LookupInfoType.</returns> Task<MetadataResult<TItemType>> GetMetadata(TLookupInfoType info, CancellationToken cancellationToken); } + /// <summary> + /// Interface IRemoteMetadataProvider. + /// </summary> public interface IRemoteSearchProvider<in TLookupInfoType> : IRemoteSearchProvider where TLookupInfoType : ItemLookupInfo { + /// <summary> + /// Gets the list of <see cref="RemoteSearchResult"/> for a specific LookupInfoType. + /// </summary> + /// <param name="searchInfo">The LookupInfoType to search for.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> + /// <returns>A task returning RemoteSearchResults for the searchInfo.</returns> Task<IEnumerable<RemoteSearchResult>> GetSearchResults(TLookupInfoType searchInfo, CancellationToken cancellationToken); } } diff --git a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs index a9e1b4a51..92ce14be2 100644 --- a/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs +++ b/MediaBrowser.LocalMetadata/Parsers/BaseItemXmlParser.cs @@ -68,7 +68,7 @@ namespace MediaBrowser.LocalMetadata.Parsers IgnoreComments = true }; - _validProviderIds = _validProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); + _validProviderIds = new Dictionary<string, string>(StringComparer.OrdinalIgnoreCase); var idInfos = ProviderManager.GetExternalIdInfos(item.Item); diff --git a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs index 9b4b1db94..8c8fc6b0f 100644 --- a/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs +++ b/MediaBrowser.MediaEncoding/Encoder/EncoderValidator.cs @@ -102,7 +102,11 @@ namespace MediaBrowser.MediaEncoding.Encoder "tonemap_vaapi", "procamp_vaapi", "overlay_vaapi", - "hwupload_vaapi" + "hwupload_vaapi", + // vulkan + "libplacebo", + "scale_vulkan", + "overlay_vulkan" }; private static readonly IReadOnlyDictionary<int, string[]> _filterOptionsDict = new Dictionary<int, string[]> @@ -111,7 +115,8 @@ namespace MediaBrowser.MediaEncoding.Encoder { 1, new string[] { "tonemap_cuda", "GPU accelerated HDR to SDR tonemapping" } }, { 2, new string[] { "tonemap_opencl", "bt2390" } }, { 3, new string[] { "overlay_opencl", "Action to take when encountering EOF from secondary input" } }, - { 4, new string[] { "overlay_vaapi", "Action to take when encountering EOF from secondary input" } } + { 4, new string[] { "overlay_vaapi", "Action to take when encountering EOF from secondary input" } }, + { 5, new string[] { "overlay_vulkan", "Action to take when encountering EOF from secondary input" } } }; // These are the library versions that corresponds to our minimum ffmpeg version 4.x according to the version table below @@ -351,6 +356,39 @@ namespace MediaBrowser.MediaEncoding.Encoder } } + public bool CheckVulkanDrmDeviceByExtensionName(string renderNodePath, string[] vulkanExtensions) + { + if (!OperatingSystem.IsLinux()) + { + return false; + } + + if (string.IsNullOrEmpty(renderNodePath)) + { + return false; + } + + try + { + var command = "-v verbose -hide_banner -init_hw_device drm=dr:" + renderNodePath + " -init_hw_device vulkan=vk@dr"; + var output = GetProcessOutput(_encoderPath, command, true, null); + foreach (string ext in vulkanExtensions) + { + if (!output.Contains(ext, StringComparison.Ordinal)) + { + return false; + } + } + + return true; + } + catch (Exception ex) + { + _logger.LogError(ex, "Error detecting the given drm render node path"); + return false; + } + } + private IEnumerable<string> GetHwaccelTypes() { string? output = null; diff --git a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs index 757a01715..b7c49ed99 100644 --- a/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs +++ b/MediaBrowser.MediaEncoding/Encoder/MediaEncoder.cs @@ -51,6 +51,7 @@ namespace MediaBrowser.MediaEncoding.Encoder private readonly IFileSystem _fileSystem; private readonly ILocalizationManager _localization; private readonly IConfiguration _config; + private readonly IServerConfigurationManager _serverConfig; private readonly string _startupOptionFFmpegPath; private readonly SemaphoreSlim _thumbnailResourcePool = new SemaphoreSlim(2, 2); @@ -72,6 +73,16 @@ namespace MediaBrowser.MediaEncoding.Encoder private bool _isVaapiDeviceAmd = false; private bool _isVaapiDeviceInteliHD = false; private bool _isVaapiDeviceInteli965 = false; + private bool _isVaapiDeviceSupportVulkanFmtModifier = false; + + private static string[] _vulkanFmtModifierExts = { + "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", + "VK_EXT_external_memory_host" + }; private Version _ffmpegVersion = null; private string _ffmpegPath = string.Empty; @@ -83,13 +94,15 @@ namespace MediaBrowser.MediaEncoding.Encoder IServerConfigurationManager configurationManager, IFileSystem fileSystem, ILocalizationManager localization, - IConfiguration config) + IConfiguration config, + IServerConfigurationManager serverConfig) { _logger = logger; _configurationManager = configurationManager; _fileSystem = fileSystem; _localization = localization; _config = config; + _serverConfig = serverConfig; _startupOptionFFmpegPath = config.GetValue<string>(Controller.Extensions.ConfigurationExtensions.FfmpegPathKey) ?? string.Empty; _jsonSerializerOptions = JsonDefaults.Options; } @@ -110,6 +123,8 @@ namespace MediaBrowser.MediaEncoding.Encoder public bool IsVaapiDeviceInteli965 => _isVaapiDeviceInteli965; + public bool IsVaapiDeviceSupportVulkanFmtModifier => _isVaapiDeviceSupportVulkanFmtModifier; + /// <summary> /// Run at startup or if the user removes a Custom path from transcode page. /// Sets global variables FFmpegPath. @@ -169,6 +184,8 @@ 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); + if (_isVaapiDeviceAmd) { _logger.LogInformation("VAAPI device {RenderNodePath} is AMD GPU", options.VaapiDevice); @@ -181,6 +198,11 @@ namespace MediaBrowser.MediaEncoding.Encoder { _logger.LogInformation("VAAPI device {RenderNodePath} is Intel GPU (i965)", options.VaapiDevice); } + + if (_isVaapiDeviceSupportVulkanFmtModifier) + { + _logger.LogInformation("VAAPI device {RenderNodePath} supports Vulkan DRM format modifier", options.VaapiDevice); + } } } @@ -587,6 +609,32 @@ namespace MediaBrowser.MediaEncoding.Encoder return await ExtractImageInternal(inputArgument, container, videoStream, imageStreamIndex, threedFormat, offset, false, targetFormat, cancellationToken).ConfigureAwait(false); } + private string GetImageResolutionParameter() + { + string imageResolutionParameter; + + imageResolutionParameter = _serverConfig.Configuration.ChapterImageResolution switch + { + ImageResolution.P144 => "256x144", + ImageResolution.P240 => "426x240", + ImageResolution.P360 => "640x360", + ImageResolution.P480 => "854x480", + ImageResolution.P720 => "1280x720", + ImageResolution.P1080 => "1920x1080", + ImageResolution.P1440 => "2560x1440", + ImageResolution.P2160 => "3840x2160", + _ => string.Empty + }; + + if (!string.IsNullOrEmpty(imageResolutionParameter)) + { + imageResolutionParameter = " -s " + imageResolutionParameter; + } + + return imageResolutionParameter; + } + + private async Task<string> ExtractImageInternal(string inputPath, string container, MediaStream videoStream, int? imageStreamIndex, Video3DFormat? threedFormat, TimeSpan? offset, bool useIFrame, ImageFormat? targetFormat, CancellationToken cancellationToken) { if (string.IsNullOrEmpty(inputPath)) @@ -657,7 +705,7 @@ namespace MediaBrowser.MediaEncoding.Encoder var vf = string.Join(',', filters); var mapArg = imageStreamIndex.HasValue ? (" -map 0:" + imageStreamIndex.Value.ToString(CultureInfo.InvariantCulture)) : string.Empty; - var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads); + var args = string.Format(CultureInfo.InvariantCulture, "-i {0}{3} -threads {4} -v quiet -vframes 1 -vf {2}{5} -f image2 \"{1}\"", inputPath, tempExtractPath, vf, mapArg, _threads, GetImageResolutionParameter()); if (offset.HasValue) { diff --git a/MediaBrowser.Model/Configuration/ServerConfiguration.cs b/MediaBrowser.Model/Configuration/ServerConfiguration.cs index e61b896b9..a07ab7121 100644 --- a/MediaBrowser.Model/Configuration/ServerConfiguration.cs +++ b/MediaBrowser.Model/Configuration/ServerConfiguration.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using MediaBrowser.Model.Drawing; using MediaBrowser.Model.Dto; using MediaBrowser.Model.Updates; @@ -240,5 +241,23 @@ namespace MediaBrowser.Model.Configuration /// 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 chapters duration in seconds. + /// </summary> + /// <value>The dummy chapters duration.</value> + public int DummyChapterDuration { get; set; } = 300; + + /// <summary> + /// Gets or sets the dummy chapter count. + /// </summary> + /// <value>The dummy chapter count.</value> + public int DummyChapterCount { get; set; } = 100; + + /// <summary> + /// Gets or sets the chapter image resolution. + /// </summary> + /// <value>The chapter image resolution.</value> + public ImageResolution ChapterImageResolution { get; set; } = ImageResolution.MatchSource; } } diff --git a/MediaBrowser.Model/Dlna/StreamBuilder.cs b/MediaBrowser.Model/Dlna/StreamBuilder.cs index b121a2905..6e9b943f7 100644 --- a/MediaBrowser.Model/Dlna/StreamBuilder.cs +++ b/MediaBrowser.Model/Dlna/StreamBuilder.cs @@ -436,9 +436,9 @@ namespace MediaBrowser.Model.Dlna { containerSupported = true; - videoSupported = videoStream != null && profile.SupportsVideoCodec(videoStream.Codec); + videoSupported = videoStream == null || profile.SupportsVideoCodec(videoStream.Codec); - audioSupported = audioStream != null && profile.SupportsAudioCodec(audioStream.Codec); + audioSupported = audioStream == null || profile.SupportsAudioCodec(audioStream.Codec); if (videoSupported && audioSupported) { @@ -447,18 +447,17 @@ namespace MediaBrowser.Model.Dlna } } - var list = new List<TranscodeReason>(); if (!containerSupported) { reasons |= TranscodeReason.ContainerNotSupported; } - if (videoStream != null && !videoSupported) + if (!videoSupported) { reasons |= TranscodeReason.VideoCodecNotSupported; } - if (audioStream != null && !audioSupported) + if (!audioSupported) { reasons |= TranscodeReason.AudioCodecNotSupported; } @@ -587,21 +586,19 @@ namespace MediaBrowser.Model.Dlna } // Collect candidate audio streams - IEnumerable<MediaStream> candidateAudioStreams = audioStream == null ? Array.Empty<MediaStream>() : new[] { audioStream }; + ICollection<MediaStream> candidateAudioStreams = audioStream == null ? Array.Empty<MediaStream>() : new[] { audioStream }; if (!options.AudioStreamIndex.HasValue || options.AudioStreamIndex < 0) { if (audioStream?.IsDefault == true) { - candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.IsDefault); + candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.IsDefault).ToArray(); } else { - candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.Language == audioStream?.Language); + candidateAudioStreams = item.MediaStreams.Where(stream => stream.Type == MediaStreamType.Audio && stream.Language == audioStream?.Language).ToArray(); } } - candidateAudioStreams = candidateAudioStreams.ToArray(); - var videoStream = item.VideoStream; var directPlayBitrateEligibility = IsBitrateEligibleForDirectPlayback(item, options.GetMaxBitrate(false) ?? 0, options, PlayMethod.DirectPlay); @@ -1057,7 +1054,7 @@ namespace MediaBrowser.Model.Dlna MediaSourceInfo mediaSource, MediaStream videoStream, MediaStream audioStream, - IEnumerable<MediaStream> candidateAudioStreams, + ICollection<MediaStream> candidateAudioStreams, MediaStream subtitleStream, bool isEligibleForDirectPlay, bool isEligibleForDirectStream) @@ -1088,9 +1085,6 @@ namespace MediaBrowser.Model.Dlna bool? isInterlaced = videoStream?.IsInterlaced; string videoCodecTag = videoStream?.CodecTag; bool? isAvc = videoStream?.IsAVC; - // Audio - var defaultLanguage = audioStream?.Language ?? string.Empty; - var defaultMarked = audioStream?.IsDefault ?? false; TransportStreamTimestamp? timestamp = videoStream == null ? TransportStreamTimestamp.None : mediaSource.Timestamp; int? packetLength = videoStream?.PacketLength; @@ -1122,7 +1116,7 @@ namespace MediaBrowser.Model.Dlna .SelectMany(codecProfile => checkVideoConditions(codecProfile.Conditions))); // Check audiocandidates profile conditions - var audioStreamMatches = candidateAudioStreams.ToDictionary(s => s, audioStream => CheckVideoAudioStreamDirectPlay(options, mediaSource, container, audioStream, defaultLanguage, defaultMarked)); + var audioStreamMatches = candidateAudioStreams.ToDictionary(s => s, audioStream => CheckVideoAudioStreamDirectPlay(options, mediaSource, container, audioStream)); TranscodeReason subtitleProfileReasons = 0; if (subtitleStream != null) @@ -1179,14 +1173,18 @@ namespace MediaBrowser.Model.Dlna } // Check audio codec - var selectedAudioStream = candidateAudioStreams.FirstOrDefault(audioStream => directPlayProfile.SupportsAudioCodec(audioStream.Codec)); - if (selectedAudioStream == null) - { - directPlayProfileReasons |= TranscodeReason.AudioCodecNotSupported; - } - else + MediaStream selectedAudioStream = null; + if (candidateAudioStreams.Any()) { - audioCodecProfileReasons = audioStreamMatches.GetValueOrDefault(selectedAudioStream); + selectedAudioStream = candidateAudioStreams.FirstOrDefault(audioStream => directPlayProfile.SupportsAudioCodec(audioStream.Codec)); + if (selectedAudioStream == null) + { + directPlayProfileReasons |= TranscodeReason.AudioCodecNotSupported; + } + else + { + audioCodecProfileReasons = audioStreamMatches.GetValueOrDefault(selectedAudioStream); + } } var failureReasons = directPlayProfileReasons | containerProfileReasons | subtitleProfileReasons; @@ -1239,10 +1237,10 @@ namespace MediaBrowser.Model.Dlna return (Profile: null, PlayMethod: null, AudioStreamIndex: null, TranscodeReasons: failureReasons); } - private TranscodeReason CheckVideoAudioStreamDirectPlay(VideoOptions options, MediaSourceInfo mediaSource, string container, MediaStream audioStream, string language, bool isDefault) + private TranscodeReason CheckVideoAudioStreamDirectPlay(VideoOptions options, MediaSourceInfo mediaSource, string container, MediaStream audioStream) { var profile = options.Profile; - var audioFailureConditions = GetProfileConditionsForVideoAudio(profile.CodecProfiles, container, audioStream.Codec, audioStream.Channels, audioStream.BitRate, audioStream.SampleRate, audioStream.BitDepth, audioStream.Profile, !audioStream.IsDefault); + 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) diff --git a/MediaBrowser.Model/Drawing/ImageResolution.cs b/MediaBrowser.Model/Drawing/ImageResolution.cs new file mode 100644 index 000000000..34738b799 --- /dev/null +++ b/MediaBrowser.Model/Drawing/ImageResolution.cs @@ -0,0 +1,52 @@ +namespace MediaBrowser.Model.Drawing; + +/// <summary> +/// Enum ImageResolution. +/// </summary> +public enum ImageResolution +{ + /// <summary> + /// MatchSource. + /// </summary> + MatchSource = 0, + + /// <summary> + /// 144p. + /// </summary> + P144 = 1, + + /// <summary> + /// 240p. + /// </summary> + P240 = 2, + + /// <summary> + /// 360p. + /// </summary> + P360 = 3, + + /// <summary> + /// 480p. + /// </summary> + P480 = 4, + + /// <summary> + /// 720p. + /// </summary> + P720 = 5, + + /// <summary> + /// 1080p. + /// </summary> + P1080 = 6, + + /// <summary> + /// 1440p. + /// </summary> + P1440 = 7, + + /// <summary> + /// 2160p. + /// </summary> + P2160 = 8 +} diff --git a/MediaBrowser.Model/Dto/MediaSourceInfo.cs b/MediaBrowser.Model/Dto/MediaSourceInfo.cs index bb9848848..c348e83ae 100644 --- a/MediaBrowser.Model/Dto/MediaSourceInfo.cs +++ b/MediaBrowser.Model/Dto/MediaSourceInfo.cs @@ -230,19 +230,15 @@ namespace MediaBrowser.Model.Dto public bool? IsSecondaryAudio(MediaStream stream) { - // Look for the first audio track marked as default - foreach (var currentStream in MediaStreams) + if (stream.IsExternal) { - if (currentStream.Type == MediaStreamType.Audio && currentStream.IsDefault) - { - return currentStream.Index != stream.Index; - } + return false; } // Look for the first audio track foreach (var currentStream in MediaStreams) { - if (currentStream.Type == MediaStreamType.Audio) + if (currentStream.Type == MediaStreamType.Audio && !currentStream.IsExternal) { return currentStream.Index != stream.Index; } diff --git a/MediaBrowser.Model/Entities/ExtraType.cs b/MediaBrowser.Model/Entities/ExtraType.cs index aca4bd282..66da80d96 100644 --- a/MediaBrowser.Model/Entities/ExtraType.cs +++ b/MediaBrowser.Model/Entities/ExtraType.cs @@ -13,6 +13,8 @@ namespace MediaBrowser.Model.Entities Scene = 6, Sample = 7, ThemeSong = 8, - ThemeVideo = 9 + ThemeVideo = 9, + Featurette = 10, + Short = 11 } } diff --git a/MediaBrowser.Model/Entities/MetadataProvider.cs b/MediaBrowser.Model/Entities/MetadataProvider.cs index 37e3d8864..bd8db9941 100644 --- a/MediaBrowser.Model/Entities/MetadataProvider.cs +++ b/MediaBrowser.Model/Entities/MetadataProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - namespace MediaBrowser.Model.Entities { /// <summary> @@ -14,38 +12,78 @@ namespace MediaBrowser.Model.Entities Custom = 0, /// <summary> - /// The imdb. + /// The IMDb provider. /// </summary> Imdb = 2, /// <summary> - /// The TMDB. + /// The TMDb provider. /// </summary> Tmdb = 3, /// <summary> - /// The TVDB. + /// The TVDb provider. /// </summary> Tvdb = 4, /// <summary> - /// The tvcom. + /// The tvcom providerd. /// </summary> Tvcom = 5, /// <summary> - /// Tmdb Collection Id. + /// TMDb collection provider. /// </summary> TmdbCollection = 7, + + /// <summary> + /// The MusicBrainz album provider. + /// </summary> MusicBrainzAlbum = 8, + + /// <summary> + /// The MusicBrainz album artist provider. + /// </summary> MusicBrainzAlbumArtist = 9, + + /// <summary> + /// The MusicBrainz artist provider. + /// </summary> MusicBrainzArtist = 10, + + /// <summary> + /// The MusicBrainz release group provider. + /// </summary> MusicBrainzReleaseGroup = 11, + + /// <summary> + /// The Zap2It provider. + /// </summary> Zap2It = 12, + + /// <summary> + /// The TvRage provider. + /// </summary> TvRage = 15, + + /// <summary> + /// The AudioDb artist provider. + /// </summary> AudioDbArtist = 16, + + /// <summary> + /// The AudioDb collection provider. + /// </summary> AudioDbAlbum = 17, + + /// <summary> + /// The MusicBrainz track provider. + /// </summary> MusicBrainzTrack = 18, + + /// <summary> + /// The TvMaze provider. + /// </summary> TvMaze = 19 } } diff --git a/MediaBrowser.Model/Entities/SeriesStatus.cs b/MediaBrowser.Model/Entities/SeriesStatus.cs index c77c4a8ad..1cff24e2a 100644 --- a/MediaBrowser.Model/Entities/SeriesStatus.cs +++ b/MediaBrowser.Model/Entities/SeriesStatus.cs @@ -1,18 +1,23 @@ namespace MediaBrowser.Model.Entities { /// <summary> - /// Enum SeriesStatus. + /// The status of a series. /// </summary> public enum SeriesStatus { /// <summary> - /// The continuing. + /// The continuing status. This indicates that a series is currently releasing. /// </summary> Continuing, /// <summary> - /// The ended. + /// The ended status. This indicates that a series has completed and is no longer being released. /// </summary> - Ended + Ended, + + /// <summary> + /// The unreleased status. This indicates that a series has not been released yet. + /// </summary> + Unreleased } } diff --git a/MediaBrowser.Model/MediaBrowser.Model.csproj b/MediaBrowser.Model/MediaBrowser.Model.csproj index ad2ff1ba2..4172e9825 100644 --- a/MediaBrowser.Model/MediaBrowser.Model.csproj +++ b/MediaBrowser.Model/MediaBrowser.Model.csproj @@ -34,13 +34,13 @@ <ItemGroup> <PackageReference Include="Microsoft.SourceLink.GitHub" Version="1.1.1" PrivateAssets="All" /> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.3" /> <PackageReference Include="MimeTypes" Version="2.4.0"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> <PackageReference Include="System.Globalization" Version="4.3.0" /> - <PackageReference Include="System.Text.Json" Version="6.0.6" /> + <PackageReference Include="System.Text.Json" Version="6.0.7" /> </ItemGroup> <ItemGroup> diff --git a/MediaBrowser.Model/Querying/ItemFields.cs b/MediaBrowser.Model/Querying/ItemFields.cs index e6c3a6c26..6fa1d778a 100644 --- a/MediaBrowser.Model/Querying/ItemFields.cs +++ b/MediaBrowser.Model/Querying/ItemFields.cs @@ -126,7 +126,7 @@ namespace MediaBrowser.Model.Querying ProductionLocations, /// <summary> - /// Imdb, tmdb, etc. + /// The ids from IMDb, TMDb, etc. /// </summary> ProviderIds, diff --git a/MediaBrowser.Providers/Manager/MetadataService.cs b/MediaBrowser.Providers/Manager/MetadataService.cs index 5a2936bd8..0f3f1bdcb 100644 --- a/MediaBrowser.Providers/Manager/MetadataService.cs +++ b/MediaBrowser.Providers/Manager/MetadataService.cs @@ -94,7 +94,7 @@ namespace MediaBrowser.Providers.Manager var localImagesFailed = false; - var allImageProviders = ((ProviderManager)ProviderManager).GetImageProviders(item, refreshOptions).ToList(); + var allImageProviders = ProviderManager.GetImageProviders(item, refreshOptions).ToList(); if (refreshOptions.RemoveOldMetadata && refreshOptions.ReplaceAllImages) { @@ -522,7 +522,7 @@ namespace MediaBrowser.Providers.Manager protected IEnumerable<IMetadataProvider> GetProviders(BaseItem item, LibraryOptions libraryOptions, MetadataRefreshOptions options, bool isFirstRefresh, bool requiresRefresh) { // Get providers to refresh - var providers = ((ProviderManager)ProviderManager).GetMetadataProviders<TItemType>(item, libraryOptions).ToList(); + var providers = ProviderManager.GetMetadataProviders<TItemType>(item, libraryOptions).ToList(); var metadataRefreshMode = options.MetadataRefreshMode; diff --git a/MediaBrowser.Providers/Manager/ProviderManager.cs b/MediaBrowser.Providers/Manager/ProviderManager.cs index bbb33ddf0..ac4dc1bc3 100644 --- a/MediaBrowser.Providers/Manager/ProviderManager.cs +++ b/MediaBrowser.Providers/Manager/ProviderManager.cs @@ -1,5 +1,3 @@ -#nullable disable - using System; using System.Collections.Concurrent; using System.Collections.Generic; @@ -48,7 +46,7 @@ namespace MediaBrowser.Providers.Manager /// </summary> public class ProviderManager : IProviderManager, IDisposable { - private readonly object _refreshQueueLock = new object(); + private readonly object _refreshQueueLock = new(); private readonly ILogger<ProviderManager> _logger; private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryMonitor _libraryMonitor; @@ -58,11 +56,11 @@ namespace MediaBrowser.Providers.Manager private readonly ISubtitleManager _subtitleManager; private readonly IServerConfigurationManager _configurationManager; private readonly IBaseItemManager _baseItemManager; - private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new ConcurrentDictionary<Guid, double>(); - private readonly CancellationTokenSource _disposeCancellationTokenSource = new CancellationTokenSource(); - private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue = - new SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>>(); + private readonly ConcurrentDictionary<Guid, double> _activeRefreshes = new(); + private readonly CancellationTokenSource _disposeCancellationTokenSource = new(); + private readonly SimplePriorityQueue<Tuple<Guid, MetadataRefreshOptions>> _refreshQueue = new(); + private IImageProvider[] _imageProviders = Array.Empty<IImageProvider>(); private IMetadataService[] _metadataServices = Array.Empty<IMetadataService>(); private IMetadataProvider[] _metadataProviders = Array.Empty<IMetadataProvider>(); private IMetadataSaver[] _savers = Array.Empty<IMetadataSaver>(); @@ -105,15 +103,13 @@ namespace MediaBrowser.Providers.Manager } /// <inheritdoc/> - public event EventHandler<GenericEventArgs<BaseItem>> RefreshStarted; + public event EventHandler<GenericEventArgs<BaseItem>>? RefreshStarted; /// <inheritdoc/> - public event EventHandler<GenericEventArgs<BaseItem>> RefreshCompleted; + public event EventHandler<GenericEventArgs<BaseItem>>? RefreshCompleted; /// <inheritdoc/> - public event EventHandler<GenericEventArgs<Tuple<BaseItem, double>>> RefreshProgress; - - private IImageProvider[] ImageProviders { get; set; } + public event EventHandler<GenericEventArgs<Tuple<BaseItem, double>>>? RefreshProgress; /// <inheritdoc/> public void AddParts( @@ -123,8 +119,7 @@ namespace MediaBrowser.Providers.Manager IEnumerable<IMetadataSaver> metadataSavers, IEnumerable<IExternalId> externalIds) { - ImageProviders = imageProviders.ToArray(); - + _imageProviders = imageProviders.ToArray(); _metadataServices = metadataServices.OrderBy(i => i.Order).ToArray(); _metadataProviders = metadataProviders.ToArray(); _externalIds = externalIds.OrderBy(i => i.ProviderName).ToArray(); @@ -138,26 +133,15 @@ namespace MediaBrowser.Providers.Manager var type = item.GetType(); var service = _metadataServices.FirstOrDefault(current => current.CanRefreshPrimary(type)); + service ??= _metadataServices.FirstOrDefault(current => current.CanRefresh(item)); if (service == null) { - foreach (var current in _metadataServices) - { - if (current.CanRefresh(item)) - { - service = current; - break; - } - } + _logger.LogError("Unable to find a metadata service for item of type {TypeName}", item.GetType().Name); + return Task.FromResult(ItemUpdateType.None); } - if (service != null) - { - return service.RefreshMetadata(item, options, cancellationToken); - } - - _logger.LogError("Unable to find a metadata service for item of type {TypeName}", item.GetType().Name); - return Task.FromResult(ItemUpdateType.None); + return service.RefreshMetadata(item, options, cancellationToken); } /// <inheritdoc/> @@ -181,9 +165,13 @@ namespace MediaBrowser.Providers.Manager { contentType = "image/png"; } + else + { + throw new HttpRequestException("Invalid image received: contentType not set.", null, response.StatusCode); + } } - // thetvdb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons... + // TVDb will sometimes serve a rubbish 404 html page with a 200 OK code, because reasons... if (contentType.Equals(MediaTypeNames.Text.Html, StringComparison.OrdinalIgnoreCase)) { throw new HttpRequestException("Invalid image received.", null, HttpStatusCode.NotFound); @@ -309,53 +297,69 @@ namespace MediaBrowser.Providers.Manager return GetRemoteImageProviders(item, true).Select(i => new ImageProviderInfo(i.Name, i.GetSupportedImages(item).ToArray())); } - /// <summary> - /// Gets the image providers for the provided item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="refreshOptions">The image refresh options.</param> - /// <returns>The image providers for the item.</returns> - public IEnumerable<IImageProvider> GetImageProviders(BaseItem item, ImageRefreshOptions refreshOptions) + private IEnumerable<IRemoteImageProvider> GetRemoteImageProviders(BaseItem item, bool includeDisabled) { - return GetImageProviders(item, _libraryManager.GetLibraryOptions(item), GetMetadataOptions(item), refreshOptions, false); + var options = GetMetadataOptions(item); + var libraryOptions = _libraryManager.GetLibraryOptions(item); + + return GetImageProvidersInternal( + item, + libraryOptions, + options, + new ImageRefreshOptions(new DirectoryService(_fileSystem)), + includeDisabled).OfType<IRemoteImageProvider>(); } - private IEnumerable<IImageProvider> GetImageProviders(BaseItem item, LibraryOptions libraryOptions, MetadataOptions options, ImageRefreshOptions refreshOptions, bool includeDisabled) + /// <inheritdoc/> + public IEnumerable<IImageProvider> GetImageProviders(BaseItem item, ImageRefreshOptions refreshOptions) { - // Avoid implicitly captured closure - var currentOptions = options; + return GetImageProvidersInternal(item, _libraryManager.GetLibraryOptions(item), GetMetadataOptions(item), refreshOptions, false); + } + private IEnumerable<IImageProvider> GetImageProvidersInternal(BaseItem item, LibraryOptions libraryOptions, MetadataOptions options, ImageRefreshOptions refreshOptions, bool includeDisabled) + { var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name); - var typeFetcherOrder = typeOptions?.ImageFetcherOrder; + var fetcherOrder = typeOptions?.ImageFetcherOrder ?? options.ImageFetcherOrder; - return ImageProviders.Where(i => CanRefresh(i, item, libraryOptions, refreshOptions, includeDisabled)) - .OrderBy(i => + return _imageProviders.Where(i => CanRefreshImages(i, item, typeOptions, refreshOptions, includeDisabled)) + .OrderBy(i => GetConfiguredOrder(fetcherOrder, i.Name)) + .ThenBy(GetDefaultOrder); + } + + private bool CanRefreshImages( + IImageProvider provider, + BaseItem item, + TypeOptions? libraryTypeOptions, + ImageRefreshOptions refreshOptions, + bool includeDisabled) + { + try + { + if (!provider.Supports(item)) { - // See if there's a user-defined order - if (i is not ILocalImageProvider) - { - var fetcherOrder = typeFetcherOrder ?? currentOptions.ImageFetcherOrder; - var index = Array.IndexOf(fetcherOrder, i.Name); + return false; + } + } + catch (Exception ex) + { + _logger.LogError(ex, "{ProviderName} failed in Supports for type {ItemType} at {ItemPath}", provider.GetType().Name, item.GetType().Name, item.Path); + return false; + } - if (index != -1) - { - return index; - } - } + if (includeDisabled || provider is ILocalImageProvider) + { + return true; + } - // Not configured. Just return some high number to put it at the end. - return 100; - }) - .ThenBy(GetOrder); + if (item.IsLocked && refreshOptions.ImageRefreshMode != MetadataRefreshMode.FullRefresh) + { + return false; + } + + return _baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, provider.Name); } - /// <summary> - /// Gets the metadata providers for the provided item. - /// </summary> - /// <param name="item">The item.</param> - /// <param name="libraryOptions">The library options.</param> - /// <typeparam name="T">The type of metadata provider.</typeparam> - /// <returns>The metadata providers.</returns> + /// <inheritdoc /> public IEnumerable<IMetadataProvider<T>> GetMetadataProviders<T>(BaseItem item, LibraryOptions libraryOptions) where T : BaseItem { @@ -367,165 +371,84 @@ namespace MediaBrowser.Providers.Manager private IEnumerable<IMetadataProvider<T>> GetMetadataProvidersInternal<T>(BaseItem item, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions, bool includeDisabled, bool forceEnableInternetMetadata) where T : BaseItem { - // Avoid implicitly captured closure - var currentOptions = globalMetadataOptions; + var localMetadataReaderOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder; + var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name); + var metadataFetcherOrder = typeOptions?.MetadataFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder; return _metadataProviders.OfType<IMetadataProvider<T>>() - .Where(i => CanRefresh(i, item, libraryOptions, includeDisabled, forceEnableInternetMetadata)) - .OrderBy(i => GetConfiguredOrder(item, i, libraryOptions, globalMetadataOptions)) + .Where(i => CanRefreshMetadata(i, item, typeOptions, includeDisabled, forceEnableInternetMetadata)) + .OrderBy(i => + // local and remote providers will be interleaved in the final order + // only relative order within a type matters: consumers of the list filter to one or the other + i switch + { + ILocalMetadataProvider => GetConfiguredOrder(localMetadataReaderOrder, i.Name), + IRemoteMetadataProvider => GetConfiguredOrder(metadataFetcherOrder, i.Name), + // Default to end + _ => int.MaxValue + }) .ThenBy(GetDefaultOrder); } - private IEnumerable<IRemoteImageProvider> GetRemoteImageProviders(BaseItem item, bool includeDisabled) - { - var options = GetMetadataOptions(item); - var libraryOptions = _libraryManager.GetLibraryOptions(item); - - return GetImageProviders( - item, - libraryOptions, - options, - new ImageRefreshOptions(new DirectoryService(_fileSystem)), - includeDisabled).OfType<IRemoteImageProvider>(); - } - - private bool CanRefresh( + private bool CanRefreshMetadata( IMetadataProvider provider, BaseItem item, - LibraryOptions libraryOptions, + TypeOptions? libraryTypeOptions, bool includeDisabled, bool forceEnableInternetMetadata) { - if (!includeDisabled) - { - // If locked only allow local providers - if (item.IsLocked && provider is not ILocalMetadataProvider && provider is not IForcedProvider) - { - return false; - } - - if (provider is IRemoteMetadataProvider) - { - if (!forceEnableInternetMetadata && !_baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, provider.Name)) - { - return false; - } - } - } - if (!item.SupportsLocalMetadata && provider is ILocalMetadataProvider) { return false; } - // If this restriction is ever lifted, movie xml providers will have to be updated to prevent owned items like trailers from reading those files - if (!item.OwnerId.Equals(default)) + // Prevent owned items from reading the same local metadata file as their owner + if (!item.OwnerId.Equals(default) && provider is ILocalMetadataProvider) { - if (provider is ILocalMetadataProvider || provider is IRemoteMetadataProvider) - { - return false; - } + return false; } - return true; - } - - private bool CanRefresh( - IImageProvider provider, - BaseItem item, - LibraryOptions libraryOptions, - ImageRefreshOptions refreshOptions, - bool includeDisabled) - { - if (!includeDisabled) + if (includeDisabled) { - // If locked only allow local providers - if (item.IsLocked && provider is not ILocalImageProvider) - { - if (refreshOptions.ImageRefreshMode != MetadataRefreshMode.FullRefresh) - { - return false; - } - } - - if (provider is IRemoteImageProvider || provider is IDynamicImageProvider) - { - if (!_baseItemManager.IsImageFetcherEnabled(item, libraryOptions, provider.Name)) - { - return false; - } - } + return true; } - try - { - return provider.Supports(item); - } - catch (Exception ex) + // If locked only allow local providers + if (item.IsLocked && provider is not ILocalMetadataProvider && provider is not IForcedProvider) { - _logger.LogError(ex, "{ProviderName} failed in Supports for type {ItemType} at {ItemPath}", provider.GetType().Name, item.GetType().Name, item.Path); return false; } - } - /// <summary> - /// Gets the order. - /// </summary> - /// <param name="provider">The provider.</param> - /// <returns>System.Int32.</returns> - private int GetOrder(IImageProvider provider) - { - if (provider is not IHasOrder hasOrder) + if (forceEnableInternetMetadata || provider is not IRemoteMetadataProvider) { - return 0; + return true; } - return hasOrder.Order; + return _baseItemManager.IsMetadataFetcherEnabled(item, libraryTypeOptions, provider.Name); } - private int GetConfiguredOrder(BaseItem item, IMetadataProvider provider, LibraryOptions libraryOptions, MetadataOptions globalMetadataOptions) + private static int GetConfiguredOrder(string[] order, string providerName) { - // See if there's a user-defined order - if (provider is ILocalMetadataProvider) - { - var configuredOrder = libraryOptions.LocalMetadataReaderOrder ?? globalMetadataOptions.LocalMetadataReaderOrder; - - var index = Array.IndexOf(configuredOrder, provider.Name); - - if (index != -1) - { - return index; - } - } + var index = Array.IndexOf(order, providerName); - // See if there's a user-defined order - if (provider is IRemoteMetadataProvider) + if (index != -1) { - var typeOptions = libraryOptions.GetTypeOptions(item.GetType().Name); - var typeFetcherOrder = typeOptions?.MetadataFetcherOrder; - - var fetcherOrder = typeFetcherOrder ?? globalMetadataOptions.MetadataFetcherOrder; - - var index = Array.IndexOf(fetcherOrder, provider.Name); - - if (index != -1) - { - return index; - } + return index; } - // Not configured. Just return some high number to put it at the end. - return 100; + // default to end + return int.MaxValue; } - private int GetDefaultOrder(IMetadataProvider provider) + private static int GetDefaultOrder(object provider) { if (provider is IHasOrder hasOrder) { return hasOrder.Order; } - return 0; + // after items that want to be first (~0) but before items that want to be last (~100) + return 50; } /// <inheritdoc/> @@ -568,7 +491,7 @@ namespace MediaBrowser.Providers.Manager var libraryOptions = new LibraryOptions(); - var imageProviders = GetImageProviders( + var imageProviders = GetImageProvidersInternal( dummy, libraryOptions, options, @@ -677,7 +600,7 @@ namespace MediaBrowser.Providers.Manager foreach (var saver in savers.Where(i => IsSaverEnabledForItem(i, item, libraryOptions, updateType, false))) { - _logger.LogDebug("Saving {0} to {1}.", item.Path ?? item.Name, saver.Name); + _logger.LogDebug("Saving {Item} to {Saver}", item.Path ?? item.Name, saver.Name); if (saver is IMetadataFileSaver fileSaver) { @@ -689,7 +612,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { - _logger.LogError(ex, "Error in {0} GetSavePath", saver.Name); + _logger.LogError(ex, "Error in {Saver} GetSavePath", saver.Name); continue; } @@ -776,7 +699,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { - _logger.LogError(ex, "Error in {0}.IsEnabledFor", saver.Name); + _logger.LogError(ex, "Error in {Saver}.IsEnabledFor", saver.Name); return false; } } @@ -786,7 +709,7 @@ namespace MediaBrowser.Providers.Manager where TItemType : BaseItem, new() where TLookupType : ItemLookupInfo { - BaseItem referenceItem = null; + BaseItem? referenceItem = null; if (!searchInfo.ItemId.Equals(default)) { @@ -796,7 +719,7 @@ namespace MediaBrowser.Providers.Manager return GetRemoteSearchResults<TItemType, TLookupType>(searchInfo, referenceItem, cancellationToken); } - private async Task<IEnumerable<RemoteSearchResult>> GetRemoteSearchResults<TItemType, TLookupType>(RemoteSearchQuery<TLookupType> searchInfo, BaseItem referenceItem, CancellationToken cancellationToken) + private async Task<IEnumerable<RemoteSearchResult>> GetRemoteSearchResults<TItemType, TLookupType>(RemoteSearchQuery<TLookupType> searchInfo, BaseItem? referenceItem, CancellationToken cancellationToken) where TItemType : BaseItem, new() where TLookupType : ItemLookupInfo { @@ -926,7 +849,7 @@ namespace MediaBrowser.Providers.Manager } catch (Exception ex) { - _logger.LogError(ex, "Error in {0}.Supports", i.GetType().Name); + _logger.LogError(ex, "Error in {Type}.Supports", i.GetType().Name); return false; } }); @@ -958,7 +881,8 @@ namespace MediaBrowser.Providers.Manager i.UrlFormatString, value) }; - }).Where(i => i != null).Concat(item.GetRelatedUrls()); + }).Where(i => i != null) + .Concat(item.GetRelatedUrls())!; // We just filtered out all the nulls } /// <inheritdoc/> @@ -991,7 +915,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void OnRefreshStart(BaseItem item) { - _logger.LogDebug("OnRefreshStart {0}", item.Id.ToString("N", CultureInfo.InvariantCulture)); + _logger.LogDebug("OnRefreshStart {Item}", item.Id.ToString("N", CultureInfo.InvariantCulture)); _activeRefreshes[item.Id] = 0; RefreshStarted?.Invoke(this, new GenericEventArgs<BaseItem>(item)); } @@ -999,7 +923,7 @@ namespace MediaBrowser.Providers.Manager /// <inheritdoc/> public void OnRefreshComplete(BaseItem item) { - _logger.LogDebug("OnRefreshComplete {0}", item.Id.ToString("N", CultureInfo.InvariantCulture)); + _logger.LogDebug("OnRefreshComplete {Item}", item.Id.ToString("N", CultureInfo.InvariantCulture)); _activeRefreshes.Remove(item.Id, out _); @@ -1021,7 +945,7 @@ namespace MediaBrowser.Providers.Manager public void OnRefreshProgress(BaseItem item, double progress) { var id = item.Id; - _logger.LogDebug("OnRefreshProgress {0} {1}", id.ToString("N", CultureInfo.InvariantCulture), progress); + _logger.LogDebug("OnRefreshProgress {Id} {Progress}", id.ToString("N", CultureInfo.InvariantCulture), progress); // TODO: Need to hunt down the conditions for this happening _activeRefreshes.AddOrUpdate( diff --git a/MediaBrowser.Providers/MediaBrowser.Providers.csproj b/MediaBrowser.Providers/MediaBrowser.Providers.csproj index 3a0e9a225..eb921e6f5 100644 --- a/MediaBrowser.Providers/MediaBrowser.Providers.csproj +++ b/MediaBrowser.Providers/MediaBrowser.Providers.csproj @@ -17,10 +17,11 @@ <ItemGroup> <PackageReference Include="LrcParser" Version="2022.529.1" /> + <PackageReference Include="MetaBrainz.MusicBrainz" Version="5.0.0" /> <PackageReference Include="Microsoft.Extensions.Configuration.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Caching.Abstractions" Version="6.0.0" /> <PackageReference Include="Microsoft.Extensions.Http" Version="6.0.0" /> - <PackageReference Include="Newtonsoft.Json" Version="13.0.1" /> + <PackageReference Include="Newtonsoft.Json" Version="13.0.2" /> <PackageReference Include="OptimizedPriorityQueue" Version="5.1.0" /> <PackageReference Include="PlaylistsNET" Version="1.2.1" /> <PackageReference Include="TagLibSharp" Version="2.3.0" /> diff --git a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs index 8c08ab30e..7412a10d0 100644 --- a/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs +++ b/MediaBrowser.Providers/MediaInfo/FFProbeVideoInfo.cs @@ -34,7 +34,7 @@ namespace MediaBrowser.Providers.MediaInfo { public class FFProbeVideoInfo { - private readonly ILogger _logger; + private readonly ILogger<FFProbeVideoInfo> _logger; private readonly IMediaEncoder _mediaEncoder; private readonly IItemRepository _itemRepo; private readonly IBlurayExaminer _blurayExaminer; @@ -48,10 +48,8 @@ namespace MediaBrowser.Providers.MediaInfo private readonly SubtitleResolver _subtitleResolver; private readonly IMediaSourceManager _mediaSourceManager; - private readonly long _dummyChapterDuration = TimeSpan.FromMinutes(5).Ticks; - public FFProbeVideoInfo( - ILogger logger, + ILogger<FFProbeVideoInfo> logger, IMediaSourceManager mediaSourceManager, IMediaEncoder mediaEncoder, IItemRepository itemRepo, @@ -651,6 +649,7 @@ namespace MediaBrowser.Providers.MediaInfo private ChapterInfo[] CreateDummyChapters(Video video) { var runtime = video.RunTimeTicks ?? 0; + long dummyChapterDuration = TimeSpan.FromSeconds(_config.Configuration.DummyChapterDuration).Ticks; if (runtime < 0) { @@ -662,13 +661,13 @@ namespace MediaBrowser.Providers.MediaInfo runtime)); } - if (runtime < _dummyChapterDuration) + if (runtime < dummyChapterDuration) { return Array.Empty<ChapterInfo>(); } - // Limit to 100 chapters just in case there's some incorrect metadata here - int chapterCount = (int)Math.Min(runtime / _dummyChapterDuration, 100); + // Limit the chapters just in case there's some incorrect metadata here + int chapterCount = (int)Math.Min(runtime / dummyChapterDuration, _config.Configuration.DummyChapterCount); var chapters = new ChapterInfo[chapterCount]; long currentChapterTicks = 0; @@ -679,7 +678,7 @@ namespace MediaBrowser.Providers.MediaInfo StartPositionTicks = currentChapterTicks }; - currentChapterTicks += _dummyChapterDuration; + currentChapterTicks += dummyChapterDuration; } return chapters; diff --git a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs index 1bc2edfd8..bb2d584c1 100644 --- a/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs +++ b/MediaBrowser.Providers/MediaInfo/MediaInfoResolver.cs @@ -175,12 +175,12 @@ namespace MediaBrowser.Providers.MediaInfo return Array.Empty<ExternalPathParserResult>(); } - var files = directoryService.GetFilePaths(folder, clearCache).ToList(); + var files = directoryService.GetFilePaths(folder, clearCache, true).ToList(); files.Remove(video.Path); var internalMetadataPath = video.GetInternalMetadataPath(); if (_fileSystem.DirectoryExists(internalMetadataPath)) { - files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache)); + files.AddRange(directoryService.GetFilePaths(internalMetadataPath, clearCache, true)); } if (!files.Any()) diff --git a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs index 659136607..75f997a28 100644 --- a/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs +++ b/MediaBrowser.Providers/MediaInfo/ProbeProvider.cs @@ -83,7 +83,7 @@ namespace MediaBrowser.Providers.MediaInfo _audioResolver = new AudioResolver(loggerFactory.CreateLogger<AudioResolver>(), localization, mediaEncoder, fileSystem, namingOptions); _subtitleResolver = new SubtitleResolver(loggerFactory.CreateLogger<SubtitleResolver>(), localization, mediaEncoder, fileSystem, namingOptions); _videoProber = new FFProbeVideoInfo( - _logger, + loggerFactory.CreateLogger<FFProbeVideoInfo>(), mediaSourceManager, mediaEncoder, itemRepo, diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs index 9c27bd7d3..22229e377 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs @@ -1,37 +1,52 @@ -#pragma warning disable CS1591 - using MediaBrowser.Model.Plugins; +using MetaBrainz.MusicBrainz; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; -namespace MediaBrowser.Providers.Plugins.MusicBrainz +/// <summary> +/// MusicBrainz plugin configuration. +/// </summary> +public class PluginConfiguration : BasePluginConfiguration { - public class PluginConfiguration : BasePluginConfiguration - { - private string _server = Plugin.DefaultServer; + private const string DefaultServer = "musicbrainz.org"; - private long _rateLimit = Plugin.DefaultRateLimit; + private const double DefaultRateLimit = 1.0; - public string Server - { - get => _server; - set => _server = value.TrimEnd('/'); - } + private string _server = DefaultServer; + + private double _rateLimit = DefaultRateLimit; + + /// <summary> + /// Gets or sets the server url. + /// </summary> + public string Server + { + get => _server; - public long RateLimit + set => _server = value.TrimEnd('/'); + } + + /// <summary> + /// Gets or sets the rate limit. + /// </summary> + public double RateLimit + { + get => _rateLimit; + set { - get => _rateLimit; - set + if (value < DefaultRateLimit && _server == DefaultServer) { - if (value < Plugin.DefaultRateLimit && _server == Plugin.DefaultServer) - { - _rateLimit = Plugin.DefaultRateLimit; - } - else - { - _rateLimit = value; - } + _rateLimit = DefaultRateLimit; + } + else + { + _rateLimit = value; } } - - public bool ReplaceArtistName { get; set; } } + + /// <summary> + /// Gets or sets a value indicating whether to replace the artist name. + /// </summary> + public bool ReplaceArtistName { get; set; } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs index c54cdda3d..f7850781e 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz album artist external id. +/// </summary> +public class MusicBrainzAlbumArtistExternalId : IExternalId { - public class MusicBrainzAlbumArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbumArtist.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.AlbumArtist; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } + /// <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 8f7fadd06..a9d4472e7 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz album external id. +/// </summary> +public class MusicBrainzAlbumExternalId : IExternalId { - public class MusicBrainzAlbumExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzAlbum.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Album; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Album; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs index 915fb97fd..4d9feca6d 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs @@ -1,805 +1,265 @@ -#nullable disable - -#pragma warning disable CS1591, SA1401 - using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Globalization; -using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; -using System.Xml; -using MediaBrowser.Common.Net; +using Jellyfin.Extensions; using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -using Microsoft.Extensions.Logging; - -namespace MediaBrowser.Providers.Music -{ - public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable - { - /// <summary> - /// For each single MB lookup/search, this is the maximum number of - /// attempts that shall be made whilst receiving a 503 Server - /// Unavailable (indicating throttled) response. - /// </summary> - private const uint MusicBrainzQueryAttempts = 5u; - - /// <summary> - /// The Jellyfin user-agent is unrestricted but source IP must not exceed - /// one request per second, therefore we rate limit to avoid throttling. - /// Be prudent, use a value slightly above the minimum required. - /// https://musicbrainz.org/doc/XML_Web_Service/Rate_Limiting. - /// </summary> - private readonly long _musicBrainzQueryIntervalMs; - - private readonly IHttpClientFactory _httpClientFactory; - private readonly ILogger<MusicBrainzAlbumProvider> _logger; - - private readonly string _musicBrainzBaseUrl; - - private SemaphoreSlim _apiRequestLock = new SemaphoreSlim(1, 1); - private Stopwatch _stopWatchMusicBrainz = new Stopwatch(); - - public MusicBrainzAlbumProvider( - IHttpClientFactory httpClientFactory, - ILogger<MusicBrainzAlbumProvider> logger) - { - _httpClientFactory = httpClientFactory; - _logger = logger; - - _musicBrainzBaseUrl = Plugin.Instance.Configuration.Server; - _musicBrainzQueryIntervalMs = Plugin.Instance.Configuration.RateLimit; - - // Use a stopwatch to ensure we don't exceed the MusicBrainz rate limit - _stopWatchMusicBrainz.Start(); - - Current = this; - } - - internal static MusicBrainzAlbumProvider Current { get; private set; } - - /// <inheritdoc /> - public string Name => "MusicBrainz"; +using MediaBrowser.Providers.Music; +using MetaBrainz.MusicBrainz; +using MetaBrainz.MusicBrainz.Interfaces.Entities; +using MetaBrainz.MusicBrainz.Interfaces.Searches; - /// <inheritdoc /> - public int Order => 0; +namespace MediaBrowser.Providers.Plugins.MusicBrainz; - /// <inheritdoc /> - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) - { - var releaseId = searchInfo.GetReleaseId(); - var releaseGroupId = searchInfo.GetReleaseGroupId(); - - string url; - - if (!string.IsNullOrEmpty(releaseId)) - { - url = "/ws/2/release/?query=reid:" + releaseId.ToString(CultureInfo.InvariantCulture); - } - else if (!string.IsNullOrEmpty(releaseGroupId)) - { - url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); - } - else - { - var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); - - if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) - { - url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND arid:{1}", - WebUtility.UrlEncode(searchInfo.Name), - artistMusicBrainzId); - } - else - { - // I'm sure there is a better way but for now it resolves search for 12" Mixes - var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal); - - url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND artist:\"{1}\"", - WebUtility.UrlEncode(queryName), - WebUtility.UrlEncode(searchInfo.GetAlbumArtist())); - } - } - - if (!string.IsNullOrWhiteSpace(url)) - { - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return GetResultsFromResponse(stream); - } - - return Enumerable.Empty<RemoteSearchResult>(); - } +/// <summary> +/// Music album metadata provider for MusicBrainz. +/// </summary> +public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable +{ + private readonly Query _musicBrainzQuery; - private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) - { - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings() + /// <summary> + /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class. + /// </summary> + public MusicBrainzAlbumProvider() + { + MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) => { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true + Query.DefaultServer = MusicBrainz.Plugin.Instance.Configuration.Server; + Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit; }; - using var reader = XmlReader.Create(oReader, settings); - var results = ReleaseResult.Parse(reader); - - return results.Select(i => - { - var result = new RemoteSearchResult - { - Name = i.Title, - ProductionYear = i.Year - }; + _musicBrainzQuery = new Query(); + } - if (i.Artists.Count > 0) - { - result.AlbumArtist = new RemoteSearchResult - { - SearchProviderName = Name, - Name = i.Artists[0].Item1 - }; + /// <inheritdoc /> + public string Name => "MusicBrainz"; - result.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, i.Artists[0].Item2); - } + /// <inheritdoc /> + public int Order => 0; - if (!string.IsNullOrWhiteSpace(i.ReleaseId)) - { - result.SetProviderId(MetadataProvider.MusicBrainzAlbum, i.ReleaseId); - } - - if (!string.IsNullOrWhiteSpace(i.ReleaseGroupId)) - { - result.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, i.ReleaseGroupId); - } + /// <inheritdoc /> + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken) + { + var releaseId = searchInfo.GetReleaseId(); + var releaseGroupId = searchInfo.GetReleaseGroupId(); - return result; - }); + if (!string.IsNullOrEmpty(releaseId)) + { + var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.ReleaseGroups, cancellationToken).ConfigureAwait(false); + return GetReleaseResult(releaseResult).SingleItemAsEnumerable(); } - /// <inheritdoc /> - public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) + if (!string.IsNullOrEmpty(releaseGroupId)) { - var releaseId = info.GetReleaseId(); - var releaseGroupId = info.GetReleaseGroupId(); - - var result = new MetadataResult<MusicAlbum> - { - Item = new MusicAlbum() - }; - - // If we have a release group Id but not a release Id... - if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) - { - releaseId = await GetReleaseIdFromReleaseGroupId(releaseGroupId, cancellationToken).ConfigureAwait(false); - result.HasMetadata = true; - } - - if (string.IsNullOrWhiteSpace(releaseId)) - { - var artistMusicBrainzId = info.GetMusicBrainzArtistId(); - - var releaseResult = await GetReleaseResult(artistMusicBrainzId, info.GetAlbumArtist(), info.Name, cancellationToken).ConfigureAwait(false); - - if (releaseResult != null) - { - if (!string.IsNullOrWhiteSpace(releaseResult.ReleaseId)) - { - releaseId = releaseResult.ReleaseId; - result.HasMetadata = true; - } - - if (!string.IsNullOrWhiteSpace(releaseResult.ReleaseGroupId)) - { - releaseGroupId = releaseResult.ReleaseGroupId; - result.HasMetadata = true; - } - - result.Item.ProductionYear = releaseResult.Year; - result.Item.Overview = releaseResult.Overview; - } - } + var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + return GetReleaseGroupResult(releaseGroupResult.Releases); + } - // If we have a release Id but not a release group Id... - if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) - { - releaseGroupId = await GetReleaseGroupFromReleaseId(releaseId, cancellationToken).ConfigureAwait(false); - result.HasMetadata = true; - } + var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId(); - if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) - { - result.HasMetadata = true; - } + if (!string.IsNullOrWhiteSpace(artistMusicBrainzId)) + { + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + .ConfigureAwait(false); - if (result.HasMetadata) + if (releaseSearchResults.Results.Count > 0) { - if (!string.IsNullOrEmpty(releaseId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); - } - - if (!string.IsNullOrEmpty(releaseGroupId)) - { - result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); - } + return GetReleaseSearchResult(releaseSearchResults.Results); } - - return result; } - - private Task<ReleaseResult> GetReleaseResult(string artistMusicBrainId, string artistName, string albumName, CancellationToken cancellationToken) + else { - if (!string.IsNullOrEmpty(artistMusicBrainId)) - { - return GetReleaseResult(albumName, artistMusicBrainId, cancellationToken); - } + // I'm sure there is a better way but for now it resolves search for 12" Mixes + var queryName = searchInfo.Name.Replace("\"", string.Empty, StringComparison.Ordinal); - if (string.IsNullOrWhiteSpace(artistName)) + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", null, null, false, cancellationToken) + .ConfigureAwait(false); + + if (releaseSearchResults.Results.Count > 0) { - return Task.FromResult(new ReleaseResult()); + return GetReleaseSearchResult(releaseSearchResults.Results); } - - return GetReleaseResultByArtistName(albumName, artistName, cancellationToken); } - private async Task<ReleaseResult> GetReleaseResult(string albumName, string artistId, CancellationToken cancellationToken) - { - var url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND arid:{1}", - WebUtility.UrlEncode(albumName), - artistId); - - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; + return Enumerable.Empty<RemoteSearchResult>(); + } - using var reader = XmlReader.Create(oReader, settings); - return ReleaseResult.Parse(reader).FirstOrDefault(); + private IEnumerable<RemoteSearchResult> GetReleaseSearchResult(IEnumerable<ISearchResult<IRelease>>? releaseSearchResults) + { + if (releaseSearchResults is null) + { + yield break; } - private async Task<ReleaseResult> GetReleaseResultByArtistName(string albumName, string artistName, CancellationToken cancellationToken) + foreach (var result in releaseSearchResults) { - var url = string.Format( - CultureInfo.InvariantCulture, - "/ws/2/release/?query=\"{0}\" AND artist:\"{1}\"", - WebUtility.UrlEncode(albumName), - WebUtility.UrlEncode(artistName)); - - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings() - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; - - using var reader = XmlReader.Create(oReader, settings); - return ReleaseResult.Parse(reader).FirstOrDefault(); + yield return GetReleaseResult(result.Item); } + } - private static (string Name, string ArtistId) ParseArtistCredit(XmlReader reader) + private IEnumerable<RemoteSearchResult> GetReleaseGroupResult(IEnumerable<IRelease>? releaseSearchResults) + { + if (releaseSearchResults is null) { - reader.MoveToContent(); - reader.Read(); - - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name-credit": - { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - using var subReader = reader.ReadSubtree(); - return ParseArtistNameCredit(subReader); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return default; + yield break; } - private static (string Name, string ArtistId) ParseArtistNameCredit(XmlReader reader) + foreach (var result in releaseSearchResults) { - reader.MoveToContent(); - reader.Read(); - - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "artist": - { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - var id = reader.GetAttribute("id"); - using var subReader = reader.ReadSubtree(); - return ParseArtistArtistCredit(subReader, id); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return (null, null); + yield return GetReleaseResult(result); } + } - private static (string Name, string ArtistId) ParseArtistArtistCredit(XmlReader reader, string artistId) + private RemoteSearchResult GetReleaseResult(IRelease releaseSearchResult) + { + var searchResult = new RemoteSearchResult { - reader.MoveToContent(); - reader.Read(); - - string name = null; + Name = releaseSearchResult.Title, + ProductionYear = releaseSearchResult.Date?.Year, + PremiereDate = releaseSearchResult.Date?.NearestDate + }; - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + if (releaseSearchResult.ArtistCredit?.Count > 0) + { + searchResult.AlbumArtist = new RemoteSearchResult + { + SearchProviderName = Name, + Name = releaseSearchResult.ArtistCredit[0].Name + }; - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (releaseSearchResult.ArtistCredit[0].Artist?.Id is not null) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name": - { - name = reader.ReadElementContentAsString(); - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } + searchResult.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, releaseSearchResult.ArtistCredit[0].Artist!.Id.ToString()); } - - return (name, artistId); } - private async Task<string> GetReleaseIdFromReleaseGroupId(string releaseGroupId, CancellationToken cancellationToken) - { - var url = "/ws/2/release?release-group=" + releaseGroupId.ToString(CultureInfo.InvariantCulture); - - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; - - using var reader = XmlReader.Create(oReader, settings); - var result = ReleaseResult.Parse(reader).FirstOrDefault(); + searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString()); - return result?.ReleaseId; + if (releaseSearchResult.ReleaseGroup?.Id is not null) + { + searchResult.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseSearchResult.ReleaseGroup.Id.ToString()); } - /// <summary> - /// Gets the release group id internal. - /// </summary> - /// <param name="releaseEntryId">The release entry id.</param> - /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task{System.String}.</returns> - private async Task<string> GetReleaseGroupFromReleaseId(string releaseEntryId, CancellationToken cancellationToken) - { - var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture); + return searchResult; + } - using var response = await GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true, - Async = true - }; + /// <inheritdoc /> + public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken) + { + // TODO: This sets essentially nothing. As-is, it's mostly useless. Make it actually pull metadata and use it. + var releaseId = info.GetReleaseId(); + var releaseGroupId = info.GetReleaseGroupId(); - using var reader = XmlReader.Create(oReader, settings); - await reader.MoveToContentAsync().ConfigureAwait(false); - await reader.ReadAsync().ConfigureAwait(false); + var result = new MetadataResult<MusicAlbum> + { + Item = new MusicAlbum() + }; - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + // If there is a release group, but no release ID, try to match the release + if (string.IsNullOrWhiteSpace(releaseId) && !string.IsNullOrWhiteSpace(releaseGroupId)) + { + // TODO: Actually try to match the release. Simply taking the first result is stupid. + var releaseGroup = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.None, null, cancellationToken).ConfigureAwait(false); + var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null; + if (release != null) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-group-list": - { - if (reader.IsEmptyElement) - { - await reader.ReadAsync().ConfigureAwait(false); - continue; - } - - using var subReader = reader.ReadSubtree(); - return GetFirstReleaseGroupId(subReader); - } - - default: - { - await reader.SkipAsync().ConfigureAwait(false); - break; - } - } - } - else - { - await reader.ReadAsync().ConfigureAwait(false); - } + releaseId = release.Id.ToString(); + result.HasMetadata = true; } - - return null; } - private string GetFirstReleaseGroupId(XmlReader reader) + // If there is no release ID, lookup a release with the info we have + if (string.IsNullOrWhiteSpace(releaseId)) { - reader.MoveToContent(); - reader.Read(); + var artistMusicBrainzId = info.GetMusicBrainzArtistId(); + IRelease? releaseResult = null; - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + if (!string.IsNullOrEmpty(artistMusicBrainzId)) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-group": - { - return reader.GetAttribute("id"); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken) + .ConfigureAwait(false); + releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; } - - return null; - } - - /// <summary> - /// Makes request to MusicBrainz server and awaits a response. - /// A 503 Service Unavailable response indicates throttling to maintain a rate limit. - /// A number of retries shall be made in order to try and satisfy the request before - /// giving up and returning null. - /// </summary> - /// <param name="url">Address of MusicBrainz server.</param> - /// <param name="cancellationToken">CancellationToken to use for method.</param> - /// <returns>Returns response from MusicBrainz service.</returns> - internal async Task<HttpResponseMessage> GetMusicBrainzResponse(string url, CancellationToken cancellationToken) - { - await _apiRequestLock.WaitAsync(cancellationToken).ConfigureAwait(false); - - try + else if (!string.IsNullOrEmpty(info.GetAlbumArtist())) { - HttpResponseMessage response; - var attempts = 0u; - var requestUrl = _musicBrainzBaseUrl.TrimEnd('/') + url; + var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{info.Name}\" AND artist:{info.GetAlbumArtist()}", null, null, false, cancellationToken) + .ConfigureAwait(false); + releaseResult = releaseSearchResults.Results.Count > 0 ? releaseSearchResults.Results[0].Item : null; + } - do - { - attempts++; - - if (_stopWatchMusicBrainz.ElapsedMilliseconds < _musicBrainzQueryIntervalMs) - { - // MusicBrainz is extremely adamant about limiting to one request per second. - var delayMs = _musicBrainzQueryIntervalMs - _stopWatchMusicBrainz.ElapsedMilliseconds; - await Task.Delay((int)delayMs, cancellationToken).ConfigureAwait(false); - } - - // Write time since last request to debug log as evidence we're meeting rate limit - // requirement, before resetting stopwatch back to zero. - _logger.LogDebug("GetMusicBrainzResponse: Time since previous request: {0} ms", _stopWatchMusicBrainz.ElapsedMilliseconds); - _stopWatchMusicBrainz.Restart(); - - using var request = new HttpRequestMessage(HttpMethod.Get, requestUrl); - response = await _httpClientFactory - .CreateClient(NamedClient.MusicBrainz) - .SendAsync(request, cancellationToken) - .ConfigureAwait(false); - - // We retry a finite number of times, and only whilst MB is indicating 503 (throttling). - } - while (attempts < MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable); + if (releaseResult != null) + { + releaseId = releaseResult.Id.ToString(); - // Log error if unable to query MB database due to throttling. - if (attempts == MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable) + if (releaseResult.ReleaseGroup?.Id is not null) { - _logger.LogError("GetMusicBrainzResponse: 503 Service Unavailable (throttled) response received {0} times whilst requesting {1}", attempts, requestUrl); + releaseGroupId = releaseResult.ReleaseGroup.Id.ToString(); } - return response; - } - finally - { - _apiRequestLock.Release(); + result.HasMetadata = true; + result.Item.ProductionYear = releaseResult.Date?.Year; + result.Item.Overview = releaseResult.Annotation; } } - /// <inheritdoc /> - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + // If we have a release ID but not a release group ID, lookup the release group + if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId)) { - throw new NotImplementedException(); + var release = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false); + releaseGroupId = release.ReleaseGroup?.Id.ToString(); + result.HasMetadata = true; } - protected virtual void Dispose(bool disposing) + // If we have a release ID and a release group ID + if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId)) { - if (disposing) - { - _apiRequestLock?.Dispose(); - } + result.HasMetadata = true; } - /// <inheritdoc /> - public void Dispose() + if (result.HasMetadata) { - Dispose(true); - GC.SuppressFinalize(this); - } - - private class ReleaseResult - { - public string ReleaseId; - public string ReleaseGroupId; - public string Title; - public string Overview; - public int? Year; - - public List<(string, string)> Artists = new(); - - public static IEnumerable<ReleaseResult> Parse(XmlReader reader) + if (!string.IsNullOrEmpty(releaseId)) { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using var subReader = reader.ReadSubtree(); - return ParseReleaseList(subReader).ToList(); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return Enumerable.Empty<ReleaseResult>(); + result.Item.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseId); } - private static IEnumerable<ReleaseResult> ParseReleaseList(XmlReader reader) + if (!string.IsNullOrEmpty(releaseGroupId)) { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "release": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - var releaseId = reader.GetAttribute("id"); - - using var subReader = reader.ReadSubtree(); - var release = ParseRelease(subReader, releaseId); - if (release != null) - { - yield return release; - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } + result.Item.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseGroupId); } + } - private static ReleaseResult ParseRelease(XmlReader reader, string releaseId) - { - var result = new ReleaseResult - { - ReleaseId = releaseId - }; - - reader.MoveToContent(); - reader.Read(); + return result; + } - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "title": - { - result.Title = reader.ReadElementContentAsString(); - break; - } - - case "date": - { - var val = reader.ReadElementContentAsString(); - if (DateTime.TryParse(val, out var date)) - { - result.Year = date.Year; - } - - break; - } - - case "annotation": - { - result.Overview = reader.ReadElementContentAsString(); - break; - } - - case "release-group": - { - result.ReleaseGroupId = reader.GetAttribute("id"); - reader.Skip(); - break; - } - - case "artist-credit": - { - if (reader.IsEmptyElement) - { - reader.Read(); - break; - } - - using var subReader = reader.ReadSubtree(); - var artist = ParseArtistCredit(subReader); - - if (!string.IsNullOrEmpty(artist.Name)) - { - result.Artists.Add(artist); - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } - return result; - } + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) + { + _musicBrainzQuery.Dispose(); } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs index 941ffea72..b89e67270 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz artist external id. +/// </summary> +public class MusicBrainzArtistExternalId : IExternalId { - public class MusicBrainzArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Artist; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is MusicArtist; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is MusicArtist; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs index 906a42f36..2cc3a13be 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs @@ -1,15 +1,7 @@ -#nullable disable - -#pragma warning disable CS1591 - using System; using System.Collections.Generic; -using System.Globalization; -using System.IO; using System.Linq; -using System.Net; using System.Net.Http; -using System.Text; using System.Threading; using System.Threading.Tasks; using System.Xml; @@ -18,257 +10,152 @@ using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; - -namespace MediaBrowser.Providers.Music -{ - public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo> - { - public string Name => "MusicBrainz"; +using MediaBrowser.Providers.Music; +using MetaBrainz.MusicBrainz; +using MetaBrainz.MusicBrainz.Interfaces.Entities; +using MetaBrainz.MusicBrainz.Interfaces.Searches; - /// <inheritdoc /> - public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) - { - var musicBrainzId = searchInfo.GetMusicBrainzArtistId(); +namespace MediaBrowser.Providers.Plugins.MusicBrainz; - if (!string.IsNullOrWhiteSpace(musicBrainzId)) - { - var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture); +/// <summary> +/// MusicBrainz artist provider. +/// </summary> +public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable +{ + private readonly Query _musicBrainzQuery; - using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return GetResultsFromResponse(stream); - } - else + /// <summary> + /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class. + /// </summary> + public MusicBrainzArtistProvider() + { + MusicBrainz.Plugin.Instance!.ConfigurationChanged += (_, _) => { - // They seem to throw bad request failures on any term with a slash - var nameToSearch = searchInfo.Name.Replace('/', ' '); - - var url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch)); - - using (var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false)) - await using (var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false)) - { - var results = GetResultsFromResponse(stream).ToList(); + Query.DefaultServer = MusicBrainz.Plugin.Instance.Configuration.Server; + Query.DelayBetweenRequests = MusicBrainz.Plugin.Instance.Configuration.RateLimit; + }; - if (results.Count > 0) - { - return results; - } - } + _musicBrainzQuery = new Query(); + } - if (searchInfo.Name.HasDiacritics()) - { - // Try again using the search with accent characters url - url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=artistaccent:\"{0}\"", UrlEncode(nameToSearch)); + /// <inheritdoc /> + public string Name => "MusicBrainz"; - using var response = await MusicBrainzAlbumProvider.Current.GetMusicBrainzResponse(url, cancellationToken).ConfigureAwait(false); - await using var stream = await response.Content.ReadAsStreamAsync(cancellationToken).ConfigureAwait(false); - return GetResultsFromResponse(stream); - } - } + /// <inheritdoc /> + public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken) + { + var artistId = searchInfo.GetMusicBrainzArtistId(); - return Enumerable.Empty<RemoteSearchResult>(); + if (!string.IsNullOrWhiteSpace(artistId)) + { + var artistResult = await _musicBrainzQuery.LookupArtistAsync(new Guid(artistId), Include.Aliases, null, null, cancellationToken).ConfigureAwait(false); + return GetResultFromResponse(artistResult).SingleItemAsEnumerable(); } - private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream) + var artistSearchResults = await _musicBrainzQuery.FindArtistsAsync($"\"{searchInfo.Name}\"", null, null, false, cancellationToken) + .ConfigureAwait(false); + if (artistSearchResults.Results.Count > 0) { - using var oReader = new StreamReader(stream, Encoding.UTF8); - var settings = new XmlReaderSettings() - { - ValidationType = ValidationType.None, - CheckCharacters = false, - IgnoreProcessingInstructions = true, - IgnoreComments = true - }; - - using var reader = XmlReader.Create(oReader, settings); - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "artist-list": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - using var subReader = reader.ReadSubtree(); - return ParseArtistList(subReader).ToList(); - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } - - return Enumerable.Empty<RemoteSearchResult>(); + return GetResultsFromResponse(artistSearchResults.Results); } - private IEnumerable<RemoteSearchResult> ParseArtistList(XmlReader reader) + if (searchInfo.Name.HasDiacritics()) { - reader.MoveToContent(); - reader.Read(); - - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) + // Try again using the search with an accented characters query + var artistAccentsSearchResults = await _musicBrainzQuery.FindArtistsAsync($"artistaccent:\"{searchInfo.Name}\"", null, null, false, cancellationToken) + .ConfigureAwait(false); + if (artistAccentsSearchResults.Results.Count > 0) { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "artist": - { - if (reader.IsEmptyElement) - { - reader.Read(); - continue; - } - - var mbzId = reader.GetAttribute("id"); - - using var subReader = reader.ReadSubtree(); - var artist = ParseArtist(subReader, mbzId); - if (artist != null) - { - yield return artist; - } - - break; - } - - default: - { - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } + return GetResultsFromResponse(artistAccentsSearchResults.Results); } } - private RemoteSearchResult ParseArtist(XmlReader reader, string artistId) - { - var result = new RemoteSearchResult(); - - reader.MoveToContent(); - reader.Read(); + return Enumerable.Empty<RemoteSearchResult>(); + } - // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator + private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults) + { + if (releaseSearchResults is null) + { + yield break; + } - // Loop through each element - while (!reader.EOF && reader.ReadState == ReadState.Interactive) - { - if (reader.NodeType == XmlNodeType.Element) - { - switch (reader.Name) - { - case "name": - { - result.Name = reader.ReadElementContentAsString(); - break; - } + foreach (var result in releaseSearchResults) + { + yield return GetResultFromResponse(result.Item); + } + } - case "annotation": - { - result.Overview = reader.ReadElementContentAsString(); - break; - } + private RemoteSearchResult GetResultFromResponse(IArtist artist) + { + var searchResult = new RemoteSearchResult + { + Name = artist.Name, + ProductionYear = artist.LifeSpan?.Begin?.Year, + PremiereDate = artist.LifeSpan?.Begin?.NearestDate + }; - default: - { - // there is sort-name if ever needed - reader.Skip(); - break; - } - } - } - else - { - reader.Read(); - } - } + searchResult.SetProviderId(MetadataProvider.MusicBrainzArtist, artist.Id.ToString()); - result.SetProviderId(MetadataProvider.MusicBrainzArtist, artistId); + return searchResult; + } - if (string.IsNullOrWhiteSpace(artistId) || string.IsNullOrWhiteSpace(result.Name)) - { - return null; - } + /// <inheritdoc /> + public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + { + var result = new MetadataResult<MusicArtist> { Item = new MusicArtist() }; - return result; - } + var musicBrainzId = info.GetMusicBrainzArtistId(); - /// <inheritdoc /> - public async Task<MetadataResult<MusicArtist>> GetMetadata(ArtistInfo info, CancellationToken cancellationToken) + if (string.IsNullOrWhiteSpace(musicBrainzId)) { - var result = new MetadataResult<MusicArtist> - { - Item = new MusicArtist() - }; + var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); - var musicBrainzId = info.GetMusicBrainzArtistId(); + var singleResult = searchResults.FirstOrDefault(); - if (string.IsNullOrWhiteSpace(musicBrainzId)) + if (singleResult != null) { - var searchResults = await GetSearchResults(info, cancellationToken).ConfigureAwait(false); + musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); + result.Item.Overview = singleResult.Overview; - var singleResult = searchResults.FirstOrDefault(); - - if (singleResult != null) + if (Plugin.Instance!.Configuration.ReplaceArtistName) { - musicBrainzId = singleResult.GetProviderId(MetadataProvider.MusicBrainzArtist); - result.Item.Overview = singleResult.Overview; - - if (Plugin.Instance.Configuration.ReplaceArtistName) - { - result.Item.Name = singleResult.Name; - } + result.Item.Name = singleResult.Name; } } - - if (!string.IsNullOrWhiteSpace(musicBrainzId)) - { - result.HasMetadata = true; - result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); - } - - return result; } - /// <summary> - /// Encodes an URL. - /// </summary> - /// <param name="name">The name.</param> - /// <returns>System.String.</returns> - private static string UrlEncode(string name) + if (!string.IsNullOrWhiteSpace(musicBrainzId)) { - return WebUtility.UrlEncode(name); + result.HasMetadata = true; + result.Item.SetProviderId(MetadataProvider.MusicBrainzArtist, musicBrainzId); } - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + return result; + } + + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + { + throw new NotImplementedException(); + } + + /// <inheritdoc /> + public void Dispose() + { + Dispose(true); + GC.SuppressFinalize(this); + } + + /// <summary> + /// Dispose all resources. + /// </summary> + /// <param name="disposing">Whether to dispose.</param> + protected virtual void Dispose(bool disposing) + { + if (disposing) { - throw new NotImplementedException(); + _musicBrainzQuery.Dispose(); } } } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs index 05db2d98f..fdaa5574f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz other artist external id. +/// </summary> +public class MusicBrainzOtherArtistExternalId : IExternalId { - public class MusicBrainzOtherArtistExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzArtist.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzArtist.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.OtherArtist; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/artist/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/artist/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } + /// <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 acb652fe0..0baab9955 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz release group external id. +/// </summary> +public class MusicBrainzReleaseGroupExternalId : IExternalId { - public class MusicBrainzReleaseGroupExternalId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzReleaseGroup.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.ReleaseGroup; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/release-group/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/release-group/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio || item is MusicAlbum; - } + /// <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 14805b9b7..5c974c411 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs @@ -1,28 +1,27 @@ -#pragma warning disable CS1591 - using MediaBrowser.Controller.Entities.Audio; using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Music +namespace MediaBrowser.Providers.Plugins.MusicBrainz; + +/// <summary> +/// MusicBrainz track id. +/// </summary> +public class MusicBrainzTrackId : IExternalId { - public class MusicBrainzTrackId : IExternalId - { - /// <inheritdoc /> - public string ProviderName => "MusicBrainz"; + /// <inheritdoc /> + public string ProviderName => "MusicBrainz"; - /// <inheritdoc /> - public string Key => MetadataProvider.MusicBrainzTrack.ToString(); + /// <inheritdoc /> + public string Key => MetadataProvider.MusicBrainzTrack.ToString(); - /// <inheritdoc /> - public ExternalIdMediaType? Type => ExternalIdMediaType.Track; + /// <inheritdoc /> + public ExternalIdMediaType? Type => ExternalIdMediaType.Track; - /// <inheritdoc /> - public string? UrlFormatString => Plugin.Instance.Configuration.Server + "/track/{0}"; + /// <inheritdoc /> + public string? UrlFormatString => Plugin.Instance!.Configuration.Server + "/track/{0}"; - /// <inheritdoc /> - public bool Supports(IHasProviderIds item) => item is Audio; - } + /// <inheritdoc /> + public bool Supports(IHasProviderIds item) => item is Audio; } diff --git a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs index cfa10dd64..39cfd727f 100644 --- a/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs @@ -1,45 +1,64 @@ -#nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; +using System.Net.Http.Headers; +using MediaBrowser.Common; using MediaBrowser.Common.Configuration; using MediaBrowser.Common.Plugins; using MediaBrowser.Model.Plugins; using MediaBrowser.Model.Serialization; +using MediaBrowser.Providers.Plugins.MusicBrainz.Configuration; +using MetaBrainz.MusicBrainz; + +namespace MediaBrowser.Providers.Plugins.MusicBrainz; -namespace MediaBrowser.Providers.Plugins.MusicBrainz +/// <summary> +/// Plugin instance. +/// </summary> +public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { - public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages + /// <summary> + /// Initializes a new instance of the <see cref="Plugin"/> class. + /// </summary> + /// <param name="applicationPaths">Instance of the <see cref="IApplicationPaths"/> interface.</param> + /// <param name="xmlSerializer">Instance of the <see cref="IXmlSerializer"/> interface.</param> + /// <param name="applicationHost">Instance of the <see cref="IApplicationHost"/> interface.</param> + public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer, IApplicationHost applicationHost) + : base(applicationPaths, xmlSerializer) { - public const string DefaultServer = "https://musicbrainz.org"; - - public const long DefaultRateLimit = 2000u; + Instance = this; - public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) - : base(applicationPaths, xmlSerializer) - { - Instance = this; - } + // TODO: Change this to "JellyfinMusicBrainzPlugin" once we take it out of the server repo. + Query.DefaultUserAgent.Add(new ProductInfoHeaderValue(applicationHost.Name.Replace(' ', '-'), applicationHost.ApplicationVersionString)); + Query.DefaultUserAgent.Add(new ProductInfoHeaderValue($"({applicationHost.ApplicationUserAgentAddress})")); + Query.DelayBetweenRequests = Instance.Configuration.RateLimit; + Query.DefaultServer = Instance.Configuration.Server; + } - public static Plugin Instance { get; private set; } + /// <summary> + /// Gets the current plugin instance. + /// </summary> + public static Plugin? Instance { get; private set; } - public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); + /// <inheritdoc /> + public override Guid Id => new Guid("8c95c4d2-e50c-4fb0-a4f3-6c06ff0f9a1a"); - public override string Name => "MusicBrainz"; + /// <inheritdoc /> + public override string Name => "MusicBrainz"; - public override string Description => "Get artist and album metadata from any MusicBrainz server."; + /// <inheritdoc /> + public override string Description => "Get artist and album metadata from any MusicBrainz server."; - // TODO remove when plugin removed from server. - public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; + /// <inheritdoc /> + // TODO remove when plugin removed from server. + public override string ConfigurationFileName => "Jellyfin.Plugin.MusicBrainz.xml"; - public IEnumerable<PluginPageInfo> GetPages() + /// <inheritdoc /> + public IEnumerable<PluginPageInfo> GetPages() + { + yield return new PluginPageInfo { - yield return new PluginPageInfo - { - Name = Name, - EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" - }; - } + Name = Name, + EmbeddedResourcePath = GetType().Namespace + ".Configuration.config.html" + }; } } diff --git a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs index 12ea2d55b..10077e5c8 100644 --- a/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs +++ b/MediaBrowser.Providers/Plugins/Omdb/OmdbProvider.cs @@ -408,10 +408,7 @@ namespace MediaBrowser.Providers.Plugins.Omdb } } - if (isEnglishRequested) - { - item.Overview = result.Plot; - } + item.Overview = result.Plot; if (!Plugin.Instance.Configuration.CastAndCrew) { diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs index cb422ef3d..0bfab9824 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/Configuration/PluginConfiguration.cs @@ -1,13 +1,17 @@ -#pragma warning disable CS1591 - -using MediaBrowser.Model.Plugins; +using MediaBrowser.Model.Plugins; namespace MediaBrowser.Providers.Plugins.StudioImages.Configuration { + /// <summary> + /// Plugin configuration class for the studio image provider. + /// </summary> public class PluginConfiguration : BasePluginConfiguration { private string _repository = Plugin.DefaultServer; + /// <summary> + /// Gets or sets the studio image repository URL. + /// </summary> public string RepositoryUrl { get diff --git a/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs b/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs index 5e653d039..78150153a 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/Plugin.cs @@ -1,5 +1,4 @@ #nullable disable -#pragma warning disable CS1591 using System; using System.Collections.Generic; @@ -11,27 +10,47 @@ using MediaBrowser.Providers.Plugins.StudioImages.Configuration; namespace MediaBrowser.Providers.Plugins.StudioImages { + /// <summary> + /// Artwork Plugin class. + /// </summary> public class Plugin : BasePlugin<PluginConfiguration>, IHasWebPages { + /// <summary> + /// Artwork repository URL. + /// </summary> public const string DefaultServer = "https://raw.github.com/jellyfin/emby-artwork/master/studios"; + /// <summary> + /// Initializes a new instance of the <see cref="Plugin"/> class. + /// </summary> + /// <param name="applicationPaths">application paths.</param> + /// <param name="xmlSerializer">xml serializer.</param> public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer) : base(applicationPaths, xmlSerializer) { Instance = this; } + /// <summary> + /// Gets the instance of Artwork plugin. + /// </summary> public static Plugin Instance { get; private set; } + /// <inheritdoc/> public override Guid Id => new Guid("872a7849-1171-458d-a6fb-3de3d442ad30"); + /// <inheritdoc/> public override string Name => "Studio Images"; + /// <inheritdoc/> public override string Description => "Get artwork for studios from any Jellyfin-compatible repository."; // TODO remove when plugin removed from server. + + /// <inheritdoc/> public override string ConfigurationFileName => "Jellyfin.Plugin.StudioImages.xml"; + /// <inheritdoc/> public IEnumerable<PluginPageInfo> GetPages() { yield return new PluginPageInfo diff --git a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs index ef822a22a..ffbb338e8 100644 --- a/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/StudioImages/StudiosImageProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -21,12 +19,21 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.StudioImages { + /// <summary> + /// Studio image provider. + /// </summary> public class StudiosImageProvider : IRemoteImageProvider { private readonly IServerConfigurationManager _config; private readonly IHttpClientFactory _httpClientFactory; private readonly IFileSystem _fileSystem; + /// <summary> + /// Initializes a new instance of the <see cref="StudiosImageProvider"/> class. + /// </summary> + /// <param name="config">The <see cref="IServerConfigurationManager"/>.</param> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="fileSystem">The <see cref="IFileSystem"/>.</param> public StudiosImageProvider(IServerConfigurationManager config, IHttpClientFactory httpClientFactory, IFileSystem fileSystem) { _config = config; @@ -34,13 +41,16 @@ namespace MediaBrowser.Providers.Plugins.StudioImages _fileSystem = fileSystem; } + /// <inheritdoc /> public string Name => "Artwork Repository"; + /// <inheritdoc /> public bool Supports(BaseItem item) { return item is Studio; } + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new List<ImageType> @@ -49,6 +59,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var thumbsPath = Path.Combine(_config.ApplicationPaths.CachePath, "imagesbyname", "remotestudiothumbs.txt"); @@ -103,6 +114,7 @@ namespace MediaBrowser.Providers.Plugins.StudioImages return EnsureList(url, file, _fileSystem, cancellationToken); } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { var httpClient = _httpClientFactory.CreateClient(NamedClient.Default); @@ -110,13 +122,13 @@ namespace MediaBrowser.Providers.Plugins.StudioImages } /// <summary> - /// Ensures the list. + /// Ensures the existence of a file listing. /// </summary> /// <param name="url">The URL.</param> /// <param name="file">The file.</param> /// <param name="fileSystem">The file system.</param> /// <param name="cancellationToken">The cancellation token.</param> - /// <returns>Task.</returns> + /// <returns>A Task to ensure existence of a file listing.</returns> public async Task<string> EnsureList(string url, string file, IFileSystem fileSystem, CancellationToken cancellationToken) { var fileInfo = fileSystem.GetFileInfo(file); @@ -134,6 +146,12 @@ namespace MediaBrowser.Providers.Plugins.StudioImages return file; } + /// <summary> + /// Get matching image for an item. + /// </summary> + /// <param name="item">The <see cref="BaseItem"/>.</param> + /// <param name="images">The enumerable of image strings.</param> + /// <returns>The matching image string.</returns> public string FindMatch(BaseItem item, IEnumerable<string> images) { var name = GetComparableName(item.Name); @@ -151,6 +169,11 @@ namespace MediaBrowser.Providers.Plugins.StudioImages .Replace("/", string.Empty, StringComparison.Ordinal); } + /// <summary> + /// Get available image strings for a file. + /// </summary> + /// <param name="file">The file.</param> + /// <returns>All images strings of a file.</returns> public IEnumerable<string> GetAvailableImages(string file) { using var fileStream = File.OpenRead(file); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs index 0bab7c3ca..ac3df1d5d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Api/TmdbController.cs @@ -8,7 +8,7 @@ using TMDbLib.Objects.General; namespace MediaBrowser.Providers.Plugins.Tmdb.Api { /// <summary> - /// The TMDb api controller. + /// The TMDb API controller. /// </summary> [ApiController] [Authorize(Policy = "DefaultAuthorization")] diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs index 3217ac2f1..0e768bb83 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetExternalId.cs @@ -7,7 +7,7 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets { /// <summary> - /// External ID for a TMDB box set. + /// External id for a TMDb box set. /// </summary> public class TmdbBoxSetExternalId : IExternalId { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs index 29a557c31..ef878e670 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetImageProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -18,26 +16,38 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets { + /// <summary> + /// BoxSet image provider powered by TMDb. + /// </summary> public class TmdbBoxSetImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbBoxSetImageProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbBoxSetImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> public int Order => 0; + /// <inheritdoc /> public bool Supports(BaseItem item) { return item is BoxSet; } + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new List<ImageType> @@ -47,6 +57,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var tmdbId = Convert.ToInt32(item.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); @@ -76,6 +87,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return remoteImages; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs index 62bc9c65f..90f2aa88f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/BoxSets/TmdbBoxSetProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -18,12 +16,21 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets { + /// <summary> + /// BoxSet provider powered by TMDb. + /// </summary> public class TmdbBoxSetProvider : IRemoteMetadataProvider<BoxSet, BoxSetInfo> { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; private readonly ILibraryManager _libraryManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbBoxSetProvider"/> class. + /// </summary> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbBoxSetProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager, ILibraryManager libraryManager) { _httpClientFactory = httpClientFactory; @@ -31,8 +38,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets _libraryManager = libraryManager; } + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(BoxSetInfo searchInfo, CancellationToken cancellationToken) { var tmdbId = Convert.ToInt32(searchInfo.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); @@ -81,6 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return collections; } + /// <inheritdoc /> public async Task<MetadataResult<BoxSet>> GetMetadata(BoxSetInfo info, CancellationToken cancellationToken) { var tmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); @@ -124,6 +134,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.BoxSets return result; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs index 31310a8d4..38d2c5c69 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieExternalId.cs @@ -7,7 +7,7 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.Movies { /// <summary> - /// External ID for a TMBD movie. + /// External id for a TMDb movie. /// </summary> public class TmdbMovieExternalId : IExternalId { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs index 16f0089f8..1646a93d2 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieImageProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -19,26 +17,38 @@ using TMDbLib.Objects.Find; namespace MediaBrowser.Providers.Plugins.Tmdb.Movies { + /// <summary> + /// Movie image provider powered by TMDb. + /// </summary> public class TmdbMovieImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMovieImageProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbMovieImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc /> public int Order => 0; + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> public bool Supports(BaseItem item) { return item is Movie || item is Trailer; } + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new List<ImageType> @@ -49,6 +59,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var language = item.GetPreferredMetadataLanguage(); @@ -96,6 +107,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return remoteImages; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs index f14f31858..dd2d5d97d 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/Movies/TmdbMovieProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -23,7 +21,7 @@ using TMDbLib.Objects.Search; namespace MediaBrowser.Providers.Plugins.Tmdb.Movies { /// <summary> - /// Class MovieDbProvider. + /// Movie provider powered by TMDb. /// </summary> public class TmdbMovieProvider : IRemoteMetadataProvider<Movie, MovieInfo>, IHasOrder { @@ -31,6 +29,12 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies private readonly ILibraryManager _libraryManager; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbMovieProvider"/> class. + /// </summary> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbMovieProvider( ILibraryManager libraryManager, TmdbClientManager tmdbClientManager, @@ -41,11 +45,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies _httpClientFactory = httpClientFactory; } - public string Name => TmdbUtils.ProviderName; - /// <inheritdoc /> public int Order => 1; + /// <inheritdoc /> + public string Name => TmdbUtils.ProviderName; + + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(MovieInfo searchInfo, CancellationToken cancellationToken) { if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var id)) @@ -133,6 +139,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies return remoteSearchResults; } + /// <inheritdoc /> public async Task<MetadataResult<Movie>> GetMetadata(MovieInfo info, CancellationToken cancellationToken) { var tmdbId = info.GetProviderId(MetadataProvider.Tmdb); @@ -144,7 +151,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.Movies // Caller provides the filename with extension stripped and NOT the parsed filename var parsedName = _libraryManager.ParseName(info.Name); var cleanedName = TmdbUtils.CleanName(parsedName.Name); - var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); + var searchResults = await _tmdbClientManager.SearchMovieAsync(cleanedName, info.Year ?? parsedName.Year ?? 0, info.MetadataLanguage, cancellationToken).ConfigureAwait(false); if (searchResults.Count > 0) { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs index 9804d60bd..027399aec 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonExternalId.cs @@ -6,7 +6,7 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.People { /// <summary> - /// External ID for a TMDB person. + /// External id for a TMDb person. /// </summary> public class TmdbPersonExternalId : IExternalId { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs index 7ce4cfe67..d7f5c99dd 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonImageProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System.Collections.Generic; using System.Globalization; using System.Linq; @@ -14,11 +12,19 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.People { + /// <summary> + /// Person image provider powered by TMDb. + /// </summary> public class TmdbPersonImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbPersonImageProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbPersonImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; @@ -31,11 +37,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People /// <inheritdoc /> public int Order => 0; + /// <inheritdoc /> public bool Supports(BaseItem item) { return item is Person; } + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new List<ImageType> @@ -44,6 +52,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var person = (Person)item; @@ -68,6 +77,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return remoteImages; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs index 8790e3759..d760ad142 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/People/TmdbPersonProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -16,19 +14,29 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.People { + /// <summary> + /// Person image provider powered by TMDb. + /// </summary> public class TmdbPersonProvider : IRemoteMetadataProvider<Person, PersonLookupInfo> { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbPersonProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbPersonProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(PersonLookupInfo searchInfo, CancellationToken cancellationToken) { if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var personTmdbId)) @@ -79,6 +87,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return remoteSearchResults; } + /// <inheritdoc /> public async Task<MetadataResult<Person>> GetMetadata(PersonLookupInfo info, CancellationToken cancellationToken) { var personTmdbId = Convert.ToInt32(info.GetProviderId(MetadataProvider.Tmdb), CultureInfo.InvariantCulture); @@ -131,6 +140,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.People return result; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs index 5eec776b5..943a3a75b 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeImageProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -17,22 +15,38 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { + /// <summary> + /// TV episode image provider powered by TheMovieDb. + /// </summary> public class TmdbEpisodeImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbEpisodeImageProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbEpisodeImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } - // After TheTvDb + /// <inheritdoc /> public int Order => 1; + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> + public bool Supports(BaseItem item) + { + return item is Controller.Entities.TV.Episode; + } + + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new List<ImageType> @@ -41,6 +55,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var episode = (Controller.Entities.TV.Episode)item; @@ -81,14 +96,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return remoteImages; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); } - - public bool Supports(BaseItem item) - { - return item is Controller.Entities.TV.Episode; - } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs index f50f15877..e20284e6f 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbEpisodeProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -19,22 +17,32 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { + /// <summary> + /// TV episode provider powered by TheMovieDb. + /// </summary> public class TmdbEpisodeProvider : IRemoteMetadataProvider<Episode, EpisodeInfo>, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbEpisodeProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbEpisodeProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } - // After TheTvDb + /// <inheritdoc /> public int Order => 1; + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(EpisodeInfo searchInfo, CancellationToken cancellationToken) { // The search query must either provide an episode number or date @@ -68,6 +76,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV }; } + /// <inheritdoc /> public async Task<MetadataResult<Episode>> GetMetadata(EpisodeInfo info, CancellationToken cancellationToken) { var metadataResult = new MetadataResult<Episode>(); @@ -209,6 +218,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return metadataResult; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs index 4446fa966..da32ea408 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonImageProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -16,26 +14,47 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { + /// <summary> + /// TV season image provider powered by TheMovieDb. + /// </summary> public class TmdbSeasonImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbSeasonImageProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbSeasonImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc/> public int Order => 1; + /// <inheritdoc/> public string Name => TmdbUtils.ProviderName; - public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) + /// <inheritdoc /> + public bool Supports(BaseItem item) { - return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); + return item is Season; + } + + /// <inheritdoc /> + public IEnumerable<ImageType> GetSupportedImages(BaseItem item) + { + return new List<ImageType> + { + ImageType.Primary + }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var season = (Season)item; @@ -68,17 +87,10 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return remoteImages; } - public IEnumerable<ImageType> GetSupportedImages(BaseItem item) - { - return new List<ImageType> - { - ImageType.Primary - }; - } - - public bool Supports(BaseItem item) + /// <inheritdoc /> + public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { - return item is Season; + return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); } } } diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs index 64ed3f408..2cf0f399e 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeasonProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -17,19 +15,29 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { + /// <summary> + /// TV season provider powered by TheMovieDb. + /// </summary> public class TmdbSeasonProvider : IRemoteMetadataProvider<Season, SeasonInfo> { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbSeasonProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbSeasonProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; + /// <inheritdoc /> public async Task<MetadataResult<Season>> GetMetadata(SeasonInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Season>(); @@ -114,11 +122,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return result; } + /// <inheritdoc /> public Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeasonInfo searchInfo, CancellationToken cancellationToken) { return Task.FromResult(Enumerable.Empty<RemoteSearchResult>()); } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs index 8a2be80cd..df04cb2e7 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesExternalId.cs @@ -6,7 +6,7 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { /// <summary> - /// External ID for a TMDB series. + /// External id for a TMDb series. /// </summary> public class TmdbSeriesExternalId : IExternalId { diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs index 130d6ce44..e96b680b4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesImageProvider.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -16,27 +14,38 @@ using MediaBrowser.Model.Providers; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { + /// <summary> + /// TV series image provider powered by TheMovieDb. + /// </summary> public class TmdbSeriesImageProvider : IRemoteImageProvider, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbSeriesImageProvider"/> class. + /// </summary> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbSeriesImageProvider(IHttpClientFactory httpClientFactory, TmdbClientManager tmdbClientManager) { _httpClientFactory = httpClientFactory; _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; - // After tvdb and fanart + /// <inheritdoc /> public int Order => 2; + /// <inheritdoc /> public bool Supports(BaseItem item) { return item is Series; } + /// <inheritdoc /> public IEnumerable<ImageType> GetSupportedImages(BaseItem item) { return new List<ImageType> @@ -47,6 +56,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV }; } + /// <inheritdoc /> public async Task<IEnumerable<RemoteImageInfo>> GetImages(BaseItem item, CancellationToken cancellationToken) { var tmdbId = item.GetProviderId(MetadataProvider.Tmdb); @@ -80,6 +90,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return remoteImages; } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs index 4d26052fa..4e8fdf0ee 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TV/TmdbSeriesProvider.cs @@ -1,7 +1,5 @@ #nullable disable -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -23,12 +21,21 @@ using TMDbLib.Objects.TvShows; namespace MediaBrowser.Providers.Plugins.Tmdb.TV { + /// <summary> + /// TV series provider powered by TheMovieDb. + /// </summary> public class TmdbSeriesProvider : IRemoteMetadataProvider<Series, SeriesInfo>, IHasOrder { private readonly IHttpClientFactory _httpClientFactory; private readonly ILibraryManager _libraryManager; private readonly TmdbClientManager _tmdbClientManager; + /// <summary> + /// Initializes a new instance of the <see cref="TmdbSeriesProvider"/> class. + /// </summary> + /// <param name="libraryManager">The <see cref="ILibraryManager"/>.</param> + /// <param name="httpClientFactory">The <see cref="IHttpClientFactory"/>.</param> + /// <param name="tmdbClientManager">The <see cref="TmdbClientManager"/>.</param> public TmdbSeriesProvider( ILibraryManager libraryManager, IHttpClientFactory httpClientFactory, @@ -39,11 +46,13 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV _tmdbClientManager = tmdbClientManager; } + /// <inheritdoc /> public string Name => TmdbUtils.ProviderName; - // After TheTVDB + /// <inheritdoc /> public int Order => 1; + /// <inheritdoc /> public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(SeriesInfo searchInfo, CancellationToken cancellationToken) { if (searchInfo.TryGetProviderId(MetadataProvider.Tmdb, out var tmdbId)) @@ -159,6 +168,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV return remoteResult; } + /// <inheritdoc /> public async Task<MetadataResult<Series>> GetMetadata(SeriesInfo info, CancellationToken cancellationToken) { var result = new MetadataResult<Series> @@ -383,6 +393,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb.TV } } + /// <inheritdoc /> public Task<HttpResponseMessage> GetImageResponse(string url, CancellationToken cancellationToken) { return _httpClientFactory.CreateClient(NamedClient.Default).GetAsync(url, cancellationToken); diff --git a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs index 685eb222f..44c2c81f4 100644 --- a/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs +++ b/MediaBrowser.Providers/Plugins/Tmdb/TmdbUtils.cs @@ -14,7 +14,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb private static readonly Regex _nonWords = new(@"[\W_]+", RegexOptions.Compiled); /// <summary> - /// URL of the TMDB instance to use. + /// URL of the TMDb instance to use. /// </summary> public const string BaseTmdbUrl = "https://www.themoviedb.org/"; @@ -50,7 +50,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Maps the TMDB provided roles for crew members to Jellyfin roles. + /// Maps the TMDb provided roles for crew members to Jellyfin roles. /// </summary> /// <param name="crew">Crew member to map against the Jellyfin person types.</param> /// <returns>The Jellyfin person type.</returns> @@ -103,9 +103,9 @@ namespace MediaBrowser.Providers.Plugins.Tmdb languages.Add(preferredLanguage); - if (preferredLanguage.Length == 5) // like en-US + if (preferredLanguage.Length == 5) // Like en-US { - // Currently, TMDB supports 2-letter language codes only + // Currently, TMDb supports 2-letter language codes only. // They are planning to change this in the future, thus we're // supplying both codes if we're having a 5-letter code. languages.Add(preferredLanguage.Substring(0, 2)); @@ -114,6 +114,7 @@ namespace MediaBrowser.Providers.Plugins.Tmdb languages.Add("null"); + // Always add English as fallback language if (!string.Equals(preferredLanguage, "en", StringComparison.OrdinalIgnoreCase)) { languages.Add("en"); @@ -134,14 +135,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb return language; } - // They require this to be uppercase - // Everything after the hyphen must be written in uppercase due to a way TMDB wrote their api. + // TMDb requires this to be uppercase + // Everything after the hyphen must be written in uppercase due to a way TMDb wrote their API. // See here: https://www.themoviedb.org/talk/5119221d760ee36c642af4ad?page=3#56e372a0c3a3685a9e0019ab var parts = language.Split('-'); if (parts.Length == 2) { - // TMDB doesn't support Switzerland (de-CH, it-CH or fr-CH) so use the language (de, it or fr) without country code + // TMDb doesn't support Switzerland (de-CH, it-CH or fr-CH) so use the language (de, it or fr) without country code if (string.Equals(parts[1], "CH", StringComparison.OrdinalIgnoreCase)) { return parts[0]; @@ -174,14 +175,14 @@ namespace MediaBrowser.Providers.Plugins.Tmdb } /// <summary> - /// Combines the metadata country code and the parental rating from the Api into the value we store in our database. + /// Combines the metadata country code and the parental rating from the API into the value we store in our database. /// </summary> - /// <param name="countryCode">The Iso 3166-1 country code of the rating country.</param> - /// <param name="ratingValue">The rating value returned by the Tmdb Api.</param> + /// <param name="countryCode">The ISO 3166-1 country code of the rating country.</param> + /// <param name="ratingValue">The rating value returned by the TMDb API.</param> /// <returns>The combined parental rating of country code+rating value.</returns> public static string BuildParentalRating(string countryCode, string ratingValue) { - // exclude US because we store us values as TV-14 without the country code. + // Exclude US because we store US values as TV-14 without the country code. var ratingPrefix = string.Equals(countryCode, "US", StringComparison.OrdinalIgnoreCase) ? string.Empty : countryCode + "-"; var newRating = ratingPrefix + ratingValue; diff --git a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs index da348239a..9e197e737 100644 --- a/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs +++ b/MediaBrowser.XbmcMetadata/Parsers/BaseNfoParser.cs @@ -1,5 +1,3 @@ -#pragma warning disable CS1591 - using System; using System.Collections.Generic; using System.Globalization; @@ -23,6 +21,10 @@ using Microsoft.Extensions.Logging; namespace MediaBrowser.XbmcMetadata.Parsers { + /// <summary> + /// The BaseNfoParser class. + /// </summary> + /// <typeparam name="T">The type.</typeparam> public class BaseNfoParser<T> where T : BaseItem { @@ -63,16 +65,22 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// </summary> protected ILogger Logger { get; } + /// <summary> + /// Gets the provider manager. + /// </summary> protected IProviderManager ProviderManager { get; } + /// <summary> + /// Gets a value indicating whether URLs after a closing XML tag are supporrted. + /// </summary> protected virtual bool SupportsUrlAfterClosingXmlTag => false; /// <summary> /// Fetches metadata for an item from one xml file. /// </summary> - /// <param name="item">The item.</param> + /// <param name="item">The <see cref="MetadataResult{T}"/>.</param> /// <param name="metadataFile">The metadata file.</param> - /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> /// <exception cref="ArgumentNullException"><c>item</c> is <c>null</c>.</exception> /// <exception cref="ArgumentException"><c>metadataFile</c> is <c>null</c> or empty.</exception> public void Fetch(MetadataResult<T> item, string metadataFile, CancellationToken cancellationToken) @@ -111,10 +119,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers /// <summary> /// Fetches the specified item. /// </summary> - /// <param name="item">The item.</param> + /// <param name="item">The <see cref="MetadataResult{T}"/>.</param> /// <param name="metadataFile">The metadata file.</param> - /// <param name="settings">The settings.</param> - /// <param name="cancellationToken">The cancellation token.</param> + /// <param name="settings">The <see cref="XmlReaderSettings"/>.</param> + /// <param name="cancellationToken">The <see cref="CancellationToken"/>.</param> protected virtual void Fetch(MetadataResult<T> item, string metadataFile, XmlReaderSettings settings, CancellationToken cancellationToken) { if (!SupportsUrlAfterClosingXmlTag) @@ -170,7 +178,7 @@ namespace MediaBrowser.XbmcMetadata.Parsers ParseProviderLinks(item.Item, endingXml); - // If the file is just an imdb url, don't go any further + // If the file is just an IMDb url, don't go any further if (index == 0) { return; @@ -216,6 +224,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers } } + /// <summary> + /// Parses a XML tag to a provider id. + /// </summary> + /// <param name="item">The item.</param> + /// <param name="xml">The xml tag.</param> protected void ParseProviderLinks(T item, ReadOnlySpan<char> xml) { if (ProviderIdParsers.TryFindImdbId(xml, out var imdbId)) @@ -245,6 +258,11 @@ namespace MediaBrowser.XbmcMetadata.Parsers } } + /// <summary> + /// Fetches metadata from an XML node. + /// </summary> + /// <param name="reader">The <see cref="XmlReader"/>.</param> + /// <param name="itemResult">The <see cref="MetadataResult{T}"/>.</param> protected virtual void FetchDataFromXmlNode(XmlReader reader, MetadataResult<T> itemResult) { var item = itemResult.Item; @@ -1100,17 +1118,14 @@ namespace MediaBrowser.XbmcMetadata.Parsers switch (reader.Name) { case "language": + _ = reader.ReadElementContentAsString(); + if (item is Video video) { - _ = reader.ReadElementContentAsString(); - - if (item is Video video) - { - video.HasSubtitles = true; - } - - break; + video.HasSubtitles = true; } + break; + default: reader.Skip(); break; @@ -1136,20 +1151,20 @@ namespace MediaBrowser.XbmcMetadata.Parsers switch (reader.Name) { case "rating": - { - if (reader.IsEmptyElement) { - reader.Read(); - continue; - } + if (reader.IsEmptyElement) + { + reader.Read(); + continue; + } - var ratingName = reader.GetAttribute("name"); + var ratingName = reader.GetAttribute("name"); - using var subtree = reader.ReadSubtree(); - FetchFromRatingNode(subtree, item, ratingName); + using var subtree = reader.ReadSubtree(); + FetchFromRatingNode(subtree, item, ratingName); - break; - } + break; + } default: reader.Skip(); @@ -1210,9 +1225,9 @@ namespace MediaBrowser.XbmcMetadata.Parsers } /// <summary> - /// Gets the persons from XML node. + /// Gets the persons from a XML node. /// </summary> - /// <param name="reader">The reader.</param> + /// <param name="reader">The <see cref="XmlReader"/>.</param> /// <returns>IEnumerable{PersonInfo}.</returns> private PersonInfo GetPersonFromXmlNode(XmlReader reader) { @@ -1348,10 +1363,10 @@ namespace MediaBrowser.XbmcMetadata.Parsers } /// <summary> - /// Parses the ImageType from the nfo aspect property. + /// Parses the <see cref="ImageType"/> from the NFO aspect property. /// </summary> - /// <param name="aspect">The nfo aspect property.</param> - /// <returns>The image type.</returns> + /// <param name="aspect">The NFO aspect property.</param> + /// <returns>The <see cref="ImageType"/>.</returns> private static ImageType GetImageType(string aspect) { return aspect switch diff --git a/deployment/Dockerfile.centos.amd64 b/deployment/Dockerfile.centos.amd64 index 0bae42bc8..fcb880283 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/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/1d2007d3-da35-48ad-80cc-a39cbc726908/1f3555baa8b14c3327bb4eaa570d7d07/dotnet-sdk-6.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.fedora.amd64 b/deployment/Dockerfile.fedora.amd64 index 20aa777b6..c18db7213 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/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/1d2007d3-da35-48ad-80cc-a39cbc726908/1f3555baa8b14c3327bb4eaa570d7d07/dotnet-sdk-6.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.amd64 b/deployment/Dockerfile.ubuntu.amd64 index ccc0f76cd..01402184a 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/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/1d2007d3-da35-48ad-80cc-a39cbc726908/1f3555baa8b14c3327bb4eaa570d7d07/dotnet-sdk-6.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.arm64 b/deployment/Dockerfile.ubuntu.arm64 index 893180974..6af22eed9 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/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/1d2007d3-da35-48ad-80cc-a39cbc726908/1f3555baa8b14c3327bb4eaa570d7d07/dotnet-sdk-6.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/deployment/Dockerfile.ubuntu.armhf b/deployment/Dockerfile.ubuntu.armhf index bf1edf777..a7e70a35a 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/8159607a-e686-4ead-ac99-b4c97290a5fd/ec6070b1b2cc0651ebe57cf1bd411315/dotnet-sdk-6.0.401-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ +RUN wget -q https://download.visualstudio.microsoft.com/download/pr/1d2007d3-da35-48ad-80cc-a39cbc726908/1f3555baa8b14c3327bb4eaa570d7d07/dotnet-sdk-6.0.403-linux-x64.tar.gz -O dotnet-sdk.tar.gz \ && mkdir -p dotnet-sdk \ && tar -xzf dotnet-sdk.tar.gz -C dotnet-sdk \ && ln -s $( pwd )/dotnet-sdk/dotnet /usr/bin/dotnet diff --git a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj index 7cd98c29a..e6196e847 100644 --- a/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj +++ b/fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj @@ -18,8 +18,8 @@ <ItemGroup> <PackageReference Include="AutoFixture" Version="4.17.0" /> <PackageReference Include="AutoFixture.AutoMoq" Version="4.17.0" /> - <PackageReference Include="Moq" Version="4.16.1" /> - <PackageReference Include="SharpFuzz" Version="1.6.2" /> + <PackageReference Include="Moq" Version="4.18.2" /> + <PackageReference Include="SharpFuzz" Version="2.0.0" /> </ItemGroup> </Project> diff --git a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj index facd8b7bb..6ffc17ff9 100644 --- a/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj +++ b/fuzz/Jellyfin.Server.Fuzz/Jellyfin.Server.Fuzz.csproj @@ -16,7 +16,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="SharpFuzz" Version="1.6.2" /> + <PackageReference Include="SharpFuzz" Version="2.0.0" /> </ItemGroup> </Project> diff --git a/src/Jellyfin.Extensions/AlphanumericComparator.cs b/src/Jellyfin.Extensions/AlphanumericComparator.cs index e3c81eba8..98a32d5b2 100644 --- a/src/Jellyfin.Extensions/AlphanumericComparator.cs +++ b/src/Jellyfin.Extensions/AlphanumericComparator.cs @@ -128,9 +128,7 @@ namespace Jellyfin.Extensions return result; } } -#pragma warning disable SA1500 // TODO remove with StyleCop.Analyzers v1.2.0 https://github.com/DotNetAnalyzers/StyleCopAnalyzers/pull/3196 } while (pos1 < len1 && pos2 < len2); -#pragma warning restore SA1500 return len1 - len2; } diff --git a/src/Jellyfin.Extensions/EnumerableExtensions.cs b/src/Jellyfin.Extensions/EnumerableExtensions.cs index a31a57dc6..fd46358a4 100644 --- a/src/Jellyfin.Extensions/EnumerableExtensions.cs +++ b/src/Jellyfin.Extensions/EnumerableExtensions.cs @@ -1,42 +1,31 @@ using System; using System.Collections.Generic; -namespace Jellyfin.Extensions +namespace Jellyfin.Extensions; + +/// <summary> +/// Static extensions for the <see cref="IEnumerable{T}"/> interface. +/// </summary> +public static class EnumerableExtensions { /// <summary> - /// Static extensions for the <see cref="IEnumerable{T}"/> interface. + /// Determines whether the value is contained in the source collection. /// </summary> - public static class EnumerableExtensions + /// <param name="source">An instance of the <see cref="IEnumerable{String}"/> interface.</param> + /// <param name="value">The value to look for in the collection.</param> + /// <param name="stringComparison">The string comparison.</param> + /// <returns>A value indicating whether the value is contained in the collection.</returns> + /// <exception cref="ArgumentNullException">The source is null.</exception> + public static bool Contains(this IEnumerable<string> source, ReadOnlySpan<char> value, StringComparison stringComparison) { - /// <summary> - /// Determines whether the value is contained in the source collection. - /// </summary> - /// <param name="source">An instance of the <see cref="IEnumerable{String}"/> interface.</param> - /// <param name="value">The value to look for in the collection.</param> - /// <param name="stringComparison">The string comparison.</param> - /// <returns>A value indicating whether the value is contained in the collection.</returns> - /// <exception cref="ArgumentNullException">The source is null.</exception> - public static bool Contains(this IEnumerable<string> source, ReadOnlySpan<char> value, StringComparison stringComparison) - { - ArgumentNullException.ThrowIfNull(source); - - if (source is IList<string> list) - { - int len = list.Count; - for (int i = 0; i < len; i++) - { - if (value.Equals(list[i], stringComparison)) - { - return true; - } - } - - return false; - } + ArgumentNullException.ThrowIfNull(source); - foreach (string element in source) + if (source is IList<string> list) + { + int len = list.Count; + for (int i = 0; i < len; i++) { - if (value.Equals(element, stringComparison)) + if (value.Equals(list[i], stringComparison)) { return true; } @@ -44,5 +33,26 @@ namespace Jellyfin.Extensions return false; } + + foreach (string element in source) + { + if (value.Equals(element, stringComparison)) + { + return true; + } + } + + return false; + } + + /// <summary> + /// Gets an IEnumerable from a single item. + /// </summary> + /// <param name="item">The item to return.</param> + /// <typeparam name="T">The type of item.</typeparam> + /// <returns>The IEnumerable{T}.</returns> + public static IEnumerable<T> SingleItemAsEnumerable<T>(this T item) + { + yield return item; } } diff --git a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs index 79aa8a354..febe9516a 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs +++ b/src/Jellyfin.MediaEncoding.Keyframes/FfProbe/FfProbeKeyframeExtractor.cs @@ -38,9 +38,28 @@ public static class FfProbeKeyframeExtractor EnableRaisingEvents = true }; - process.Start(); + try + { + process.Start(); - return ParseStream(process.StandardOutput); + return ParseStream(process.StandardOutput); + } + catch (Exception) + { + try + { + if (!process.HasExited) + { + process.Kill(); + } + } + catch + { + // We do not care if this fails + } + + throw; + } } internal static KeyframeData ParseStream(StreamReader reader) diff --git a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj index 9585cb60c..8be5cd8dc 100644 --- a/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj +++ b/src/Jellyfin.MediaEncoding.Keyframes/Jellyfin.MediaEncoding.Keyframes.csproj @@ -21,7 +21,7 @@ </ItemGroup> <ItemGroup> - <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.2" /> + <PackageReference Include="Microsoft.Extensions.Logging.Abstractions" Version="6.0.3" /> </ItemGroup> <ItemGroup> diff --git a/stylecop.json b/stylecop.json new file mode 100644 index 000000000..6da4bf51d --- /dev/null +++ b/stylecop.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://raw.githubusercontent.com/DotNetAnalyzers/StyleCopAnalyzers/master/StyleCop.Analyzers/StyleCop.Analyzers/Settings/stylecop.schema.json", + "settings": { + "layoutRules": { + "newlineAtEndOfFile": "require", + "allowDoWhileOnClosingBrace": true + } + } +} diff --git a/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs b/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs index 463e17ad3..f67e6d1ef 100644 --- a/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs +++ b/tests/Jellyfin.Controller.Tests/BaseItemManagerTests.cs @@ -20,17 +20,13 @@ namespace Jellyfin.Controller.Tests { BaseItem item = (BaseItem)Activator.CreateInstance(itemType)!; - var libraryOptions = new LibraryOptions - { - TypeOptions = new[] + var libraryTypeOptions = itemType == typeof(Book) + ? new TypeOptions { - new TypeOptions - { - Type = "Book", - MetadataFetchers = new[] { "LibraryEnabled" } - } + Type = "Book", + MetadataFetchers = new[] { "LibraryEnabled" } } - }; + : null; var serverConfiguration = new ServerConfiguration(); foreach (var typeConfig in serverConfiguration.MetadataOptions) @@ -43,7 +39,7 @@ namespace Jellyfin.Controller.Tests .Returns(serverConfiguration); var baseItemManager = new BaseItemManager(serverConfigurationManager.Object); - var actual = baseItemManager.IsMetadataFetcherEnabled(item, libraryOptions, fetcherName); + var actual = baseItemManager.IsMetadataFetcherEnabled(item, libraryTypeOptions, fetcherName); Assert.Equal(expected, actual); } @@ -57,17 +53,13 @@ namespace Jellyfin.Controller.Tests { BaseItem item = (BaseItem)Activator.CreateInstance(itemType)!; - var libraryOptions = new LibraryOptions - { - TypeOptions = new[] + var libraryTypeOptions = itemType == typeof(Book) + ? new TypeOptions { - new TypeOptions - { - Type = "Book", - ImageFetchers = new[] { "LibraryEnabled" } - } + Type = "Book", + ImageFetchers = new[] { "LibraryEnabled" } } - }; + : null; var serverConfiguration = new ServerConfiguration(); foreach (var typeConfig in serverConfiguration.MetadataOptions) @@ -80,7 +72,7 @@ namespace Jellyfin.Controller.Tests .Returns(serverConfiguration); var baseItemManager = new BaseItemManager(serverConfigurationManager.Object); - var actual = baseItemManager.IsImageFetcherEnabled(item, libraryOptions, fetcherName); + var actual = baseItemManager.IsImageFetcherEnabled(item, libraryTypeOptions, fetcherName); Assert.Equal(expected, actual); } diff --git a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs index 9baf6877d..e1bd2fe0f 100644 --- a/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs +++ b/tests/Jellyfin.Model.Tests/Dlna/StreamBuilderTests.cs @@ -21,8 +21,8 @@ namespace Jellyfin.Model.Tests [Theory] // Chrome [InlineData("Chrome", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Chrome", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] @@ -32,8 +32,8 @@ namespace Jellyfin.Model.Tests [InlineData("Chrome", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 // Firefox [InlineData("Firefox", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Firefox", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Firefox", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Firefox", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] @@ -59,11 +59,11 @@ namespace Jellyfin.Model.Tests [InlineData("AndroidPixel", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.ContainerBitrateExceedsLimit, "Transcode")] // Yatse [InlineData("Yatse", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Yatse", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] [InlineData("Yatse", "mp4-hevc-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 should be DirectPlay @@ -83,8 +83,8 @@ namespace Jellyfin.Model.Tests [InlineData("JellyfinMediaPlayer", "mkv-vp9-vorbis-vtt-2600k", PlayMethod.DirectPlay)] // #6450 // Chrome-NoHLS [InlineData("Chrome-NoHLS", "mp4-h264-aac-vtt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 - [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacDef-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Chrome-NoHLS", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Chrome-NoHLS", "mp4-h264-ac3-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioCodecNotSupported)] // #6450 [InlineData("Chrome-NoHLS", "mp4-hevc-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode", "http")] @@ -273,15 +273,15 @@ namespace Jellyfin.Model.Tests [Theory] // Chrome - [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Chrome", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Chrome", "mp4-h264-ac3-aacExt-srt-2600k", PlayMethod.DirectStream, TranscodeReason.AudioIsExternal)] // #6450 [InlineData("Chrome", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] // Firefox - [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay)] // #6450 + [InlineData("Firefox", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 [InlineData("Firefox", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.Transcode, TranscodeReason.VideoCodecNotSupported, "Transcode")] // Yatse - [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 - [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 + [InlineData("Yatse", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 + [InlineData("Yatse", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectStream, TranscodeReason.SecondaryAudioNotSupported, "Remux")] // #6450 // RokuSSPlus [InlineData("RokuSSPlus", "mp4-h264-ac3-aac-srt-2600k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 [InlineData("RokuSSPlus", "mp4-hevc-ac3-aac-srt-15200k", PlayMethod.DirectPlay, (TranscodeReason)0, "Remux")] // #6450 @@ -359,7 +359,7 @@ namespace Jellyfin.Model.Tests Assert.Single(val.TargetAudioCodec); // Assert.Single(val.AudioCodecs); - if (transcodeMode == "DirectStream") + if (transcodeMode.Equals("DirectStream", StringComparison.Ordinal)) { Assert.Equal(val.Container, uri.Extension); } @@ -371,14 +371,14 @@ namespace Jellyfin.Model.Tests Assert.NotEmpty(val.AudioCodecs); // Check expected container (todo: this could be a test param) - if (transcodeProtocol == "http") + if (transcodeProtocol.Equals("http", StringComparison.Ordinal)) { // Assert.Equal("webm", val.Container); Assert.Equal(val.Container, uri.Extension); Assert.Equal("stream", uri.Filename); Assert.Equal("http", val.SubProtocol); } - else if (transcodeProtocol == "HLS.mp4") + else if (transcodeProtocol.Equals("HLS.mp4", StringComparison.Ordinal)) { Assert.Equal("mp4", val.Container); Assert.Equal("m3u8", uri.Extension); @@ -394,7 +394,7 @@ namespace Jellyfin.Model.Tests } // Full transcode - if (transcodeMode == "Transcode") + if (transcodeMode.Equals("Transcode", StringComparison.Ordinal)) { if ((val.TranscodeReasons & (StreamBuilder.ContainerReasons | TranscodeReason.DirectPlayError)) == 0) { @@ -413,7 +413,7 @@ namespace Jellyfin.Model.Tests Assert.Contains(targetVideoStream.Codec, val.TargetVideoCodec); Assert.Single(val.TargetVideoCodec); - if (transcodeMode == "DirectStream") + if (transcodeMode.Equals("DirectStream", StringComparison.Ordinal)) { // Check expected audio codecs (1) if (!targetAudioStream.IsExternal) @@ -428,7 +428,7 @@ namespace Jellyfin.Model.Tests } } } - else if (transcodeMode == "Remux") + else if (transcodeMode.Equals("Remux", StringComparison.Ordinal)) { // Check expected audio codecs (1) Assert.Contains(targetAudioStream.Codec, val.AudioCodecs); diff --git a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs index 731580e0c..2c33ab492 100644 --- a/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs +++ b/tests/Jellyfin.Naming.Tests/Video/ExtraTests.cs @@ -51,8 +51,9 @@ namespace Jellyfin.Naming.Tests.Video [InlineData(ExtraType.Interview, "interviews")] [InlineData(ExtraType.Scene, "scenes")] [InlineData(ExtraType.Sample, "samples")] - [InlineData(ExtraType.Clip, "shorts")] - [InlineData(ExtraType.Clip, "featurettes")] + [InlineData(ExtraType.Short, "shorts")] + [InlineData(ExtraType.Featurette, "featurettes")] + [InlineData(ExtraType.Clip, "clips")] [InlineData(ExtraType.ThemeVideo, "backdrops")] [InlineData(ExtraType.Unknown, "extras")] public void TestDirectories(ExtraType type, string dirName) diff --git a/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs new file mode 100644 index 000000000..725e295b9 --- /dev/null +++ b/tests/Jellyfin.Providers.Tests/Manager/ProviderManagerTests.cs @@ -0,0 +1,614 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Net.Http; +using System.Runtime.CompilerServices; +using System.Threading; +using System.Threading.Tasks; +using MediaBrowser.Controller; +using MediaBrowser.Controller.BaseItemManager; +using MediaBrowser.Controller.Configuration; +using MediaBrowser.Controller.Entities; +using MediaBrowser.Controller.Entities.Movies; +using MediaBrowser.Controller.Library; +using MediaBrowser.Controller.Providers; +using MediaBrowser.Controller.Subtitles; +using MediaBrowser.Model.Configuration; +using MediaBrowser.Model.IO; +using MediaBrowser.Providers.Manager; +using Microsoft.Extensions.Logging; +using Microsoft.Extensions.Logging.Abstractions; +using Moq; +using Xunit; + +// Allow Moq to see internal class +[assembly: InternalsVisibleTo("DynamicProxyGenAssembly2")] + +namespace Jellyfin.Providers.Tests.Manager +{ + public class ProviderManagerTests + { + private static readonly ILogger<ProviderManager> _logger = new NullLogger<ProviderManager>(); + + public static TheoryData<Mock<IMetadataService>[], int> RefreshSingleItemOrderData() + => new() + { + // no order set, uses provided order + { + new[] + { + MockIMetadataService(true, true), + MockIMetadataService(true, true) + }, + 0 + }, + // sort order sets priority when all match + { + new[] + { + MockIMetadataService(true, true, 1), + MockIMetadataService(true, true, 0), + MockIMetadataService(true, true, 2) + }, + 1 + }, + // CanRefreshPrimary prioritized + { + new[] + { + MockIMetadataService(false, true), + MockIMetadataService(true, true), + }, + 1 + }, + // falls back to CanRefresh + { + new[] + { + MockIMetadataService(false, false), + MockIMetadataService(false, true) + }, + 1 + }, + }; + + [Theory] + [MemberData(nameof(RefreshSingleItemOrderData))] + public async Task RefreshSingleItem_ServiceOrdering_FollowsPriority(Mock<IMetadataService>[] servicesList, int expectedIndex) + { + var item = new Movie(); + + using var providerManager = GetProviderManager(); + 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); + + Assert.Equal(ItemUpdateType.MetadataDownload, actual); + for (var i = 0; i < servicesList.Length; i++) + { + var times = i == expectedIndex ? Times.Once() : Times.Never(); + servicesList[i].Verify(mock => mock.RefreshMetadata(It.IsAny<BaseItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>()), times); + } + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + public async Task RefreshSingleItem_RefreshMetadata_WhenServiceFound(bool serviceFound) + { + var item = new Movie(); + + var servicesList = new[] { MockIMetadataService(false, serviceFound) }; + + using var providerManager = GetProviderManager(); + 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 expectedResult = serviceFound ? ItemUpdateType.MetadataDownload : ItemUpdateType.None; + Assert.Equal(expectedResult, actual); + } + + public static TheoryData<int, int[]?, int[]?, int?[]?, int[]> GetImageProvidersOrderData() + => new() + { + { 3, null, null, null, new[] { 0, 1, 2 } }, // no order options set + + // library options ordering + { 3, Array.Empty<int>(), null, null, new[] { 0, 1, 2 } }, // no order provided + { 3, new[] { 1 }, null, null, new[] { 1, 0, 2 } }, // one item in order + { 3, new[] { 2, 1, 0 }, null, null, new[] { 2, 1, 0 } }, // full reverse order + + // server options ordering + { 3, null, Array.Empty<int>(), null, new[] { 0, 1, 2 } }, // no order provided + { 3, null, new[] { 1 }, null, new[] { 1, 0, 2 } }, // one item in order + { 3, null, new[] { 2, 1, 0 }, null, new[] { 2, 1, 0 } }, // full reverse order + + // IHasOrder ordering + { 3, null, null, new int?[] { null, 1, null }, new[] { 1, 0, 2 } }, // one item with defined order + { 3, null, null, new int?[] { 2, 1, 0 }, new[] { 2, 1, 0 } }, // full reverse order + + // multiple orders set + { 3, new[] { 1 }, new[] { 2, 0, 1 }, null, new[] { 1, 0, 2 } }, // partial library order first, server order ignored + { 3, new[] { 1 }, null, new int?[] { 2, 0, 1 }, new[] { 1, 2, 0 } }, // library order first, then orderby + { 3, new[] { 2, 1, 0 }, new[] { 1, 2, 0 }, new int?[] { 2, 0, 1 }, new[] { 2, 1, 0 } }, // library order wins + }; + + [Theory] + [MemberData(nameof(GetImageProvidersOrderData))] + public void GetImageProviders_ProviderOrder_MatchesExpected(int providerCount, int[]? libraryOrder, int[]? serverOrder, int?[]? hasOrderOrder, int[] expectedOrder) + { + var item = new Movie(); + + var nameProvider = new Func<int, string>(i => "Provider" + i); + + var providerList = new List<IImageProvider>(); + for (var i = 0; i < providerCount; i++) + { + var order = hasOrderOrder?[i]; + providerList.Add(MockIImageProvider<ILocalImageProvider>(nameProvider(i), item, order: order)); + } + + var libraryOptions = CreateLibraryOptions(item.GetType().Name, imageFetcherOrder: libraryOrder?.Select(nameProvider).ToArray()); + var serverConfiguration = CreateServerConfiguration(item.GetType().Name, imageFetcherOrder: serverOrder?.Select(nameProvider).ToArray()); + + using var providerManager = GetProviderManager(serverConfiguration: serverConfiguration, libraryOptions: libraryOptions); + AddParts(providerManager, imageProviders: providerList); + + var refreshOptions = new ImageRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict)); + var actualProviders = providerManager.GetImageProviders(item, refreshOptions).ToList(); + + Assert.Equal(providerList.Count, actualProviders.Count); + var actualOrder = actualProviders.Select(i => providerList.IndexOf(i)).ToArray(); + Assert.Equal(expectedOrder, actualOrder); + } + + [Theory] + [InlineData(true, false, true)] + [InlineData(false, false, false)] + [InlineData(true, true, false)] + public void GetImageProviders_CanRefreshImagesBasic_WhenSupportsWithoutError(bool supports, bool errorOnSupported, bool expected) + { + GetImageProviders_CanRefreshImages_Tester(nameof(IImageProvider), supports, expected, errorOnSupported: errorOnSupported); + } + + [Theory] + [InlineData(nameof(ILocalImageProvider), false, true)] + [InlineData(nameof(ILocalImageProvider), true, true)] + [InlineData(nameof(IImageProvider), false, false)] + [InlineData(nameof(IImageProvider), true, true)] + public void GetImageProviders_CanRefreshImagesLocked_WhenLocalOrFullRefresh(string providerType, bool fullRefresh, bool expected) + { + GetImageProviders_CanRefreshImages_Tester(providerType, true, expected, itemLocked: true, fullRefresh: fullRefresh); + } + + [Theory] + [InlineData(nameof(ILocalImageProvider), false, true)] + [InlineData(nameof(IRemoteImageProvider), true, true)] + [InlineData(nameof(IDynamicImageProvider), true, true)] + [InlineData(nameof(IRemoteImageProvider), false, false)] + [InlineData(nameof(IDynamicImageProvider), false, false)] + public void GetImageProviders_CanRefreshImagesBaseItemEnabled_WhenLocalOrEnabled(string providerType, bool enabled, bool expected) + { + GetImageProviders_CanRefreshImages_Tester(providerType, true, expected, baseItemEnabled: enabled); + } + + private static void GetImageProviders_CanRefreshImages_Tester( + string providerType, + bool supports, + bool expected, + bool errorOnSupported = false, + bool itemLocked = false, + bool fullRefresh = false, + bool baseItemEnabled = true) + { + var item = new Movie + { + IsLocked = itemLocked + }; + + var providerName = "provider"; + IImageProvider provider = providerType switch + { + "IImageProvider" => MockIImageProvider<IImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported), + "ILocalImageProvider" => MockIImageProvider<ILocalImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported), + "IRemoteImageProvider" => MockIImageProvider<IRemoteImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported), + "IDynamicImageProvider" => MockIImageProvider<IDynamicImageProvider>(providerName, item, supports: supports, errorOnSupported: errorOnSupported), + _ => throw new ArgumentException("Unexpected provider type") + }; + + var refreshOptions = new ImageRefreshOptions(Mock.Of<IDirectoryService>(MockBehavior.Strict)) + { + ImageRefreshMode = fullRefresh ? MetadataRefreshMode.FullRefresh : MetadataRefreshMode.Default + }; + + var baseItemManager = new Mock<IBaseItemManager>(MockBehavior.Strict); + baseItemManager.Setup(i => i.IsImageFetcherEnabled(item, It.IsAny<TypeOptions>(), providerName)) + .Returns(baseItemEnabled); + + using var providerManager = GetProviderManager(baseItemManager: baseItemManager.Object); + AddParts(providerManager, imageProviders: new[] { provider }); + + var actualProviders = providerManager.GetImageProviders(item, refreshOptions).ToArray(); + + Assert.Equal(expected ? 1 : 0, actualProviders.Length); + } + + public static TheoryData<string[], int[]?, int[]?, int[]?, int[]?, int?[]?, int[]> GetMetadataProvidersOrderData() + { + var l = nameof(ILocalMetadataProvider); + var r = nameof(IRemoteMetadataProvider); + return new() + { + { new[] { l, l, r, r }, null, null, null, null, null, new[] { 0, 1, 2, 3 } }, // no order options set + + // library options ordering + { new[] { l, l, r, r }, Array.Empty<int>(), Array.Empty<int>(), null, null, null, new[] { 0, 1, 2, 3 } }, // no order provided + // local only + { new[] { r, l, l, l }, new[] { 2 }, null, null, null, null, new[] { 2, 0, 1, 3 } }, // one item in order + { new[] { r, l, l, l }, new[] { 3, 2, 1 }, null, null, null, null, new[] { 3, 2, 1, 0 } }, // full reverse order + // remote only + { new[] { l, r, r, r }, null, new[] { 2 }, null, null, null, new[] { 2, 0, 1, 3 } }, // one item in order + { new[] { l, r, r, r }, null, new[] { 3, 2, 1 }, null, null, null, new[] { 3, 2, 1, 0 } }, // full reverse order + // local and remote, note that results will be interleaved (odd but expected) + { new[] { l, l, r, r }, new[] { 1 }, new[] { 3 }, null, null, null, new[] { 1, 3, 0, 2 } }, // one item in each order + { new[] { l, l, l, r, r, r }, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, null, null, null, new[] { 2, 5, 1, 4, 0, 3 } }, // full reverse order + + // // server options ordering + { new[] { l, l, r, r }, null, null, Array.Empty<int>(), Array.Empty<int>(), null, new[] { 0, 1, 2, 3 } }, // no order provided + // local only + { new[] { r, l, l, l }, null, null, new[] { 2 }, null, null, new[] { 2, 0, 1, 3 } }, // one item in order + { new[] { r, l, l, l }, null, null, new[] { 3, 2, 1 }, null, null, new[] { 3, 2, 1, 0 } }, // full reverse order + // remote only + { new[] { l, r, r, r }, null, null, null, new[] { 2 }, null, new[] { 2, 0, 1, 3 } }, // one item in order + { new[] { l, r, r, r }, null, null, null, new[] { 3, 2, 1 }, null, new[] { 3, 2, 1, 0 } }, // full reverse order + // local and remote, note that results will be interleaved (odd but expected) + { new[] { l, l, r, r }, null, null, new[] { 1 }, new[] { 3 }, null, new[] { 1, 3, 0, 2 } }, // one item in each order + { new[] { l, l, l, r, r, r }, null, null, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, null, new[] { 2, 5, 1, 4, 0, 3 } }, // full reverse order + + // IHasOrder ordering (not interleaved, doesn't care about types) + { new[] { l, l, r, r }, null, null, null, null, new int?[] { 2, null, 1, null }, new[] { 2, 0, 1, 3 } }, // partially defined + { new[] { l, l, r, r }, null, null, null, null, new int?[] { 3, 2, 1, 0 }, new[] { 3, 2, 1, 0 } }, // full reverse order + + // multiple orders set + { new[] { l, l, l, r, r, r }, new[] { 1 }, new[] { 4 }, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, null, new[] { 1, 4, 0, 2, 3, 5 } }, // partial library order first, server order ignored + { new[] { l, l, l }, new[] { 1 }, null, null, null, new int?[] { 2, 0, 1 }, new[] { 1, 2, 0 } }, // library order first, then orderby + { new[] { l, l, l, r, r, r }, new[] { 2, 1, 0 }, new[] { 5, 4, 3 }, new[] { 1, 2, 0 }, new[] { 4, 5, 3 }, new int?[] { 5, 4, 1, 6, 3, 2 }, new[] { 2, 5, 4, 1, 0, 3 } }, // library order wins (with orderby between local/remote) + }; + } + + [Theory] + [MemberData(nameof(GetMetadataProvidersOrderData))] + public void GetMetadataProviders_ProviderOrder_MatchesExpected( + string[] providers, + int[]? libraryLocalOrder, + int[]? libraryRemoteOrder, + int[]? serverLocalOrder, + int[]? serverRemoteOrder, + int?[]? hasOrderOrder, + int[] expectedOrder) + { + var item = new MetadataTestItem(); + + var nameProvider = new Func<int, string>(i => "Provider" + i); + + var providerList = new List<IMetadataProvider<MetadataTestItem>>(); + for (var i = 0; i < providers.Length; i++) + { + var order = hasOrderOrder?[i]; + providerList.Add(MockIMetadataProviderMapper<MetadataTestItem, MetadataTestItemInfo>(providers[i], nameProvider(i), order: order)); + } + + var libraryOptions = CreateLibraryOptions( + item.GetType().Name, + localMetadataReaderOrder: libraryLocalOrder?.Select(nameProvider).ToArray(), + metadataFetcherOrder: libraryRemoteOrder?.Select(nameProvider).ToArray()); + var serverConfiguration = CreateServerConfiguration( + item.GetType().Name, + localMetadataReaderOrder: serverLocalOrder?.Select(nameProvider).ToArray(), + metadataFetcherOrder: serverRemoteOrder?.Select(nameProvider).ToArray()); + + var baseItemManager = new Mock<IBaseItemManager>(MockBehavior.Strict); + baseItemManager.Setup(i => i.IsMetadataFetcherEnabled(item, It.IsAny<TypeOptions>(), It.IsAny<string>())) + .Returns(true); + + using var providerManager = GetProviderManager(serverConfiguration: serverConfiguration, baseItemManager: baseItemManager.Object); + AddParts(providerManager, metadataProviders: providerList); + + var actualProviders = providerManager.GetMetadataProviders<MetadataTestItem>(item, libraryOptions).ToList(); + + Assert.Equal(providerList.Count, actualProviders.Count); + var actualOrder = actualProviders.Select(i => providerList.IndexOf(i)).ToArray(); + Assert.Equal(expectedOrder, actualOrder); + } + + [Theory] + [InlineData(nameof(IMetadataProvider))] + [InlineData(nameof(ILocalMetadataProvider))] + [InlineData(nameof(IRemoteMetadataProvider))] + [InlineData(nameof(ICustomMetadataProvider))] + public void GetMetadataProviders_CanRefreshMetadataBasic_ReturnsTrue(string providerType) + { + GetMetadataProviders_CanRefreshMetadata_Tester(providerType, true); + } + + [Theory] + [InlineData(nameof(ILocalMetadataProvider), false, true)] + [InlineData(nameof(IRemoteMetadataProvider), false, false)] + [InlineData(nameof(ICustomMetadataProvider), false, false)] + [InlineData(nameof(ILocalMetadataProvider), true, true)] + [InlineData(nameof(ICustomMetadataProvider), true, false)] + public void GetMetadataProviders_CanRefreshMetadataLocked_WhenLocalOrForced(string providerType, bool forced, bool expected) + { + GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, itemLocked: true, providerForced: forced); + } + + [Theory] + [InlineData(nameof(ILocalMetadataProvider), false, true)] + [InlineData(nameof(ICustomMetadataProvider), false, true)] + [InlineData(nameof(IRemoteMetadataProvider), false, false)] + [InlineData(nameof(IRemoteMetadataProvider), true, true)] + public void GetMetadataProviders_CanRefreshMetadataBaseItemEnabled_WhenEnabledOrNotRemote(string providerType, bool baseItemEnabled, bool expected) + { + GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, baseItemEnabled: baseItemEnabled); + } + + [Theory] + [InlineData(nameof(IRemoteMetadataProvider), false, true)] + [InlineData(nameof(ICustomMetadataProvider), false, true)] + [InlineData(nameof(ILocalMetadataProvider), false, false)] + [InlineData(nameof(ILocalMetadataProvider), true, true)] + public void GetMetadataProviders_CanRefreshMetadataSupportsLocal_WhenSupportsOrNotLocal(string providerType, bool supportsLocalMetadata, bool expected) + { + GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, supportsLocalMetadata: supportsLocalMetadata); + } + + [Theory] + [InlineData(nameof(ICustomMetadataProvider), true)] + [InlineData(nameof(IRemoteMetadataProvider), true)] + [InlineData(nameof(ILocalMetadataProvider), false)] + public void GetMetadataProviders_CanRefreshMetadataOwned_WhenNotLocal(string providerType, bool expected) + { + GetMetadataProviders_CanRefreshMetadata_Tester(providerType, expected, ownedItem: true); + } + + private static void GetMetadataProviders_CanRefreshMetadata_Tester( + string providerType, + bool expected, + bool itemLocked = false, + bool baseItemEnabled = true, + bool providerForced = false, + bool supportsLocalMetadata = true, + bool ownedItem = false) + { + var item = new MetadataTestItem + { + IsLocked = itemLocked, + OwnerId = ownedItem ? Guid.NewGuid() : Guid.Empty, + EnableLocalMetadata = supportsLocalMetadata + }; + + var providerName = "provider"; + var provider = MockIMetadataProviderMapper<MetadataTestItem, MetadataTestItemInfo>(providerType, providerName, forced: providerForced); + + var baseItemManager = new Mock<IBaseItemManager>(MockBehavior.Strict); + baseItemManager.Setup(i => i.IsMetadataFetcherEnabled(item, It.IsAny<TypeOptions>(), providerName)) + .Returns(baseItemEnabled); + + using var providerManager = GetProviderManager(baseItemManager: baseItemManager.Object); + AddParts(providerManager, metadataProviders: new[] { provider }); + + var actualProviders = providerManager.GetMetadataProviders<MetadataTestItem>(item, new LibraryOptions()).ToArray(); + + Assert.Equal(expected ? 1 : 0, actualProviders.Length); + } + + private static Mock<IMetadataService> MockIMetadataService(bool refreshPrimary, bool canRefresh, int order = 0) + { + var service = new Mock<IMetadataService>(MockBehavior.Strict); + service.Setup(s => s.Order) + .Returns(order); + service.Setup(s => s.CanRefreshPrimary(It.IsAny<Type>())) + .Returns(refreshPrimary); + service.Setup(s => s.CanRefresh(It.IsAny<BaseItem>())) + .Returns(canRefresh); + service.Setup(s => s.RefreshMetadata(It.IsAny<BaseItem>(), It.IsAny<MetadataRefreshOptions>(), It.IsAny<CancellationToken>())) + .Returns(Task.FromResult(ItemUpdateType.MetadataDownload)); + return service; + } + + private static IImageProvider MockIImageProvider<TProviderType>(string name, BaseItem expectedType, bool supports = true, int? order = null, bool errorOnSupported = false) + where TProviderType : class, IImageProvider + { + Mock<IHasOrder>? hasOrder = null; + if (order != null) + { + hasOrder = new Mock<IHasOrder>(MockBehavior.Strict); + hasOrder.Setup(i => i.Order) + .Returns((int)order); + } + + var provider = hasOrder == null + ? new Mock<TProviderType>(MockBehavior.Strict) + : hasOrder.As<TProviderType>(); + provider.Setup(p => p.Name) + .Returns(name); + if (errorOnSupported) + { + provider.Setup(p => p.Supports(It.IsAny<BaseItem>())) + .Throws(new ArgumentException("Provider threw exception on Supports(item)")); + } + else + { + provider.Setup(p => p.Supports(expectedType)) + .Returns(supports); + } + + return provider.Object; + } + + private static IMetadataProvider<TItemType> MockIMetadataProviderMapper<TItemType, TLookupInfoType>(string typeName, string providerName, int? order = null, bool forced = false) + where TItemType : BaseItem, IHasLookupInfo<TLookupInfoType> + where TLookupInfoType : ItemLookupInfo, new() + => typeName switch + { + "ILocalMetadataProvider" => MockIMetadataProvider<ILocalMetadataProvider<TItemType>, TItemType>(providerName, order, forced), + "IRemoteMetadataProvider" => MockIMetadataProvider<IRemoteMetadataProvider<TItemType, TLookupInfoType>, TItemType>(providerName, order, forced), + "ICustomMetadataProvider" => MockIMetadataProvider<ICustomMetadataProvider<TItemType>, TItemType>(providerName, order, forced), + _ => MockIMetadataProvider<IMetadataProvider<TItemType>, TItemType>(providerName, order, forced) + }; + + private static IMetadataProvider<TItemType> MockIMetadataProvider<TProviderType, TItemType>(string name, int? order = null, bool forced = false) + where TProviderType : class, IMetadataProvider<TItemType> + where TItemType : BaseItem + { + Mock<IForcedProvider>? forcedProvider = null; + if (forced) + { + forcedProvider = new Mock<IForcedProvider>(); + } + + Mock<IHasOrder>? hasOrder = null; + if (order != null) + { + hasOrder = forcedProvider == null ? new Mock<IHasOrder>() : forcedProvider.As<IHasOrder>(); + hasOrder.Setup(i => i.Order) + .Returns((int)order); + } + + var provider = hasOrder == null + ? new Mock<TProviderType>(MockBehavior.Strict) + : hasOrder.As<TProviderType>(); + provider.Setup(p => p.Name) + .Returns(name); + + return provider.Object; + } + + private static LibraryOptions CreateLibraryOptions( + string typeName, + string[]? imageFetcherOrder = null, + string[]? localMetadataReaderOrder = null, + string[]? metadataFetcherOrder = null) + { + var libraryOptions = new LibraryOptions + { + LocalMetadataReaderOrder = localMetadataReaderOrder + }; + + // only create type options if populating it with something + if (imageFetcherOrder != null || metadataFetcherOrder != null) + { + imageFetcherOrder ??= Array.Empty<string>(); + metadataFetcherOrder ??= Array.Empty<string>(); + + libraryOptions.TypeOptions = new[] + { + new TypeOptions + { + Type = typeName, + ImageFetcherOrder = imageFetcherOrder, + MetadataFetcherOrder = metadataFetcherOrder + } + }; + } + + return libraryOptions; + } + + private static ServerConfiguration CreateServerConfiguration( + string typeName, + string[]? imageFetcherOrder = null, + string[]? localMetadataReaderOrder = null, + string[]? metadataFetcherOrder = null) + { + var serverConfiguration = new ServerConfiguration(); + + // only create type options if populating it with something + if (imageFetcherOrder != null || localMetadataReaderOrder != null || metadataFetcherOrder != null) + { + imageFetcherOrder ??= Array.Empty<string>(); + localMetadataReaderOrder ??= Array.Empty<string>(); + metadataFetcherOrder ??= Array.Empty<string>(); + + serverConfiguration.MetadataOptions = new[] + { + new MetadataOptions + { + ItemType = typeName, + ImageFetcherOrder = imageFetcherOrder, + LocalMetadataReaderOrder = localMetadataReaderOrder, + MetadataFetcherOrder = metadataFetcherOrder + } + }; + } + + return serverConfiguration; + } + + private static ProviderManager GetProviderManager( + ServerConfiguration? serverConfiguration = null, + LibraryOptions? libraryOptions = null, + IBaseItemManager? baseItemManager = null) + { + var serverConfigurationManager = new Mock<IServerConfigurationManager>(MockBehavior.Strict); + serverConfigurationManager.Setup(i => i.Configuration) + .Returns(serverConfiguration ?? new ServerConfiguration()); + + var libraryManager = new Mock<ILibraryManager>(MockBehavior.Strict); + libraryManager.Setup(i => i.GetLibraryOptions(It.IsAny<BaseItem>())) + .Returns(libraryOptions ?? new LibraryOptions()); + + var providerManager = new ProviderManager( + Mock.Of<IHttpClientFactory>(), + Mock.Of<ISubtitleManager>(), + serverConfigurationManager.Object, + Mock.Of<ILibraryMonitor>(), + _logger, + Mock.Of<IFileSystem>(), + Mock.Of<IServerApplicationPaths>(), + libraryManager.Object, + baseItemManager!); + + return providerManager; + } + + private static void AddParts( + ProviderManager providerManager, + IEnumerable<IImageProvider>? imageProviders = null, + IEnumerable<IMetadataService>? metadataServices = null, + IEnumerable<IMetadataProvider>? metadataProviders = null, + IEnumerable<IMetadataSaver>? metadataSavers = null, + IEnumerable<IExternalId>? externalIds = null) + { + imageProviders ??= Array.Empty<IImageProvider>(); + metadataServices ??= Array.Empty<IMetadataService>(); + metadataProviders ??= Array.Empty<IMetadataProvider>(); + metadataSavers ??= Array.Empty<IMetadataSaver>(); + externalIds ??= Array.Empty<IExternalId>(); + + providerManager.AddParts(imageProviders, metadataServices, metadataProviders, metadataSavers, externalIds); + } + + /// <summary> + /// Simple <see cref="BaseItem"/> extension to make SupportsLocalMetadata directly settable. + /// </summary> + internal class MetadataTestItem : BaseItem, IHasLookupInfo<MetadataTestItemInfo> + { + public bool EnableLocalMetadata { get; set; } = true; + + public override bool SupportsLocalMetadata => EnableLocalMetadata; + + public MetadataTestItemInfo GetLookupInfo() + { + return GetItemLookupInfo<MetadataTestItemInfo>(); + } + } + + internal class MetadataTestItemInfo : ItemLookupInfo + { + } + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs new file mode 100644 index 000000000..82ce8fc4e --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/LiveTv/Listings/XmlTvListingsProviderTests.cs @@ -0,0 +1,70 @@ +using System; +using System.IO; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using AutoFixture; +using AutoFixture.AutoMoq; +using Emby.Server.Implementations.LiveTv.Listings; +using MediaBrowser.Model.LiveTv; +using Moq; +using Moq.Protected; +using Xunit; + +namespace Jellyfin.Server.Implementations.Tests.LiveTv.Listings; + +public class XmlTvListingsProviderTests +{ + private readonly Fixture _fixture; + private readonly XmlTvListingsProvider _xmlTvListingsProvider; + + public XmlTvListingsProviderTests() + { + var messageHandler = new Mock<HttpMessageHandler>(); + messageHandler.Protected() + .Setup<Task<HttpResponseMessage>>("SendAsync", ItExpr.IsAny<HttpRequestMessage>(), ItExpr.IsAny<CancellationToken>()) + .Returns<HttpRequestMessage, CancellationToken>( + (m, _) => + { + return Task.FromResult(new HttpResponseMessage() + { + Content = new StreamContent(File.OpenRead(Path.Combine("Test Data/LiveTv/Listings/XmlTv", m.RequestUri!.Segments[^1]))) + }); + }); + + var http = new Mock<IHttpClientFactory>(); + http.Setup(x => x.CreateClient(It.IsAny<string>())) + .Returns(new HttpClient(messageHandler.Object)); + _fixture = new Fixture(); + _fixture.Customize(new AutoMoqCustomization + { + ConfigureMembers = true + }).Inject(http); + _xmlTvListingsProvider = _fixture.Create<XmlTvListingsProvider>(); + } + + [Theory] + [InlineData("Test Data/LiveTv/Listings/XmlTv/notitle.xml")] + [InlineData("https://example.com/notitle.xml")] + public async Task GetProgramsAsync_NoTitle_Success(string path) + { + var info = new ListingsProviderInfo() + { + Path = path + }; + + var startDate = new DateTime(2022, 11, 4); + var programs = await _xmlTvListingsProvider.GetProgramsAsync(info, "3297", startDate, startDate.AddDays(1), CancellationToken.None); + var programsList = programs.ToList(); + Assert.Single(programsList); + var program = programsList[0]; + Assert.Null(program.Name); + Assert.Null(program.SeriesId); + Assert.Null(program.EpisodeTitle); + Assert.True(program.IsSports); + Assert.True(program.HasImage); + Assert.Equal("https://domain.tld/image.png", program.ImageUrl); + Assert.Equal("3297", program.ChannelId); + } +} diff --git a/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml new file mode 100644 index 000000000..5a5be7997 --- /dev/null +++ b/tests/Jellyfin.Server.Implementations.Tests/Test Data/LiveTv/Listings/XmlTv/notitle.xml @@ -0,0 +1,10 @@ +<tv date="20221104"> + <programme channel="3297" start="20221104130000 -0400" stop="20221105235959 -0400"> + <category lang="en">sports</category> + <episode-num system="original-air-date">2022-11-04 13:00:00</episode-num> + <icon height="" src="https://domain.tld/image.png" width=""/> + <credits/> + <video/> + <date/> + </programme> +</tv> diff --git a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs index 48c49bf84..c38faeda1 100644 --- a/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs +++ b/tests/Jellyfin.Server.Integration.Tests/JellyfinApplicationFactory.cs @@ -1,5 +1,6 @@ using System; using System.Collections.Concurrent; +using System.Globalization; using System.IO; using System.Threading; using Emby.Server.Implementations; @@ -28,7 +29,9 @@ namespace Jellyfin.Server.Integration.Tests static JellyfinApplicationFactory() { // Perform static initialization that only needs to happen once per test-run - Log.Logger = new LoggerConfiguration().WriteTo.Console().CreateLogger(); + Log.Logger = new LoggerConfiguration() + .WriteTo.Console(formatProvider: CultureInfo.InvariantCulture) + .CreateLogger(); Program.PerformStaticInitialization(); } diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs index eea8cb50a..8f276d03f 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs @@ -7,7 +7,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Music; +using MediaBrowser.Providers.Plugins.MusicBrainz; using MediaBrowser.XbmcMetadata.Parsers; using Microsoft.Extensions.Logging.Abstractions; using Moq; diff --git a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs index 8ca3dd96e..78183d9ff 100644 --- a/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs +++ b/tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs @@ -7,7 +7,7 @@ using MediaBrowser.Controller.Providers; using MediaBrowser.Model.Configuration; using MediaBrowser.Model.Entities; using MediaBrowser.Model.Providers; -using MediaBrowser.Providers.Music; +using MediaBrowser.Providers.Plugins.MusicBrainz; using MediaBrowser.XbmcMetadata.Parsers; using Microsoft.Extensions.Logging.Abstractions; using Moq; |
