Files
Musify/backend/Core/Musify.Application/Tracks/GetTracks.cs
T
Admin 29a7fed6a5 Generalize YouTube-backed endpoints behind a source parameter
Removes the last YouTube-specific routes (/youtube/*) and merges them
into the general track/album/playlist endpoints, keyed by a `source`
string validated against TrackSource. This mirrors the earlier track
search merge for albums and playlist-track additions:

- GetAlbums now merges local albums with live YouTube Music search
  results, paginated via a youtubeContinuationToken, deduped against
  already-materialized ExternalAlbum rows.
- New GET /albums/external/{source}/{externalId} and
  GET /tracks/external/{source}/{externalId}/stream replace the
  YouTube-only equivalents.
- POST /playlists/{id}/tracks replaces the separate
  .../youtube-tracks endpoint, accepting an optional Source/ExternalId
  pair alongside TrackId.
- AlbumSearchItemResponse/AlbumsSearchResponse follow the same
  discriminated-union + trailing-HasNextPage-field pattern already
  used for track search, so HasNextPage stays a required field in the
  generated OpenAPI schema.
- Frontend routes renamed accordingly (/albums/youtube/[albumId] ->
  /albums/external/[source]/[externalId], etc.) with a single
  /api/thumbnail proxy replacing /api/youtube/thumbnail.

Musify.Api.Endpoints.YouTubeEndpoints and the standalone
SearchYouTubeAlbums query are removed as a result.
2026-09-10 15:03:36 +02:00

109 lines
4.6 KiB
C#

using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
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, string? YoutubeContinuationToken = null)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
ILogger<GetTracksQueryHandler> logger)
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
{
// 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()
.Include(ut => ((ExternalTrack)ut.Track).TrackArtists)
.ThenInclude(trackArtist => trackArtist.Artist)
.Include(ut => ((LocalTrack)ut.Track).Owner)
.AsQueryable();
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var localItems = pagedEntities
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
.ToList();
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 TracksSearchResponse.Create(
[.. 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);
}
}
}