feat(backend): YouTube Music search and playback resolution endpoints

This commit is contained in:
2026-07-16 01:05:42 +02:00
parent 87997d90a4
commit 2bd5ef729e
20 changed files with 506 additions and 27 deletions
+2
View File
@@ -19,6 +19,7 @@
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.8" />
<PackageVersion Include="Microsoft.EntityFrameworkCore.Relational" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Caching.Memory" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" Version="10.0.8" />
@@ -46,6 +47,7 @@
<PackageVersion Include="X.PagedList" Version="10.5.9" />
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
<PackageVersion Include="YouTubeMusicAPI" Version="3.0.7" />
</ItemGroup>
</Project>
@@ -47,6 +47,9 @@
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"YouTube": {
"GeographicalLocation": "ES"
},
"Workers": {
"Routes": {
"TemporaryFilesDirectory": "D:\\tempsFilesDev"
+5
View File
@@ -48,6 +48,11 @@
"OpenTelemetry": {
"OtlpEndpoint": ""
},
"YouTube": {
"GeographicalLocation": "US",
"StreamUrlCacheSeconds": 1800,
"SearchContinuationCacheSeconds": 600
},
"PlayList": {
"Routes": {
"UploadsFolder": "uploads",
@@ -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<YouTubeSearchResult>()
.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<YouTubeStreamResponse>()
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
return app;
}
private static async Task<IResult> 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<IResult> 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();
}
}
@@ -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();
+6 -1
View File
@@ -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();
@@ -12,6 +12,7 @@ public static class ApplicationDependencyInjection
{
services.AddMediator();
services.AddScoped<UploadIntentValidator>();
services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
@@ -0,0 +1,9 @@
namespace Musify.Application.Contracts
{
public record YouTubeDownloadResult(int ExitCode, string StandardError);
public interface IYouTubeDownloaderService
{
Task<YouTubeDownloadResult> DownloadAudioAsync(string videoId, string destinationFilePath, CancellationToken cancellationToken);
}
}
@@ -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<YouTubeSongResult> Items,
string ContinuationToken);
public record YouTubeStreamInfo(string Url, int ExpiresInSeconds);
public interface IYouTubeMusicService
{
Task<ErrorOr<YouTubeSearchResult>> SearchSongsAsync(string query, string continuationToken, CancellationToken cancellationToken);
Task<ErrorOr<YouTubeStreamInfo>> GetAudioStreamAsync(string videoId, CancellationToken cancellationToken);
}
}
@@ -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<TrackStreamResponse> 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);
}
}
@@ -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<GetTrackStreamQueryHandler> logger)
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
{
@@ -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;
}
}
}
@@ -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<ErrorOr<YouTubeStreamResponse>>;
public class GetYouTubeTrackStreamQueryHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
TrackStreamIssuer streamIssuer)
: IQueryHandler<GetYouTubeTrackStreamQuery, ErrorOr<YouTubeStreamResponse>>
{
public async ValueTask<ErrorOr<YouTubeStreamResponse>> 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);
}
}
}
@@ -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);
}
@@ -0,0 +1,21 @@
using ErrorOr;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.YouTube
{
public record SearchYouTubeTracksQuery(string Query, string ContinuationToken)
: IQuery<ErrorOr<YouTubeSearchResult>>;
public class SearchYouTubeTracksQueryHandler(IYouTubeMusicService youTubeMusicService)
: IQueryHandler<SearchYouTubeTracksQuery, ErrorOr<YouTubeSearchResult>>
{
public async ValueTask<ErrorOr<YouTubeSearchResult>> 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);
}
}
}
@@ -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}";
}
}