Преглед изворни кода

Merge branch 'master' into renovate/dotnet-monorepo

Cody Robibero пре 2 година
родитељ
комит
2d8b375a64
42 измењених фајлова са 602 додато и 1192 уклоњено
  1. 6 6
      .ci/azure-pipelines-abi.yml
  2. 4 4
      .ci/azure-pipelines-main.yml
  3. 8 8
      .ci/azure-pipelines-package.yml
  4. 1 1
      .ci/azure-pipelines-test.yml
  5. 6 6
      .github/workflows/automation.yml
  6. 5 5
      .github/workflows/codeql-analysis.yml
  7. 8 8
      .github/workflows/commands.yml
  8. 11 11
      .github/workflows/openapi.yml
  9. 1 1
      .github/workflows/repo-stale.yaml
  10. 1 9
      Emby.Server.Implementations/ApplicationHost.cs
  11. 1 1
      Emby.Server.Implementations/Emby.Server.Implementations.csproj
  12. 1 1
      Emby.Server.Implementations/Localization/Core/ar.json
  13. 2 1
      Emby.Server.Implementations/Localization/Core/cs.json
  14. 2 1
      Emby.Server.Implementations/Localization/Core/en-GB.json
  15. 2 1
      Emby.Server.Implementations/Localization/Core/es-AR.json
  16. 8 1
      Emby.Server.Implementations/Localization/Core/eu.json
  17. 2 1
      Emby.Server.Implementations/Localization/Core/fi.json
  18. 3 2
      Emby.Server.Implementations/Localization/Core/fr-CA.json
  19. 6 2
      Emby.Server.Implementations/Localization/Core/gl.json
  20. 2 1
      Emby.Server.Implementations/Localization/Core/he.json
  21. 2 1
      Emby.Server.Implementations/Localization/Core/hu.json
  22. 2 1
      Emby.Server.Implementations/Localization/Core/id.json
  23. 7 1
      Emby.Server.Implementations/Localization/Core/jbo.json
  24. 2 1
      Emby.Server.Implementations/Localization/Core/pt-BR.json
  25. 5 1
      Emby.Server.Implementations/Localization/Core/sq.json
  26. 2 1
      Emby.Server.Implementations/Localization/Core/uk.json
  27. 2 1
      Emby.Server.Implementations/Localization/Core/zh-CN.json
  28. 1 0
      MediaBrowser.Providers/MediaBrowser.Providers.csproj
  29. 40 25
      MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs
  30. 16 17
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumArtistExternalId.cs
  31. 16 17
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumExternalId.cs
  32. 169 709
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs
  33. 16 17
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistExternalId.cs
  34. 106 219
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzArtistProvider.cs
  35. 16 17
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzOtherArtistExternalId.cs
  36. 16 17
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzReleaseGroupExternalId.cs
  37. 16 17
      MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzTrackId.cs
  38. 45 26
      MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs
  39. 1 1
      fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj
  40. 40 30
      src/Jellyfin.Extensions/EnumerableExtensions.cs
  41. 1 1
      tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicAlbumNfoProviderTests.cs
  42. 1 1
      tests/Jellyfin.XbmcMetadata.Tests/Parsers/MusicArtistNfoParserTests.cs

+ 6 - 6
.ci/azure-pipelines-abi.yml

@@ -35,14 +35,14 @@ jobs:
           packageType: sdk
           version: ${{ parameters.DotNetSdkVersion }}
 
-      - task: DotNetCoreCLI@2
+      - task: DotNetCoreCLI@2.210.0
         displayName: 'Install ABI CompatibilityChecker Tool'
         inputs:
           command: custom
           custom: tool
           arguments: 'update compatibilitychecker -g'
 
-      - task: DownloadPipelineArtifact@2
+      - task: DownloadPipelineArtifact@2.198.0
         displayName: 'Download New Assembly Build Artifact'
         inputs:
           source: 'current'
@@ -50,7 +50,7 @@ jobs:
           path: "$(System.ArtifactsDirectory)/new-artifacts"
           runVersion: "latest"
 
-      - task: CopyFiles@2
+      - task: CopyFiles@2.211.0
         displayName: 'Copy New Assembly Build Artifact'
         inputs:
           sourceFolder: $(System.ArtifactsDirectory)/new-artifacts
@@ -60,7 +60,7 @@ jobs:
           overWrite: true
           flattenFolders: true
 
-      - task: DownloadPipelineArtifact@2
+      - task: DownloadPipelineArtifact@2.198.0
         displayName: 'Download Reference Assembly Build Artifact'
         enabled: false
         inputs:
@@ -72,7 +72,7 @@ jobs:
           runVersion: "latestFromBranch"
           runBranch: "refs/heads/$(System.PullRequest.TargetBranch)"
 
-      - task: CopyFiles@2
+      - task: CopyFiles@2.211.0
         displayName: 'Copy Reference Assembly Build Artifact'
         enabled: false
         inputs:
@@ -83,7 +83,7 @@ jobs:
           overWrite: true
           flattenFolders: true
 
-      - task: DotNetCoreCLI@2
+      - task: DotNetCoreCLI@2.210.0
         displayName: 'Execute ABI Compatibility Check Tool'
         enabled: false
         inputs:

+ 4 - 4
.ci/azure-pipelines-main.yml

@@ -20,7 +20,7 @@ jobs:
         submodules: true
         persistCredentials: true
 
-      - task: DownloadPipelineArtifact@2
+      - task: DownloadPipelineArtifact@2.198.0
         displayName: 'Download Web Branch'
         condition: in(variables['Build.Reason'], 'IndividualCI', 'BatchedCI', 'BuildCompletion')
         inputs:
@@ -31,7 +31,7 @@ jobs:
           pipeline: 'Jellyfin Web'
           runBranch: variables['Build.SourceBranch']
 
-      - task: DownloadPipelineArtifact@2
+      - task: DownloadPipelineArtifact@2.198.0
         displayName: 'Download Web Target'
         condition: eq(variables['Build.Reason'], 'PullRequest')
         inputs:
@@ -42,7 +42,7 @@ jobs:
           pipeline: 'Jellyfin Web'
           runBranch: variables['System.PullRequest.TargetBranch']
 
-      - task: ExtractFiles@1
+      - task: ExtractFiles@1.211.0
         displayName: 'Extract Web Client'
         inputs:
           archiveFilePatterns: '$(Agent.TempDirectory)/*.zip'
@@ -55,7 +55,7 @@ jobs:
           packageType: sdk
           version: ${{ parameters.DotNetSdkVersion }}
 
-      - task: DotNetCoreCLI@2
+      - task: DotNetCoreCLI@2.210.0
         displayName: 'Publish Server'
         inputs:
           command: publish

+ 8 - 8
.ci/azure-pipelines-package.yml

@@ -69,7 +69,7 @@ jobs:
       runOptions: 'inline'
       inline: 'mkdir -p /srv/repository/incoming/azure/$(Build.BuildNumber)/$(BuildConfiguration)'
 
-  - task: CopyFilesOverSSH@0
+  - task: CopyFilesOverSSH@0.212.0
     displayName: 'Upload artifacts to repository server'
     inputs:
       sshEndpoint: repository
@@ -90,7 +90,7 @@ jobs:
     displayName: Set release version (stable)
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
 
-  - task: DownloadPipelineArtifact@2
+  - task: DownloadPipelineArtifact@2.198.0
     displayName: 'Download OpenAPI Spec'
     inputs:
       source: 'current'
@@ -105,7 +105,7 @@ jobs:
       runOptions: 'inline'
       inline: 'mkdir -p /srv/repository/incoming/azure/$(Build.BuildNumber)'
 
-  - task: CopyFilesOverSSH@0
+  - task: CopyFilesOverSSH@0.212.0
     displayName: 'Upload artifacts to repository server'
     inputs:
       sshEndpoint: repository
@@ -137,7 +137,7 @@ jobs:
     displayName: Set release version (stable)
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
 
-  - task: Docker@2
+  - task: Docker@2.211.0
     displayName: 'Push Unstable Image'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
     inputs:
@@ -150,7 +150,7 @@ jobs:
         unstable-$(Build.BuildNumber)-$(BuildConfiguration)
         unstable-$(BuildConfiguration)
 
-  - task: Docker@2
+  - task: Docker@2.211.0
     displayName: 'Push Stable Image'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     inputs:
@@ -210,7 +210,7 @@ jobs:
       packageType: 'sdk'
       version: '6.0.x'
 
-  - task: DotNetCoreCLI@2
+  - task: DotNetCoreCLI@2.210.0
     displayName: 'Build Stable Nuget packages'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/tags/v')
     inputs:
@@ -225,7 +225,7 @@ jobs:
       custom: 'pack'
       arguments: -o $(Build.ArtifactStagingDirectory) -p:Version=$(JellyfinVersion)
 
-  - task: DotNetCoreCLI@2
+  - task: DotNetCoreCLI@2.210.0
     displayName: 'Build Unstable Nuget packages'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
     inputs:
@@ -256,7 +256,7 @@ jobs:
       publishFeedCredentials: 'NugetOrg'
       allowPackageConflicts: true # This ignores an error if the version already exists
 
-  - task: NuGetAuthenticate@0
+  - task: NuGetAuthenticate@0.203.0
     displayName: 'Authenticate to unstable Nuget feed'
     condition: startsWith(variables['Build.SourceBranch'], 'refs/heads/master')
 

+ 1 - 1
.ci/azure-pipelines-test.yml

@@ -51,7 +51,7 @@ jobs:
           organization: 'jellyfin'
           projectKey: 'jellyfin_jellyfin'
 
-      - task: DotNetCoreCLI@2
+      - task: DotNetCoreCLI@2.210.0
         displayName: 'Run CLI Tests'
         inputs:
           command: "test"

+ 6 - 6
.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@b8bf8341285ec9a4567d4318ba474fee998a6919 # tag=v2.0.1
         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:

+ 5 - 5
.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@4d4a70f4a5b2a5a5329f13be4ac933f2c9206ac0 # tag=v3
       with:
         dotnet-version: '6.0.x'
 
     - name: Initialize CodeQL
-      uses: github/codeql-action/init@v2
+      uses: github/codeql-action/init@cc7986c02bac29104a72998e67239bb5ee2ee110 # tag=v2
       with:
         languages: ${{ matrix.language }}
         queries: +security-extended
     - name: Autobuild
-      uses: github/codeql-action/autobuild@v2
+      uses: github/codeql-action/autobuild@cc7986c02bac29104a72998e67239bb5ee2ee110 # tag=v2
     - name: Perform CodeQL Analysis
-      uses: github/codeql-action/analyze@v2
+      uses: github/codeql-action/analyze@cc7986c02bac29104a72998e67239bb5ee2ee110 # tag=v2

+ 8 - 8
.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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # 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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # 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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # 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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # 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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # tag=v2
         if: ${{ github.event.comment != null && failure() }}
         with:
           token: ${{ secrets.JF_BOT_TOKEN }}

+ 11 - 11
.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@4d4a70f4a5b2a5a5329f13be4ac933f2c9206ac0 # 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@3cea5372237819ed00197afe530f5a7ea3e805c8 # 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@4d4a70f4a5b2a5a5329f13be4ac933f2c9206ac0 # 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@3cea5372237819ed00197afe530f5a7ea3e805c8 # 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@fb598a63ae348fa914e94cd0ff38f362e927b741 # tag=v3
         with:
           name: openapi-head
           path: openapi-head
       - name: Download openapi-base
-        uses: actions/download-artifact@v3
+        uses: actions/download-artifact@fb598a63ae348fa914e94cd0ff38f362e927b741 # 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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # 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@2b2c85d0bf1b8a7b4e7e344bd5c71dc4b9196e9f # tag=v2
         if: ${{ steps.read-diff.outputs.body == '' && steps.find-comment.outputs.comment-id != '' }}
         with:
           issue-number: ${{ github.event.pull_request.number }}

+ 1 - 1
.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

+ 1 - 9
Emby.Server.Implementations/ApplicationHost.cs

@@ -1088,15 +1088,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/>

+ 1 - 1
Emby.Server.Implementations/Emby.Server.Implementations.csproj

@@ -30,7 +30,7 @@
     <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.10" />
-    <PackageReference Include="Mono.Nat" Version="3.0.3" />
+    <PackageReference Include="Mono.Nat" Version="3.0.4" />
     <PackageReference Include="prometheus-net.DotNetRuntime" Version="4.3.0" />
     <PackageReference Include="SQLitePCL.pretty.netstandard" Version="3.1.0" />
     <PackageReference Include="DotNet.Glob" Version="3.1.3" />

+ 1 - 1
Emby.Server.Implementations/Localization/Core/ar.json

@@ -97,7 +97,7 @@
     "TasksChannelsCategory": "قنوات الإنترنت",
     "TasksLibraryCategory": "مكتبة",
     "TasksMaintenanceCategory": "صيانة",
-    "TaskRefreshLibraryDescription": "يفصح مكتبة الوسائط الخاصة بك بحثًا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.",
+    "TaskRefreshLibraryDescription": "يفحص مكتبة الوسائط الخاصة بك باحثا عن ملفات جديدة، ومن ثم يتحدث البيانات الوصفية.",
     "TaskRefreshLibrary": "افحص مكتبة الوسائط",
     "TaskRefreshChapterImagesDescription": "يُنشئ صور مصغرة لمقاطع الفيديو التي تحتوي على فصول.",
     "TaskRefreshChapterImages": "استخراج صور الفصل",

+ 2 - 1
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í"
 }

+ 2 - 1
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"
 }

+ 2 - 1
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"
 }

+ 8 - 1
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"
 }

+ 2 - 1
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"
 }

+ 3 - 2
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"
 }

+ 6 - 2
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"
 }

+ 2 - 1
Emby.Server.Implementations/Localization/Core/he.json

@@ -123,5 +123,6 @@
     "TaskOptimizeDatabaseDescription": "דוחס את מסד הנתונים ומוריד את שטח האחסון שבשימוש. הרצה של פעולה זו לאחר סריקת הספרייה או שינויים אחרים שמשפיעים על מסד הנתונים יכולה לשפר ביצועים.",
     "TaskKeyframeExtractorDescription": "חלץ תמונות מפתח מקבצי וידאו בכדי ליצור רשימות השמעה מדויקות יותר של HLS. משימה זו עלולה להימשך זמן רב.",
     "TaskKeyframeExtractor": "מחלץ תמונות מפתח",
-    "External": "חיצוני"
+    "External": "חיצוני",
+    "HearingImpaired": "לקוי שמיעה"
 }

+ 2 - 1
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"
 }

+ 2 - 1
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"
 }

+ 7 - 1
Emby.Server.Implementations/Localization/Core/jbo.json

@@ -1 +1,7 @@
-{}
+{
+    "Albums": "lo albuma",
+    "Artists": "lo larpra",
+    "Books": "lo cukta",
+    "HeaderAlbumArtists": "lo albuma larpra",
+    "Playlists": "lo zgipor"
+}

+ 2 - 1
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"
 }

+ 5 - 1
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"
 }

+ 2 - 1
Emby.Server.Implementations/Localization/Core/uk.json

@@ -122,5 +122,6 @@
     "TaskOptimizeDatabaseDescription": "Стискає базу даних та збільшує вільний простір. Виконання цього завдання після сканування медіатеки або внесення інших змін, які передбачають модифікацію бази даних може покращити продуктивність.",
     "TaskKeyframeExtractorDescription": "Витягує ключові кадри з відеофайлів для створення більш точних списків відтворення HLS. Це завдання може виконуватися протягом тривалого часу.",
     "TaskKeyframeExtractor": "Екстрактор ключових кадрів",
-    "External": "Зовнішній"
+    "External": "Зовнішній",
+    "HearingImpaired": "З порушеннями слуху"
 }

+ 2 - 1
Emby.Server.Implementations/Localization/Core/zh-CN.json

@@ -123,5 +123,6 @@
     "TaskOptimizeDatabase": "优化数据库",
     "TaskKeyframeExtractorDescription": "从视频文件中提取关键帧以创建更准确的HLS播放列表。这项任务可能需要很长时间。",
     "TaskKeyframeExtractor": "关键帧提取器",
-    "External": "外部"
+    "External": "外部",
+    "HearingImpaired": "听力障碍"
 }

+ 1 - 0
MediaBrowser.Providers/MediaBrowser.Providers.csproj

@@ -17,6 +17,7 @@
 
   <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" />

+ 40 - 25
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; }
 }

+ 16 - 17
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;
 }

+ 16 - 17
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;
 }

+ 169 - 709
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();
         }
     }
 }

+ 16 - 17
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;
 }

+ 106 - 219
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();
         }
     }
 }

+ 16 - 17
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;
 }

+ 16 - 17
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;
 }

+ 16 - 17
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;
 }

+ 45 - 26
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"
+        };
     }
 }

+ 1 - 1
fuzz/Emby.Server.Implementations.Fuzz/Emby.Server.Implementations.Fuzz.csproj

@@ -18,7 +18,7 @@
   <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="Moq" Version="4.18.2" />
     <PackageReference Include="SharpFuzz" Version="1.6.2" />
   </ItemGroup>
 

+ 40 - 30
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;
     }
 }

+ 1 - 1
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;

+ 1 - 1
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;