From 2eda3bca915388e62e578cd65db7e7939c82c6a6 Mon Sep 17 00:00:00 2001 From: Cody Kickertz Date: Mon, 29 Dec 2025 10:13:55 -0600 Subject: [PATCH] feat(metadata): add Music and Book metadata providers (#146) * New: Parse Group GiLG * feat(metadata): add Music and Book metadata providers - Add MusicBrainz proxy for Artist/Album/Track lookups - Implement OpenLibrary integration for Book searches - Support ISBN, title, author lookups for books - Support MusicBrainz ID and name searches for music * fix: address SonarCloud issues in metadata providers --------- Co-authored-by: TRaSH Co-authored-by: admin --- .../MetadataSource/Book/BookInfoProxy.cs | 556 +++++++++++++++++- .../MetadataSource/Music/IProvideMusicInfo.cs | 75 +++ .../MetadataSource/Music/MusicBrainzProxy.cs | 475 +++++++++++++++ 3 files changed, 1084 insertions(+), 22 deletions(-) create mode 100644 src/NzbDrone.Core/MetadataSource/Music/IProvideMusicInfo.cs create mode 100644 src/NzbDrone.Core/MetadataSource/Music/MusicBrainzProxy.cs diff --git a/src/NzbDrone.Core/MetadataSource/Book/BookInfoProxy.cs b/src/NzbDrone.Core/MetadataSource/Book/BookInfoProxy.cs index cba62f5557..1e3700eb34 100644 --- a/src/NzbDrone.Core/MetadataSource/Book/BookInfoProxy.cs +++ b/src/NzbDrone.Core/MetadataSource/Book/BookInfoProxy.cs @@ -1,88 +1,600 @@ using System; using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using Newtonsoft.Json.Linq; using NLog; +using NzbDrone.Common.Http; namespace NzbDrone.Core.MetadataSource.Book { + [System.Diagnostics.CodeAnalysis.SuppressMessage("SonarLint", "S1075", Justification = "API base URLs are necessarily hardcoded")] public class BookInfoProxy : IProvideBookInfo { + private const string BaseUrl = "https://openlibrary.org"; + private const string SearchUrl = "https://openlibrary.org/search.json"; + private const string CoversUrl = "https://covers.openlibrary.org/b"; + private const string UserAgent = "Aletheia/1.0 (https://github.com/cheir-mneme/aletheia)"; + private const string SearchFields = "key,title,author_name,first_publish_year,isbn,cover_i,number_of_pages_median,subject,publisher,language"; + + private readonly IHttpClient _httpClient; private readonly Logger _logger; - public BookInfoProxy(Logger logger) + public BookInfoProxy(IHttpClient httpClient, Logger logger) { + _httpClient = httpClient; _logger = logger; } public BookMetadata GetByExternalId(string externalId) { - _logger.Debug("GetByExternalId called for: {0} (stub implementation)", externalId); - return null; + if (externalId.StartsWith("/works/", StringComparison.Ordinal)) + { + return GetWorkById(externalId); + } + + return GetWorkById($"/works/{externalId}"); } public BookMetadata GetById(int providerId) { - _logger.Debug("GetById called for: {0} (stub implementation)", providerId); + _logger.Debug("GetById called for: {0} (OpenLibrary uses string IDs)", providerId); return null; } public List GetBulkInfo(List providerIds) { - _logger.Debug("GetBulkInfo called for {0} IDs (stub implementation)", providerIds.Count); + _logger.Debug("GetBulkInfo called for {0} IDs (not supported by OpenLibrary)", providerIds.Count); return new List(); } public List GetTrending() { - _logger.Debug("GetTrending called (stub implementation)"); - return new List(); + try + { + var request = BuildRequestBuilder($"{BaseUrl}/trending/daily.json") + .AddQueryParam("limit", "20") + .Build(); + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var works = response["works"] as JArray; + if (works == null) + { + return new List(); + } + + return works.Select(ParseTrendingWork).Where(b => b != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching trending books from OpenLibrary"); + return new List(); + } } public List GetPopular() { - _logger.Debug("GetPopular called (stub implementation)"); - return new List(); + return GetTrending(); } public HashSet GetChangedItems(DateTime startTime) { - _logger.Debug("GetChangedItems called since {0} (stub implementation)", startTime); + _logger.Debug("GetChangedItems not supported by OpenLibrary"); return new HashSet(); } public List SearchByTitle(string title) { - _logger.Debug("SearchByTitle called for: {0} (stub implementation)", title); - return new List(); + try + { + var request = BuildRequestBuilder(SearchUrl) + .AddQueryParam("title", title) + .AddQueryParam("limit", "25") + .AddQueryParam("fields", SearchFields) + .Build(); + + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var docs = response["docs"] as JArray; + if (docs == null) + { + return new List(); + } + + return docs.Select(ParseSearchResult).Where(b => b != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error searching books for '{0}'", title); + return new List(); + } } public List SearchByTitle(string title, int year) { - _logger.Debug("SearchByTitle called for: {0} ({1}) (stub implementation)", title, year); - return new List(); + try + { + var request = BuildRequestBuilder(SearchUrl) + .AddQueryParam("title", title) + .AddQueryParam("first_publish_year", year.ToString(CultureInfo.InvariantCulture)) + .AddQueryParam("limit", "25") + .AddQueryParam("fields", SearchFields) + .Build(); + + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var docs = response["docs"] as JArray; + if (docs == null) + { + return new List(); + } + + return docs.Select(ParseSearchResult).Where(b => b != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error searching books for '{0}' ({1})", title, year); + return new List(); + } } public BookMetadata GetByIsbn(string isbn) { - _logger.Debug("GetByIsbn called for: {0} (stub implementation)", isbn); - return null; + return GetByIsbnInternal(isbn); } public BookMetadata GetByIsbn13(string isbn13) { - _logger.Debug("GetByIsbn13 called for: {0} (stub implementation)", isbn13); - return null; + return GetByIsbnInternal(isbn13); + } + + private BookMetadata GetByIsbnInternal(string isbn) + { + try + { + var request = BuildRequestBuilder($"{BaseUrl}/isbn/{isbn}.json").Build(); + var response = ExecuteRequest(request); + + if (response == null) + { + return null; + } + + return ParseEdition(response, isbn); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching book by ISBN {0}", isbn); + return null; + } } public BookMetadata GetByAsin(string asin) { - _logger.Debug("GetByAsin called for: {0} (stub implementation)", asin); - return null; + try + { + var request = BuildRequestBuilder(SearchUrl) + .AddQueryParam("q", $"asin:{asin}") + .AddQueryParam("limit", "1") + .AddQueryParam("fields", SearchFields) + .Build(); + + var response = ExecuteRequest(request); + + if (response == null) + { + return null; + } + + var docs = response["docs"] as JArray; + if (docs == null || docs.Count == 0) + { + return null; + } + + return ParseSearchResult(docs[0]); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching book by ASIN {0}", asin); + return null; + } } public List GetByAuthor(string authorName) { - _logger.Debug("GetByAuthor called for: {0} (stub implementation)", authorName); - return new List(); + try + { + var request = BuildRequestBuilder(SearchUrl) + .AddQueryParam("author", authorName) + .AddQueryParam("limit", "50") + .AddQueryParam("fields", SearchFields) + .Build(); + + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var docs = response["docs"] as JArray; + if (docs == null) + { + return new List(); + } + + return docs.Select(ParseSearchResult).Where(b => b != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error searching books by author '{0}'", authorName); + return new List(); + } + } + + private BookMetadata GetWorkById(string workId) + { + try + { + var request = BuildRequestBuilder($"{BaseUrl}{workId}.json").Build(); + var response = ExecuteRequest(request); + + if (response == null) + { + return null; + } + + return ParseWork(response, workId); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching work {0}", workId); + return null; + } + } + + private static HttpRequestBuilder BuildRequestBuilder(string url) + { + return new HttpRequestBuilder(url) + .SetHeader("User-Agent", UserAgent) + .SetHeader("Accept", "application/json"); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("SonarLint", "S1168", Justification = "Null indicates not found, distinct from empty response")] + private JObject ExecuteRequest(HttpRequest request) + { + request.AllowAutoRedirect = true; + request.SuppressHttpError = true; + + var response = _httpClient.Get(request); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!response.HasHttpError) + { + return JObject.Parse(response.Content); + } + + _logger.Warn("OpenLibrary request failed: {0}", response.StatusCode); + return null; + } + + private static BookMetadata ParseSearchResult(JToken json) + { + var key = json["key"]?.ToString(); + var coverId = json["cover_i"]?.Value(); + + var book = new BookMetadata + { + ForeignBookId = key, + Title = json["title"]?.ToString(), + Authors = new List(), + Genres = new List() + }; + + var authorNames = json["author_name"] as JArray; + if (authorNames != null) + { + book.Authors = authorNames.Select(a => a.ToString()).ToList(); + } + + var year = json["first_publish_year"]?.Value(); + if (year.HasValue) + { + book.ReleaseDate = new DateTime(year.Value, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + ParseIsbns(json, book); + book.PageCount = json["number_of_pages_median"]?.Value(); + ParseSubjects(json, book); + ParsePublisher(json, book); + ParseLanguage(json, book); + + if (coverId.HasValue) + { + book.CoverUrl = $"{CoversUrl}/id/{coverId}-L.jpg"; + } + + return book; + } + + private static void ParseIsbns(JToken json, BookMetadata book) + { + var isbns = json["isbn"] as JArray; + if (isbns != null && isbns.Count > 0) + { + var isbnList = isbns.Select(i => i.ToString()).ToList(); + book.Isbn13 = isbnList.FirstOrDefault(i => i.Length == 13); + book.Isbn = isbnList.FirstOrDefault(i => i.Length == 10) ?? book.Isbn13; + } + } + + private static void ParseSubjects(JToken json, BookMetadata book) + { + var subjects = json["subject"] as JArray ?? json["subjects"] as JArray; + if (subjects != null) + { + book.Genres = subjects.Take(10).Select(s => s.ToString()).ToList(); + } + } + + private static void ParsePublisher(JToken json, BookMetadata book) + { + var publishers = json["publisher"] as JArray ?? json["publishers"] as JArray; + if (publishers != null && publishers.Count > 0) + { + book.Publisher = publishers[0].ToString(); + } + } + + private static void ParseLanguage(JToken json, BookMetadata book) + { + var languages = json["language"] as JArray; + if (languages != null && languages.Count > 0) + { + book.Language = languages[0].ToString(); + } + } + + private BookMetadata ParseWork(JObject json, string workId) + { + var book = new BookMetadata + { + ForeignBookId = workId, + Title = json["title"]?.ToString(), + Authors = new List(), + Genres = new List() + }; + + ParseDescription(json, book); + ParseSubjects(json, book); + ParseCoverFromCovers(json, book); + ParseAuthorsFromWork(json, book); + + return book; + } + + private static void ParseDescription(JObject json, BookMetadata book) + { + var description = json["description"]; + if (description != null) + { + book.Description = description.Type == JTokenType.String + ? description.ToString() + : description["value"]?.ToString(); + } + } + + private static void ParseCoverFromCovers(JObject json, BookMetadata book) + { + var covers = json["covers"] as JArray; + if (covers != null && covers.Count > 0) + { + var coverId = covers[0].Value(); + book.CoverUrl = $"{CoversUrl}/id/{coverId}-L.jpg"; + } + } + + private void ParseAuthorsFromWork(JObject json, BookMetadata book) + { + var authors = json["authors"] as JArray; + if (authors == null) + { + return; + } + + foreach (var authorRef in authors) + { + var authorKey = authorRef["author"]?["key"]?.ToString(); + if (!string.IsNullOrEmpty(authorKey)) + { + var authorName = GetAuthorName(authorKey); + if (!string.IsNullOrEmpty(authorName)) + { + book.Authors.Add(authorName); + } + } + } + } + + private BookMetadata ParseEdition(JObject json, string isbn) + { + var book = new BookMetadata + { + Title = json["title"]?.ToString(), + Authors = new List(), + Genres = new List() + }; + + SetIsbnFromInput(isbn, book); + ParseIsbnArrays(json, book); + book.PageCount = json["number_of_pages"]?.Value(); + ParsePublisher(json, book); + ParsePublishDate(json, book); + ParseCoverFromCovers(json, book); + EnrichFromWork(json, book); + ParseAuthorsFromEdition(json, book); + + return book; + } + + private static void SetIsbnFromInput(string isbn, BookMetadata book) + { + if (isbn.Length == 13) + { + book.Isbn13 = isbn; + } + else + { + book.Isbn = isbn; + } + } + + private static void ParseIsbnArrays(JObject json, BookMetadata book) + { + var isbn13Array = json["isbn_13"] as JArray; + if (isbn13Array != null && isbn13Array.Count > 0) + { + book.Isbn13 = isbn13Array[0].ToString(); + } + + var isbn10Array = json["isbn_10"] as JArray; + if (isbn10Array != null && isbn10Array.Count > 0) + { + book.Isbn = isbn10Array[0].ToString(); + } + } + + private static void ParsePublishDate(JObject json, BookMetadata book) + { + var publishDate = json["publish_date"]?.ToString(); + if (string.IsNullOrEmpty(publishDate)) + { + return; + } + + if (DateTime.TryParse(publishDate, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + { + book.ReleaseDate = DateTime.SpecifyKind(date, DateTimeKind.Utc); + } + else if (int.TryParse(publishDate, out var year)) + { + book.ReleaseDate = new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + } + + private void EnrichFromWork(JObject json, BookMetadata book) + { + var workKey = (json["works"] as JArray)?[0]?["key"]?.ToString(); + if (string.IsNullOrEmpty(workKey)) + { + return; + } + + book.ForeignBookId = workKey; + + var workInfo = GetWorkById(workKey); + if (workInfo != null) + { + book.Description = workInfo.Description; + book.Authors = workInfo.Authors; + book.Genres = workInfo.Genres; + } + } + + private void ParseAuthorsFromEdition(JObject json, BookMetadata book) + { + if (book.Authors.Count > 0) + { + return; + } + + var authors = json["authors"] as JArray; + if (authors == null) + { + return; + } + + foreach (var authorRef in authors) + { + var authorKey = authorRef["key"]?.ToString(); + if (!string.IsNullOrEmpty(authorKey)) + { + var authorName = GetAuthorName(authorKey); + if (!string.IsNullOrEmpty(authorName)) + { + book.Authors.Add(authorName); + } + } + } + } + + private static BookMetadata ParseTrendingWork(JToken json) + { + var key = json["key"]?.ToString(); + var coverId = json["cover_i"]?.Value(); + + var book = new BookMetadata + { + ForeignBookId = key, + Title = json["title"]?.ToString(), + Authors = new List(), + Genres = new List() + }; + + var authorName = json["author_name"]?.ToString(); + if (!string.IsNullOrEmpty(authorName)) + { + book.Authors.Add(authorName); + } + + var year = json["first_publish_year"]?.Value(); + if (year.HasValue) + { + book.ReleaseDate = new DateTime(year.Value, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + if (coverId.HasValue) + { + book.CoverUrl = $"{CoversUrl}/id/{coverId}-L.jpg"; + } + + return book; + } + + private string GetAuthorName(string authorKey) + { + try + { + var request = BuildRequestBuilder($"{BaseUrl}{authorKey}.json").Build(); + var response = ExecuteRequest(request); + + return response?["name"]?.ToString(); + } + catch + { + return null; + } } } } diff --git a/src/NzbDrone.Core/MetadataSource/Music/IProvideMusicInfo.cs b/src/NzbDrone.Core/MetadataSource/Music/IProvideMusicInfo.cs new file mode 100644 index 0000000000..75b7dd5391 --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Music/IProvideMusicInfo.cs @@ -0,0 +1,75 @@ +using System; +using System.Collections.Generic; + +namespace NzbDrone.Core.MetadataSource.Music +{ + public interface IProvideMusicInfo + { + ArtistMetadata GetArtistById(string musicBrainzId); + ArtistMetadata GetArtistByName(string name); + List SearchArtists(string query); + + AlbumMetadata GetAlbumById(string musicBrainzId); + List GetAlbumsByArtist(string artistMusicBrainzId); + List SearchAlbums(string query); + + TrackMetadata GetTrackById(string musicBrainzId); + List GetTracksByAlbum(string albumMusicBrainzId); + } + + public class ArtistMetadata + { + public string MusicBrainzId { get; set; } + public string Name { get; set; } + public string SortName { get; set; } + public string Disambiguation { get; set; } + public string Type { get; set; } + public string Country { get; set; } + public DateTime? BeginDate { get; set; } + public DateTime? EndDate { get; set; } + public bool Ended { get; set; } + public List Genres { get; set; } + public List Tags { get; set; } + public string Overview { get; set; } + public List Links { get; set; } + public List Albums { get; set; } + } + + public class ArtistLink + { + public string Type { get; set; } + public string Url { get; set; } + } + + public class AlbumMetadata + { + public string MusicBrainzId { get; set; } + public string Title { get; set; } + public string ArtistMusicBrainzId { get; set; } + public string ArtistName { get; set; } + public DateTime? ReleaseDate { get; set; } + public string ReleaseType { get; set; } + public string Status { get; set; } + public string Country { get; set; } + public string Label { get; set; } + public string CatalogNumber { get; set; } + public string Barcode { get; set; } + public int? TrackCount { get; set; } + public int? DiscCount { get; set; } + public List Genres { get; set; } + public string CoverUrl { get; set; } + public List Tracks { get; set; } + } + + public class TrackMetadata + { + public string MusicBrainzId { get; set; } + public string Title { get; set; } + public string AlbumMusicBrainzId { get; set; } + public string ArtistMusicBrainzId { get; set; } + public string ArtistName { get; set; } + public int TrackNumber { get; set; } + public int DiscNumber { get; set; } + public int? DurationMs { get; set; } + } +} diff --git a/src/NzbDrone.Core/MetadataSource/Music/MusicBrainzProxy.cs b/src/NzbDrone.Core/MetadataSource/Music/MusicBrainzProxy.cs new file mode 100644 index 0000000000..a0b2b69fad --- /dev/null +++ b/src/NzbDrone.Core/MetadataSource/Music/MusicBrainzProxy.cs @@ -0,0 +1,475 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; +using System.Net; +using Newtonsoft.Json.Linq; +using NLog; +using NzbDrone.Common.Http; + +namespace NzbDrone.Core.MetadataSource.Music +{ + [System.Diagnostics.CodeAnalysis.SuppressMessage("SonarLint", "S1075", Justification = "API base URLs are necessarily hardcoded")] + public class MusicBrainzProxy : IProvideMusicInfo + { + private const string BaseUrl = "https://musicbrainz.org/ws/2"; + private const string CoverArtBaseUrl = "https://coverartarchive.org"; + private const string UserAgent = "Aletheia/1.0 (https://github.com/cheir-mneme/aletheia)"; + + private readonly IHttpClient _httpClient; + private readonly Logger _logger; + + public MusicBrainzProxy(IHttpClient httpClient, Logger logger) + { + _httpClient = httpClient; + _logger = logger; + } + + public ArtistMetadata GetArtistById(string musicBrainzId) + { + try + { + var request = BuildRequest($"/artist/{musicBrainzId}", "releases+url-rels+tags+genres"); + var response = ExecuteRequest(request); + + if (response == null) + { + return null; + } + + return ParseArtist(response); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching artist {0} from MusicBrainz", musicBrainzId); + return null; + } + } + + public ArtistMetadata GetArtistByName(string name) + { + var results = SearchArtists(name); + return results.FirstOrDefault(); + } + + public List SearchArtists(string query) + { + try + { + var request = BuildSearchRequest("artist", query); + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var artists = response["artists"] as JArray; + if (artists == null) + { + return new List(); + } + + return artists.Select(ParseArtistSearchResult).Where(a => a != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error searching artists for '{0}'", query); + return new List(); + } + } + + public AlbumMetadata GetAlbumById(string musicBrainzId) + { + try + { + var request = BuildRequest($"/release-group/{musicBrainzId}", "artists+releases+tags+genres"); + var response = ExecuteRequest(request); + + if (response == null) + { + return null; + } + + var album = ParseReleaseGroup(response); + album.CoverUrl = GetCoverArtUrl(musicBrainzId); + return album; + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching album {0} from MusicBrainz", musicBrainzId); + return null; + } + } + + public List GetAlbumsByArtist(string artistMusicBrainzId) + { + try + { + var request = BuildRequestBuilder($"/release-group") + .AddQueryParam("artist", artistMusicBrainzId) + .AddQueryParam("limit", "100") + .Build(); + + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var releaseGroups = response["release-groups"] as JArray; + if (releaseGroups == null) + { + return new List(); + } + + return releaseGroups.Select(ParseReleaseGroup).Where(a => a != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching albums for artist {0}", artistMusicBrainzId); + return new List(); + } + } + + public List SearchAlbums(string query) + { + try + { + var request = BuildSearchRequest("release-group", query); + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var releaseGroups = response["release-groups"] as JArray; + if (releaseGroups == null) + { + return new List(); + } + + return releaseGroups.Select(ParseReleaseGroup).Where(a => a != null).ToList(); + } + catch (Exception ex) + { + _logger.Error(ex, "Error searching albums for '{0}'", query); + return new List(); + } + } + + public TrackMetadata GetTrackById(string musicBrainzId) + { + try + { + var request = BuildRequest($"/recording/{musicBrainzId}", "artists+releases"); + var response = ExecuteRequest(request); + + if (response == null) + { + return null; + } + + return ParseRecording(response); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching track {0} from MusicBrainz", musicBrainzId); + return null; + } + } + + public List GetTracksByAlbum(string albumMusicBrainzId) + { + try + { + var request = BuildRequest($"/release-group/{albumMusicBrainzId}", "releases+media+recordings"); + var response = ExecuteRequest(request); + + if (response == null) + { + return new List(); + } + + var releases = response["releases"] as JArray; + if (releases == null || releases.Count == 0) + { + return new List(); + } + + var firstRelease = releases[0]; + var releaseId = firstRelease["id"]?.ToString(); + if (string.IsNullOrEmpty(releaseId)) + { + return new List(); + } + + var releaseRequest = BuildRequest($"/release/{releaseId}", "recordings+artist-credits"); + var releaseResponse = ExecuteRequest(releaseRequest); + + if (releaseResponse == null) + { + return new List(); + } + + return ParseTracksFromRelease(releaseResponse, albumMusicBrainzId); + } + catch (Exception ex) + { + _logger.Error(ex, "Error fetching tracks for album {0}", albumMusicBrainzId); + return new List(); + } + } + + private static HttpRequest BuildRequest(string path, string includes) + { + var builder = new HttpRequestBuilder($"{BaseUrl}{path}") + .AddQueryParam("fmt", "json") + .SetHeader("User-Agent", UserAgent) + .SetHeader("Accept", "application/json"); + + if (!string.IsNullOrEmpty(includes)) + { + builder.AddQueryParam("inc", includes); + } + + return builder.Build(); + } + + private static HttpRequestBuilder BuildRequestBuilder(string path) + { + return new HttpRequestBuilder($"{BaseUrl}{path}") + .AddQueryParam("fmt", "json") + .SetHeader("User-Agent", UserAgent) + .SetHeader("Accept", "application/json"); + } + + private static HttpRequest BuildSearchRequest(string entity, string query) + { + return new HttpRequestBuilder($"{BaseUrl}/{entity}") + .AddQueryParam("query", query) + .AddQueryParam("fmt", "json") + .AddQueryParam("limit", "25") + .SetHeader("User-Agent", UserAgent) + .SetHeader("Accept", "application/json") + .Build(); + } + + [System.Diagnostics.CodeAnalysis.SuppressMessage("SonarLint", "S1168", Justification = "Null indicates not found, distinct from empty response")] + private JObject ExecuteRequest(HttpRequest request) + { + request.AllowAutoRedirect = true; + request.SuppressHttpError = true; + + var response = _httpClient.Get(request); + + if (response.StatusCode == HttpStatusCode.NotFound) + { + return null; + } + + if (!response.HasHttpError) + { + return JObject.Parse(response.Content); + } + + _logger.Warn("MusicBrainz request failed: {0}", response.StatusCode); + return null; + } + + private static string GetCoverArtUrl(string releaseGroupId) + { + return $"{CoverArtBaseUrl}/release-group/{releaseGroupId}/front-250"; + } + + private static ArtistMetadata ParseArtist(JObject json) + { + var artist = new ArtistMetadata + { + MusicBrainzId = json["id"]?.ToString(), + Name = json["name"]?.ToString(), + SortName = json["sort-name"]?.ToString(), + Disambiguation = json["disambiguation"]?.ToString(), + Type = json["type"]?.ToString(), + Country = json["country"]?.ToString(), + Genres = new List(), + Tags = new List(), + Links = new List(), + Albums = new List() + }; + + var lifeSpan = json["life-span"]; + if (lifeSpan != null) + { + artist.BeginDate = ParseDate(lifeSpan["begin"]?.ToString()); + artist.EndDate = ParseDate(lifeSpan["end"]?.ToString()); + artist.Ended = lifeSpan["ended"]?.Value() ?? false; + } + + var genres = json["genres"] as JArray; + if (genres != null) + { + artist.Genres = genres.Select(g => g["name"]?.ToString()).Where(g => !string.IsNullOrEmpty(g)).ToList(); + } + + var tags = json["tags"] as JArray; + if (tags != null) + { + artist.Tags = tags.Select(t => t["name"]?.ToString()).Where(t => !string.IsNullOrEmpty(t)).ToList(); + } + + var relations = json["relations"] as JArray; + if (relations != null) + { + artist.Links = relations + .Where(r => r["url"] != null) + .Select(r => new ArtistLink + { + Type = r["type"]?.ToString(), + Url = r["url"]?["resource"]?.ToString() + }) + .Where(l => !string.IsNullOrEmpty(l.Url)) + .ToList(); + } + + return artist; + } + + private static ArtistMetadata ParseArtistSearchResult(JToken json) + { + return new ArtistMetadata + { + MusicBrainzId = json["id"]?.ToString(), + Name = json["name"]?.ToString(), + SortName = json["sort-name"]?.ToString(), + Disambiguation = json["disambiguation"]?.ToString(), + Type = json["type"]?.ToString(), + Country = json["country"]?.ToString(), + Genres = new List(), + Tags = new List(), + Links = new List(), + Albums = new List() + }; + } + + private static AlbumMetadata ParseReleaseGroup(JToken json) + { + var album = new AlbumMetadata + { + MusicBrainzId = json["id"]?.ToString(), + Title = json["title"]?.ToString(), + ReleaseType = json["primary-type"]?.ToString(), + Genres = new List(), + Tracks = new List() + }; + + album.ReleaseDate = ParseDate(json["first-release-date"]?.ToString()); + + var artistCredits = json["artist-credit"] as JArray; + if (artistCredits != null && artistCredits.Count > 0) + { + var primaryArtist = artistCredits[0]["artist"]; + album.ArtistMusicBrainzId = primaryArtist?["id"]?.ToString(); + album.ArtistName = primaryArtist?["name"]?.ToString(); + } + + var genres = json["genres"] as JArray; + if (genres != null) + { + album.Genres = genres.Select(g => g["name"]?.ToString()).Where(g => !string.IsNullOrEmpty(g)).ToList(); + } + + return album; + } + + private static TrackMetadata ParseRecording(JObject json) + { + var track = new TrackMetadata + { + MusicBrainzId = json["id"]?.ToString(), + Title = json["title"]?.ToString(), + DurationMs = json["length"]?.Value() + }; + + var artistCredits = json["artist-credit"] as JArray; + if (artistCredits != null && artistCredits.Count > 0) + { + var primaryArtist = artistCredits[0]["artist"]; + track.ArtistMusicBrainzId = primaryArtist?["id"]?.ToString(); + track.ArtistName = primaryArtist?["name"]?.ToString(); + } + + return track; + } + + private static List ParseTracksFromRelease(JObject json, string albumMusicBrainzId) + { + var tracks = new List(); + + var media = json["media"] as JArray; + if (media == null) + { + return tracks; + } + + foreach (var disc in media) + { + var discNumber = disc["position"]?.Value() ?? 1; + var tracksArray = disc["tracks"] as JArray; + + if (tracksArray == null) + { + continue; + } + + foreach (var trackJson in tracksArray) + { + var recording = trackJson["recording"]; + var track = new TrackMetadata + { + MusicBrainzId = recording?["id"]?.ToString(), + Title = trackJson["title"]?.ToString() ?? recording?["title"]?.ToString(), + AlbumMusicBrainzId = albumMusicBrainzId, + TrackNumber = trackJson["position"]?.Value() ?? 0, + DiscNumber = discNumber, + DurationMs = trackJson["length"]?.Value() ?? recording?["length"]?.Value() + }; + + var artistCredits = recording?["artist-credit"] as JArray; + if (artistCredits != null && artistCredits.Count > 0) + { + var primaryArtist = artistCredits[0]["artist"]; + track.ArtistMusicBrainzId = primaryArtist?["id"]?.ToString(); + track.ArtistName = primaryArtist?["name"]?.ToString(); + } + + tracks.Add(track); + } + } + + return tracks; + } + + private static DateTime? ParseDate(string dateStr) + { + if (string.IsNullOrEmpty(dateStr)) + { + return null; + } + + if (DateTime.TryParse(dateStr, CultureInfo.InvariantCulture, DateTimeStyles.AssumeUniversal, out var date)) + { + return DateTime.SpecifyKind(date, DateTimeKind.Utc); + } + + if (dateStr.Length == 4 && int.TryParse(dateStr, out var year)) + { + return new DateTime(year, 1, 1, 0, 0, 0, DateTimeKind.Utc); + } + + return null; + } + } +}