From 0afb3654273e142e09252b6e764a8d3af937949f Mon Sep 17 00:00:00 2001 From: XShine555 Date: Mon, 20 Jul 2026 20:36:28 +0200 Subject: [PATCH] feat(backend): search albums on YouTube Music Two read-only endpoints: the album search and the album detail with its tracklist. Both go through the same single-flight cache as the song search, and the detail one especially benefits since it costs two upstream calls, the browse id lookup and the album itself, for metadata that never changes. Paginator handling is now shared between the song and album searches. --- .../Musify.Api/Endpoints/YouTubeEndpoints.cs | 38 +++++ .../Contracts/IYouTubeMusicService.cs | 31 ++++ .../YouTube/GetYouTubeAlbum.cs | 21 +++ .../YouTube/SearchYouTubeAlbums.cs | 21 +++ .../Services/YouTubeMusicService.cs | 141 ++++++++++++++++-- 5 files changed, 237 insertions(+), 15 deletions(-) create mode 100644 backend/Musify.Application/YouTube/GetYouTubeAlbum.cs create mode 100644 backend/Musify.Application/YouTube/SearchYouTubeAlbums.cs diff --git a/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs b/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs index 5e84c0e..24f4098 100644 --- a/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs +++ b/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs @@ -22,6 +22,22 @@ public static class YouTubeEndpoints .Produces(StatusCodes.Status401Unauthorized) .ProducesValidationProblem(); + group.MapGet("/albums", SearchAlbums) + .WithName("SearchYouTubeAlbums") + .WithSummary("Search Albums On YouTube Music.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .ProducesValidationProblem(); + + group.MapGet("/albums/{albumId}", GetAlbum) + .WithName("GetYouTubeAlbum") + .WithSummary("Get A YouTube Music Album With Its Tracklist.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/tracks/{videoId}/stream", GetTrackStream) .WithName("GetYouTubeTrackStream") .WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.") @@ -46,6 +62,28 @@ public static class YouTubeEndpoints return result.ToHttpResult(); } + private static async Task SearchAlbums( + IMediator mediator, + CancellationToken cancellationToken, + string query, + string? continuation = null) + { + var result = await mediator.Send( + new SearchYouTubeAlbumsQuery(query, continuation ?? string.Empty), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task GetAlbum( + IMediator mediator, + string albumId, + CancellationToken cancellationToken) + { + var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken); + return result.ToHttpResult(); + } + private static async Task SearchTracks( IMediator mediator, CancellationToken cancellationToken, diff --git a/backend/Musify.Application/Contracts/IYouTubeMusicService.cs b/backend/Musify.Application/Contracts/IYouTubeMusicService.cs index 4149a3a..b01cf3f 100644 --- a/backend/Musify.Application/Contracts/IYouTubeMusicService.cs +++ b/backend/Musify.Application/Contracts/IYouTubeMusicService.cs @@ -18,12 +18,43 @@ namespace Musify.Application.Contracts IReadOnlyList Items, string ContinuationToken); + public record YouTubeAlbumResult( + string AlbumId, + string Title, + string Artist, + string ThumbnailUrl, + int? ReleaseYear, + bool IsSingle, + bool IsEp, + IReadOnlyList Artists); + + public record YouTubeAlbumSearchResult( + IReadOnlyList Items, + string ContinuationToken); + + public record YouTubeAlbumTrack( + string VideoId, + string Title, + int DurationSeconds, + int TrackNumber, + bool IsExplicit); + + public record YouTubeAlbumDetail( + YouTubeAlbumResult Album, + string Description, + int TotalDurationSeconds, + IReadOnlyList Tracks); + public record YouTubeStreamInfo(string Url, int ExpiresInSeconds); public interface IYouTubeMusicService { Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken); + Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken); + + Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken); + Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken); Task> GetSongAsync(string videoId, CancellationToken cancellationToken); diff --git a/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs b/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs new file mode 100644 index 0000000..9f79651 --- /dev/null +++ b/backend/Musify.Application/YouTube/GetYouTubeAlbum.cs @@ -0,0 +1,21 @@ +using ErrorOr; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.YouTube +{ + public record GetYouTubeAlbumQuery(string AlbumId) + : IQuery>; + + public class GetYouTubeAlbumQueryHandler(IYouTubeMusicService youTubeMusicService) + : IQueryHandler> + { + public async ValueTask> Handle(GetYouTubeAlbumQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.AlbumId)) + return Error.Validation(description: "The album id must not be empty."); + + return await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken); + } + } +} diff --git a/backend/Musify.Application/YouTube/SearchYouTubeAlbums.cs b/backend/Musify.Application/YouTube/SearchYouTubeAlbums.cs new file mode 100644 index 0000000..d422f51 --- /dev/null +++ b/backend/Musify.Application/YouTube/SearchYouTubeAlbums.cs @@ -0,0 +1,21 @@ +using ErrorOr; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.YouTube +{ + public record SearchYouTubeAlbumsQuery(string Query, string ContinuationToken) + : IQuery>; + + public class SearchYouTubeAlbumsQueryHandler(IYouTubeMusicService youTubeMusicService) + : IQueryHandler> + { + public async ValueTask> Handle(SearchYouTubeAlbumsQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.Query)) + return Error.Validation(description: "The search query must not be empty."); + + return await youTubeMusicService.SearchAlbumsAsync(request.Query, request.ContinuationToken, cancellationToken); + } + } +} diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs index 7ae84c2..07623b9 100644 --- a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -28,12 +28,18 @@ namespace Musify.Infrastructure.Services () => FetchSearchPageAsync(query, continuationToken, cancellationToken)); } - private async Task> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) + private sealed record SearchPage(IReadOnlyList Results, string NextToken); + + private async Task> FetchPageAsync( + string query, + SearchCategory category, + string continuationToken, + CancellationToken cancellationToken) { PaginatedAsyncEnumerable? paginator; if (string.IsNullOrEmpty(continuationToken)) { - paginator = client.SearchAsync(query, SearchCategory.Songs); + paginator = client.SearchAsync(query, category); } else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null) { @@ -47,11 +53,30 @@ namespace Musify.Infrastructure.Services } catch (Exception exception) { - logger.LogError(exception, "YouTube Music search failed for query '{Query}'", query); + logger.LogError(exception, "YouTube Music {Category} search failed for query '{Query}'", category, query); return Error.Failure(description: $"YouTube Music search failed: {exception.Message}"); } - var items = results + var nextToken = string.Empty; + if (paginator.HasMore) + { + nextToken = Guid.NewGuid().ToString("N"); + cache.Set( + SearchCacheKey(nextToken), + paginator, + TimeSpan.FromSeconds(configuration.SearchContinuationCacheSeconds)); + } + + return new SearchPage(results, nextToken); + } + + private async Task> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) + { + var page = await FetchPageAsync(query, SearchCategory.Songs, continuationToken, cancellationToken); + if (page.IsError) + return page.Errors; + + var items = page.Value.Results .OfType() .Select(song => new YouTubeSongResult( song.Id, @@ -68,19 +93,101 @@ namespace Musify.Infrastructure.Services .ToList())) .ToList(); - var nextToken = string.Empty; - if (paginator.HasMore) - { - nextToken = Guid.NewGuid().ToString("N"); - cache.Set( - SearchCacheKey(nextToken), - paginator, - TimeSpan.FromSeconds(configuration.SearchContinuationCacheSeconds)); - } - - return new YouTubeSearchResult(items, nextToken); + return new YouTubeSearchResult(items, page.Value.NextToken); } + public async Task> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken) + { + if (!string.IsNullOrEmpty(continuationToken)) + return await FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken); + + return await singleFlight.GetOrCreateAsync( + AlbumSearchResultsCacheKey(query), + TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds), + () => FetchAlbumSearchPageAsync(query, continuationToken, cancellationToken)); + } + + private async Task> FetchAlbumSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken) + { + var page = await FetchPageAsync(query, SearchCategory.Albums, continuationToken, cancellationToken); + if (page.IsError) + return page.Errors; + + var items = page.Value.Results + .OfType() + .Select(ToAlbumResult) + .ToList(); + + return new YouTubeAlbumSearchResult(items, page.Value.NextToken); + } + + public async Task> GetAlbumAsync(string albumId, CancellationToken cancellationToken) + { + return await singleFlight.GetOrCreateAsync( + AlbumCacheKey(albumId), + TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds), + () => FetchAlbumAsync(albumId, cancellationToken)); + } + + private async Task> FetchAlbumAsync(string albumId, CancellationToken cancellationToken) + { + try + { + var browseId = await client.GetAlbumBrowseIdAsync(albumId, cancellationToken); + var info = await client.GetAlbumInfoAsync(browseId, cancellationToken); + + var album = new YouTubeAlbumResult( + albumId, + info.Name, + string.Join(", ", info.Artists.Select(artist => artist.Name)), + PickThumbnail(info.Thumbnails), + info.ReleaseYear, + info.IsSingle, + info.IsEp, + info.Artists + .Select(artist => new YouTubeArtistRef( + string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + artist.Name)) + .ToList()); + + var tracks = info.Songs + .Select((song, index) => new YouTubeAlbumTrack( + song.Id ?? string.Empty, + song.Name, + (int)song.Duration.TotalSeconds, + song.SongNumber ?? index + 1, + song.IsExplicit)) + .Where(track => !string.IsNullOrEmpty(track.VideoId)) + .ToList(); + + return new YouTubeAlbumDetail( + album, + info.Description ?? string.Empty, + (int)info.Duration.TotalSeconds, + tracks); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to load YouTube album '{AlbumId}'", albumId); + return Error.Failure(description: $"Failed to load YouTube album '{albumId}': {exception.Message}"); + } + } + + private YouTubeAlbumResult ToAlbumResult(AlbumSearchResult album) => + new( + album.Id, + album.Name, + string.Join(", ", album.Artists.Select(artist => artist.Name)), + PickThumbnail(album.Thumbnails), + album.ReleaseYear, + album.IsSingle, + album.IsEp, + album.Artists + .Select(artist => new YouTubeArtistRef( + string.IsNullOrEmpty(artist.Id) ? null : artist.Id, + artist.Name)) + .ToList()); + public async Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken) { var now = DateTime.UtcNow; @@ -182,6 +289,10 @@ namespace Musify.Infrastructure.Services private static string SearchResultsCacheKey(string query) => $"yt-search-results:{query.Trim().ToLowerInvariant()}"; + private static string AlbumSearchResultsCacheKey(string query) => $"yt-album-search:{query.Trim().ToLowerInvariant()}"; + + private static string AlbumCacheKey(string albumId) => $"yt-album:{albumId}"; + private static string SongInfoCacheKey(string videoId) => $"yt-song:{videoId}"; private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}";