mirror of
https://github.com/Radarr/Radarr
synced 2026-01-23 07:54:53 +01:00
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:
parent
840f65fe99
commit
2eda3bca91
3 changed files with 1084 additions and 22 deletions
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
75
src/NzbDrone.Core/MetadataSource/Music/IProvideMusicInfo.cs
Normal file
75
src/NzbDrone.Core/MetadataSource/Music/IProvideMusicInfo.cs
Normal 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; }
|
||||
}
|
||||
}
|
||||
475
src/NzbDrone.Core/MetadataSource/Music/MusicBrainzProxy.cs
Normal file
475
src/NzbDrone.Core/MetadataSource/Music/MusicBrainzProxy.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue