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