From 11ed8b40ff21238da18352dbc7327e9257357e77 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Thu, 16 Jul 2026 01:05:42 +0200 Subject: [PATCH] feat(backend): YouTube Music search and playback resolution endpoints --- backend/Directory.Packages.props | 2 + .../Musify.Api/AppSettings.Development.json | 3 + backend/Musify.Api/AppSettings.json | 5 + .../Musify.Api/Endpoints/YouTubeEndpoints.cs | 61 ++++++++++ .../Extensions/ServiceCollectionExtensions.cs | 1 + backend/Musify.Api/Program.cs | 7 +- .../ApplicationDependencyInjection.cs | 1 + .../Contracts/IYouTubeDownloaderService.cs | 9 ++ .../Contracts/IYouTubeMusicService.cs | 25 ++++ .../Services/TrackStreamIssuer.cs | 41 +++++++ .../Tracks/GetTrackStream.cs | 30 +---- .../YouTube/GetYouTubeTrackStream.cs | 52 ++++++++ .../Responses/YouTubeStreamResponse.cs | 15 +++ .../YouTube/SearchYouTubeTracks.cs | 21 ++++ .../Configuration/YouTubeConfiguration.cs | 18 +++ .../Configuration/YtDlpConfiguration.cs | 20 +++ .../Musify.Infrastructure.csproj | 2 + .../Services/ServicesDependencyInjection.cs | 17 +++ .../Services/YouTubeDownloaderService.cs | 89 ++++++++++++++ .../Services/YouTubeMusicService.cs | 114 ++++++++++++++++++ 20 files changed, 506 insertions(+), 27 deletions(-) create mode 100644 backend/Musify.Api/Endpoints/YouTubeEndpoints.cs create mode 100644 backend/Musify.Application/Contracts/IYouTubeDownloaderService.cs create mode 100644 backend/Musify.Application/Contracts/IYouTubeMusicService.cs create mode 100644 backend/Musify.Application/Services/TrackStreamIssuer.cs create mode 100644 backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs create mode 100644 backend/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs create mode 100644 backend/Musify.Application/YouTube/SearchYouTubeTracks.cs create mode 100644 backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs create mode 100644 backend/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs create mode 100644 backend/Musify.Infrastructure/Services/YouTubeDownloaderService.cs create mode 100644 backend/Musify.Infrastructure/Services/YouTubeMusicService.cs diff --git a/backend/Directory.Packages.props b/backend/Directory.Packages.props index 2e34c09..5f73e88 100644 --- a/backend/Directory.Packages.props +++ b/backend/Directory.Packages.props @@ -19,6 +19,7 @@ + @@ -46,6 +47,7 @@ + diff --git a/backend/Musify.Api/AppSettings.Development.json b/backend/Musify.Api/AppSettings.Development.json index 430b1ac..f12a107 100644 --- a/backend/Musify.Api/AppSettings.Development.json +++ b/backend/Musify.Api/AppSettings.Development.json @@ -47,6 +47,9 @@ "OpenTelemetry": { "OtlpEndpoint": "http://localhost:4317" }, + "YouTube": { + "GeographicalLocation": "ES" + }, "Workers": { "Routes": { "TemporaryFilesDirectory": "D:\\tempsFilesDev" diff --git a/backend/Musify.Api/AppSettings.json b/backend/Musify.Api/AppSettings.json index ed3bd07..811b7df 100644 --- a/backend/Musify.Api/AppSettings.json +++ b/backend/Musify.Api/AppSettings.json @@ -48,6 +48,11 @@ "OpenTelemetry": { "OtlpEndpoint": "" }, + "YouTube": { + "GeographicalLocation": "US", + "StreamUrlCacheSeconds": 1800, + "SearchContinuationCacheSeconds": 600 + }, "PlayList": { "Routes": { "UploadsFolder": "uploads", diff --git a/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs b/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs new file mode 100644 index 0000000..45a225d --- /dev/null +++ b/backend/Musify.Api/Endpoints/YouTubeEndpoints.cs @@ -0,0 +1,61 @@ +using Mediator; +using Musify.Api.Authentication; +using Musify.Api.Extensions; +using Musify.Application.Contracts; +using Musify.Application.YouTube; +using Musify.Application.YouTube.Responses; + +namespace Musify.Api.Endpoints; + +public static class YouTubeEndpoints +{ + public static IEndpointRouteBuilder MapYouTubeEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/youtube") + .WithTags("YouTube"); + + group.MapGet("/search", SearchTracks) + .WithName("SearchYouTubeTracks") + .WithSummary("Search Songs On YouTube Music.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .ProducesValidationProblem(); + + group.MapGet("/tracks/{videoId}/stream", GetTrackStream) + .WithName("GetYouTubeTrackStream") + .WithSummary("Resolve Playback For A YouTube Track: Server Stream If Downloaded, Direct YouTube Stream Otherwise.") + .RequireAuthorization() + .Produces() + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + + return app; + } + + private static async Task GetTrackStream( + IMediator mediator, + CurrentUser currentUser, + string videoId, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new GetYouTubeTrackStreamQuery(videoId, currentUser.RequiredId), + cancellationToken); + + return result.ToHttpResult(); + } + + private static async Task SearchTracks( + IMediator mediator, + CancellationToken cancellationToken, + string query, + string? continuation = null) + { + var result = await mediator.Send( + new SearchYouTubeTracksQuery(query, continuation ?? string.Empty), + cancellationToken); + + return result.ToHttpResult(); + } +} diff --git a/backend/Musify.Api/Extensions/ServiceCollectionExtensions.cs b/backend/Musify.Api/Extensions/ServiceCollectionExtensions.cs index 1ceb89e..78b93dd 100644 --- a/backend/Musify.Api/Extensions/ServiceCollectionExtensions.cs +++ b/backend/Musify.Api/Extensions/ServiceCollectionExtensions.cs @@ -18,6 +18,7 @@ public static class ServiceCollectionExtensions services.AddDatabase(configuration); services.AddStorageService(configuration); services.AddStreamTicketService(configuration); + services.AddYouTubeMusicService(configuration); services.AddMassTransitClient(configuration); services.AddAuthenticationConfiguration(configuration); services.AddOpenApiConfiguration(); diff --git a/backend/Musify.Api/Program.cs b/backend/Musify.Api/Program.cs index c7ab2bd..bc5341d 100644 --- a/backend/Musify.Api/Program.cs +++ b/backend/Musify.Api/Program.cs @@ -1,3 +1,4 @@ +using System.Text.Json.Serialization; using Musify.Infrastructure.Observability; using Scalar.AspNetCore; using Musify.Api.Endpoints; @@ -5,6 +6,9 @@ using Musify.Api.Extensions; var builder = WebApplication.CreateBuilder(); +builder.Services.ConfigureHttpJsonOptions(options => + options.SerializerOptions.Converters.Add(new JsonStringEnumConverter())); + const string devCorsPolicy = "dev-cors"; builder.Services @@ -36,6 +40,7 @@ app.UseAuthorization(); app .MapPlayListEndpoints() .MapTrackEndpoints() - .MapUserEndpoints(); + .MapUserEndpoints() + .MapYouTubeEndpoints(); app.Run(); \ No newline at end of file diff --git a/backend/Musify.Application/ApplicationDependencyInjection.cs b/backend/Musify.Application/ApplicationDependencyInjection.cs index c34340c..c5e7669 100644 --- a/backend/Musify.Application/ApplicationDependencyInjection.cs +++ b/backend/Musify.Application/ApplicationDependencyInjection.cs @@ -12,6 +12,7 @@ public static class ApplicationDependencyInjection { services.AddMediator(); services.AddScoped(); + services.AddScoped(); services.AddValidatedOptions(configuration, ApplicationStorageConfiguration.SectionName); services.AddValidatedOptions(configuration, PlayListConfiguration.SectionName); diff --git a/backend/Musify.Application/Contracts/IYouTubeDownloaderService.cs b/backend/Musify.Application/Contracts/IYouTubeDownloaderService.cs new file mode 100644 index 0000000..8d3a98d --- /dev/null +++ b/backend/Musify.Application/Contracts/IYouTubeDownloaderService.cs @@ -0,0 +1,9 @@ +namespace Musify.Application.Contracts +{ + public record YouTubeDownloadResult(int ExitCode, string StandardError); + + public interface IYouTubeDownloaderService + { + Task DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken); + } +} diff --git a/backend/Musify.Application/Contracts/IYouTubeMusicService.cs b/backend/Musify.Application/Contracts/IYouTubeMusicService.cs new file mode 100644 index 0000000..8f43632 --- /dev/null +++ b/backend/Musify.Application/Contracts/IYouTubeMusicService.cs @@ -0,0 +1,25 @@ +using ErrorOr; + +namespace Musify.Application.Contracts +{ + public record YouTubeSongResult( + string VideoId, + string Title, + string Artist, + string Album, + int DurationSeconds, + string ThumbnailUrl); + + public record YouTubeSearchResult( + IReadOnlyList Items, + string ContinuationToken); + + public record YouTubeStreamInfo(string Url, int ExpiresInSeconds); + + public interface IYouTubeMusicService + { + Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken); + + Task> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken); + } +} diff --git a/backend/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Musify.Application/Services/TrackStreamIssuer.cs new file mode 100644 index 0000000..db08450 --- /dev/null +++ b/backend/Musify.Application/Services/TrackStreamIssuer.cs @@ -0,0 +1,41 @@ +using Musify.Application.Configuration; +using Musify.Application.Contracts; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; + +namespace Musify.Application.Services; + +public sealed class TrackStreamIssuer( + IDatabase database, + IStreamTicketService ticketService, + TrackConfiguration trackConfiguration, + StreamGatewayConfiguration streamGatewayConfiguration) +{ + public async Task IssueAsync( + Guid trackId, + string audioFolderName, + long userId, + CancellationToken cancellationToken) + { + var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); + var keyPrefix = $"{folderPath}/"; + + var ticket = ticketService.IssueTicket(userId, keyPrefix); + + var manifestUrl = string.Join('/', + streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), + "media", + folderPath, + streamGatewayConfiguration.AudioFileName); + + var newListeningHistory = new ListeningHistory + { + UserId = userId, + TrackId = trackId, + }; + await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); + await database.SaveChangesAsync(cancellationToken); + + return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); + } +} diff --git a/backend/Musify.Application/Tracks/GetTrackStream.cs b/backend/Musify.Application/Tracks/GetTrackStream.cs index a729511..1790a62 100644 --- a/backend/Musify.Application/Tracks/GetTrackStream.cs +++ b/backend/Musify.Application/Tracks/GetTrackStream.cs @@ -2,10 +2,9 @@ using ErrorOr; using Mediator; using Microsoft.EntityFrameworkCore; using Microsoft.Extensions.Logging; -using Musify.Application.Configuration; using Musify.Application.Contracts; +using Musify.Application.Services; using Musify.Application.Tracks.Responses; -using Musify.Domain.Entities; using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks @@ -15,9 +14,7 @@ namespace Musify.Application.Tracks public class GetTrackStreamQueryHandler( IDatabase database, - IStreamTicketService ticketService, - TrackConfiguration trackConfiguration, - StreamGatewayConfiguration streamGatewayConfiguration, + TrackStreamIssuer streamIssuer, ILogger logger) : IQueryHandler> { @@ -38,30 +35,11 @@ namespace Musify.Application.Tracks return Error.Conflict(description: "Track audio is not available for streaming yet."); } - var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName); - var keyPrefix = $"{folderPath}/"; - - var ticket = ticketService.IssueTicket(request.UserId, keyPrefix); - - var manifestUrl = string.Join('/', - streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'), - "media", - folderPath, - streamGatewayConfiguration.AudioFileName); + var response = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken); logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId); - var newListeningHistory = new ListeningHistory - { - UserId = request.UserId, - TrackId = request.TrackId, - }; - await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); - await database.SaveChangesAsync(cancellationToken); - - logger.LogDebug("Listening history added for track {TrackId} by user {UserId}", request.TrackId, request.UserId); - - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); + return response; } } } diff --git a/backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs b/backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs new file mode 100644 index 0000000..bc0456f --- /dev/null +++ b/backend/Musify.Application/YouTube/GetYouTubeTrackStream.cs @@ -0,0 +1,52 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Application.Services; +using Musify.Application.YouTube.Responses; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.YouTube +{ + public record GetYouTubeTrackStreamQuery(string VideoId, long UserId) + : IQuery>; + + public class GetYouTubeTrackStreamQueryHandler( + IDatabase database, + IYouTubeMusicService youTubeMusicService, + TrackStreamIssuer streamIssuer) + : IQueryHandler> + { + public async ValueTask> Handle(GetYouTubeTrackStreamQuery request, CancellationToken cancellationToken) + { + var track = await database.Tracks.AsNoTracking() + .Where(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId) + .Select(t => new { t.Id, t.AudioTranscodeProcessingStatus, t.AudioFolderName } ) + .SingleOrDefaultAsync(cancellationToken); + + if (track != null + && !string.IsNullOrWhiteSpace(track.AudioFolderName) + && track.AudioTranscodeProcessingStatus == ProcessingStatus.Completed) + { + var serverStream = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken); + return new YouTubeStreamResponse( + YouTubeStreamMode.Server, + serverStream.ManifestUrl, + serverStream.Ticket, + serverStream.ExpiresInSeconds, + track.Id); + } + + var streamInfo = await youTubeMusicService.GetAudioStreamAsync(request.VideoId, cancellationToken); + if (streamInfo.IsError) + return streamInfo.Errors; + + return new YouTubeStreamResponse( + YouTubeStreamMode.YouTube, + streamInfo.Value.Url, + string.Empty, + streamInfo.Value.ExpiresInSeconds, + Guid.Empty); + } + } +} diff --git a/backend/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs b/backend/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs new file mode 100644 index 0000000..f4d20c1 --- /dev/null +++ b/backend/Musify.Application/YouTube/Responses/YouTubeStreamResponse.cs @@ -0,0 +1,15 @@ +namespace Musify.Application.YouTube.Responses +{ + public enum YouTubeStreamMode + { + Server = 0, + YouTube = 1 + } + + public record YouTubeStreamResponse( + YouTubeStreamMode Mode, + string StreamUrl, + string Ticket, + int ExpiresInSeconds, + Guid TrackId); +} diff --git a/backend/Musify.Application/YouTube/SearchYouTubeTracks.cs b/backend/Musify.Application/YouTube/SearchYouTubeTracks.cs new file mode 100644 index 0000000..3d09350 --- /dev/null +++ b/backend/Musify.Application/YouTube/SearchYouTubeTracks.cs @@ -0,0 +1,21 @@ +using ErrorOr; +using Mediator; +using Musify.Application.Contracts; + +namespace Musify.Application.YouTube +{ + public record SearchYouTubeTracksQuery(string Query, string ContinuationToken) + : IQuery>; + + public class SearchYouTubeTracksQueryHandler(IYouTubeMusicService youTubeMusicService) + : IQueryHandler> + { + public async ValueTask> Handle(SearchYouTubeTracksQuery request, CancellationToken cancellationToken) + { + if (string.IsNullOrEmpty(request.Query)) + return Error.Validation(description: "The search query must not be empty."); + + return await youTubeMusicService.SearchSongsAsync(request.Query, request.ContinuationToken, cancellationToken); + } + } +} diff --git a/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs b/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs new file mode 100644 index 0000000..bcff643 --- /dev/null +++ b/backend/Musify.Infrastructure/Configuration/YouTubeConfiguration.cs @@ -0,0 +1,18 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class YouTubeConfiguration + { + public const string SectionName = "YouTube"; + + [Required] + public string GeographicalLocation { get; set; } = "US"; + + [Range(60, 21600)] + public int StreamUrlCacheSeconds { get; set; } = 1800; + + [Range(60, 3600)] + public int SearchContinuationCacheSeconds { get; set; } = 600; + } +} diff --git a/backend/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs b/backend/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs new file mode 100644 index 0000000..2f9c597 --- /dev/null +++ b/backend/Musify.Infrastructure/Configuration/YtDlpConfiguration.cs @@ -0,0 +1,20 @@ +using System.ComponentModel.DataAnnotations; + +namespace Musify.Infrastructure.Configuration +{ + public class YtDlpConfiguration + { + public const string SectionName = "YtDlp"; + + [Required] + public string ExecutableName { get; set; } = "yt-dlp"; + + [Required] + public string Format { get; set; } = "bestaudio"; + + public string AdditionalArguments { get; set; } = string.Empty; + + [Required] + public TimeSpan DownloadTimeout { get; set; } = TimeSpan.FromMinutes(5); + } +} diff --git a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj b/backend/Musify.Infrastructure/Musify.Infrastructure.csproj index 5a937af..fc7bbca 100644 --- a/backend/Musify.Infrastructure/Musify.Infrastructure.csproj +++ b/backend/Musify.Infrastructure/Musify.Infrastructure.csproj @@ -14,6 +14,7 @@ runtime; build; native; contentfiles; analyzers; buildtransitive all + @@ -28,6 +29,7 @@ + diff --git a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index af27b96..e38e9b6 100644 --- a/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -74,6 +74,23 @@ namespace Musify.Infrastructure.Services return serviceDescriptors; } + public static IServiceCollection AddYouTubeMusicService(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddValidatedOptions(configuration, YouTubeConfiguration.SectionName); + + serviceDescriptors.AddMemoryCache(); + serviceDescriptors.AddSingleton(); + return serviceDescriptors; + } + + public static IServiceCollection AddYouTubeDownloader(this IServiceCollection serviceDescriptors, IConfiguration configuration) + { + serviceDescriptors.AddValidatedOptions(configuration, YtDlpConfiguration.SectionName); + + serviceDescriptors.AddScoped(); + return serviceDescriptors; + } + public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration) { serviceDescriptors diff --git a/backend/Musify.Infrastructure/Services/YouTubeDownloaderService.cs b/backend/Musify.Infrastructure/Services/YouTubeDownloaderService.cs new file mode 100644 index 0000000..ea1c70f --- /dev/null +++ b/backend/Musify.Infrastructure/Services/YouTubeDownloaderService.cs @@ -0,0 +1,89 @@ +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; +using Musify.Infrastructure.Configuration; +using System.Diagnostics; + +namespace Musify.Infrastructure.Services +{ + public class YouTubeDownloaderService( + ILogger logger, + YtDlpConfiguration configuration) + : IYouTubeDownloaderService + { + public async Task DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken) + { + var destinationDirectory = Path.GetDirectoryName(destinationFilePath); + if (!string.IsNullOrWhiteSpace(destinationDirectory)) + Directory.CreateDirectory(destinationDirectory); + + var additionalArguments = string.IsNullOrWhiteSpace(configuration.AdditionalArguments) + ? string.Empty + : $" {configuration.AdditionalArguments.Trim()}"; + + var arguments = + $"-f {configuration.Format} " + + "--no-playlist " + + $"-o \"{destinationFilePath}\"" + + additionalArguments + + $" -- {videoId}"; + + using var process = new Process + { + StartInfo = new ProcessStartInfo + { + FileName = configuration.ExecutableName, + WorkingDirectory = destinationDirectory ?? Environment.CurrentDirectory, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + CreateNoWindow = true, + } + }; + + process.Start(); + + logger.LogInformation("yt-dlp started (PID: {ProcessId} ) for video {VideoId}", process.Id, videoId); + + var outputTask = process.StandardOutput.ReadToEndAsync(cancellationToken); + var errorTask = process.StandardError.ReadToEndAsync(cancellationToken); + + using var linkedCancellationTokens = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken); + linkedCancellationTokens.CancelAfter(configuration.DownloadTimeout); + + try + { + await process.WaitForExitAsync(linkedCancellationTokens.Token); + } + catch (OperationCanceledException) when (!cancellationToken.IsCancellationRequested) + { + logger.LogError("yt-dlp timed out after {Timeout} for video {VideoId} (PID: {ProcessId} )", + configuration.DownloadTimeout, videoId, process.Id); + + TryKillProcess(process); + throw new TimeoutException($"yt-dlp execution exceeded timeout {configuration.DownloadTimeout}."); + } + + var standardError = await errorTask; + var standardOutput = await outputTask; + + logger.LogDebug("yt-dlp finished (PID: {ProcessId} ). Exit code: {ExitCode}, Output: {OutputLength}B, Error: {ErrorLength}B", + process.Id, process.ExitCode, standardOutput.Length, standardError.Length); + + return new YouTubeDownloadResult(process.ExitCode, standardError); + } + + void TryKillProcess(Process process) + { + try + { + if (!process.HasExited) + process.Kill(); + } + catch (Exception exception) + { + logger.LogError(exception, "Failed to kill yt-dlp process (PID: {ProcessId} )", process.Id); + } + } + } +} diff --git a/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs new file mode 100644 index 0000000..2ba06b7 --- /dev/null +++ b/backend/Musify.Infrastructure/Services/YouTubeMusicService.cs @@ -0,0 +1,114 @@ +using ErrorOr; +using Microsoft.Extensions.Caching.Memory; +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) : IYouTubeMusicService + { + private readonly YouTubeMusicClient client = new(geographicalLocation: configuration.GeographicalLocation); + + private sealed record CachedStreamInfo(string Url, DateTime ExpiresAtUtc); + + public async Task> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken) + { + PaginatedAsyncEnumerable? paginator; + if (string.IsNullOrEmpty(continuationToken)) + { + paginator = client.SearchAsync(query, SearchCategory.Songs); + } + else if (!cache.TryGetValue(SearchCacheKey(continuationToken), out paginator) || paginator is null) + { + return Error.NotFound(description: "The search continuation has expired."); + } + + IReadOnlyList results; + try + { + results = await paginator.FetchNextPageAsync(cancellationToken); + } + catch (Exception exception) + { + return Error.Failure(description: $"YouTube Music search failed: {exception.Message}"); + } + + var items = results + .OfType() + .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, + song.Thumbnails + .OrderByDescending(thumbnail => thumbnail.Width) + .Select(thumbnail => thumbnail.Url) + .FirstOrDefault() ?? string.Empty)) + .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); + } + + public async Task> 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) + { + return Error.Failure(description: $"Failed to resolve YouTube stream for '{videoId}': {exception.Message}"); + } + + var audioStream = streamingData.StreamInfo + .OfType() + .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); + } + + private static string SearchCacheKey(string token) => $"yt-search:{token}"; + + private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}"; + } +}