Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
5 changed files with 237 additions and 15 deletions
Showing only changes of commit 0afb365427 - Show all commits
@@ -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<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)
.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<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(
IMediator mediator,
CancellationToken cancellationToken,
@@ -18,12 +18,43 @@ namespace Musify.Application.Contracts
IReadOnlyList<YouTubeSongResult> Items,
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 interface IYouTubeMusicService
{
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<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));
}
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;
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<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>()
.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, page.Value.NextToken);
}
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)
{
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}";