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 <trash-pm@protonmail.ch>
Co-authored-by: admin <admin@ardentleatherworks.com>
This commit is contained in:
Cody Kickertz 2025-12-29 10:13:55 -06:00 committed by GitHub
parent 840f65fe99
commit 2eda3bca91
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 1084 additions and 22 deletions

View file

@ -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<BookMetadata> GetBulkInfo(List<int> 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<BookMetadata>();
}
public List<BookMetadata> GetTrending()
{
_logger.Debug("GetTrending called (stub implementation)");
return new List<BookMetadata>();
try
{
var request = BuildRequestBuilder($"{BaseUrl}/trending/daily.json")
.AddQueryParam("limit", "20")
.Build();
var response = ExecuteRequest(request);
if (response == null)
{
return new List<BookMetadata>();
}
var works = response["works"] as JArray;
if (works == null)
{
return new List<BookMetadata>();
}
return works.Select(ParseTrendingWork).Where(b => b != null).ToList();
}
catch (Exception ex)
{
_logger.Error(ex, "Error fetching trending books from OpenLibrary");
return new List<BookMetadata>();
}
}
public List<BookMetadata> GetPopular()
{
_logger.Debug("GetPopular called (stub implementation)");
return new List<BookMetadata>();
return GetTrending();
}
public HashSet<int> GetChangedItems(DateTime startTime)
{
_logger.Debug("GetChangedItems called since {0} (stub implementation)", startTime);
_logger.Debug("GetChangedItems not supported by OpenLibrary");
return new HashSet<int>();
}
public List<BookMetadata> SearchByTitle(string title)
{
_logger.Debug("SearchByTitle called for: {0} (stub implementation)", title);
return new List<BookMetadata>();
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<BookMetadata>();
}
var docs = response["docs"] as JArray;
if (docs == null)
{
return new List<BookMetadata>();
}
return docs.Select(ParseSearchResult).Where(b => b != null).ToList();
}
catch (Exception ex)
{
_logger.Error(ex, "Error searching books for '{0}'", title);
return new List<BookMetadata>();
}
}
public List<BookMetadata> SearchByTitle(string title, int year)
{
_logger.Debug("SearchByTitle called for: {0} ({1}) (stub implementation)", title, year);
return new List<BookMetadata>();
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<BookMetadata>();
}
var docs = response["docs"] as JArray;
if (docs == null)
{
return new List<BookMetadata>();
}
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<BookMetadata>();
}
}
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<BookMetadata> GetByAuthor(string authorName)
{
_logger.Debug("GetByAuthor called for: {0} (stub implementation)", authorName);
return new List<BookMetadata>();
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<BookMetadata>();
}
var docs = response["docs"] as JArray;
if (docs == null)
{
return new List<BookMetadata>();
}
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<BookMetadata>();
}
}
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<int?>();
var book = new BookMetadata
{
ForeignBookId = key,
Title = json["title"]?.ToString(),
Authors = new List<string>(),
Genres = new List<string>()
};
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<int?>();
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<int?>();
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<string>(),
Genres = new List<string>()
};
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<int>();
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<string>(),
Genres = new List<string>()
};
SetIsbnFromInput(isbn, book);
ParseIsbnArrays(json, book);
book.PageCount = json["number_of_pages"]?.Value<int?>();
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<int?>();
var book = new BookMetadata
{
ForeignBookId = key,
Title = json["title"]?.ToString(),
Authors = new List<string>(),
Genres = new List<string>()
};
var authorName = json["author_name"]?.ToString();
if (!string.IsNullOrEmpty(authorName))
{
book.Authors.Add(authorName);
}
var year = json["first_publish_year"]?.Value<int?>();
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;
}
}
}
}

View file

@ -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<ArtistMetadata> SearchArtists(string query);
AlbumMetadata GetAlbumById(string musicBrainzId);
List<AlbumMetadata> GetAlbumsByArtist(string artistMusicBrainzId);
List<AlbumMetadata> SearchAlbums(string query);
TrackMetadata GetTrackById(string musicBrainzId);
List<TrackMetadata> 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<string> Genres { get; set; }
public List<string> Tags { get; set; }
public string Overview { get; set; }
public List<ArtistLink> Links { get; set; }
public List<AlbumMetadata> 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<string> Genres { get; set; }
public string CoverUrl { get; set; }
public List<TrackMetadata> 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; }
}
}

View file

@ -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<ArtistMetadata> SearchArtists(string query)
{
try
{
var request = BuildSearchRequest("artist", query);
var response = ExecuteRequest(request);
if (response == null)
{
return new List<ArtistMetadata>();
}
var artists = response["artists"] as JArray;
if (artists == null)
{
return new List<ArtistMetadata>();
}
return artists.Select(ParseArtistSearchResult).Where(a => a != null).ToList();
}
catch (Exception ex)
{
_logger.Error(ex, "Error searching artists for '{0}'", query);
return new List<ArtistMetadata>();
}
}
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<AlbumMetadata> 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<AlbumMetadata>();
}
var releaseGroups = response["release-groups"] as JArray;
if (releaseGroups == null)
{
return new List<AlbumMetadata>();
}
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<AlbumMetadata>();
}
}
public List<AlbumMetadata> SearchAlbums(string query)
{
try
{
var request = BuildSearchRequest("release-group", query);
var response = ExecuteRequest(request);
if (response == null)
{
return new List<AlbumMetadata>();
}
var releaseGroups = response["release-groups"] as JArray;
if (releaseGroups == null)
{
return new List<AlbumMetadata>();
}
return releaseGroups.Select(ParseReleaseGroup).Where(a => a != null).ToList();
}
catch (Exception ex)
{
_logger.Error(ex, "Error searching albums for '{0}'", query);
return new List<AlbumMetadata>();
}
}
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<TrackMetadata> GetTracksByAlbum(string albumMusicBrainzId)
{
try
{
var request = BuildRequest($"/release-group/{albumMusicBrainzId}", "releases+media+recordings");
var response = ExecuteRequest(request);
if (response == null)
{
return new List<TrackMetadata>();
}
var releases = response["releases"] as JArray;
if (releases == null || releases.Count == 0)
{
return new List<TrackMetadata>();
}
var firstRelease = releases[0];
var releaseId = firstRelease["id"]?.ToString();
if (string.IsNullOrEmpty(releaseId))
{
return new List<TrackMetadata>();
}
var releaseRequest = BuildRequest($"/release/{releaseId}", "recordings+artist-credits");
var releaseResponse = ExecuteRequest(releaseRequest);
if (releaseResponse == null)
{
return new List<TrackMetadata>();
}
return ParseTracksFromRelease(releaseResponse, albumMusicBrainzId);
}
catch (Exception ex)
{
_logger.Error(ex, "Error fetching tracks for album {0}", albumMusicBrainzId);
return new List<TrackMetadata>();
}
}
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<string>(),
Tags = new List<string>(),
Links = new List<ArtistLink>(),
Albums = new List<AlbumMetadata>()
};
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<bool>() ?? 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<string>(),
Tags = new List<string>(),
Links = new List<ArtistLink>(),
Albums = new List<AlbumMetadata>()
};
}
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<string>(),
Tracks = new List<TrackMetadata>()
};
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<int>()
};
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<TrackMetadata> ParseTracksFromRelease(JObject json, string albumMusicBrainzId)
{
var tracks = new List<TrackMetadata>();
var media = json["media"] as JArray;
if (media == null)
{
return tracks;
}
foreach (var disc in media)
{
var discNumber = disc["position"]?.Value<int>() ?? 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<int>() ?? 0,
DiscNumber = discNumber,
DurationMs = trackJson["length"]?.Value<int>() ?? recording?["length"]?.Value<int>()
};
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;
}
}
}