feat(backend): add Artist entity and track ownership

Track.Artist was a plain string snapshot with no identity, and
UserHasTrack doubled as both "in my library" and "I own this",
conflating collection membership with administrative ownership.

Introduce Artist/TrackArtist to give artist credits their own
identity (needed so YouTube tracks with multiple credited artists,
and future renames, can be modeled), and Track.OwnerUserId to
represent ownership explicitly (local tracks only, enforced by a
check constraint). CreateTrack now provisions the uploader's Artist
row and links it via TrackArtist; DeleteTrack authorizes against
OwnerUserId instead of any UserHasTrack row.
This commit is contained in:
2026-07-17 17:55:32 +02:00
parent 3f4f248221
commit b1618a39c4
10 changed files with 1160 additions and 7 deletions
@@ -11,6 +11,10 @@ namespace Musify.Application.Contracts
DbSet<Track> Tracks { get; }
DbSet<Artist> Artists { get; }
DbSet<TrackArtist> TrackArtists { get; }
DbSet<UserHasTrack> UserHasTracks { get; }
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
@@ -32,9 +32,9 @@ namespace Musify.Application.Tracks
{
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
var user = await database.Users.AsNoTracking()
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
if (user is null)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
@@ -59,10 +59,14 @@ namespace Musify.Application.Tracks
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
var artist = await GetOrCreateUserArtistAsync(user, cancellationToken);
var trackEntity = new Track
{
Title = request.Title,
NormalizedTitle = request.Title.ToUpperInvariant(),
Artist = user.Name,
OwnerUserId = user.Id,
OriginalPictureName = pictureIntent.ObjectName,
OriginalAudioName = audioIntent.ObjectName,
SmallPictureName = trackConfiguration.Routes.PresetSmallPicture,
@@ -74,6 +78,13 @@ namespace Musify.Application.Tracks
await database.Tracks.AddAsync(trackEntity, cancellationToken);
await database.TrackArtists.AddAsync(new TrackArtist
{
TrackId = trackEntity.Id,
ArtistId = artist.Id,
Position = 0
}, cancellationToken);
await database.UserHasTracks.AddAsync(new UserHasTrack
{
UserId = request.UserId,
@@ -125,5 +136,32 @@ namespace Musify.Application.Tracks
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
}
private async Task<Artist> GetOrCreateUserArtistAsync(User user, CancellationToken cancellationToken)
{
var existing = await database.Artists.SingleOrDefaultAsync(a => a.UserId == user.Id, cancellationToken);
if (existing is not null)
return existing;
var artist = new Artist
{
Name = user.Name,
NormalizedName = user.NormalizedName,
UserId = user.Id
};
await database.Artists.AddAsync(artist, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken);
return artist;
}
catch (DbUpdateException)
{
database.Artists.Remove(artist);
return await database.Artists.SingleAsync(a => a.UserId == user.Id, cancellationToken);
}
}
}
}
@@ -28,10 +28,7 @@ namespace Musify.Application.Tracks
return Error.NotFound();
}
var isOwner = await database.UserHasTracks
.AsNoTracking()
.AnyAsync(ut => ut.TrackId == request.TrackId && ut.UserId == request.UserId, cancellationToken);
if (!isOwner)
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
@@ -12,6 +12,7 @@ namespace Musify.Application.Tracks.Responses
ProcessingStatus AudioStatus,
int Duration,
int ListensCount,
long? OwnerUserId,
DateTime CreatedAt,
DateTime UpdatedAt)
{
@@ -25,6 +26,7 @@ namespace Musify.Application.Tracks.Responses
track.AudioTranscodeProcessingStatus,
track.Duration,
listensCount,
track.OwnerUserId,
track.CreatedAt,
track.UpdatedAt);
}