Browse Source

Migrate MusicBrainz plugin to MetaBrainz.MusicBrainz

Co-authored-by: crobibero <cody@robibe.ro>
Co-authored-by: Shadowghost <Shadowghost@users.noreply.github.com>
MrTimscampi 3 years ago
parent
commit
7ad0c9ba24

+ 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" />

+ 44 - 23
MediaBrowser.Providers/Plugins/MusicBrainz/Configuration/PluginConfiguration.cs

@@ -1,37 +1,58 @@
-#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 const double DefaultRateLimit = 1.0;
 
-        private long _rateLimit = Plugin.DefaultRateLimit;
+    private string _server = DefaultServer;
+
+    private double _rateLimit = DefaultRateLimit;
+
+    /// <summary>
+    /// Gets or sets the server url.
+    /// </summary>
+    public string Server
+    {
+        get => _server;
 
-        public string Server
+        set
         {
-            get => _server;
-            set => _server = value.TrimEnd('/');
+            _server = value.TrimEnd('/');
+            Query.DefaultServer = _server;
         }
+    }
 
-        public long RateLimit
+    /// <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; }
+            Query.DelayBetweenRequests = _rateLimit;
+        }
     }
+
+    /// <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;
 }

+ 162 - 711
MediaBrowser.Providers/Plugins/MusicBrainz/MusicBrainzAlbumProvider.cs

@@ -1,805 +1,256 @@
-#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;
+using MediaBrowser.Providers.Music;
+using MetaBrainz.MusicBrainz;
+using MetaBrainz.MusicBrainz.Interfaces.Entities;
+using MetaBrainz.MusicBrainz.Interfaces.Searches;
 
-            _musicBrainzBaseUrl = Plugin.Instance.Configuration.Server;
-            _musicBrainzQueryIntervalMs = Plugin.Instance.Configuration.RateLimit;
+namespace MediaBrowser.Providers.Plugins.MusicBrainz;
 
-            // Use a stopwatch to ensure we don't exceed the MusicBrainz rate limit
-            _stopWatchMusicBrainz.Start();
+/// <summary>
+/// Music album metadata provider for MusicBrainz.
+/// </summary>
+public class MusicBrainzAlbumProvider : IRemoteMetadataProvider<MusicAlbum, AlbumInfo>, IHasOrder, IDisposable
+{
+    private readonly Query _musicBrainzQuery;
 
-            Current = this;
-        }
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MusicBrainzAlbumProvider"/> class.
+    /// </summary>
+    public MusicBrainzAlbumProvider()
+    {
+        _musicBrainzQuery = new Query();
+    }
 
-        internal static MusicBrainzAlbumProvider Current { get; private set; }
+    /// <inheritdoc />
+    public string Name => "MusicBrainz";
 
-        /// <inheritdoc />
-        public string Name => "MusicBrainz";
+    /// <inheritdoc />
+    public int Order => 0;
 
-        /// <inheritdoc />
-        public int Order => 0;
+    /// <inheritdoc />
+    public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
+    {
+        var releaseId = searchInfo.GetReleaseId();
+        var releaseGroupId = searchInfo.GetReleaseGroupId();
 
-        /// <inheritdoc />
-        public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(AlbumInfo searchInfo, CancellationToken cancellationToken)
+        if (!string.IsNullOrEmpty(releaseId))
         {
-            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>();
+            var releaseResult = await _musicBrainzQuery.LookupReleaseAsync(new Guid(releaseId), Include.Releases, cancellationToken).ConfigureAwait(false);
+            return GetResultFromResponse(releaseResult).SingleItemAsEnumerable();
         }
 
-        private IEnumerable<RemoteSearchResult> GetResultsFromResponse(Stream stream)
+        if (!string.IsNullOrEmpty(releaseGroupId))
         {
-            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 results = ReleaseResult.Parse(reader);
-
-            return results.Select(i =>
-            {
-                var result = new RemoteSearchResult
-                {
-                    Name = i.Title,
-                    ProductionYear = i.Year
-                };
-
-                if (i.Artists.Count > 0)
-                {
-                    result.AlbumArtist = new RemoteSearchResult
-                    {
-                        SearchProviderName = Name,
-                        Name = i.Artists[0].Item1
-                    };
-
-                    result.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, i.Artists[0].Item2);
-                }
-
-                if (!string.IsNullOrWhiteSpace(i.ReleaseId))
-                {
-                    result.SetProviderId(MetadataProvider.MusicBrainzAlbum, i.ReleaseId);
-                }
-
-                if (!string.IsNullOrWhiteSpace(i.ReleaseGroupId))
-                {
-                    result.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, i.ReleaseGroupId);
-                }
-
-                return result;
-            });
+            var releaseGroupResult = await _musicBrainzQuery.LookupReleaseGroupAsync(new Guid(releaseGroupId), Include.ReleaseGroups, null, cancellationToken).ConfigureAwait(false);
+            return GetResultsFromResponse(releaseGroupResult.Releases);
         }
 
-        /// <inheritdoc />
-        public async Task<MetadataResult<MusicAlbum>> GetMetadata(AlbumInfo info, CancellationToken cancellationToken)
-        {
-            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);
+        var artistMusicBrainzId = searchInfo.GetMusicBrainzArtistId();
 
-                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;
-                }
-            }
+        if (!string.IsNullOrWhiteSpace(artistMusicBrainzId))
+        {
+            var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{searchInfo.Name}\" AND arid:{artistMusicBrainzId}", null, null, false, cancellationToken)
+                .ConfigureAwait(false);
 
-            // If we have a release Id but not a release group Id...
-            if (!string.IsNullOrWhiteSpace(releaseId) && string.IsNullOrWhiteSpace(releaseGroupId))
+            if (releaseSearchResults.Results.Count > 0)
             {
-                releaseGroupId = await GetReleaseGroupFromReleaseId(releaseId, cancellationToken).ConfigureAwait(false);
-                result.HasMetadata = true;
+                return GetResultsFromResponse(releaseSearchResults.Results);
             }
+        }
+        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);
 
-            if (!string.IsNullOrWhiteSpace(releaseId) || !string.IsNullOrWhiteSpace(releaseGroupId))
-            {
-                result.HasMetadata = true;
-            }
+            var releaseSearchResults = await _musicBrainzQuery.FindReleasesAsync($"\"{queryName}\" AND artist:\"{searchInfo.GetAlbumArtist()}\"c", 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 GetResultsFromResponse(releaseSearchResults.Results);
             }
-
-            return result;
         }
 
-        private Task<ReleaseResult> GetReleaseResult(string artistMusicBrainId, string artistName, string albumName, CancellationToken cancellationToken)
-        {
-            if (!string.IsNullOrEmpty(artistMusicBrainId))
-            {
-                return GetReleaseResult(albumName, artistMusicBrainId, cancellationToken);
-            }
-
-            if (string.IsNullOrWhiteSpace(artistName))
-            {
-                return Task.FromResult(new ReleaseResult());
-            }
+        return Enumerable.Empty<RemoteSearchResult>();
+    }
 
-            return GetReleaseResultByArtistName(albumName, artistName, cancellationToken);
+    private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IRelease>>? releaseSearchResults)
+    {
+        if (releaseSearchResults is null)
+        {
+            yield break;
         }
 
-        private async Task<ReleaseResult> GetReleaseResult(string albumName, string artistId, CancellationToken cancellationToken)
+        foreach (var result in releaseSearchResults)
         {
-            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
-            };
-
-            using var reader = XmlReader.Create(oReader, settings);
-            return ReleaseResult.Parse(reader).FirstOrDefault();
+            yield return GetResultFromResponse(result.Item);
         }
+    }
 
-        private async Task<ReleaseResult> GetReleaseResultByArtistName(string albumName, string artistName, CancellationToken cancellationToken)
+    private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<IRelease>? releaseSearchResults)
+    {
+        if (releaseSearchResults is null)
         {
-            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 break;
         }
 
-        private static (string Name, string ArtistId) ParseArtistCredit(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 "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 return GetResultFromResponse(result);
         }
+    }
 
-        private static (string Name, string ArtistId) ParseArtistNameCredit(XmlReader reader)
+    private RemoteSearchResult GetResultFromResponse(IRelease releaseSearchResult)
+    {
+        var searchResult = new RemoteSearchResult
         {
-            reader.MoveToContent();
-            reader.Read();
+            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 "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();
-                }
+                searchResult.AlbumArtist.SetProviderId(MetadataProvider.MusicBrainzArtist, releaseSearchResult.ArtistCredit[0].Artist!.Id.ToString());
             }
-
-            return (null, null);
         }
 
-        private static (string Name, string ArtistId) ParseArtistArtistCredit(XmlReader reader, string artistId)
-        {
-            reader.MoveToContent();
-            reader.Read();
-
-            string name = null;
-
-            // 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":
-                            {
-                                name = reader.ReadElementContentAsString();
-                                break;
-                            }
-
-                        default:
-                            {
-                                reader.Skip();
-                                break;
-                            }
-                    }
-                }
-                else
-                {
-                    reader.Read();
-                }
-            }
+        searchResult.SetProviderId(MetadataProvider.MusicBrainzAlbum, releaseSearchResult.Id.ToString());
 
-            return (name, artistId);
+        if (releaseSearchResult.ReleaseGroup?.Id is not null)
+        {
+            searchResult.SetProviderId(MetadataProvider.MusicBrainzReleaseGroup, releaseSearchResult.ReleaseGroup.Id.ToString());
         }
 
-        private async Task<string> GetReleaseIdFromReleaseGroupId(string releaseGroupId, CancellationToken cancellationToken)
-        {
-            var url = "/ws/2/release?release-group=" + releaseGroupId.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
-            };
+    /// <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);
-            var result = ReleaseResult.Parse(reader).FirstOrDefault();
+        var result = new MetadataResult<MusicAlbum>
+        {
+            Item = new MusicAlbum()
+        };
 
-            return result?.ReleaseId;
+        // 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.ReleaseGroups, null, cancellationToken).ConfigureAwait(false);
+            var release = releaseGroup.Releases?.Count > 0 ? releaseGroup.Releases[0] : null;
+            releaseId = release?.Id.ToString();
+            result.HasMetadata = true;
         }
 
-        /// <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)
+        // If there is no release ID, lookup a release with the info we have
+        if (string.IsNullOrWhiteSpace(releaseId))
         {
-            var url = "/ws/2/release-group/?query=reid:" + releaseEntryId.ToString(CultureInfo.InvariantCulture);
+            var artistMusicBrainzId = info.GetMusicBrainzArtistId();
+            IRelease? releaseResult = null;
 
-            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
+            if (!string.IsNullOrEmpty(artistMusicBrainzId))
             {
-                ValidationType = ValidationType.None,
-                CheckCharacters = false,
-                IgnoreProcessingInstructions = true,
-                IgnoreComments = true,
-                Async = true
-            };
-
-            using var reader = XmlReader.Create(oReader, settings);
-            await reader.MoveToContentAsync().ConfigureAwait(false);
-            await reader.ReadAsync().ConfigureAwait(false);
-
-            // Loop through each element
-            while (!reader.EOF && reader.ReadState == ReadState.Interactive)
-            {
-                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);
-                }
+                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;
-        }
-
-        private string GetFirstReleaseGroupId(XmlReader reader)
-        {
-            reader.MoveToContent();
-            reader.Read();
-
-            // Loop through each element
-            while (!reader.EOF && reader.ReadState == ReadState.Interactive)
+            else if (!string.IsNullOrEmpty(info.GetAlbumArtist()))
             {
-                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 artist:{info.GetAlbumArtist()}", 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
+            if (releaseResult != null)
             {
-                HttpResponseMessage response;
-                var attempts = 0u;
-                var requestUrl = _musicBrainzBaseUrl.TrimEnd('/') + url;
+                releaseId = releaseResult.Id.ToString();
 
-                do
+                if (releaseResult.ReleaseGroup?.Id is not null)
                 {
-                    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).
+                    releaseGroupId = releaseResult.ReleaseGroup.Id.ToString();
                 }
-                while (attempts < MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable);
 
-                // Log error if unable to query MB database due to throttling.
-                if (attempts == MusicBrainzQueryAttempts && response.StatusCode == HttpStatusCode.ServiceUnavailable)
-                {
-                    _logger.LogError("GetMusicBrainzResponse: 503 Service Unavailable (throttled) response received {0} times whilst requesting {1}", attempts, requestUrl);
-                }
-
-                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>
+/// MusicBrains Artist ExternalId.
+/// </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;
 }

+ 113 - 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,159 @@ 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";
-
-        /// <inheritdoc />
-        public async Task<IEnumerable<RemoteSearchResult>> GetSearchResults(ArtistInfo searchInfo, CancellationToken cancellationToken)
-        {
-            var musicBrainzId = searchInfo.GetMusicBrainzArtistId();
-
-            if (!string.IsNullOrWhiteSpace(musicBrainzId))
-            {
-                var url = "/ws/2/artist/?query=arid:{0}" + musicBrainzId.ToString(CultureInfo.InvariantCulture);
-
-                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
-            {
-                // They seem to throw bad request failures on any term with a slash
-                var nameToSearch = searchInfo.Name.Replace('/', ' ');
+using MediaBrowser.Providers.Music;
+using MetaBrainz.MusicBrainz;
+using MetaBrainz.MusicBrainz.Interfaces.Entities;
+using MetaBrainz.MusicBrainz.Interfaces.Searches;
 
-                var url = string.Format(CultureInfo.InvariantCulture, "/ws/2/artist/?query=\"{0}\"&dismax=true", UrlEncode(nameToSearch));
+namespace MediaBrowser.Providers.Plugins.MusicBrainz;
 
-                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();
+/// <summary>
+/// MusicBrainz artist provider.
+/// </summary>
+public class MusicBrainzArtistProvider : IRemoteMetadataProvider<MusicArtist, ArtistInfo>, IDisposable
+{
+    private readonly Query _musicBrainzQuery;
 
-                    if (results.Count > 0)
-                    {
-                        return results;
-                    }
-                }
+    /// <summary>
+    /// Initializes a new instance of the <see cref="MusicBrainzArtistProvider"/> class.
+    /// </summary>
+    public MusicBrainzArtistProvider()
+    {
+        _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.Artists, 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)
+        return Enumerable.Empty<RemoteSearchResult>();
+    }
+
+    private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<ISearchResult<IArtist>>? releaseSearchResults)
+    {
+        if (releaseSearchResults is null)
         {
-            var result = new RemoteSearchResult();
+            yield break;
+        }
 
-            reader.MoveToContent();
-            reader.Read();
+        foreach (var result in releaseSearchResults)
+        {
+            yield return GetResultFromResponse(result.Item);
+        }
+    }
 
-            // http://stackoverflow.com/questions/2299632/why-does-xmlreader-skip-every-other-element-if-there-is-no-whitespace-separator
+    private IEnumerable<RemoteSearchResult> GetResultsFromResponse(IEnumerable<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);
+        }
+    }
 
-                        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;
 }

+ 44 - 26
MediaBrowser.Providers/Plugins/MusicBrainz/Plugin.cs

@@ -1,45 +1,63 @@
-#nullable disable
-#pragma warning disable CS1591
-
 using System;
 using System.Collections.Generic;
+using System.Net.Http.Headers;
+using System.Reflection;
 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>
+    public Plugin(IApplicationPaths applicationPaths, IXmlSerializer xmlSerializer)
+        : 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("Jellyfin", Assembly.GetExecutingAssembly().GetName().Version?.ToString(3)));
+        Query.DefaultUserAgent.Add(new ProductInfoHeaderValue("(apps@jellyfin.org)"));
+        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"
+        };
     }
 }

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