Design Prot Merge #1
@@ -1,8 +1,3 @@
|
|||||||
namespace Musify.Api.DataTransferObjects.PlayLists;
|
namespace Musify.Api.DataTransferObjects.PlayLists;
|
||||||
|
|
||||||
public record AddYouTubeTrackRequest(
|
public record AddYouTubeTrackRequest(string VideoId);
|
||||||
string VideoId,
|
|
||||||
string Title,
|
|
||||||
string Artist,
|
|
||||||
int DurationSeconds,
|
|
||||||
string ThumbnailUrl);
|
|
||||||
|
|||||||
@@ -254,11 +254,7 @@ public static class PlayListEndpoints
|
|||||||
new AddYouTubeTrackToPlayListCommand(
|
new AddYouTubeTrackToPlayListCommand(
|
||||||
currentUser.RequiredId,
|
currentUser.RequiredId,
|
||||||
playlistId,
|
playlistId,
|
||||||
request.VideoId,
|
request.VideoId),
|
||||||
request.Title,
|
|
||||||
request.Artist,
|
|
||||||
request.DurationSeconds,
|
|
||||||
request.ThumbnailUrl),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
||||||
|
|||||||
@@ -40,7 +40,7 @@ public static class YouTubeEndpoints
|
|||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(
|
var result = await mediator.Send(
|
||||||
new GetYouTubeTrackStreamQuery(videoId, currentUser.RequiredId),
|
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
|
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
|
|||||||
@@ -10,20 +10,5 @@ public sealed class AddYouTubeTrackRequestValidator : AbstractValidator<AddYouTu
|
|||||||
RuleFor(x => x.VideoId)
|
RuleFor(x => x.VideoId)
|
||||||
.NotEmpty()
|
.NotEmpty()
|
||||||
.Matches("^[A-Za-z0-9_-]{11}$");
|
.Matches("^[A-Za-z0-9_-]{11}$");
|
||||||
|
|
||||||
RuleFor(x => x.Title)
|
|
||||||
.NotEmpty()
|
|
||||||
.MaximumLength(200);
|
|
||||||
|
|
||||||
RuleFor(x => x.Artist)
|
|
||||||
.MaximumLength(400);
|
|
||||||
|
|
||||||
RuleFor(x => x.DurationSeconds)
|
|
||||||
.GreaterThan(0);
|
|
||||||
|
|
||||||
RuleFor(x => x.ThumbnailUrl)
|
|
||||||
.NotEmpty()
|
|
||||||
.Must(url => Uri.TryCreate(url, UriKind.Absolute, out var uri) && uri.Scheme == Uri.UriSchemeHttps)
|
|
||||||
.WithMessage("ThumbnailUrl must be an absolute https URL.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,7 @@ public static class ApplicationDependencyInjection
|
|||||||
services.AddMediator();
|
services.AddMediator();
|
||||||
services.AddScoped<UploadIntentValidator>();
|
services.AddScoped<UploadIntentValidator>();
|
||||||
services.AddScoped<TrackStreamIssuer>();
|
services.AddScoped<TrackStreamIssuer>();
|
||||||
|
services.AddScoped<YouTubeTrackProvisioner>();
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
||||||
|
|||||||
@@ -2,6 +2,8 @@ using ErrorOr;
|
|||||||
|
|
||||||
namespace Musify.Application.Contracts
|
namespace Musify.Application.Contracts
|
||||||
{
|
{
|
||||||
|
public record YouTubeArtistRef(string? Id, string Name);
|
||||||
|
|
||||||
public record YouTubeSongResult(
|
public record YouTubeSongResult(
|
||||||
string VideoId,
|
string VideoId,
|
||||||
string Title,
|
string Title,
|
||||||
@@ -9,7 +11,8 @@ namespace Musify.Application.Contracts
|
|||||||
string Album,
|
string Album,
|
||||||
int DurationSeconds,
|
int DurationSeconds,
|
||||||
string ThumbnailUrl,
|
string ThumbnailUrl,
|
||||||
bool IsExplicit);
|
bool IsExplicit,
|
||||||
|
IReadOnlyList<YouTubeArtistRef> Artists);
|
||||||
|
|
||||||
public record YouTubeSearchResult(
|
public record YouTubeSearchResult(
|
||||||
IReadOnlyList<YouTubeSongResult> Items,
|
IReadOnlyList<YouTubeSongResult> Items,
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
|
|||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
@@ -15,16 +16,14 @@ namespace Musify.Application.PlayLists
|
|||||||
public record AddYouTubeTrackToPlayListCommand(
|
public record AddYouTubeTrackToPlayListCommand(
|
||||||
long UserId,
|
long UserId,
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string VideoId,
|
string VideoId)
|
||||||
string Title,
|
|
||||||
string Artist,
|
|
||||||
int DurationSeconds,
|
|
||||||
string ThumbnailUrl)
|
|
||||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class AddYouTubeTrackToPlayListCommandHandler(
|
public class AddYouTubeTrackToPlayListCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
YouTubeTrackProvisioner provisioner,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
TrackConfiguration trackConfiguration,
|
TrackConfiguration trackConfiguration,
|
||||||
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
|
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
|
||||||
@@ -46,11 +45,34 @@ namespace Musify.Application.PlayLists
|
|||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var trackResult = await GetOrCreateTrackAsync(request, cancellationToken);
|
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
|
||||||
if (trackResult.IsError)
|
if (provisionResult.IsError)
|
||||||
return trackResult.Errors;
|
return provisionResult.Errors;
|
||||||
|
|
||||||
var track = trackResult.Value;
|
var track = provisionResult.Value.Track;
|
||||||
|
|
||||||
|
var failed = track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed;
|
||||||
|
var neverQueued = !track.DownloadRequested && !track.IsAudioProcessed;
|
||||||
|
if (failed || neverQueued)
|
||||||
|
{
|
||||||
|
if (failed)
|
||||||
|
{
|
||||||
|
track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending;
|
||||||
|
track.PicturesProcessingStatus = ProcessingStatus.Pending;
|
||||||
|
track.LifeCycleStatus = LifeCycleStatus.Active;
|
||||||
|
track.RetryCount += 1;
|
||||||
|
track.LastRetryAt = DateTime.UtcNow;
|
||||||
|
}
|
||||||
|
track.DownloadRequested = true;
|
||||||
|
|
||||||
|
var thumbnailUrl = await ResolveThumbnailUrlAsync(request.VideoId, provisionResult.Value.Song, cancellationToken);
|
||||||
|
if (thumbnailUrl.IsError)
|
||||||
|
return thumbnailUrl.Errors;
|
||||||
|
|
||||||
|
await PublishDownloadEventAsync(track.Id, request.VideoId, thumbnailUrl.Value, cancellationToken);
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
|
||||||
|
}
|
||||||
|
|
||||||
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
|
var alreadyAdded = await database.PlayListHasTracks.AsNoTracking()
|
||||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
|
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
|
||||||
@@ -83,77 +105,27 @@ namespace Musify.Application.PlayLists
|
|||||||
return TrackApplicationResponse.FromEntity(track, listensCount);
|
return TrackApplicationResponse.FromEntity(track, listensCount);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Track>> GetOrCreateTrackAsync(AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
|
private async Task<ErrorOr<string>> ResolveThumbnailUrlAsync(string videoId, YouTubeSongResult? song, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.Tracks
|
if (!string.IsNullOrEmpty(song?.ThumbnailUrl))
|
||||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken);
|
return song.ThumbnailUrl;
|
||||||
|
|
||||||
if (track is not null)
|
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
||||||
{
|
if (songResult.IsError)
|
||||||
var failed = track.AudioTranscodeProcessingStatus == ProcessingStatus.Failed;
|
return songResult.Errors;
|
||||||
var neverQueued = !track.DownloadRequested && !track.IsAudioProcessed;
|
|
||||||
if (failed || neverQueued)
|
return songResult.Value.ThumbnailUrl;
|
||||||
{
|
|
||||||
if (failed)
|
|
||||||
{
|
|
||||||
track.AudioTranscodeProcessingStatus = ProcessingStatus.Pending;
|
|
||||||
track.PicturesProcessingStatus = ProcessingStatus.Pending;
|
|
||||||
track.LifeCycleStatus = LifeCycleStatus.Active;
|
|
||||||
track.RetryCount += 1;
|
|
||||||
track.LastRetryAt = DateTime.UtcNow;
|
|
||||||
}
|
|
||||||
track.DownloadRequested = true;
|
|
||||||
await PublishDownloadEventAsync(track.Id, request, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
|
|
||||||
}
|
|
||||||
return track;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = Truncate(request.Title, 50);
|
private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken)
|
||||||
track = new Track
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
|
||||||
Artist = Truncate(request.Artist, 200),
|
|
||||||
Source = TrackSource.YouTube,
|
|
||||||
ExternalId = request.VideoId,
|
|
||||||
DownloadRequested = true,
|
|
||||||
Duration = request.DurationSeconds,
|
|
||||||
OriginalPictureName = null,
|
|
||||||
OriginalAudioName = null,
|
|
||||||
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
|
||||||
PicturesProcessingStatus = ProcessingStatus.Pending,
|
|
||||||
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Tracks.AddAsync(track, cancellationToken);
|
|
||||||
await PublishDownloadEventAsync(track.Id, request, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Created YouTube track {VideoId} ({TrackId}) and queued its download", request.VideoId, track.Id);
|
|
||||||
return track;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
logger.LogInformation("YouTube track {VideoId} was created concurrently", request.VideoId);
|
|
||||||
return Error.Conflict(description: "The track was just created by another request. Retry the operation.");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task PublishDownloadEventAsync(Guid trackId, AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new DownloadYouTubeTrackEvent(
|
new DownloadYouTubeTrackEvent(
|
||||||
trackId,
|
trackId,
|
||||||
request.VideoId,
|
videoId,
|
||||||
request.ThumbnailUrl,
|
thumbnailUrl,
|
||||||
storageConfiguration.Bucket,
|
storageConfiguration.Bucket,
|
||||||
audioProcessedFolderKey,
|
audioProcessedFolderKey,
|
||||||
new ImageSize(
|
new ImageSize(
|
||||||
@@ -170,8 +142,5 @@ namespace Musify.Application.PlayLists
|
|||||||
trackConfiguration.PicturesSizes.LargePictureHeight)),
|
trackConfiguration.PicturesSizes.LargePictureHeight)),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength) =>
|
|
||||||
value.Length <= maxLength ? value : value[..maxLength];
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,133 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Services;
|
||||||
|
|
||||||
|
public sealed record YouTubeTrackProvision(Track Track, bool Created, YouTubeSongResult? Song);
|
||||||
|
|
||||||
|
public sealed class YouTubeTrackProvisioner(
|
||||||
|
IDatabase database,
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
TrackConfiguration trackConfiguration,
|
||||||
|
ILogger<YouTubeTrackProvisioner> logger)
|
||||||
|
{
|
||||||
|
public Task<Track?> FindAsync(string videoId, CancellationToken cancellationToken) =>
|
||||||
|
database.Tracks
|
||||||
|
.Include(track => track.TrackArtists)
|
||||||
|
.ThenInclude(trackArtist => trackArtist.Artist)
|
||||||
|
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<ErrorOr<YouTubeTrackProvision>> GetOrCreateAsync(string videoId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var existing = await FindAsync(videoId, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return new YouTubeTrackProvision(existing, Created: false, Song: null);
|
||||||
|
|
||||||
|
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
||||||
|
if (songResult.IsError)
|
||||||
|
return songResult.Errors;
|
||||||
|
|
||||||
|
var song = songResult.Value;
|
||||||
|
|
||||||
|
var artistRefs = song.Artists
|
||||||
|
.GroupBy(artistRef => artistRef.Id ?? artistRef.Name.ToUpperInvariant())
|
||||||
|
.Select(group => group.First())
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
var artists = new List<Artist>();
|
||||||
|
foreach (var artistRef in artistRefs)
|
||||||
|
artists.Add(await GetOrCreateArtistAsync(artistRef, cancellationToken));
|
||||||
|
|
||||||
|
var title = Truncate(song.Title, 50);
|
||||||
|
var track = new Track
|
||||||
|
{
|
||||||
|
Title = title,
|
||||||
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
|
Artist = Truncate(song.Artist, 200),
|
||||||
|
Source = TrackSource.YouTube,
|
||||||
|
ExternalId = videoId,
|
||||||
|
DownloadRequested = false,
|
||||||
|
Duration = song.DurationSeconds,
|
||||||
|
OriginalPictureName = null,
|
||||||
|
OriginalAudioName = null,
|
||||||
|
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
||||||
|
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
||||||
|
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
||||||
|
PicturesProcessingStatus = ProcessingStatus.Pending,
|
||||||
|
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
|
||||||
|
};
|
||||||
|
|
||||||
|
await database.Tracks.AddAsync(track, cancellationToken);
|
||||||
|
|
||||||
|
for (var position = 0; position < artists.Count; position++)
|
||||||
|
{
|
||||||
|
await database.TrackArtists.AddAsync(new TrackArtist
|
||||||
|
{
|
||||||
|
TrackId = track.Id,
|
||||||
|
ArtistId = artists[position].Id,
|
||||||
|
Position = position
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id);
|
||||||
|
return new YouTubeTrackProvision(track, Created: true, Song: song);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
database.Tracks.Remove(track);
|
||||||
|
var refetched = await FindAsync(videoId, cancellationToken);
|
||||||
|
if (refetched is null)
|
||||||
|
{
|
||||||
|
logger.LogWarning("YouTube track {VideoId} creation raced but could not be refetched", videoId);
|
||||||
|
return Error.Conflict(description: "The track was just created by another request. Retry the operation.");
|
||||||
|
}
|
||||||
|
return new YouTubeTrackProvision(refetched, Created: false, Song: null);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<Artist> GetOrCreateArtistAsync(YouTubeArtistRef reference, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var name = Truncate(reference.Name, 200);
|
||||||
|
var normalized = name.ToUpperInvariant();
|
||||||
|
var byExternalId = !string.IsNullOrEmpty(reference.Id);
|
||||||
|
|
||||||
|
var existing = byExternalId
|
||||||
|
? await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == reference.Id, cancellationToken)
|
||||||
|
: await database.Artists.SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken);
|
||||||
|
if (existing is not null)
|
||||||
|
return existing;
|
||||||
|
|
||||||
|
var artist = new Artist
|
||||||
|
{
|
||||||
|
Name = name,
|
||||||
|
NormalizedName = normalized,
|
||||||
|
ExternalId = byExternalId ? reference.Id : null
|
||||||
|
};
|
||||||
|
|
||||||
|
await database.Artists.AddAsync(artist, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
return artist;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException)
|
||||||
|
{
|
||||||
|
database.Artists.Remove(artist);
|
||||||
|
return byExternalId
|
||||||
|
? await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken)
|
||||||
|
: await database.Artists.SingleAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string Truncate(string value, int maxLength) =>
|
||||||
|
value.Length <= maxLength ? value : value[..maxLength];
|
||||||
|
}
|
||||||
@@ -1,122 +0,0 @@
|
|||||||
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.YouTube.Responses;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
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,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
ILogger<GetYouTubeTrackStreamQueryHandler> logger)
|
|
||||||
: IQueryHandler<GetYouTubeTrackStreamQuery, ErrorOr<YouTubeStreamResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(GetYouTubeTrackStreamQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var track = await database.Tracks
|
|
||||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken);
|
|
||||||
|
|
||||||
if (track is not null && track.IsAudioProcessed)
|
|
||||||
{
|
|
||||||
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;
|
|
||||||
|
|
||||||
track ??= await CreateTrackFromYouTubeAsync(request.VideoId, cancellationToken);
|
|
||||||
|
|
||||||
if (track is not null)
|
|
||||||
{
|
|
||||||
await database.ListeningHistories.AddAsync(new ListeningHistory
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
TrackId = track.Id,
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (DbUpdateException exception)
|
|
||||||
{
|
|
||||||
logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return new YouTubeStreamResponse(
|
|
||||||
YouTubeStreamMode.YouTube,
|
|
||||||
streamInfo.Value.Url,
|
|
||||||
string.Empty,
|
|
||||||
streamInfo.Value.ExpiresInSeconds,
|
|
||||||
track?.Id ?? Guid.Empty);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<Track?> CreateTrackFromYouTubeAsync(string videoId, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
|
|
||||||
if (songResult.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Could not load YouTube metadata for {VideoId}: {Error}", videoId, songResult.FirstError.Description);
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
var song = songResult.Value;
|
|
||||||
var title = Truncate(song.Title, 50);
|
|
||||||
|
|
||||||
var track = new Track
|
|
||||||
{
|
|
||||||
Title = title,
|
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
|
||||||
Artist = Truncate(song.Artist, 200),
|
|
||||||
Source = TrackSource.YouTube,
|
|
||||||
ExternalId = videoId,
|
|
||||||
DownloadRequested = false,
|
|
||||||
Duration = song.DurationSeconds,
|
|
||||||
OriginalPictureName = null,
|
|
||||||
OriginalAudioName = null,
|
|
||||||
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
|
|
||||||
MediumPictureName = trackConfiguration.Routes.PresetMediumPicture,
|
|
||||||
LargePictureName = trackConfiguration.Routes.PresetLargePicture,
|
|
||||||
PicturesProcessingStatus = ProcessingStatus.Pending,
|
|
||||||
AudioTranscodeProcessingStatus = ProcessingStatus.Pending
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Tracks.AddAsync(track, cancellationToken);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
logger.LogInformation("Created YouTube track {VideoId} ({TrackId}) from playback", videoId, track.Id);
|
|
||||||
return track;
|
|
||||||
}
|
|
||||||
catch (DbUpdateException)
|
|
||||||
{
|
|
||||||
database.Tracks.Remove(track);
|
|
||||||
return await database.Tracks
|
|
||||||
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == videoId, cancellationToken);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string Truncate(string value, int maxLength) =>
|
|
||||||
value.Length <= maxLength ? value : value[..maxLength];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.YouTube.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
|
namespace Musify.Application.YouTube
|
||||||
|
{
|
||||||
|
public record ResolveYouTubeTrackStreamCommand(string VideoId, long UserId)
|
||||||
|
: ICommand<ErrorOr<YouTubeStreamResponse>>;
|
||||||
|
|
||||||
|
public class ResolveYouTubeTrackStreamCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IYouTubeMusicService youTubeMusicService,
|
||||||
|
YouTubeTrackProvisioner provisioner,
|
||||||
|
TrackStreamIssuer streamIssuer,
|
||||||
|
ILogger<ResolveYouTubeTrackStreamCommandHandler> logger)
|
||||||
|
: ICommandHandler<ResolveYouTubeTrackStreamCommand, ErrorOr<YouTubeStreamResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<YouTubeStreamResponse>> Handle(ResolveYouTubeTrackStreamCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var track = await provisioner.FindAsync(request.VideoId, cancellationToken);
|
||||||
|
|
||||||
|
if (track is not null && track.IsAudioProcessed)
|
||||||
|
{
|
||||||
|
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;
|
||||||
|
|
||||||
|
if (track is null)
|
||||||
|
{
|
||||||
|
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
|
||||||
|
if (!provisionResult.IsError)
|
||||||
|
track = provisionResult.Value.Track;
|
||||||
|
else
|
||||||
|
logger.LogWarning("Could not provision YouTube track {VideoId}: {Error}", request.VideoId, provisionResult.FirstError.Description);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (track is not null)
|
||||||
|
{
|
||||||
|
await database.ListeningHistories.AddAsync(new ListeningHistory
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
TrackId = track.Id,
|
||||||
|
}, cancellationToken);
|
||||||
|
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception)
|
||||||
|
{
|
||||||
|
logger.LogWarning(exception, "Failed to record listening history for YouTube video {VideoId}", request.VideoId);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return new YouTubeStreamResponse(
|
||||||
|
YouTubeStreamMode.YouTube,
|
||||||
|
streamInfo.Value.Url,
|
||||||
|
string.Empty,
|
||||||
|
streamInfo.Value.ExpiresInSeconds,
|
||||||
|
track?.Id ?? Guid.Empty);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -49,7 +49,12 @@ namespace Musify.Infrastructure.Services
|
|||||||
.OrderByDescending(thumbnail => thumbnail.Width)
|
.OrderByDescending(thumbnail => thumbnail.Width)
|
||||||
.Select(thumbnail => thumbnail.Url)
|
.Select(thumbnail => thumbnail.Url)
|
||||||
.FirstOrDefault() ?? string.Empty,
|
.FirstOrDefault() ?? string.Empty,
|
||||||
song.IsExplicit))
|
song.IsExplicit,
|
||||||
|
song.Artists
|
||||||
|
.Select(artist => new YouTubeArtistRef(
|
||||||
|
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
|
||||||
|
artist.Name))
|
||||||
|
.ToList()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
var nextToken = string.Empty;
|
var nextToken = string.Empty;
|
||||||
@@ -124,7 +129,12 @@ namespace Musify.Infrastructure.Services
|
|||||||
.OrderByDescending(thumbnail => thumbnail.Width)
|
.OrderByDescending(thumbnail => thumbnail.Width)
|
||||||
.Select(thumbnail => thumbnail.Url)
|
.Select(thumbnail => thumbnail.Url)
|
||||||
.FirstOrDefault() ?? string.Empty,
|
.FirstOrDefault() ?? string.Empty,
|
||||||
info.IsExplicit);
|
info.IsExplicit,
|
||||||
|
info.Artists
|
||||||
|
.Select(artist => new YouTubeArtistRef(
|
||||||
|
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
|
||||||
|
artist.Name))
|
||||||
|
.ToList());
|
||||||
}
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
|
|||||||
Reference in New Issue
Block a user