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:
@@ -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);
|
||||
|
||||
+12
-2
@@ -108,6 +108,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
WHERE t."Source" = 0 AND ut."TrackId" = t."Id";
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
DELETE FROM "Tracks" WHERE "Source" = 0 AND "OwnerUserId" IS NULL;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
"""
|
||||
INSERT INTO "Artists" ("Id", "Name", "NormalizedName", "UserId", "ExternalId", "CreatedAt", "UpdatedAt")
|
||||
@@ -120,7 +125,12 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
"""
|
||||
INSERT INTO "Artists" ("Id", "Name", "NormalizedName", "UserId", "ExternalId", "CreatedAt", "UpdatedAt")
|
||||
SELECT gen_random_uuid(), s."Artist", upper(s."Artist"), NULL, NULL, now(), now()
|
||||
FROM (SELECT DISTINCT "Artist" FROM "Tracks" WHERE "Source" = 1 AND "Artist" IS NOT NULL) s;
|
||||
FROM (
|
||||
SELECT DISTINCT ON (upper("Artist")) "Artist"
|
||||
FROM "Tracks"
|
||||
WHERE "Source" = 1 AND "Artist" IS NOT NULL
|
||||
ORDER BY upper("Artist"), "Artist"
|
||||
) s;
|
||||
""");
|
||||
|
||||
migrationBuilder.Sql(
|
||||
@@ -135,7 +145,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
"""
|
||||
INSERT INTO "TrackArtist" ("TrackId", "ArtistId", "Position")
|
||||
SELECT t."Id", a."Id", 0 FROM "Tracks" t
|
||||
JOIN "Artists" a ON a."UserId" IS NULL AND a."ExternalId" IS NULL AND a."Name" = t."Artist"
|
||||
JOIN "Artists" a ON a."UserId" IS NULL AND a."ExternalId" IS NULL AND a."NormalizedName" = upper(t."Artist")
|
||||
WHERE t."Source" = 1 AND t."Artist" IS NOT NULL;
|
||||
""");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user