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:
@@ -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];
|
||||
}
|
||||
Reference in New Issue
Block a user