Design Prot Merge #1
@@ -22,6 +22,22 @@ public static class YouTubeEndpoints
|
|||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.ProducesValidationProblem();
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
group.MapGet("/albums", SearchAlbums)
|
||||||
|
.WithName("SearchYouTubeAlbums")
|
||||||
|
.WithSummary("Search Albums On YouTube Music.")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<YouTubeAlbumSearchResult>()
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.ProducesValidationProblem();
|
||||||
|
|
||||||
|
group.MapGet("/albums/{albumId}", GetAlbum)
|
||||||
|
.WithName("GetYouTubeAlbum")
|
||||||
|
.WithSummary("Get A YouTube Music Album With Its Tracklist.")
|
||||||
|
.RequireAuthorization()
|
||||||
|
.Produces<YouTubeAlbumDetail>()
|
||||||
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/tracks/{videoId}/stream", GetTrackStream)
|
group.MapGet("/tracks/{videoId}/stream", GetTrackStream)
|
||||||
.WithName("GetYouTubeTrackStream")
|
.WithName("GetYouTubeTrackStream")
|
||||||
.WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.")
|
.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();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static async Task<IResult> 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<IResult> GetAlbum(
|
||||||
|
IMediator mediator,
|
||||||
|
string albumId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var result = await mediator.Send(new GetYouTubeAlbumQuery(albumId), cancellationToken);
|
||||||
|
return result.ToHttpResult();
|
||||||
|
}
|
||||||
|
|
||||||
private static async Task<IResult> SearchTracks(
|
private static async Task<IResult> SearchTracks(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
|
|||||||
@@ -18,12 +18,43 @@ namespace Musify.Application.Contracts
|
|||||||
IReadOnlyList<YouTubeSongResult> Items,
|
IReadOnlyList<YouTubeSongResult> Items,
|
||||||
string ContinuationToken);
|
string ContinuationToken);
|
||||||
|
|
||||||
|
public record YouTubeAlbumResult(
|
||||||
|
string AlbumId,
|
||||||
|
string Title,
|
||||||
|
string Artist,
|
||||||
|
string ThumbnailUrl,
|
||||||
|
int? ReleaseYear,
|
||||||
|
bool IsSingle,
|
||||||
|
bool IsEp,
|
||||||
|
IReadOnlyList<YouTubeArtistRef> Artists);
|
||||||
|
|
||||||
|
public record YouTubeAlbumSearchResult(
|
||||||
|
IReadOnlyList<YouTubeAlbumResult> 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<YouTubeAlbumTrack> Tracks);
|
||||||
|
|
||||||
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
|
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
|
||||||
|
|
||||||
public interface IYouTubeMusicService
|
public interface IYouTubeMusicService
|
||||||
{
|
{
|
||||||
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ErrorOr<YouTubeAlbumSearchResult>> SearchAlbumsAsync(string query, string continuationToken, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
|
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
|
Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Musify.Application.YouTube
|
||||||
|
{
|
||||||
|
public record GetYouTubeAlbumQuery(string AlbumId)
|
||||||
|
: IQuery<ErrorOr<YouTubeAlbumDetail>>;
|
||||||
|
|
||||||
|
public class GetYouTubeAlbumQueryHandler(IYouTubeMusicService youTubeMusicService)
|
||||||
|
: IQueryHandler<GetYouTubeAlbumQuery, ErrorOr<YouTubeAlbumDetail>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<YouTubeAlbumDetail>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
|
||||||
|
namespace Musify.Application.YouTube
|
||||||
|
{
|
||||||
|
public record SearchYouTubeAlbumsQuery(string Query, string ContinuationToken)
|
||||||
|
: IQuery<ErrorOr<YouTubeAlbumSearchResult>>;
|
||||||
|
|
||||||
|
public class SearchYouTubeAlbumsQueryHandler(IYouTubeMusicService youTubeMusicService)
|
||||||
|
: IQueryHandler<SearchYouTubeAlbumsQuery, ErrorOr<YouTubeAlbumSearchResult>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<YouTubeAlbumSearchResult>> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,12 +28,18 @@ namespace Musify.Infrastructure.Services
|
|||||||
() => FetchSearchPageAsync(query, continuationToken, cancellationToken));
|
() => FetchSearchPageAsync(query, continuationToken, cancellationToken));
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<YouTubeSearchResult>> FetchSearchPageAsync(string query, string continuationToken, CancellationToken cancellationToken)
|
private sealed record SearchPage(IReadOnlyList<SearchResult> Results, string NextToken);
|
||||||
|
|
||||||
|
private async Task<ErrorOr<SearchPage>> FetchPageAsync(
|
||||||
|
string query,
|
||||||
|
SearchCategory category,
|
||||||
|
string continuationToken,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
PaginatedAsyncEnumerable<SearchResult>? paginator;
|
PaginatedAsyncEnumerable<SearchResult>? paginator;
|
||||||
if (string.IsNullOrEmpty(continuationToken))
|
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)
|
else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null)
|
||||||
{
|
{
|
||||||
@@ -47,11 +53,30 @@ namespace Musify.Infrastructure.Services
|
|||||||
}
|
}
|
||||||
catch (Exception exception)
|
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}");
|
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<ErrorOr<YouTubeSearchResult>> 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<SongSearchResult>()
|
.OfType<SongSearchResult>()
|
||||||
.Select(song => new YouTubeSongResult(
|
.Select(song => new YouTubeSongResult(
|
||||||
song.Id,
|
song.Id,
|
||||||
@@ -68,19 +93,101 @@ namespace Musify.Infrastructure.Services
|
|||||||
.ToList()))
|
.ToList()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var nextToken = string.Empty;
|
return new YouTubeSearchResult(items, page.Value.NextToken);
|
||||||
if (paginator.HasMore)
|
|
||||||
{
|
|
||||||
nextToken = Guid.NewGuid().ToString("N");
|
|
||||||
cache.Set(
|
|
||||||
SearchCacheKey(nextToken),
|
|
||||||
paginator,
|
|
||||||
TimeSpan.FromSeconds(configuration.SearchContinuationCacheSeconds));
|
|
||||||
}
|
|
||||||
|
|
||||||
return new YouTubeSearchResult(items, nextToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<YouTubeAlbumSearchResult>> 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<ErrorOr<YouTubeAlbumSearchResult>> 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<AlbumSearchResult>()
|
||||||
|
.Select(ToAlbumResult)
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return new YouTubeAlbumSearchResult(items, page.Value.NextToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<YouTubeAlbumDetail>> GetAlbumAsync(string albumId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await singleFlight.GetOrCreateAsync(
|
||||||
|
AlbumCacheKey(albumId),
|
||||||
|
TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds),
|
||||||
|
() => FetchAlbumAsync(albumId, cancellationToken));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<ErrorOr<YouTubeAlbumDetail>> 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<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken)
|
public async Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var now = DateTime.UtcNow;
|
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 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 SongInfoCacheKey(string videoId) => $"yt-song:{videoId}";
|
||||||
|
|
||||||
private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}";
|
private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}";
|
||||||
|
|||||||
Reference in New Issue
Block a user