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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user