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

This commit is contained in:
2026-07-16 01:05:42 +02:00
parent 2cecd118ed
commit 11ed8b40ff
20 changed files with 506 additions and 27 deletions
@@ -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);
}
}
}