Files
lampac/mods/Music/Providers/Metadata/MusicBrainz/MusicBrainzMetadataProvider.cs
T

1554 lines
59 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
using System.Net.Http;
using System.Text.Json.Nodes;
using System.Text.RegularExpressions;
namespace Music;
public class MusicBrainzMetadataProvider : IMusicMetadataProvider
{
static readonly HttpClient httpClient = MusicHttp.CreateClient("musicbrainz");
static readonly TimeSpan requestTimeout = TimeSpan.FromSeconds(20);
static readonly SemaphoreSlim requestGate = new(1, 1);
static DateTime nextRequestAt = DateTime.MinValue;
const string baseUrl = "https://musicbrainz.org/ws/2";
const string userAgent = "LampacNextgenMusic/0.1 (https://github.com/lampac-nextgen/lampac)";
public string Id => "musicbrainz";
public string Name => "MusicBrainz";
public bool Enabled => true;
static MusicBrainzMetadataProvider()
{
httpClient.Timeout = requestTimeout;
}
public async Task<MusicSearchResult> SearchAsync(string query, bool expanded = false, CancellationToken cancellationToken = default)
{
if (string.IsNullOrWhiteSpace(query))
return new MusicSearchResult();
query = query.Trim();
var artistQuery = query;
var usedTranslitArtistFallback = false;
var artistsJson = await GetJsonAsync($"artist?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 16 : 8)}&fmt=json", cancellationToken);
var artists = RankArtists(ParseArtists(artistsJson?["artists"] as JsonArray), query, expanded ? 16 : 8);
if (artists.Count == 0 && MusicMapSupport.ContainsCyrillic(query))
{
string fallbackQuery = MusicMapSupport.TransliterateCyrillicToLatinForSearch(query);
if (!string.IsNullOrWhiteSpace(fallbackQuery)
&& !string.Equals(fallbackQuery, query, StringComparison.OrdinalIgnoreCase))
{
var fallbackArtistsJson = await GetJsonAsync($"artist?query={HttpUtility.UrlEncode(fallbackQuery)}&limit={(expanded ? 16 : 8)}&fmt=json", cancellationToken);
var fallbackArtists = DeduplicateFallbackArtists(
RankArtists(ParseArtists(fallbackArtistsJson?["artists"] as JsonArray), fallbackQuery, expanded ? 16 : 8)
);
if (fallbackArtists.Count > 0)
{
artistQuery = fallbackQuery;
artists = fallbackArtists;
usedTranslitArtistFallback = true;
}
}
}
await DiscogsArtistImageService.ApplyCachedAsync(artists, cancellationToken);
DiscogsArtistImageService.WarmupMissing(artists);
if (usedTranslitArtistFallback)
{
return new MusicSearchResult
{
artists = artists
};
}
var context = BuildSearchContext(artistQuery, artists);
var isArtistOnly = context?.BestArtist != null && context.IsArtistOnly;
var albumsTask = GetJsonAsync(BuildAlbumSearchPath(context, expanded), cancellationToken);
var tracksTask = isArtistOnly ? null : GetJsonAsync(BuildTrackSearchPath(context, expanded), cancellationToken);
var albumsJson = await albumsTask;
var allAlbums = ParseAlbums(albumsJson?["release-groups"] as JsonArray);
ApplyBestArtistFallback(allAlbums, context?.BestArtist);
var albums = RankAlbums(allAlbums, context, expanded ? 100 : 24);
var preferredAlbumTitles = albums
.Take(8)
.Select(item => NormalizeText(item.title))
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToHashSet(StringComparer.Ordinal);
var preferredAlbumArtists = albums
.Take(8)
.Select(item => NormalizeText(item.artist_name))
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToHashSet(StringComparer.Ordinal);
List<MusicTrack> tracks;
if (isArtistOnly)
tracks = await BuildArtistOnlyTracksAsync(context, allAlbums, preferredAlbumTitles, preferredAlbumArtists, expanded, cancellationToken);
else
{
var tracksJson = await tracksTask;
tracks = RankTracks(ParseTracks(tracksJson?["recordings"] as JsonArray), context, preferredAlbumTitles, preferredAlbumArtists, expanded ? 100 : 24);
}
return new MusicSearchResult
{
artists = artists,
albums = albums,
tracks = tracks
};
}
static List<MusicArtist> DeduplicateFallbackArtists(IEnumerable<MusicArtist> artists)
{
var result = new List<MusicArtist>();
var seen = new HashSet<string>(StringComparer.Ordinal);
foreach (var artist in artists ?? Enumerable.Empty<MusicArtist>())
{
string key = NormalizeText(artist?.name);
if (string.IsNullOrWhiteSpace(key))
key = artist?.id ?? string.Empty;
if (!seen.Add(key))
continue;
result.Add(artist);
}
return result;
}
public async Task<MusicArtist> GetArtistAsync(string id, CancellationToken cancellationToken = default)
{
var artistTask = GetJsonAsync($"artist/{HttpUtility.UrlEncode(id)}?fmt=json", cancellationToken);
var albumsTask = GetJsonAsync($"release-group?artist={HttpUtility.UrlEncode(id)}&limit=12&fmt=json", cancellationToken);
await Task.WhenAll(artistTask, albumsTask);
var artistJson = await artistTask;
if (artistJson == null)
return null;
var albumsJson = await albumsTask;
var artist = ParseArtist(artistJson);
artist.albums = ParseAlbums(albumsJson?["release-groups"] as JsonArray);
await DiscogsArtistImageService.EnrichAsync(artist, cancellationToken);
return artist;
}
public async Task<MusicAlbum> GetAlbumAsync(string id, CancellationToken cancellationToken = default)
{
var releaseJson = await GetJsonAsync($"release/{HttpUtility.UrlEncode(id)}?inc=recordings+artist-credits+release-groups+media+isrcs&fmt=json", cancellationToken);
if (releaseJson != null)
return ParseAlbumFromRelease(releaseJson);
var releasesJson = await GetJsonAsync($"release?release-group={HttpUtility.UrlEncode(id)}&limit=12&status=official&inc=media&fmt=json", cancellationToken);
var releases = RankReleaseCandidates(releasesJson?["releases"] as JsonArray).Take(5).ToList();
if (releases.Count == 0)
return null;
MusicAlbum bestAlbum = null;
int bestScore = int.MinValue;
foreach (var release in releases)
{
var releaseId = release["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(releaseId))
continue;
releaseJson = await GetJsonAsync($"release/{HttpUtility.UrlEncode(releaseId)}?inc=recordings+artist-credits+release-groups+media+isrcs&fmt=json", cancellationToken);
if (releaseJson == null)
continue;
var parsedAlbum = ParseAlbumFromRelease(releaseJson);
if (parsedAlbum == null)
continue;
var score = ScoreParsedRelease(parsedAlbum, release);
if (score > bestScore)
{
bestScore = score;
bestAlbum = parsedAlbum;
}
if ((parsedAlbum.tracks?.Count ?? 0) >= 8 && !LooksLikeEditionNoise(parsedAlbum.title) && !LooksLikeEditionNoise(parsedAlbum.description))
break;
}
return bestAlbum;
}
public async Task<MusicTrack> GetTrackAsync(string id, CancellationToken cancellationToken = default)
{
var trackJson = await GetJsonAsync($"recording/{HttpUtility.UrlEncode(id)}?inc=artist-credits+releases+isrcs&fmt=json", cancellationToken);
return trackJson == null ? null : ParseTrack(trackJson);
}
static async Task<JsonObject> GetJsonAsync(string path, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(path))
return null;
using var timeoutCts = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken);
timeoutCts.CancelAfter(requestTimeout);
var token = timeoutCts.Token;
long started = System.Diagnostics.Stopwatch.GetTimestamp();
try
{
for (int attempt = 0; attempt < 2; attempt++)
{
await RespectRateLimitAsync(token);
TimeSpan delay;
using (var request = new HttpRequestMessage(HttpMethod.Get, $"{baseUrl}/{path}"))
{
request.Headers.TryAddWithoutValidation("User-Agent", userAgent);
request.Headers.TryAddWithoutValidation("Accept", "application/json");
using var response = await httpClient.SendAsync(request, token);
if (response.IsSuccessStatusCode)
{
var json = await response.Content.ReadAsStringAsync(token);
return JsonNode.Parse(json) as JsonObject;
}
if (attempt != 0 || (int)response.StatusCode is not (429 or 502 or 503 or 504))
return null;
var retryAfter = response.Headers.RetryAfter;
delay = retryAfter?.Delta
?? (retryAfter?.Date is DateTimeOffset retryAt ? retryAt - DateTimeOffset.UtcNow : TimeSpan.Zero);
if (delay < TimeSpan.Zero) delay = TimeSpan.Zero;
}
// Never shorten Retry-After or give the retry a fresh timeout budget.
var remaining = requestTimeout - System.Diagnostics.Stopwatch.GetElapsedTime(started);
if (delay >= remaining) return null;
if (delay > TimeSpan.Zero) await Task.Delay(delay, token);
}
return null;
}
catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested)
{
return null;
}
catch (HttpRequestException)
{
return null;
}
catch (System.Text.Json.JsonException)
{
return null;
}
}
// MusicBrainz asks clients to keep Web Service requests at 1 req/s.
static async Task RespectRateLimitAsync(CancellationToken cancellationToken)
{
await requestGate.WaitAsync(cancellationToken);
try
{
var now = DateTime.UtcNow;
if (nextRequestAt > now)
await Task.Delay(nextRequestAt - now, cancellationToken);
nextRequestAt = DateTime.UtcNow.AddSeconds(1);
}
finally
{
requestGate.Release();
}
}
static List<MusicArtist> ParseArtists(JsonArray items)
{
if (items == null)
return new List<MusicArtist>();
return items
.Select(node => ParseArtist(node as JsonObject))
.Where(item => item != null)
.ToList();
}
static MusicArtist ParseArtist(JsonObject item)
{
if (item == null)
return null;
var id = item["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(id))
return null;
return new MusicArtist
{
id = id,
name = item["name"]?.GetValue<string>(),
sort_name = item["sort-name"]?.GetValue<string>(),
country = item["country"]?.GetValue<string>(),
description = item["disambiguation"]?.GetValue<string>(),
search_score = ParseInt(item["score"]),
provider_refs = new List<MusicProviderRef>
{
new() { provider = "musicbrainz", external_id = $"artist:{id}" }
}
};
}
static List<MusicAlbum> ParseAlbums(JsonArray items)
{
if (items == null)
return new List<MusicAlbum>();
return items
.Select(node => ParseAlbum(node as JsonObject))
.Where(item => item != null)
.Where(item => string.IsNullOrWhiteSpace(item.type) || item.type is "Album" or "EP" or "Single" or "Soundtrack")
.ToList();
}
static MusicAlbum ParseAlbum(JsonObject item)
{
if (item == null)
return null;
var id = item["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(id))
return null;
var artistCredit = item["artist-credit"] as JsonArray;
var artist = FirstArtist(artistCredit);
return new MusicAlbum
{
id = id,
title = item["title"]?.GetValue<string>(),
artist_id = artist.id,
artist_name = JoinArtistCredit(artistCredit),
year = ParseYear(item["first-release-date"]?.GetValue<string>()),
date = item["first-release-date"]?.GetValue<string>(),
type = item["primary-type"]?.GetValue<string>(),
description = item["disambiguation"]?.GetValue<string>(),
search_score = ParseInt(item["score"]),
secondary_types = ParseStringList(item["secondary-types"] as JsonArray),
images = BuildReleaseGroupImages(id),
provider_refs = new List<MusicProviderRef>
{
new() { provider = "musicbrainz", external_id = $"release-group:{id}" }
}
};
}
static List<MusicTrack> ParseTracks(JsonArray items)
{
if (items == null)
return new List<MusicTrack>();
return items
.Select(node => ParseTrack(node as JsonObject))
.Where(item => item != null)
.ToList();
}
static MusicTrack ParseTrack(JsonObject item)
{
if (item == null)
return null;
var id = item["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(id))
return null;
var artistCredit = item["artist-credit"] as JsonArray;
var release = SelectPreferredRelease(item["releases"] as JsonArray);
return new MusicTrack
{
id = id,
title = item["title"]?.GetValue<string>(),
artist_id = FirstArtist(artistCredit).id,
artist_name = JoinArtistCredit(artistCredit),
artists = ParseArtistNames(artistCredit),
album_id = release?["id"]?.GetValue<string>(),
album_title = release?["title"]?.GetValue<string>(),
isrc = ParseIsrc(item["isrcs"] as JsonArray),
duration_ms = item["length"]?.GetValue<int?>(),
date = release?["date"]?.GetValue<string>(),
search_score = ParseInt(item["score"]),
provider_refs = new List<MusicProviderRef>
{
new() { provider = "musicbrainz", external_id = $"recording:{id}" }
}
};
}
static MusicAlbum ParseAlbumFromRelease(JsonObject release)
{
if (release == null)
return null;
var releaseId = release["id"]?.GetValue<string>();
var releaseGroup = release["release-group"] as JsonObject;
var releaseGroupId = releaseGroup?["id"]?.GetValue<string>();
var releaseGroupTitle = releaseGroup?["title"]?.GetValue<string>();
var artistCredit = (releaseGroup?["artist-credit"] ?? release["artist-credit"]) as JsonArray;
var artist = FirstArtist(artistCredit);
var album = new MusicAlbum
{
id = releaseGroupId ?? releaseId,
release_id = releaseId,
title = releaseGroupTitle ?? release["title"]?.GetValue<string>(),
artist_id = artist.id,
artist_name = JoinArtistCredit(artistCredit),
year = ParseYear(release["date"]?.GetValue<string>() ?? releaseGroup?["first-release-date"]?.GetValue<string>()),
date = release["date"]?.GetValue<string>() ?? releaseGroup?["first-release-date"]?.GetValue<string>(),
type = releaseGroup?["primary-type"]?.GetValue<string>(),
description = releaseGroup?["disambiguation"]?.GetValue<string>(),
images = !string.IsNullOrWhiteSpace(releaseGroupId) ? BuildReleaseGroupImages(releaseGroupId) : BuildReleaseImages(releaseId),
provider_refs = new List<MusicProviderRef>
{
new() { provider = "musicbrainz", external_id = $"release:{releaseId}" },
new() { provider = "musicbrainz", external_id = $"release-group:{releaseGroupId ?? releaseId}" }
}
};
var media = release["media"] as JsonArray;
if (media != null)
{
foreach (var discNode in media.OfType<JsonObject>())
{
var discNumber = ParseInt(discNode["position"]);
var tracks = discNode["tracks"] as JsonArray;
if (tracks == null)
continue;
foreach (var trackNode in tracks.OfType<JsonObject>())
{
var recording = trackNode["recording"] as JsonObject;
var trackId = recording?["id"]?.GetValue<string>() ?? trackNode["id"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(trackId))
continue;
var trackArtistCredit = (recording?["artist-credit"] ?? trackNode["artist-credit"]) as JsonArray ?? artistCredit;
var trackArtist = FirstArtist(trackArtistCredit);
album.tracks.Add(new MusicTrack
{
id = trackId,
title = recording?["title"]?.GetValue<string>() ?? trackNode["title"]?.GetValue<string>(),
artist_id = trackArtist.id,
artist_name = JoinArtistCredit(trackArtistCredit),
artists = ParseArtistNames(trackArtistCredit),
album_id = album.id,
album_title = album.title,
isrc = ParseIsrc(recording?["isrcs"] as JsonArray),
duration_ms = recording?["length"]?.GetValue<int?>() ?? trackNode["length"]?.GetValue<int?>(),
track_number = ParseInt(trackNode["number"]),
disc_number = discNumber,
date = album.date,
provider_refs = new List<MusicProviderRef>
{
new() { provider = "musicbrainz", external_id = $"recording:{trackId}" }
}
});
}
}
}
return album;
}
static JsonObject SelectPreferredRelease(JsonArray releases)
{
return RankReleaseCandidates(releases).FirstOrDefault();
}
sealed class SearchContext
{
public string Query { get; init; }
public string NormalizedQuery { get; init; }
public MusicArtist BestArtist { get; init; }
public MusicArtist SecondaryArtist { get; init; }
public int BestArtistScore { get; init; }
public bool IsArtistOnly { get; init; }
public bool IsArtistPair { get; init; }
public bool IsMixed { get; init; }
public string TitleTerms { get; init; }
public string RankingQuery => !string.IsNullOrWhiteSpace(TitleTerms) ? TitleTerms : Query;
}
sealed class ArtistTrackCandidate
{
public string Title { get; init; }
public string NormalizedTitle { get; init; }
public int? Year { get; init; }
}
static SearchContext BuildSearchContext(string query, List<MusicArtist> artists)
{
var bestArtist = artists.FirstOrDefault();
var bestArtistScore = bestArtist == null ? 0 : ScoreArtistCandidate(query, bestArtist);
if (!ShouldAnchorArtistQuery(query, bestArtist, bestArtistScore))
{
bestArtist = null;
bestArtistScore = 0;
}
var secondaryArtist = bestArtist == null
? null
: artists
.Skip(1)
.FirstOrDefault(item => ShouldUseAsSecondaryArtist(query, bestArtist, item));
var isArtistPair = bestArtist != null && secondaryArtist != null;
var isArtistOnly = bestArtist != null && IsExactArtistQuery(query, bestArtist);
var titleTerms = !isArtistOnly && !isArtistPair && bestArtistScore >= 180 ? ExtractQueryRemainder(query, bestArtist) : null;
return new SearchContext
{
Query = query,
NormalizedQuery = NormalizeText(query),
BestArtist = bestArtist,
SecondaryArtist = secondaryArtist,
BestArtistScore = bestArtistScore,
IsArtistOnly = isArtistOnly,
IsArtistPair = isArtistPair,
IsMixed = !string.IsNullOrWhiteSpace(titleTerms),
TitleTerms = titleTerms
};
}
static void ApplyBestArtistFallback(List<MusicAlbum> albums, MusicArtist bestArtist)
{
if (albums == null || bestArtist == null)
return;
foreach (var album in albums.Where(item => string.IsNullOrWhiteSpace(item.artist_name)))
{
album.artist_id = bestArtist.id;
album.artist_name = bestArtist.name;
}
}
static List<MusicArtist> RankArtists(List<MusicArtist> artists, string query, int limit)
{
return artists
.OrderByDescending(item => ScoreArtistCandidate(query, item))
.ThenBy(item => item.name ?? string.Empty)
.Take(limit)
.ToList();
}
static string BuildAlbumSearchPath(SearchContext context, bool expanded)
{
if (context?.BestArtist != null && context.IsArtistOnly)
return $"release-group?artist={HttpUtility.UrlEncode(context.BestArtist.id)}&limit=100&fmt=json";
if (context?.BestArtist != null && context.IsArtistPair)
{
var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND {BuildArtistIdentityQuery(context.SecondaryArtist)}";
return $"release-group?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 80 : 24)}&fmt=json";
}
if (context?.BestArtist != null && context.IsMixed)
{
var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND (releasegroup:{QuoteSearchTerm(context.TitleTerms)} OR release:{QuoteSearchTerm(context.TitleTerms)})";
return $"release-group?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 80 : 24)}&fmt=json";
}
return $"release-group?query={HttpUtility.UrlEncode(context?.Query ?? string.Empty)}&limit={(expanded ? 80 : 24)}&fmt=json";
}
static string BuildTrackSearchPath(SearchContext context, bool expanded)
{
if (context?.BestArtist != null && context.IsArtistOnly)
{
var query = BuildArtistIdentityQuery(context.BestArtist);
return $"recording?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 140 : 60)}&fmt=json";
}
if (context?.BestArtist != null && context.IsArtistPair)
{
var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND {BuildArtistIdentityQuery(context.SecondaryArtist)}";
return $"recording?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 120 : 40)}&fmt=json";
}
if (context?.BestArtist != null && context.IsMixed)
{
var query = $"{BuildArtistIdentityQuery(context.BestArtist)} AND recording:{QuoteSearchTerm(context.TitleTerms)}";
return $"recording?query={HttpUtility.UrlEncode(query)}&limit={(expanded ? 120 : 40)}&fmt=json";
}
return $"recording?query={HttpUtility.UrlEncode(context?.Query ?? string.Empty)}&limit={(expanded ? 140 : 60)}&fmt=json";
}
static string BuildFeaturedTracksSearchPath(SearchContext context, IEnumerable<string> preferredTrackTitles, bool expanded)
{
if (context?.BestArtist == null || preferredTrackTitles == null)
return null;
var identityQuery = BuildArtistIdentityQuery(context.BestArtist);
var titleQuery = string.Join(" OR ", preferredTrackTitles
.Where(item => !string.IsNullOrWhiteSpace(item))
.Select(item =>
{
var quoted = QuoteSearchTerm(item);
return $"(recording:{quoted} AND (releasegroup:{quoted} OR release:{quoted}))";
}));
if (string.IsNullOrWhiteSpace(identityQuery) || string.IsNullOrWhiteSpace(titleQuery))
return null;
return $"recording?query={HttpUtility.UrlEncode($"({titleQuery}) AND {identityQuery}")}&limit={(expanded ? 300 : 200)}&fmt=json";
}
static string BuildSupplementalSinglesSearchPath(SearchContext context)
{
var name = context?.BestArtist?.name;
if (string.IsNullOrWhiteSpace(name))
return null;
return $"release-group?query={HttpUtility.UrlEncode($"artist:{QuoteSearchTerm(name)} AND primarytype:single")}&limit=60&fmt=json";
}
async Task<List<MusicTrack>> BuildArtistOnlyTracksAsync(
SearchContext context,
List<MusicAlbum> albums,
HashSet<string> preferredAlbumTitles,
HashSet<string> preferredAlbumArtists,
bool expanded,
CancellationToken cancellationToken)
{
var candidateLimit = expanded ? 40 : 24;
var fallbackLimit = expanded ? 10 : 2;
var targetFeaturedCount = expanded ? 40 : 20;
var browseCandidates = SelectArtistTrackCandidates(albums);
ArtistTrackCandidate[] candidates;
if (browseCandidates.Count >= 20)
{
candidates = browseCandidates
.Where(item => !LooksLikeCandidateSingleNoise(item.Title, context?.BestArtist))
.Take(candidateLimit)
.ToArray();
}
else
{
var candidateAlbums = new List<MusicAlbum>();
var supplementalSinglesJson = await GetJsonAsync(BuildSupplementalSinglesSearchPath(context), cancellationToken);
if (supplementalSinglesJson?["release-groups"] is JsonArray supplementalSingles)
candidateAlbums.AddRange(ParseAlbums(supplementalSingles));
ApplyBestArtistFallback(candidateAlbums, context?.BestArtist);
var supplementalCandidates = SelectArtistTrackCandidates(candidateAlbums);
candidates = MergeArtistTrackCandidates(browseCandidates, supplementalCandidates, context);
}
var featuredTracks = new List<MusicTrack>();
if (candidates.Length > 0)
{
var bestByTitle = new Dictionary<string, (MusicTrack track, int score)>(StringComparer.Ordinal);
var chunkRequests = candidates
.Chunk(8)
.Select(chunk => new
{
candidates = chunk.ToArray(),
task = GetJsonAsync(BuildFeaturedTracksSearchPath(context, chunk.Select(item => item.Title), expanded), cancellationToken)
})
.ToArray();
await Task.WhenAll(chunkRequests.Select(item => item.task));
foreach (var request in chunkRequests)
{
var json = await request.task;
CollectFeaturedTracks(bestByTitle, json?["recordings"] as JsonArray, context, request.candidates, preferredAlbumTitles);
}
var missingCandidates = candidates
.Where(item => !bestByTitle.ContainsKey(item.NormalizedTitle))
.Take(fallbackLimit)
.ToArray();
foreach (var candidate in missingCandidates)
{
var json = await GetJsonAsync(BuildFeaturedTracksSearchPath(context, new[] { candidate.Title }, expanded), cancellationToken);
CollectFeaturedTracks(bestByTitle, json?["recordings"] as JsonArray, context, new[] { candidate }, preferredAlbumTitles);
if (bestByTitle.Count >= targetFeaturedCount)
break;
}
featuredTracks = bestByTitle.Values
.OrderByDescending(item => item.score)
.ThenByDescending(item => ParseYear(item.track.date) ?? 0)
.ThenBy(item => item.track.title ?? string.Empty)
.Select(item => item.track)
.ToList();
}
if (featuredTracks.Count >= targetFeaturedCount)
return RankTracks(featuredTracks, context, preferredAlbumTitles, preferredAlbumArtists, expanded ? 100 : 24);
var genericTracksJson = await GetJsonAsync(BuildTrackSearchPath(context, expanded), cancellationToken);
var genericTracks = ParseTracks(genericTracksJson?["recordings"] as JsonArray)
.Where(item => item != null)
.Where(item => !LooksLikeEditionNoise(item.album_title))
.Where(item => !LooksLikeFeaturedTrackNoise(item.title))
.ToList();
return RankTracks(featuredTracks.Concat(genericTracks).ToList(), context, preferredAlbumTitles, preferredAlbumArtists, expanded ? 100 : 24);
}
static string BuildArtistIdentityQuery(MusicArtist artist)
{
var parts = new List<string>();
if (!string.IsNullOrWhiteSpace(artist?.id))
parts.Add($"(arid:{artist.id})");
if (!string.IsNullOrWhiteSpace(artist?.name))
{
parts.Add($"(artistname:{QuoteSearchTerm(artist.name)})");
parts.Add($"(creditname:{QuoteSearchTerm(artist.name)})");
}
if (!string.IsNullOrWhiteSpace(artist?.sort_name)
&& !string.Equals(NormalizeText(artist.sort_name), NormalizeText(artist.name), StringComparison.Ordinal))
{
parts.Add($"(artistname:{QuoteSearchTerm(artist.sort_name)})");
parts.Add($"(creditname:{QuoteSearchTerm(artist.sort_name)})");
}
return parts.Count == 0 ? string.Empty : "(" + string.Join(" OR ", parts) + ")";
}
static List<MusicAlbum> RankAlbums(List<MusicAlbum> albums, SearchContext context, int limit)
{
return OrderAlbums(albums, context)
.Take(limit)
.ToList();
}
static List<MusicAlbum> OrderAlbums(List<MusicAlbum> albums, SearchContext context)
{
return albums
.GroupBy(item => item.id)
.Select(group => group.First())
.OrderByDescending(item => ScoreAlbum(item, context))
.ThenBy(item => AlbumSortYear(item, context))
.ThenBy(item => item.title ?? string.Empty)
.ToList();
}
static List<MusicTrack> RankTracks(List<MusicTrack> tracks, SearchContext context, HashSet<string> preferredAlbumTitles, HashSet<string> preferredAlbumArtists, int limit)
{
return tracks
.GroupBy(BuildTrackDedupKey)
.Select(group => group
.OrderByDescending(item => ScoreTrack(item, context, preferredAlbumTitles, preferredAlbumArtists))
.ThenBy(item => TrackSortYear(item, context))
.ThenBy(item => item.title ?? string.Empty)
.First())
.OrderByDescending(item => ScoreTrack(item, context, preferredAlbumTitles, preferredAlbumArtists))
.ThenBy(item => TrackSortYear(item, context))
.ThenBy(item => item.title ?? string.Empty)
.Take(limit)
.ToList();
}
static string BuildTrackDedupKey(MusicTrack track)
{
if (track == null)
return string.Empty;
var title = NormalizeText(track.title);
var artist = NormalizeText(track.artist_name);
if (!string.IsNullOrWhiteSpace(title) && !string.IsNullOrWhiteSpace(artist))
return $"{title}|{artist}";
return track.id ?? string.Empty;
}
static List<ArtistTrackCandidate> SelectArtistTrackCandidates(List<MusicAlbum> albums)
{
return albums?
.Where(item => item != null)
.Where(item => string.Equals(item.type, "Single", StringComparison.OrdinalIgnoreCase))
.Where(item => item.secondary_types.Count == 0)
.Where(item => !LooksLikeEditionNoise(item.title))
.OrderByDescending(item => item.year ?? 0)
.ThenBy(item => item.title ?? string.Empty)
.GroupBy(item => NormalizeText(item.title))
.Select(group =>
{
var album = group.First();
return new ArtistTrackCandidate
{
Title = album.title,
NormalizedTitle = NormalizeText(album.title),
Year = album.year
};
})
.Where(item => !string.IsNullOrWhiteSpace(item.NormalizedTitle))
.Take(40)
.ToList()
?? new List<ArtistTrackCandidate>();
}
static ArtistTrackCandidate[] MergeArtistTrackCandidates(List<ArtistTrackCandidate> browseCandidates, List<ArtistTrackCandidate> supplementalCandidates, SearchContext context)
{
var selected = new List<ArtistTrackCandidate>();
var seen = new HashSet<string>(StringComparer.Ordinal);
void append(IEnumerable<ArtistTrackCandidate> source, int limit)
{
foreach (var item in source)
{
if (selected.Count >= 24 || limit <= 0)
break;
if (item == null || string.IsNullOrWhiteSpace(item.NormalizedTitle))
continue;
if (LooksLikeCandidateSingleNoise(item.Title, context?.BestArtist))
continue;
if (!seen.Add(item.NormalizedTitle))
continue;
selected.Add(item);
limit--;
}
}
append(supplementalCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty<ArtistTrackCandidate>(), 14);
append(browseCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty<ArtistTrackCandidate>(), 12);
append(supplementalCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty<ArtistTrackCandidate>(), 24);
append(browseCandidates?.OrderByDescending(item => item.Year ?? 0).ThenBy(item => item.Title ?? string.Empty) ?? Enumerable.Empty<ArtistTrackCandidate>(), 24);
return selected.Take(24).ToArray();
}
static void ApplyPreferredCandidateRelease(MusicTrack track, JsonArray releases, ArtistTrackCandidate candidate)
{
if (track == null || releases == null || candidate == null || string.IsNullOrWhiteSpace(candidate.NormalizedTitle))
return;
var preferred = releases
.OfType<JsonObject>()
.Where(item => NormalizeText(item["title"]?.GetValue<string>()) == candidate.NormalizedTitle)
.OrderBy(item => LooksLikeEditionNoise(item["title"]?.GetValue<string>()) ? 1 : 0)
.ThenBy(item => item["date"]?.GetValue<string>() ?? "9999")
.FirstOrDefault();
if (preferred == null)
return;
track.album_id = preferred["id"]?.GetValue<string>() ?? track.album_id;
track.album_title = preferred["title"]?.GetValue<string>() ?? track.album_title;
track.date = preferred["date"]?.GetValue<string>() ?? track.date;
}
static void CollectFeaturedTracks(
Dictionary<string, (MusicTrack track, int score)> bestByTitle,
JsonArray recordings,
SearchContext context,
ArtistTrackCandidate[] candidates,
HashSet<string> preferredAlbumTitles)
{
if (bestByTitle == null || recordings == null || context?.BestArtist == null || candidates == null || candidates.Length == 0)
return;
var candidateMap = candidates.ToDictionary(item => item.NormalizedTitle, item => item, StringComparer.Ordinal);
foreach (var item in recordings.OfType<JsonObject>())
{
var title = item["title"]?.GetValue<string>();
if (string.IsNullOrWhiteSpace(title) || LooksLikeFeaturedTrackNoise(title))
continue;
var candidate = MatchArtistTrackCandidate(title, candidateMap);
if (candidate == null)
continue;
var track = ParseTrack(item);
if (track == null || LooksLikeFeaturedTrackNoise(track.album_title))
continue;
ApplyPreferredCandidateRelease(track, item["releases"] as JsonArray, candidate);
var artistCredit = item["artist-credit"] as JsonArray;
var artistName = JoinArtistCredit(artistCredit);
var artistMatch = ScoreArtistMatch(artistName, context.BestArtist);
if (artistMatch < 55)
continue;
var score = ScoreArtistTrackCandidate(item, track, candidate, context.BestArtist, preferredAlbumTitles, artistMatch);
if (score < 320)
continue;
track.search_score = Math.Max(track.search_score ?? 0, 300 + score);
if (!bestByTitle.TryGetValue(candidate.NormalizedTitle, out var current) || score > current.score)
bestByTitle[candidate.NormalizedTitle] = (track, score);
}
}
static int ScoreAlbum(MusicAlbum album, SearchContext context)
{
if (album == null)
return int.MinValue;
var score = (album.search_score ?? 0) * (context?.IsArtistOnly == true ? 1 : 4);
var artistMatch = ScoreArtistMatch(album.artist_name, context?.BestArtist);
if (context?.BestArtist != null)
score += context.IsArtistOnly ? artistMatch * 3 : artistMatch * 2;
if (context == null || !context.IsArtistOnly)
score += ScoreTitleMatch(album.title, context?.RankingQuery);
if (context?.BestArtist == null && IsExactQueryTitle(album.title, context))
score += 90;
score += album.type switch
{
"Album" => 80,
"EP" => 55,
"Single" => 35,
"Soundtrack" => 20,
_ => 10
};
if (context?.IsArtistOnly == true)
{
if (album.type == "Album" && album.secondary_types.Count == 0)
score += 140;
else if (album.type == "EP" && album.secondary_types.Count == 0)
score += 80;
else if (album.type == "Single" && album.secondary_types.Count == 0)
score += 30;
}
if (album.year.HasValue)
score += 5;
if (album.secondary_types.Count > 0)
{
if (album.secondary_types.Contains("Compilation", StringComparer.OrdinalIgnoreCase))
score -= 90;
if (album.secondary_types.Contains("Live", StringComparer.OrdinalIgnoreCase))
score -= 85;
if (album.secondary_types.Contains("Remix", StringComparer.OrdinalIgnoreCase))
score -= 85;
if (album.secondary_types.Contains("DJ-mix", StringComparer.OrdinalIgnoreCase))
score -= 180;
if (album.secondary_types.Contains("Mixtape/Street", StringComparer.OrdinalIgnoreCase))
score -= 160;
if (album.secondary_types.Contains("Soundtrack", StringComparer.OrdinalIgnoreCase) && album.type != "Soundtrack")
score -= 35;
}
if (LooksLikeEditionNoise(album.title) || LooksLikeEditionNoise(album.description))
score -= 55;
return score;
}
static int ScoreTrack(MusicTrack track, SearchContext context, HashSet<string> preferredAlbumTitles, HashSet<string> preferredAlbumArtists)
{
if (track == null)
return int.MinValue;
var score = (track.search_score ?? 0) * 4;
var artistMatch = ScoreArtistMatch(track.artist_name, context?.BestArtist);
if (context?.BestArtist != null)
score += context.IsArtistOnly ? artistMatch * 3 : artistMatch * 2;
if (context == null || !context.IsArtistOnly)
score += ScoreTitleMatch(track.title, context?.RankingQuery);
if (context?.BestArtist == null && IsExactQueryTitle(track.title, context))
score += 90;
if (track.duration_ms.HasValue)
score += 8;
if (!string.IsNullOrWhiteSpace(track.album_title))
score += 6;
if (IsExactQueryTitle(track.album_title, context))
score += 40;
var normalizedAlbumTitle = NormalizeText(track.album_title);
if (!string.IsNullOrWhiteSpace(normalizedAlbumTitle) && preferredAlbumTitles?.Contains(normalizedAlbumTitle) == true)
score += 70;
var normalizedArtistName = NormalizeText(track.artist_name);
if (!string.IsNullOrWhiteSpace(normalizedArtistName) && preferredAlbumArtists?.Any(item => normalizedArtistName.Contains(item, StringComparison.Ordinal) || item.Contains(normalizedArtistName, StringComparison.Ordinal)) == true)
score += 55;
if (LooksLikeEditionNoise(track.album_title))
score -= 70;
if (LooksLikeTrackNoise(track.title))
score -= 65;
return score;
}
static int ScoreArtistCandidate(string query, MusicArtist artist)
{
if (artist == null)
return int.MinValue;
var normalizedQuery = NormalizeText(query);
var normalizedName = NormalizeText(artist.name);
var normalizedSortName = NormalizeText(artist.sort_name);
var score = 0;
if (normalizedQuery == normalizedName)
score += 320;
else if (normalizedQuery == normalizedSortName)
score += 280;
else if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedQuery.StartsWith(normalizedName + " ", StringComparison.Ordinal))
score += 220;
else if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedQuery.Contains(normalizedName, StringComparison.Ordinal))
score += 170;
score += ScoreTokenOverlap(normalizedQuery, normalizedName) * 25;
score += ScoreTokenOverlap(normalizedQuery, normalizedSortName) * 12;
if (!string.IsNullOrWhiteSpace(artist.description))
score -= 5;
return score;
}
static bool ShouldAnchorArtistQuery(string query, MusicArtist artist, int score)
{
if (artist == null)
return false;
if (IsExactArtistQuery(query, artist))
return true;
if (score < 220)
return false;
if (string.IsNullOrWhiteSpace(artist.country) && string.IsNullOrWhiteSpace(artist.description))
return false;
var normalizedQuery = NormalizeText(query);
var normalizedName = NormalizeText(artist.name);
var normalizedSortName = NormalizeText(artist.sort_name);
if (string.IsNullOrWhiteSpace(normalizedQuery))
return false;
if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedQuery.StartsWith(normalizedName + " ", StringComparison.Ordinal))
return true;
if (!string.IsNullOrWhiteSpace(normalizedSortName) && normalizedQuery.StartsWith(normalizedSortName + " ", StringComparison.Ordinal))
return true;
return Regex.IsMatch(query, @"(?i)(^|\s)(feat\.?|ft\.?|featuring|with|vs\.?|x|×)(\s|$)|[-–—:/]");
}
static bool ShouldUseAsSecondaryArtist(string query, MusicArtist primaryArtist, MusicArtist secondaryArtist)
{
if (secondaryArtist == null)
return false;
if (!Regex.IsMatch(query, @"(?i)(^|\s)(feat\.?|ft\.?|featuring|with|and|vs\.?|x|×)(\s|$)|[-–—:/]"))
return false;
var normalizedPrimary = NormalizeText(primaryArtist?.name);
var normalizedSecondary = NormalizeText(secondaryArtist?.name);
var normalizedQuery = NormalizeText(query);
if (string.IsNullOrWhiteSpace(normalizedSecondary) || normalizedSecondary == normalizedPrimary)
return false;
if (!normalizedQuery.Contains(normalizedSecondary, StringComparison.Ordinal))
return false;
return !string.IsNullOrWhiteSpace(secondaryArtist.country) || !string.IsNullOrWhiteSpace(secondaryArtist.description);
}
static bool IsExactArtistQuery(string query, MusicArtist artist)
{
var normalizedQuery = NormalizeText(query);
return normalizedQuery == NormalizeText(artist?.name)
|| normalizedQuery == NormalizeText(artist?.sort_name);
}
static string ExtractQueryRemainder(string query, MusicArtist artist)
{
foreach (var candidate in new[] { artist?.name, artist?.sort_name }.Where(value => !string.IsNullOrWhiteSpace(value)).Distinct(StringComparer.OrdinalIgnoreCase))
{
var stripped = Regex.Replace(query, Regex.Escape(candidate), " ", RegexOptions.IgnoreCase);
stripped = Regex.Replace(stripped, @"(?i)\b(feat\.?|ft\.?|featuring|with|and|x|×|vs\.?)\b", " ");
stripped = Regex.Replace(stripped, @"[-–—:;,/()]+", " ");
stripped = Regex.Replace(stripped, @"\s+", " ").Trim();
if (!string.IsNullOrWhiteSpace(stripped) && stripped.Length >= 3 && !string.Equals(stripped, query.Trim(), StringComparison.OrdinalIgnoreCase))
return stripped;
}
return null;
}
static int ScoreTitleMatch(string title, string query)
{
var normalizedTitle = NormalizeText(title);
var normalizedQuery = NormalizeText(query);
if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(normalizedQuery))
return 0;
var score = 0;
if (normalizedTitle == normalizedQuery)
score += 220;
else if (normalizedTitle.StartsWith(normalizedQuery + " ", StringComparison.Ordinal) || normalizedTitle.Contains(" " + normalizedQuery + " ", StringComparison.Ordinal))
score += 140;
else if (normalizedTitle.Contains(normalizedQuery, StringComparison.Ordinal))
score += 90;
score += ScoreTokenOverlap(normalizedQuery, normalizedTitle) * 20;
return score;
}
static ArtistTrackCandidate MatchArtistTrackCandidate(string title, Dictionary<string, ArtistTrackCandidate> candidates)
{
var normalizedTitle = NormalizeText(title);
if (string.IsNullOrWhiteSpace(normalizedTitle))
return null;
foreach (var candidate in candidates.Values)
{
var matchScore = ScoreArtistTrackTitleVariant(normalizedTitle, candidate.NormalizedTitle);
if (matchScore > 0)
return candidate;
}
return null;
}
static int ScoreArtistTrackTitleVariant(string normalizedTitle, string candidateTitle)
{
if (string.IsNullOrWhiteSpace(normalizedTitle) || string.IsNullOrWhiteSpace(candidateTitle))
return 0;
if (normalizedTitle == candidateTitle)
return 220;
if (!normalizedTitle.StartsWith(candidateTitle + " ", StringComparison.Ordinal))
return 0;
var suffix = normalizedTitle[(candidateTitle.Length + 1)..];
return suffix switch
{
"edited version" => 180,
"single version" => 175,
"album version" => 170,
"radio version" => 165,
_ => 0
};
}
static int ScoreArtistTrackCandidate(
JsonObject item,
MusicTrack track,
ArtistTrackCandidate candidate,
MusicArtist bestArtist,
HashSet<string> preferredAlbumTitles,
int artistMatch)
{
var titleScore = ScoreArtistTrackTitleVariant(NormalizeText(track.title), candidate.NormalizedTitle);
if (titleScore == 0)
return int.MinValue;
var score = titleScore;
score += artistMatch * 3;
score += (ParseInt(item["score"]) ?? 0) * 2;
var releases = item["releases"] as JsonArray;
if (HasExactReleaseTitle(releases, candidate.Title))
score += 120;
var releaseYearDelta = BestReleaseYearDelta(releases, candidate.Year);
if (releaseYearDelta.HasValue)
score += Math.Max(0, 40 - (releaseYearDelta.Value * 10));
if (StartsWithArtist(track.artist_name, bestArtist))
score += 20;
if (CountDistinctArtists(item["artist-credit"] as JsonArray) >= 2)
score += 12;
if (track.duration_ms.HasValue)
score += 8;
var normalizedAlbumTitle = NormalizeText(track.album_title);
if (!string.IsNullOrWhiteSpace(normalizedAlbumTitle) && preferredAlbumTitles?.Contains(normalizedAlbumTitle) == true)
score += 50;
if (LooksLikeEditionNoise(track.album_title))
score -= 50;
if (LooksLikeFeaturedTrackNoise(track.title) || LooksLikeFeaturedTrackNoise(track.album_title))
score -= 120;
return score;
}
static bool StartsWithArtist(string artistName, MusicArtist bestArtist)
{
var normalizedArtist = NormalizeText(artistName);
var normalizedName = NormalizeText(bestArtist?.name);
var normalizedSortName = NormalizeText(bestArtist?.sort_name);
return (!string.IsNullOrWhiteSpace(normalizedName) && normalizedArtist.StartsWith(normalizedName, StringComparison.Ordinal))
|| (!string.IsNullOrWhiteSpace(normalizedSortName) && normalizedArtist.StartsWith(normalizedSortName, StringComparison.Ordinal));
}
static bool HasExactReleaseTitle(JsonArray releases, string title)
{
var normalizedTitle = NormalizeText(title);
if (string.IsNullOrWhiteSpace(normalizedTitle) || releases == null)
return false;
return releases
.OfType<JsonObject>()
.Select(item => NormalizeText(item["title"]?.GetValue<string>()))
.Any(item => item == normalizedTitle);
}
static int? BestReleaseYearDelta(JsonArray releases, int? year)
{
if (releases == null || !year.HasValue)
return null;
var deltas = releases
.OfType<JsonObject>()
.Select(item => ParseYear(item["date"]?.GetValue<string>()))
.Where(item => item.HasValue)
.Select(item => Math.Abs(item.Value - year.Value))
.ToList();
return deltas.Count == 0 ? null : deltas.Min();
}
static int CountDistinctArtists(JsonArray artistCredit)
{
if (artistCredit == null)
return 0;
return artistCredit
.OfType<JsonObject>()
.Select(item => NormalizeText(item["name"]?.GetValue<string>() ?? item["artist"]?["name"]?.GetValue<string>()))
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.Ordinal)
.Count();
}
static bool IsExactQueryTitle(string value, SearchContext context)
{
return !string.IsNullOrWhiteSpace(value)
&& !string.IsNullOrWhiteSpace(context?.NormalizedQuery)
&& NormalizeText(value) == context.NormalizedQuery;
}
static int AlbumSortYear(MusicAlbum album, SearchContext context)
{
if (context?.BestArtist == null && IsExactQueryTitle(album.title, context))
return album.year ?? 9999;
return -(album.year ?? 0);
}
static int TrackSortYear(MusicTrack track, SearchContext context)
{
var year = ParseYear(track.date) ?? 9999;
if (context?.BestArtist == null && IsExactQueryTitle(track.title, context))
return year;
return -year;
}
static int ScoreArtistMatch(string artistName, MusicArtist artist)
{
if (artist == null || string.IsNullOrWhiteSpace(artistName))
return 0;
var normalizedArtist = NormalizeText(artistName);
var normalizedName = NormalizeText(artist.name);
var normalizedSortName = NormalizeText(artist.sort_name);
if (normalizedArtist == normalizedName || normalizedArtist == normalizedSortName)
return 100;
if (!string.IsNullOrWhiteSpace(normalizedName) && normalizedArtist.Contains(normalizedName, StringComparison.Ordinal))
return 70;
if (!string.IsNullOrWhiteSpace(normalizedSortName) && normalizedArtist.Contains(normalizedSortName, StringComparison.Ordinal))
return 55;
return ScoreTokenOverlap(normalizedArtist, normalizedName) * 10;
}
static int ScoreTokenOverlap(string left, string right)
{
var leftTokens = SplitTokens(left);
var rightTokens = SplitTokens(right);
if (leftTokens.Count == 0 || rightTokens.Count == 0)
return 0;
return leftTokens.Count(token => rightTokens.Contains(token));
}
static HashSet<string> SplitTokens(string value)
{
return NormalizeText(value)
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToHashSet(StringComparer.Ordinal);
}
static string NormalizeText(string value)
{
return Regex.Replace((value ?? string.Empty).ToLowerInvariant(), @"[^a-z0-9а-яёіїєґ]+", " ")
.Trim();
}
static string QuoteSearchTerm(string value)
{
value = (value ?? string.Empty).Trim().Replace("\\", "\\\\").Replace("\"", "\\\"");
return $"\"{value}\"";
}
static List<string> ParseStringList(JsonArray items)
{
if (items == null)
return new List<string>();
return items
.Select(item => item?.GetValue<string>())
.Where(item => !string.IsNullOrWhiteSpace(item))
.ToList();
}
static IEnumerable<JsonObject> RankReleaseCandidates(JsonArray releases)
{
return releases?
.OfType<JsonObject>()
.OrderByDescending(item => ScoreReleaseCandidate(item))
.ThenBy(item => item["date"]?.GetValue<string>() ?? "9999")
?? Enumerable.Empty<JsonObject>();
}
static int ScoreReleaseCandidate(JsonObject release)
{
if (release == null)
return int.MinValue;
var score = 0;
if (string.Equals(release["status"]?.GetValue<string>(), "Official", StringComparison.OrdinalIgnoreCase))
score += 200;
score += Math.Min(180, ReadTrackCount(release) * 10);
var title = release["title"]?.GetValue<string>();
var comment = release["disambiguation"]?.GetValue<string>();
if (LooksLikeEditionNoise(title) || LooksLikeEditionNoise(comment))
score -= 80;
if (!string.IsNullOrWhiteSpace(release["country"]?.GetValue<string>()))
score += 10;
if (!string.IsNullOrWhiteSpace(release["date"]?.GetValue<string>()))
score += 5;
return score;
}
static int ScoreParsedRelease(MusicAlbum album, JsonObject release)
{
if (album == null)
return int.MinValue;
var score = ScoreReleaseCandidate(release);
score += Math.Min(240, (album.tracks?.Count ?? 0) * 18);
if (album.type == "Album")
score += 35;
else if (album.type == "EP")
score += 20;
else if (album.type == "Single")
score += 5;
if (LooksLikeEditionNoise(album.title) || LooksLikeEditionNoise(album.description))
score -= 60;
return score;
}
static int ReadTrackCount(JsonObject release)
{
var media = release?["media"] as JsonArray;
if (media == null)
return 0;
return media
.OfType<JsonObject>()
.Sum(item => ParseInt(item["track-count"]) ?? 0);
}
static bool LooksLikeEditionNoise(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return Regex.IsMatch(value, @"(?i)\b(deluxe|remaster|anniversary|collector|bonus|expanded|instrumental|karaoke|tribute|radio edit|clean|explicit|commentary|acappella|a cappella|remix|mix|edit|edited|video|live|bootleg|mixtape|street)\b");
}
static bool LooksLikeTrackNoise(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return Regex.IsMatch(value, @"(?i)\b(live|remix|karaoke|instrumental|demo|radio edit|clean|explicit|acappella|a cappella)\b");
}
static bool LooksLikeFeaturedTrackNoise(string value)
{
if (string.IsNullOrWhiteSpace(value))
return false;
return Regex.IsMatch(value, @"(?i)\b(live|remix|karaoke|instrumental|demo|acappella|a cappella|bootleg|mix)\b");
}
static bool LooksLikeCandidateSingleNoise(string value, MusicArtist bestArtist)
{
if (string.IsNullOrWhiteSpace(value))
return false;
if (LooksLikeEditionNoise(value))
return true;
if (Regex.IsMatch(value, @"(?i)\b(feat\.?|ft\.?|featuring|cypher|bootleg)\b"))
return true;
if (value.Contains('/') || value.Contains('\\'))
return true;
if (Regex.IsMatch(value, @"(?i)\bgodzilla\s*\d+\b"))
return true;
var normalizedTitle = NormalizeText(value);
var normalizedArtist = NormalizeText(bestArtist?.name);
if (!string.IsNullOrWhiteSpace(normalizedArtist) && normalizedTitle.StartsWith(normalizedArtist + " ", StringComparison.Ordinal))
return true;
return false;
}
static List<MusicImage> BuildReleaseGroupImages(string id)
{
if (string.IsNullOrWhiteSpace(id))
return new List<MusicImage>();
return new List<MusicImage>
{
new() { url = $"https://coverartarchive.org/release-group/{id}/front-250", width = 250, height = 250 }
};
}
static List<MusicImage> BuildReleaseImages(string id)
{
if (string.IsNullOrWhiteSpace(id))
return new List<MusicImage>();
return new List<MusicImage>
{
new() { url = $"https://coverartarchive.org/release/{id}/front-250", width = 250, height = 250 }
};
}
static (string id, string name) FirstArtist(JsonArray artistCredit)
{
var artist = artistCredit?
.OfType<JsonObject>()
.Select(item => item["artist"] as JsonObject)
.FirstOrDefault(item => item != null);
return artist == null
? default
: (artist["id"]?.GetValue<string>(), artist["name"]?.GetValue<string>());
}
static string JoinArtistCredit(JsonArray artistCredit)
{
if (artistCredit == null)
return null;
return string.Concat(artistCredit.OfType<JsonObject>().Select(item =>
(item["name"]?.GetValue<string>() ?? item["artist"]?["name"]?.GetValue<string>() ?? string.Empty) +
(item["joinphrase"]?.GetValue<string>() ?? string.Empty)));
}
static List<string> ParseArtistNames(JsonArray artistCredit)
{
if (artistCredit == null)
return new List<string>();
return artistCredit
.OfType<JsonObject>()
.Select(item => item["artist"]?["name"]?.GetValue<string>() ?? item["name"]?.GetValue<string>())
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
static string ParseIsrc(JsonArray values)
{
if (values == null)
return null;
foreach (var item in values.OfType<JsonValue>())
{
if (item.TryGetValue<string>(out var value))
{
string isrc = MusicIsrc.Normalize(value);
if (isrc != null)
return isrc;
}
}
return null;
}
static int? ParseYear(string date)
{
if (!string.IsNullOrWhiteSpace(date) && date.Length >= 4 && int.TryParse(date[..4], out var year))
return year;
return null;
}
static int? ParseInt(JsonNode value)
{
if (value == null)
return null;
if (value.GetValueKind() == System.Text.Json.JsonValueKind.Number)
return value.GetValue<int>();
return int.TryParse(value.ToString(), out var number) ? number : null;
}
}