Combine track search with live YouTube Music results
GET /tracks now merges the local catalog page with a live YouTube Music search (deduplicated against tracks already provisioned locally) whenever a name filter is given, appending the YouTube hits after the local matches and carrying a continuation token for further pages. Each item is tagged with its source via the new TrackSearchItemResponse/TracksSearchResponse shapes, wrapping the existing TrackApplicationResponse or a raw YouTubeSongResult so neither type had to change. GET /youtube/search is removed along with its now-unused handler; the web-player's explore page, its /api/tracks proxy, and the YouTube filler helper are updated to call the combined endpoint instead of hitting the two separately.
This commit is contained in:
@@ -1,22 +1,31 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Shared;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse> >>;
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, string? YoutubeContinuationToken = null)
|
||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>> >
|
||||
public class GetTracksQueryHandler(
|
||||
IDatabase database,
|
||||
IYouTubeMusicService youTubeMusicService,
|
||||
ILogger<GetTracksQueryHandler> logger)
|
||||
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse> >> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
// Mirrors the web client's search-box debounce threshold: a query shorter than this is too
|
||||
// likely to still be mid-keystroke to justify a live call to the YouTube Music API for every
|
||||
// client that hits this endpoint.
|
||||
private const int MinNameLengthForYouTubeSearch = 2;
|
||||
|
||||
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var tracksQuery = database.UserHasTracks
|
||||
.AsNoTracking()
|
||||
@@ -39,11 +48,61 @@ namespace Musify.Application.Tracks
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
||||
var localItems = pagedEntities
|
||||
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
||||
.ToList();
|
||||
|
||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
||||
var knownVideoIds = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
foreach (var entry in pagedEntities)
|
||||
{
|
||||
if (entry.Track is ExternalTrack { Source: TrackSource.YouTube } external)
|
||||
knownVideoIds.Add(external.ExternalId);
|
||||
}
|
||||
|
||||
var youtubeItems = new List<TrackSearchItemResponse>();
|
||||
string? nextContinuationToken = null;
|
||||
var youtubeUnavailable = false;
|
||||
|
||||
if (request.Name?.Trim().Length >= MinNameLengthForYouTubeSearch)
|
||||
(youtubeItems, nextContinuationToken, youtubeUnavailable) =
|
||||
await SearchYouTubeAsync(request.Name, request.YoutubeContinuationToken, knownVideoIds, cancellationToken);
|
||||
|
||||
return new TracksSearchResponse(
|
||||
[.. localItems, .. youtubeItems],
|
||||
pagedEntities.PageNumber,
|
||||
pagedEntities.PageSize,
|
||||
pagedEntities.PageCount,
|
||||
pagedEntities.TotalItemCount,
|
||||
pagedEntities.HasPreviousPage,
|
||||
pagedEntities.HasNextPage,
|
||||
nextContinuationToken,
|
||||
youtubeUnavailable);
|
||||
}
|
||||
|
||||
private async Task<(List<TrackSearchItemResponse> Items, string? NextContinuationToken, bool Unavailable)> SearchYouTubeAsync(
|
||||
string name,
|
||||
string? continuationToken,
|
||||
HashSet<string> knownVideoIds,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var searchResult = await youTubeMusicService.SearchSongsAsync(name, continuationToken ?? string.Empty, cancellationToken);
|
||||
if (searchResult.IsError)
|
||||
{
|
||||
logger.LogInformation(
|
||||
"YouTube search unavailable while combining track results: {Error}", searchResult.FirstError.Description);
|
||||
return ([], null, true);
|
||||
}
|
||||
|
||||
var items = searchResult.Value.Items
|
||||
.Where(song => knownVideoIds.Add(song.VideoId))
|
||||
.Select(TrackSearchItemResponse.FromYouTubeSong)
|
||||
.ToList();
|
||||
|
||||
var nextToken = string.IsNullOrEmpty(searchResult.Value.ContinuationToken)
|
||||
? null
|
||||
: searchResult.Value.ContinuationToken;
|
||||
|
||||
return (items, nextToken, false);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
/// <summary>One item of a combined local/YouTube track listing. Exactly one of
|
||||
/// <see cref="Track"/> or <see cref="YouTubeSong"/> is populated: <see cref="Track"/> for
|
||||
/// anything already in the catalog (local uploads and YouTube tracks provisioned earlier),
|
||||
/// <see cref="YouTubeSong"/> for a live YouTube Music search hit that has not been
|
||||
/// provisioned yet, so it has no track id.</summary>
|
||||
public record TrackSearchItemResponse(
|
||||
TrackSource Source,
|
||||
TrackApplicationResponse? Track,
|
||||
YouTubeSongResult? YouTubeSong)
|
||||
{
|
||||
public static TrackSearchItemResponse FromTrack(TrackApplicationResponse track) =>
|
||||
new(track.Source, track, null);
|
||||
|
||||
public static TrackSearchItemResponse FromYouTubeSong(YouTubeSongResult song) =>
|
||||
new(TrackSource.YouTube, null, song);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
/// <summary>Result of a combined tracks listing/search: a page of local catalog tracks
|
||||
/// (uploads and previously provisioned YouTube tracks), plus, when a search term was given,
|
||||
/// live YouTube Music hits appended after them. Pagination metadata (<see cref="PageNumber"/>,
|
||||
/// <see cref="PageSize"/>, <see cref="PageCount"/>, <see cref="TotalItemCount"/>) describes only
|
||||
/// the local page — YouTube has no stable total, so its continuation is tracked separately via
|
||||
/// <see cref="NextYoutubeContinuationToken"/>.</summary>
|
||||
public record TracksSearchResponse(
|
||||
IReadOnlyList<TrackSearchItemResponse> Items,
|
||||
int PageNumber,
|
||||
int PageSize,
|
||||
int PageCount,
|
||||
int TotalItemCount,
|
||||
bool HasPreviousPage,
|
||||
bool HasNextLocalPage,
|
||||
string? NextYoutubeContinuationToken,
|
||||
bool YoutubeUnavailable)
|
||||
{
|
||||
public bool HasNextPage => HasNextLocalPage || !string.IsNullOrEmpty(NextYoutubeContinuationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,21 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.YouTube
|
||||
{
|
||||
public record SearchYouTubeTracksQuery(string Query, string ContinuationToken)
|
||||
: IQuery<ErrorOr<YouTubeSearchResult>>;
|
||||
|
||||
public class SearchYouTubeTracksQueryHandler(IYouTubeMusicService youTubeMusicService)
|
||||
: IQueryHandler<SearchYouTubeTracksQuery, ErrorOr<YouTubeSearchResult>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<YouTubeSearchResult>> Handle(SearchYouTubeTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(request.Query))
|
||||
return Error.Validation(description: "The search query must not be empty.");
|
||||
|
||||
return await youTubeMusicService.SearchSongsAsync(request.Query, request.ContinuationToken, cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,8 +19,8 @@ public static class TrackEndpoints
|
||||
|
||||
group.MapGet("/", GetTracks)
|
||||
.WithName("GetTracks")
|
||||
.WithSummary("Get Paginated Tracks.")
|
||||
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
||||
.WithSummary("Get Paginated Tracks, Combined With Live YouTube Music Results When A Name Filter Is Given.")
|
||||
.Produces<TracksSearchResponse>();
|
||||
|
||||
group.MapGet("/{id}", GetTrackById)
|
||||
.WithName("GetTrackById")
|
||||
@@ -86,9 +86,10 @@ public static class TrackEndpoints
|
||||
CancellationToken cancellationToken,
|
||||
string? name,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10)
|
||||
int pageSize = 10,
|
||||
string? youtubeContinuationToken = null)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
|
||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, youtubeContinuationToken), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
|
||||
@@ -14,14 +14,6 @@ public static class YouTubeEndpoints
|
||||
var group = app.MapGroup("/youtube")
|
||||
.WithTags("YouTube");
|
||||
|
||||
group.MapGet("/search", SearchTracks)
|
||||
.WithName("SearchYouTubeTracks")
|
||||
.WithSummary("Search Songs On YouTube Music.")
|
||||
.RequireAuthorization()
|
||||
.Produces<YouTubeSearchResult>()
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/albums", SearchAlbums)
|
||||
.WithName("SearchYouTubeAlbums")
|
||||
.WithSummary("Search Albums On YouTube Music.")
|
||||
@@ -83,17 +75,4 @@ public static class YouTubeEndpoints
|
||||
var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> SearchTracks(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken,
|
||||
string query,
|
||||
string? continuation = null)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new SearchYouTubeTracksQuery(query, continuation ?? string.Empty),
|
||||
cancellationToken);
|
||||
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,15 +1,22 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class GetTracksQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetTracksQueryHandler CreateHandler() => new(Database);
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private GetTracksQueryHandler CreateHandler() =>
|
||||
new(Database, youTubeMusicService, NoOpLogger<GetTracksQueryHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns()
|
||||
public async Task Handle_NoNameFilter_ReturnsEveryTrackAUserOwns_AndDoesNotCallYouTube()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var trackA = TestEntities.LocalTrack(owner, "Alpha");
|
||||
@@ -23,10 +30,14 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.TotalItemCount);
|
||||
Assert.Equal(2, result.Value.Items.Count);
|
||||
Assert.All(result.Value.Items, item => Assert.NotNull(item.Track));
|
||||
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
||||
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NameFilter_ReturnsOnlyMatchingTracks()
|
||||
public async Task Handle_NameFilter_ReturnsMatchingLocalTracks_FollowedByYouTubeResults()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
|
||||
@@ -36,11 +47,65 @@ namespace Musify.Application.Tests.Tracks
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = other.Id });
|
||||
|
||||
var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody (Live)", "Queen", "A Night at the Opera",
|
||||
354, "thumb.jpg", false, []);
|
||||
youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any<CancellationToken>())
|
||||
.Returns(new YouTubeSearchResult([song], "next-token"));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var track = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", track.Title);
|
||||
Assert.Equal(2, result.Value.Items.Count);
|
||||
Assert.NotNull(result.Value.Items[0].Track);
|
||||
Assert.Equal("Bohemian Rhapsody", result.Value.Items[0].Track!.Title);
|
||||
Assert.Null(result.Value.Items[1].Track);
|
||||
Assert.NotNull(result.Value.Items[1].YouTubeSong);
|
||||
Assert.Equal("video-1", result.Value.Items[1].YouTubeSong!.VideoId);
|
||||
Assert.Equal("next-token", result.Value.NextYoutubeContinuationToken);
|
||||
Assert.False(result.Value.YoutubeUnavailable);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NameFilter_SkipsYouTubeResultAlreadyProvisionedLocally()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var provisioned = TestEntities.ExternalTrack(externalId: "video-1", title: "Bohemian Rhapsody", source: TrackSource.YouTube);
|
||||
await SeedAsync(
|
||||
owner, provisioned,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = provisioned.Id });
|
||||
|
||||
var song = new YouTubeSongResult("video-1", "Bohemian Rhapsody", "Queen", "A Night at the Opera",
|
||||
354, "thumb.jpg", false, []);
|
||||
youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any<CancellationToken>())
|
||||
.Returns(new YouTubeSearchResult([song], string.Empty));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.NotNull(item.Track);
|
||||
Assert.Null(item.YouTubeSong);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NameFilter_YouTubeUnavailable_StillReturnsLocalResults()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var match = TestEntities.LocalTrack(owner, "Bohemian Rhapsody");
|
||||
await SeedAsync(
|
||||
owner, match,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = match.Id });
|
||||
|
||||
youTubeMusicService.SearchSongsAsync("rhapsody", string.Empty, Arg.Any<CancellationToken>())
|
||||
.Returns(Error.Forbidden(description: "The YouTube Music source is disabled."));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(Name: "rhapsody", PageNumber: 1, PageSize: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.NotNull(item.Track);
|
||||
Assert.True(result.Value.YoutubeUnavailable);
|
||||
Assert.Null(result.Value.NextYoutubeContinuationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-37
@@ -1,37 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.YouTube;
|
||||
using NSubstitute;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.YouTube
|
||||
{
|
||||
public sealed class SearchYouTubeTracksQueryHandlerTests
|
||||
{
|
||||
private readonly IYouTubeMusicService youTubeMusicService = Substitute.For<IYouTubeMusicService>();
|
||||
|
||||
private SearchYouTubeTracksQueryHandler CreateHandler() => new(youTubeMusicService);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_EmptyQuery_ReturnsValidationErrorWithoutCallingTheService()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery(string.Empty, string.Empty), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
await youTubeMusicService.DidNotReceiveWithAnyArgs()
|
||||
.SearchSongsAsync(default!, default!, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NonEmptyQuery_DelegatesToTheService()
|
||||
{
|
||||
var expected = new YouTubeSearchResult([], "next-token");
|
||||
youTubeMusicService.SearchSongsAsync("query", "token", Arg.Any<CancellationToken>()).Returns(expected);
|
||||
|
||||
var result = await CreateHandler().Handle(new SearchYouTubeTracksQuery("query", "token"), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
Assert.Same(expected, result.Value);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user