feat(backend): YouTube Music search and playback resolution endpoints
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -14,6 +14,7 @@
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||
@@ -28,6 +29,7 @@
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||
<PackageReference Include="SixLabors.ImageSharp" />
|
||||
<PackageReference Include="YouTubeMusicAPI" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -74,6 +74,23 @@ namespace Musify.Infrastructure.Services
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddYouTubeMusicService(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors.AddValidatedOptions<YouTubeConfiguration>(configuration, YouTubeConfiguration.SectionName);
|
||||
|
||||
serviceDescriptors.AddMemoryCache();
|
||||
serviceDescriptors.AddSingleton<IYouTubeMusicService, YouTubeMusicService>();
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddYouTubeDownloader(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors.AddValidatedOptions<YtDlpConfiguration>(configuration, YtDlpConfiguration.SectionName);
|
||||
|
||||
serviceDescriptors.AddScoped<IYouTubeDownloaderService, YouTubeDownloaderService>();
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||
{
|
||||
serviceDescriptors
|
||||
|
||||
@@ -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<YouTubeDownloaderService> logger,
|
||||
YtDlpConfiguration configuration)
|
||||
: IYouTubeDownloaderService
|
||||
{
|
||||
public async Task<YouTubeDownloadResult> 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken)
|
||||
{
|
||||
PaginatedAsyncEnumerable<SearchResult>? 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<SearchResult> results;
|
||||
try
|
||||
{
|
||||
results = await paginator.FetchNextPageAsync(cancellationToken);
|
||||
}
|
||||
catch (Exception exception)
|
||||
{
|
||||
return Error.Failure(description: $"YouTube Music search failed: {exception.Message}");
|
||||
}
|
||||
|
||||
var items = 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,
|
||||
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<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)
|
||||
{
|
||||
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);
|
||||
}
|
||||
|
||||
private static string SearchCacheKey(string token) => $"yt-search:{token}";
|
||||
|
||||
private static string StreamCacheKey(string videoId) => $"yt-stream:{videoId}";
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user