feat(backend): YouTube Music search and playback resolution endpoints
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user