fix(backend): propagate user renames to artist and harden migration backfill

Code review of the Artist/ownership refactor surfaced three gaps:

- SyncUser updated User.Name on every login but never the linked
  Artist row, so renames never reached track credits - defeating the
  point of centralizing names in Artist.
- YouTubeTrackProvisioner looked up artists by ExternalId only when a
  browseId was known, so it could never reuse the ExternalId-less
  rows the migration backfilled from legacy Artist strings, creating
  duplicate Artist rows for the same person. It now adopts the
  backfilled row (assigning it the browseId) when one matches by
  normalized name.
- AddArtistsAndTrackOwnership deduped legacy YouTube artist strings
  case-sensitively before hashing them into a case-insensitive unique
  index, and never guarded against local tracks with no UserHasTrack
  row - either would abort the migration on real data. Backfill now
  dedupes via DISTINCT ON over the normalized name and drops orphaned
  local tracks before the ownership check constraint is added.
This commit is contained in:
2026-07-17 18:00:00 +02:00
parent aae45a5186
commit db63aabd9c
3 changed files with 48 additions and 6 deletions
@@ -8,7 +8,7 @@ using Musify.Domain.ValueObjects;
namespace Musify.Application.Services;
public sealed record YouTubeTrackProvision(Track Track, bool Created, YouTubeSongResult? Song);
public sealed record YouTubeTrackProvision(Track Track, YouTubeSongResult? Song);
public sealed class YouTubeTrackProvisioner(
IDatabase database,
@@ -26,7 +26,7 @@ public sealed class YouTubeTrackProvisioner(
{
var existing = await FindAsync(videoId, cancellationToken);
if (existing is not null)
return new YouTubeTrackProvision(existing, Created: false, Song: null);
return new YouTubeTrackProvision(existing, Song: null);
var songResult = await youTubeMusicService.GetSongAsync(videoId, cancellationToken);
if (songResult.IsError)
@@ -81,7 +81,7 @@ public sealed class YouTubeTrackProvisioner(
{
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created YouTube track {VideoId} ({TrackId})", videoId, track.Id);
return new YouTubeTrackProvision(track, Created: true, Song: song);
return new YouTubeTrackProvision(track, Song: song);
}
catch (DbUpdateException)
{
@@ -92,7 +92,7 @@ public sealed class YouTubeTrackProvisioner(
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);
return new YouTubeTrackProvision(refetched, Song: null);
}
}
@@ -108,6 +108,30 @@ public sealed class YouTubeTrackProvisioner(
if (existing is not null)
return existing;
if (byExternalId)
{
var adoptable = await database.Artists
.SingleOrDefaultAsync(a => a.ExternalId == null && a.UserId == null && a.NormalizedName == normalized, cancellationToken);
if (adoptable is not null)
{
var previousName = adoptable.Name;
adoptable.ExternalId = reference.Id;
adoptable.Name = name;
try
{
await database.SaveChangesAsync(cancellationToken);
return adoptable;
}
catch (DbUpdateException)
{
adoptable.ExternalId = null;
adoptable.Name = previousName;
return await database.Artists.SingleAsync(a => a.ExternalId == reference.Id, cancellationToken);
}
}
}
var artist = new Artist
{
Name = name,
@@ -47,6 +47,14 @@ namespace Musify.Application.Users
user.SecondName = request.SecondName;
user.ProfilePictureUrl = request.ProfilePictureUrl;
var artist = await database.Artists
.SingleOrDefaultAsync(a => a.UserId == request.Id, cancellationToken);
if (artist is not null)
{
artist.Name = request.Name;
artist.NormalizedName = request.Name.ToUpperInvariant();
}
var written = await database.SaveChangesAsync(cancellationToken);
if (written > 0)
logger.LogInformation("Synced profile for user {UserId}", request.Id);