Files
Musify/backend/Musify.Application/Services/YouTubeTrackProvisioner.cs
T
Admin 64e1f3903e refactor(backend): read artist credit from TrackArtists join
Drop the now-redundant Track.Artist string column now that every
track (local and YouTube) links its credited artists through
TrackArtist. TrackApplicationResponse formats the display string by
joining TrackArtists ordered by position, and every read query that
projects a track now includes TrackArtists/Artist so the join isn't
empty at response time.
2026-07-17 17:57:22 +02:00

133 lines
5.3 KiB
C#

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(),
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];
}