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:
2026-09-10 09:55:58 +02:00
parent c418844178
commit 92a06a3c15
15 changed files with 266 additions and 240 deletions
@@ -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);
}
}
}
@@ -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);
}
}
}