feat(backend): YouTube Music search and playback resolution endpoints
This commit is contained in:
@@ -19,6 +19,7 @@
|
|||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.EntityFrameworkCore" Version="10.0.8" />
|
||||||
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" 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.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" Version="10.0.8" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
<PackageVersion Include="Microsoft.Extensions.Configuration.Binder" Version="10.0.8" />
|
||||||
<PackageVersion Include="Microsoft.Extensions.Configuration.FileExtensions" 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" Version="10.5.9" />
|
||||||
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
|
<PackageVersion Include="X.PagedList.EF" Version="10.5.9" />
|
||||||
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
<PackageVersion Include="Yarp.ReverseProxy" Version="2.3.0" />
|
||||||
|
<PackageVersion Include="YouTubeMusicAPI" Version="3.0.7" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
</Project>
|
</Project>
|
||||||
|
|||||||
@@ -47,6 +47,9 @@
|
|||||||
"OpenTelemetry": {
|
"OpenTelemetry": {
|
||||||
"OtlpEndpoint": "http://localhost:4317"
|
"OtlpEndpoint": "http://localhost:4317"
|
||||||
},
|
},
|
||||||
|
"YouTube": {
|
||||||
|
"GeographicalLocation": "ES"
|
||||||
|
},
|
||||||
"Workers": {
|
"Workers": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
"TemporaryFilesDirectory": "D:\\tempsFilesDev"
|
"TemporaryFilesDirectory": "D:\\tempsFilesDev"
|
||||||
|
|||||||
@@ -48,6 +48,11 @@
|
|||||||
"OpenTelemetry": {
|
"OpenTelemetry": {
|
||||||
"OtlpEndpoint": ""
|
"OtlpEndpoint": ""
|
||||||
},
|
},
|
||||||
|
"YouTube": {
|
||||||
|
"GeographicalLocation": "US",
|
||||||
|
"StreamUrlCacheSeconds": 1800,
|
||||||
|
"SearchContinuationCacheSeconds": 600
|
||||||
|
},
|
||||||
"PlayList": {
|
"PlayList": {
|
||||||
"Routes": {
|
"Routes": {
|
||||||
"UploadsFolder": "uploads",
|
"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.AddDatabase(configuration);
|
||||||
services.AddStorageService(configuration);
|
services.AddStorageService(configuration);
|
||||||
services.AddStreamTicketService(configuration);
|
services.AddStreamTicketService(configuration);
|
||||||
|
services.AddYouTubeMusicService(configuration);
|
||||||
services.AddMassTransitClient(configuration);
|
services.AddMassTransitClient(configuration);
|
||||||
services.AddAuthenticationConfiguration(configuration);
|
services.AddAuthenticationConfiguration(configuration);
|
||||||
services.AddOpenApiConfiguration();
|
services.AddOpenApiConfiguration();
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using System.Text.Json.Serialization;
|
||||||
using Musify.Infrastructure.Observability;
|
using Musify.Infrastructure.Observability;
|
||||||
using Scalar.AspNetCore;
|
using Scalar.AspNetCore;
|
||||||
using Musify.Api.Endpoints;
|
using Musify.Api.Endpoints;
|
||||||
@@ -5,6 +6,9 @@ using Musify.Api.Extensions;
|
|||||||
|
|
||||||
var builder = WebApplication.CreateBuilder();
|
var builder = WebApplication.CreateBuilder();
|
||||||
|
|
||||||
|
builder.Services.ConfigureHttpJsonOptions(options =>
|
||||||
|
options.SerializerOptions.Converters.Add(new JsonStringEnumConverter()));
|
||||||
|
|
||||||
const string devCorsPolicy = "dev-cors";
|
const string devCorsPolicy = "dev-cors";
|
||||||
|
|
||||||
builder.Services
|
builder.Services
|
||||||
@@ -36,6 +40,7 @@ app.UseAuthorization();
|
|||||||
app
|
app
|
||||||
.MapPlayListEndpoints()
|
.MapPlayListEndpoints()
|
||||||
.MapTrackEndpoints()
|
.MapTrackEndpoints()
|
||||||
.MapUserEndpoints();
|
.MapUserEndpoints()
|
||||||
|
.MapYouTubeEndpoints();
|
||||||
|
|
||||||
app.Run();
|
app.Run();
|
||||||
@@ -12,6 +12,7 @@ public static class ApplicationDependencyInjection
|
|||||||
{
|
{
|
||||||
services.AddMediator();
|
services.AddMediator();
|
||||||
services.AddScoped<UploadIntentValidator>();
|
services.AddScoped<UploadIntentValidator>();
|
||||||
|
services.AddScoped<TrackStreamIssuer>();
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.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 Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
@@ -15,9 +14,7 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
public class GetTrackStreamQueryHandler(
|
public class GetTrackStreamQueryHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IStreamTicketService ticketService,
|
TrackStreamIssuer streamIssuer,
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
|
||||||
ILogger<GetTrackStreamQueryHandler> logger)
|
ILogger<GetTrackStreamQueryHandler> logger)
|
||||||
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
|
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
|
||||||
{
|
{
|
||||||
@@ -38,30 +35,11 @@ namespace Musify.Application.Tracks
|
|||||||
return Error.Conflict(description: "Track audio is not available for streaming yet.");
|
return Error.Conflict(description: "Track audio is not available for streaming yet.");
|
||||||
}
|
}
|
||||||
|
|
||||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(track.AudioFolderName);
|
var response = await streamIssuer.IssueAsync(track.Id, track.AudioFolderName, request.UserId, cancellationToken);
|
||||||
var keyPrefix = $"{folderPath}/";
|
|
||||||
|
|
||||||
var ticket = ticketService.IssueTicket(request.UserId, keyPrefix);
|
|
||||||
|
|
||||||
var manifestUrl = string.Join('/',
|
|
||||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
|
||||||
"media",
|
|
||||||
folderPath,
|
|
||||||
streamGatewayConfiguration.AudioFileName);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
|
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId);
|
||||||
|
|
||||||
var newListeningHistory = new ListeningHistory
|
return response;
|
||||||
{
|
|
||||||
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);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
<PrivateAssets>all</PrivateAssets>
|
<PrivateAssets>all</PrivateAssets>
|
||||||
</PackageReference>
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.Extensions.Caching.Memory" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.FileExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.Json" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
<PackageReference Include="Microsoft.Extensions.Configuration.UserSecrets" />
|
||||||
@@ -28,6 +29,7 @@
|
|||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Process" />
|
||||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" />
|
||||||
<PackageReference Include="SixLabors.ImageSharp" />
|
<PackageReference Include="SixLabors.ImageSharp" />
|
||||||
|
<PackageReference Include="YouTubeMusicAPI" />
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -74,6 +74,23 @@ namespace Musify.Infrastructure.Services
|
|||||||
return serviceDescriptors;
|
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)
|
public static IServiceCollection AddAudioTranscoder(this IServiceCollection serviceDescriptors, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
serviceDescriptors
|
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