mirror of
https://github.com/SoPat712/allstarr.git
synced 2026-10-07 22:03:14 -04:00
fix(metadata): enrich external relationship ids
This commit is contained in:
2 files changed
+197
-4
No files matched your search
@@ -95,6 +95,89 @@ public sealed class ProtocolProviderStreamingGatewayTests
|
||||
healthy.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task MetadataRelationships_UseOnlyUniqueExactIdsFromTheSameProvider()
|
||||
{
|
||||
const string providerId = "spotiflac-ytmusic-spotiflac";
|
||||
var artistId = new ProviderExternalResourceId(providerId, ProviderResourceKind.Artist, "artist-1");
|
||||
var albumId = new ProviderExternalResourceId(providerId, ProviderResourceKind.Album, "album-1");
|
||||
var track = new ProviderTrackMetadata(
|
||||
new(providerId, ProviderResourceKind.Track, "track-1"),
|
||||
"Track",
|
||||
[new("Artist"), new("Featured")],
|
||||
albumTitle: "Album");
|
||||
var album = new ProviderAlbumMetadata(
|
||||
albumId,
|
||||
"Album",
|
||||
[new("Artist", artistId)]);
|
||||
var artists = new[]
|
||||
{
|
||||
new ProviderArtistMetadata(artistId, "Artist"),
|
||||
new ProviderArtistMetadata(
|
||||
new(providerId, ProviderResourceKind.Artist, "featured-1"), "Featured"),
|
||||
new ProviderArtistMetadata(
|
||||
new(providerId, ProviderResourceKind.Artist, "featured-2"), "Featured")
|
||||
};
|
||||
var capability = new Mock<IProviderMetadataCapability>(MockBehavior.Strict);
|
||||
capability.SetupGet(item => item.ProviderId).Returns(providerId);
|
||||
capability.SetupGet(item => item.Capability).Returns(ProviderCapabilityKind.Metadata);
|
||||
capability.Setup(item => item.SearchTracksAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Track")))
|
||||
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderTrackMetadata>>.Success(
|
||||
new(providerId, [track])));
|
||||
capability.Setup(item => item.SearchAlbumsAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderMetadataSearchRequest>(request =>
|
||||
request.Query == "Track" || request.Query == "Album")))
|
||||
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderAlbumMetadata>>.Success(
|
||||
new(providerId, [album])));
|
||||
capability.Setup(item => item.SearchArtistsAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Track")))
|
||||
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
||||
new(providerId, artists)));
|
||||
capability.Setup(item => item.SearchArtistsAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Artist")))
|
||||
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
||||
new(providerId, [artists[0]])));
|
||||
capability.Setup(item => item.SearchArtistsAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderMetadataSearchRequest>(request => request.Query == "Featured")))
|
||||
.ReturnsAsync(ProviderOutcome<ProviderPage<ProviderArtistMetadata>>.Success(
|
||||
new(providerId, artists[1..])));
|
||||
capability.Setup(item => item.GetTrackAsync(
|
||||
It.IsAny<ProviderExecutionContext>(),
|
||||
It.Is<ProviderTrackLookupRequest>(request => request.Id.Value == "track-1")))
|
||||
.ReturnsAsync(ProviderOutcome<ProviderTrackMetadata>.Success(track));
|
||||
var registry = MetadataRegistry(capability.Object);
|
||||
var router = new Mock<IProviderRouter>(MockBehavior.Strict);
|
||||
router.Setup(item => item.PlanAsync<IProviderMetadataCapability>(
|
||||
It.IsAny<ProviderRouteRequest>()))
|
||||
.ReturnsAsync((ProviderRouteRequest request) =>
|
||||
MetadataPlan(request, registry, capability.Object));
|
||||
var gateway = new ProtocolProviderGateway(
|
||||
router.Object,
|
||||
registry,
|
||||
Mock.Of<IProviderRouteAccountResolver>(),
|
||||
Mock.Of<IMusicMetadataService>(),
|
||||
new HttpClientFactory());
|
||||
|
||||
var searchSong = Assert.Single((await gateway.SearchAsync(Context(), "Track", 10, 10, 10)).Songs);
|
||||
var detailSong = await gateway.GetSongAsync(Context(), providerId, "track-1");
|
||||
Assert.NotNull(detailSong);
|
||||
|
||||
foreach (var song in new[] { searchSong, detailSong! })
|
||||
{
|
||||
Assert.Equal($"ext-{providerId}-artist-artist-1", song.ArtistId);
|
||||
Assert.Equal([$"ext-{providerId}-artist-artist-1", string.Empty], song.ArtistIds);
|
||||
Assert.Equal($"ext-{providerId}-album-album-1", song.AlbumId);
|
||||
}
|
||||
capability.VerifyAll();
|
||||
router.VerifyAll();
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PlayableSearch_ExcludesProviderWithoutAUsablePlaybackAccount()
|
||||
{
|
||||
|
||||
@@ -100,6 +100,7 @@ public sealed class ProtocolProviderGateway(
|
||||
{
|
||||
private const string StreamingClientName = "ProtocolProviderStreaming";
|
||||
private const int ProviderSearchConcurrency = 4;
|
||||
private const int RelationshipSearchLimit = 10;
|
||||
private static readonly TimeSpan ExactRouteMissTtl = TimeSpan.FromMinutes(2);
|
||||
|
||||
public IReadOnlyList<string> GetProviderOrder(ProviderCapabilityKind capability) =>
|
||||
@@ -164,9 +165,16 @@ public sealed class ProtocolProviderGateway(
|
||||
|
||||
foreach (var outcome in searchOutcomes)
|
||||
{
|
||||
var albums = outcome.AlbumsResult.IsSuccess
|
||||
? outcome.AlbumsResult.RequireValue().Items
|
||||
: [];
|
||||
var artists = outcome.ArtistsResult.IsSuccess
|
||||
? outcome.ArtistsResult.RequireValue().Items
|
||||
: [];
|
||||
if (outcome.SongsResult.IsSuccess)
|
||||
{
|
||||
routed.Songs.AddRange(outcome.SongsResult.RequireValue().Items.Select(Map));
|
||||
routed.Songs.AddRange(outcome.SongsResult.RequireValue().Items
|
||||
.Select(item => EnrichRelationships(Map(item), albums, artists)));
|
||||
}
|
||||
if (outcome.AlbumsResult.IsSuccess)
|
||||
{
|
||||
@@ -315,7 +323,8 @@ public sealed class ProtocolProviderGateway(
|
||||
var outcome = await routed.Candidate.Implementation.GetTrackAsync(
|
||||
routed.Candidate.Context,
|
||||
new ProviderTrackLookupRequest(id));
|
||||
if (outcome.IsSuccess) return Map(outcome.RequireValue());
|
||||
if (outcome.IsSuccess)
|
||||
return await EnrichRelationshipsAsync(routed.Candidate, outcome.RequireValue());
|
||||
if (outcome.Error!.Kind == ProviderErrorKind.NotFound) return null;
|
||||
ThrowRouteFailure(outcome.Error);
|
||||
}
|
||||
@@ -974,8 +983,9 @@ public sealed class ProtocolProviderGateway(
|
||||
ArtistId = item.Artists.FirstOrDefault()?.ArtistId is { } primaryArtist
|
||||
? ProtocolItemId(primaryArtist)
|
||||
: null,
|
||||
ArtistIds = item.Artists.Where(artist => artist.ArtistId != null)
|
||||
.Select(artist => ProtocolItemId(artist.ArtistId!)).ToList(),
|
||||
ArtistIds = item.Artists
|
||||
.Select(artist => artist.ArtistId is { } artistId ? ProtocolItemId(artistId) : string.Empty)
|
||||
.ToList(),
|
||||
Album = item.AlbumTitle ?? string.Empty,
|
||||
AlbumId = item.AlbumId is { } albumId ? ProtocolItemId(albumId) : null,
|
||||
Duration = item.Duration.HasValue ? (int)item.Duration.Value.TotalSeconds : null,
|
||||
@@ -1005,6 +1015,106 @@ public sealed class ProtocolProviderGateway(
|
||||
};
|
||||
}
|
||||
|
||||
private async Task<Song> EnrichRelationshipsAsync(
|
||||
ProviderRouteCandidate<IProviderMetadataCapability> candidate,
|
||||
ProviderTrackMetadata item)
|
||||
{
|
||||
var song = Map(item);
|
||||
if (!string.IsNullOrWhiteSpace(song.AlbumId) &&
|
||||
song.ArtistIds.All(id => !string.IsNullOrWhiteSpace(id)))
|
||||
return song;
|
||||
|
||||
async Task<IReadOnlyList<T>> Search<T>(Func<Task<ProviderOutcome<ProviderPage<T>>>> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
var outcome = await action();
|
||||
return outcome.IsSuccess ? outcome.RequireValue().Items : [];
|
||||
}
|
||||
catch (OperationCanceledException) when (candidate.Context.CancellationToken.IsCancellationRequested)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch
|
||||
{
|
||||
logger?.LogDebug(
|
||||
"Optional metadata relationship enrichment failed for provider {ProviderId}",
|
||||
candidate.Provider.Id);
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
var missingArtists = item.Artists
|
||||
.Where(artist => artist.ArtistId == null)
|
||||
.Select(artist => artist.Name)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase)
|
||||
.Take(RelationshipSearchLimit)
|
||||
.Select(name => Search(() => candidate.Implementation.SearchArtistsAsync(
|
||||
candidate.Context,
|
||||
new ProviderMetadataSearchRequest(name, new ProviderPageRequest(RelationshipSearchLimit)))))
|
||||
.ToArray();
|
||||
var albumTask = item.AlbumId == null && !string.IsNullOrWhiteSpace(item.AlbumTitle)
|
||||
? Search(() => candidate.Implementation.SearchAlbumsAsync(
|
||||
candidate.Context,
|
||||
new ProviderMetadataSearchRequest(
|
||||
item.AlbumTitle,
|
||||
new ProviderPageRequest(RelationshipSearchLimit))))
|
||||
: Task.FromResult<IReadOnlyList<ProviderAlbumMetadata>>([]);
|
||||
|
||||
var artistTask = Task.WhenAll(missingArtists);
|
||||
await Task.WhenAll(artistTask, albumTask);
|
||||
return EnrichRelationships(
|
||||
song,
|
||||
await albumTask,
|
||||
(await artistTask).SelectMany(artists => artists));
|
||||
}
|
||||
|
||||
private static Song EnrichRelationships(
|
||||
Song song,
|
||||
IEnumerable<ProviderAlbumMetadata> albums,
|
||||
IEnumerable<ProviderArtistMetadata> artists)
|
||||
{
|
||||
ProviderAlbumMetadata? album = null;
|
||||
if (string.IsNullOrWhiteSpace(song.AlbumId) && !string.IsNullOrWhiteSpace(song.Album))
|
||||
{
|
||||
var artistNames = song.Artists
|
||||
.Append(song.AlbumArtist)
|
||||
.Where(name => !string.IsNullOrWhiteSpace(name))
|
||||
.ToHashSet(StringComparer.OrdinalIgnoreCase);
|
||||
var matches = albums.Where(candidate =>
|
||||
candidate.Title.Equals(song.Album, StringComparison.OrdinalIgnoreCase) &&
|
||||
candidate.Artists.Any(credit => artistNames.Contains(credit.Name)))
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (matches.Length == 1)
|
||||
{
|
||||
album = matches[0];
|
||||
song.AlbumId = ProtocolItemId(album.Id);
|
||||
}
|
||||
}
|
||||
|
||||
for (var index = 0; index < song.Artists.Count && index < song.ArtistIds.Count; index++)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(song.ArtistIds[index])) continue;
|
||||
var name = song.Artists[index];
|
||||
var ids = artists
|
||||
.Where(candidate => candidate.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(candidate => candidate.Id)
|
||||
.Concat((album?.Artists ?? [])
|
||||
.Where(credit => credit.ArtistId != null &&
|
||||
credit.Name.Equals(name, StringComparison.OrdinalIgnoreCase))
|
||||
.Select(credit => credit.ArtistId!))
|
||||
.Distinct()
|
||||
.Take(2)
|
||||
.ToArray();
|
||||
if (ids.Length == 1) song.ArtistIds[index] = ProtocolItemId(ids[0]);
|
||||
}
|
||||
|
||||
if (song.ArtistIds.Count > 0 && !string.IsNullOrWhiteSpace(song.ArtistIds[0]))
|
||||
song.ArtistId = song.ArtistIds[0];
|
||||
return song;
|
||||
}
|
||||
|
||||
private static Album Map(ProviderAlbumMetadata item) => new()
|
||||
{
|
||||
Id = $"ext-{item.Id.ProviderId}-album-{item.Id.Value}",
|
||||
|
||||
Reference in new issue
Block a user