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}";