refactor(backend): unify YouTube track creation via a provisioner

Track get-or-create for YouTube videos was duplicated between the
old GetYouTubeTrackStream query handler (which also wrote to the
database) and AddYouTubeTrackToPlayList, with frontend-supplied
metadata (title/artist/duration/thumbnail) trusted at add-to-playlist
time instead of resolving it server-side.

Extract YouTubeTrackProvisioner as the single get-or-create path,
resolving structured artist refs (id + name) from YouTube Music so
multi-artist songs map to distinct Artist rows. Rename
GetYouTubeTrackStream to ResolveYouTubeTrackStream (it writes, so it
is a command, not a query) and delegate to the provisioner.
AddYouTubeTrackToPlayList slims down to (UserId, PlayListId, VideoId)
since all metadata now comes from the provisioner/YouTube API.
This commit is contained in:
2026-07-17 17:56:37 +02:00
parent cdf89dc632
commit ca275fe7a8
11 changed files with 271 additions and 224 deletions
@@ -1,8 +1,3 @@
namespace Musify.Api.DataTransferObjects.PlayLists;
public record AddYouTubeTrackRequest(
string VideoId,
string Title,
string Artist,
int DurationSeconds,
string ThumbnailUrl);
public record AddYouTubeTrackRequest(string VideoId);
@@ -254,11 +254,7 @@ public static class PlayListEndpoints
new AddYouTubeTrackToPlayListCommand(
currentUser.RequiredId,
playlistId,
request.VideoId,
request.Title,
request.Artist,
request.DurationSeconds,
request.ThumbnailUrl),
request.VideoId),
cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -40,7 +40,7 @@ public static class YouTubeEndpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new GetYouTubeTrackStreamQuery(videoId, currentUser.RequiredId),
new ResolveYouTubeTrackStreamCommand(videoId, currentUser.RequiredId),
cancellationToken);
return result.ToHttpResult();
@@ -10,20 +10,5 @@ public sealed class AddYouTubeTrackRequestValidator : AbstractValidator<AddYouTu
RuleFor(x => x.VideoId)
.NotEmpty()
.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.AddScoped<UploadIntentValidator>();
services.AddScoped<TrackStreamIssuer>();
services.AddScoped<YouTubeTrackProvisioner>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
@@ -2,6 +2,8 @@ using ErrorOr;
namespace Musify.Application.Contracts
{
public record YouTubeArtistRef(string? Id, string Name);
public record YouTubeSongResult(
string VideoId,
string Title,
@@ -9,7 +11,8 @@ namespace Musify.Application.Contracts
string Album,
int DurationSeconds,
string ThumbnailUrl,
bool IsExplicit);
bool IsExplicit,
IReadOnlyList<YouTubeArtistRef> Artists);
public record YouTubeSearchResult(
IReadOnlyList<YouTubeSongResult> Items,
@@ -5,6 +5,7 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
@@ -15,16 +16,14 @@ namespace Musify.Application.PlayLists
public record AddYouTubeTrackToPlayListCommand(
long UserId,
Guid PlayListId,
string VideoId,
string Title,
string Artist,
int DurationSeconds,
string ThumbnailUrl)
string VideoId)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class AddYouTubeTrackToPlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
ILogger<AddYouTubeTrackToPlayListCommandHandler> logger)
@@ -46,11 +45,34 @@ namespace Musify.Application.PlayLists
return Error.Unauthorized();
}
var trackResult = await GetOrCreateTrackAsync(request, cancellationToken);
if (trackResult.IsError)
return trackResult.Errors;
var provisionResult = await provisioner.GetOrCreateAsync(request.VideoId, cancellationToken);
if (provisionResult.IsError)
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()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == track.Id, cancellationToken);
@@ -83,77 +105,27 @@ namespace Musify.Application.PlayLists
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
.SingleOrDefaultAsync(t => t.Source == TrackSource.YouTube && t.ExternalId == request.VideoId, cancellationToken);
if (!string.IsNullOrEmpty(song?.ThumbnailUrl))
return song.ThumbnailUrl;
if (track is not null)
{
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;
await PublishDownloadEventAsync(track.Id, request, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Queued YouTube track {VideoId} for download", request.VideoId);
}
return track;
}
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
if (songResult.IsError)
return songResult.Errors;
var title = Truncate(request.Title, 50);
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.");
}
return songResult.Value.ThumbnailUrl;
}
private async Task PublishDownloadEventAsync(Guid trackId, AddYouTubeTrackToPlayListCommand request, CancellationToken cancellationToken)
private async Task PublishDownloadEventAsync(Guid trackId, string videoId, string thumbnailUrl, CancellationToken cancellationToken)
{
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
await eventBus.PublishAsync(
new DownloadYouTubeTrackEvent(
trackId,
request.VideoId,
request.ThumbnailUrl,
videoId,
thumbnailUrl,
storageConfiguration.Bucket,
audioProcessedFolderKey,
new ImageSize(
@@ -170,8 +142,5 @@ namespace Musify.Application.PlayLists
trackConfiguration.PicturesSizes.LargePictureHeight)),
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)
.Select(thumbnail => thumbnail.Url)
.FirstOrDefault() ?? string.Empty,
song.IsExplicit))
song.IsExplicit,
song.Artists
.Select(artist => new YouTubeArtistRef(
string.IsNullOrEmpty(artist.Id) ? null : artist.Id,
artist.Name))
.ToList()))
.ToList();
var nextToken = string.Empty;
@@ -124,7 +129,12 @@ namespace Musify.Infrastructure.Services
.OrderByDescending(thumbnail => thumbnail.Width)
.Select(thumbnail => thumbnail.Url)
.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)
{