Files
Musify/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs
T
Admin 07e1daf2ce 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.
2026-07-20 20:36:28 +02:00

301 lines
13 KiB
C#

using ErrorOr;
using Microsoft.Extensions.Caching.Memory;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Infrastructure.Configuration;
using YouTubeMusicAPI.Client;
using YouTubeMusicAPI.Models.Search;
using YouTubeMusicAPI.Models.Streaming;
using YouTubeMusicAPI.Pagination;
namespace Musify.Infrastructure.Services
{
public class YouTubeMusicService(YouTubeConfiguration configuration, IMemoryCache cache, ILogger<YouTubeMusicService> logger) : IYouTubeMusicService
{
private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation);
private readonly SingleFlightCache singleFlight = new(cache);
private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc);
public async Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken)
{
if (!string.IsNullOrEmpty(continuationToken))
return await FetchSearchPageAsync(query, continuationToken, cancellationToken);
return await singleFlight.GetOrCreateAsync(
SearchResultsCacheKey(query),
TimeSpan.FromSeconds(configuration.SearchResultsCacheSeconds),
() => FetchSearchPageAsync(query, continuationToken, 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, category);
}
else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null)
{
return Error.NotFound(description: "The search continuation has expired.");
}
IReadOnlyList<SearchResult> results;
try
{
results = await paginator.FetchNextPageAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "YouTube Music {Category} search failed for query '{Query}'", category, query);
return Error.Failure(description: $"YouTube Music search failed: {exception.Message}");
}
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,
song.Name,
string.Join(", ", song.Artists.Select(artist => artist.Name)),
song.Album?.Name ?? string.Empty,
(int)song.Duration.TotalSeconds,
PickThumbnail(song.Thumbnails),
song.IsExplicit,
song.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
artist.Name))
.ToList()))
.ToList();
return new YouTubeSearchResult(items, page.Value.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;
if (cache.TryGetValue(StreamCacheKey(videoId), out CachedStreamInfo? cached)
&& cached is not null
&& cached.ExpiresAtUtc > now)
{
return new YouTubeStreamInfo(cached.Url, (int)(cached.ExpiresAtUtc - now).TotalSeconds);
}
StreamingData streamingData;
try
{
streamingData = await client.GetStreamingDataAsync(videoId, cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to resolve YouTube stream for '{VideoId}'", videoId);
return Error.Failure(description: $"Failed to resolve YouTube stream for '{videoId}': {exception.Message}");
}
var audioStream = streamingData.StreamInfo
.OfType<AudioStreamInfo>()
.OrderByDescending(stream => stream.Bitrate)
.FirstOrDefault();
if (audioStream is null || string.IsNullOrEmpty(audioStream.Url))
return Error.NotFound(description: $"No audio stream available for '{videoId}'.");
var expiresInSeconds = Math.Min(
configuration.StreamUrlCacheSeconds,
(int)streamingData.ExpiresIn.TotalSeconds);
if (expiresInSeconds > 0)
{
cache.Set(
StreamCacheKey(videoId),
new CachedStreamInfo(audioStream.Url, now.AddSeconds(expiresInSeconds)),
TimeSpan.FromSeconds(expiresInSeconds));
}
return new YouTubeStreamInfo(audioStream.Url, expiresInSeconds);
}
public async Task<ErrorOr<YouTubeSongResult>> GetSongAsync(string videoId, CancellationToken cancellationToken)
{
return await singleFlight.GetOrCreateAsync(
SongInfoCacheKey(videoId),
TimeSpan.FromSeconds(configuration.SongInfoCacheSeconds),
() => FetchSongAsync(videoId, cancellationToken));
}
private async Task<ErrorOr<YouTubeSongResult>> FetchSongAsync(string videoId, CancellationToken cancellationToken)
{
try
{
var info = await client.GetSongVideoInfoAsync(videoId, cancellationToken);
return new YouTubeSongResult(
videoId,
info.Name,
string.Join(", ", info.Artists.Select(artist => artist.Name)),
string.Empty,
(int)info.Duration.TotalSeconds,
PickThumbnail(info.Thumbnails),
info.IsExplicit,
info.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
artist.Name))
.ToList());
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to load YouTube song info for '{VideoId}'", videoId);
return Error.Failure(description: $"Failed to load YouTube song info for '{videoId}': {exception.Message}");
}
}
public string ResolveArtworkUrl(string thumbnailUrl) =>
YouTubeThumbnail.WithSize(thumbnailUrl, configuration.ArtworkSize);
private string PickThumbnail(IEnumerable<YouTubeMusicAPI.Models.Thumbnail> thumbnails)
{
var ordered = thumbnails.OrderBy(thumbnail => thumbnail.Width).ToList();
var square = ordered.Where(thumbnail => YouTubeThumbnail.IsSquare(thumbnail.Url)).ToList();
var candidates = square.Count > 0 ? square : ordered;
var chosen = candidates.FirstOrDefault(thumbnail => thumbnail.Width >= configuration.ThumbnailSize)
?? candidates.LastOrDefault();
return chosen is null
? string.Empty
: YouTubeThumbnail.WithSize(chosen.Url, configuration.ThumbnailSize);
}
private static string SearchCacheKey(string token) => $"yt-search:{token}";
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}";
}
}