Design Prot Merge #1

Merged
NotS1mp merged 217 commits from design-prot into master 2026-09-21 15:48:16 +00:00
8 changed files with 1256 additions and 7 deletions
Showing only changes of commit df1cd5f1e9 - Show all commits
@@ -50,6 +50,15 @@ public static class AlbumEndpoints
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPost("/youtube/{albumId}", SaveYouTubeAlbum)
.WithName("SaveYouTubeAlbum")
.WithSummary("Save A YouTube Music Album To Your Library With All Of Its Tracks.")
.RequireAuthorization()
.Produces<AlbumApplicationResponse>(StatusCodes.Status201Created)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound)
.Produces(StatusCodes.Status409Conflict);
group.MapPut("/{albumId}", UpdateAlbum)
.WithName("UpdateAlbum")
.WithSummary("Update An Album.")
@@ -143,6 +152,19 @@ public static class AlbumEndpoints
return result.ToCreatedResult(album => $"/albums/{album.Id}");
}
private static async Task<IResult> SaveYouTubeAlbum(
IMediator mediator,
CurrentUser currentUser,
string albumId,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new SaveYouTubeAlbumCommand(currentUser.RequiredId, albumId),
cancellationToken);
return result.ToCreatedResult(album => $"/albums/{album.Id}");
}
private static async Task<IResult> UpdateAlbum(
IMediator mediator,
CurrentUser currentUser,
@@ -32,16 +32,19 @@ namespace Musify.Application.Albums
return Error.Unauthorized();
}
var track = await database.LocalTracks
var trackExists = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track is null)
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Local track {TrackId} not found", request.TrackId);
return Error.NotFound(description: "Only tracks you uploaded can be added to an album.");
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
if (track.OwnerUserId != request.UserId)
var localTrack = await database.LocalTracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (localTrack is not null && localTrack.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
@@ -0,0 +1,101 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Services;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record SaveYouTubeAlbumCommand(long UserId, string AlbumId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class SaveYouTubeAlbumCommandHandler(
IDatabase database,
IYouTubeMusicService youTubeMusicService,
YouTubeTrackProvisioner provisioner,
ILogger<SaveYouTubeAlbumCommandHandler> logger)
: ICommandHandler<SaveYouTubeAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(SaveYouTubeAlbumCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var alreadySaved = await database.UserAlbums
.AsNoTracking()
.AnyAsync(album => album.OwnerUserId == request.UserId && album.ExternalId == request.AlbumId, cancellationToken);
if (alreadySaved)
{
logger.LogInformation("Album {AlbumId} is already saved for user {UserId}", request.AlbumId, request.UserId);
return Error.Conflict(description: "The album is already in your library.");
}
var detailResult = await youTubeMusicService.GetAlbumAsync(request.AlbumId, cancellationToken);
if (detailResult.IsError)
return detailResult.Errors;
var detail = detailResult.Value;
if (detail.Tracks.Count == 0)
return Error.NotFound(description: "The album has no playable tracks.");
var title = Truncate(detail.Album.Title, 200);
var album = new UserAlbum
{
OwnerUserId = request.UserId,
ExternalId = request.AlbumId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = Truncate(detail.Description, 256),
ReleaseYear = detail.Album.ReleaseYear
};
await database.UserAlbums.AddAsync(album, cancellationToken);
foreach (var track in detail.Tracks)
{
var provisionResult = await provisioner.GetOrCreateAsync(track.VideoId, cancellationToken);
if (provisionResult.IsError)
{
logger.LogWarning("Skipped track {VideoId} of album {AlbumId}: {Error}",
track.VideoId, request.AlbumId, provisionResult.FirstError.Description);
continue;
}
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = album.Id,
TrackId = provisionResult.Value.Track.Id,
TrackNumber = track.TrackNumber
}, cancellationToken);
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save YouTube album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return Error.Failure(description: $"Failed to save album {request.AlbumId}");
}
var trackCount = await database.AlbumHasTracks
.AsNoTracking()
.CountAsync(albumTrack => albumTrack.AlbumId == album.Id, cancellationToken);
logger.LogInformation("Saved YouTube album {AlbumId} as {AlbumGuid} with {TrackCount} tracks",
request.AlbumId, album.Id, trackCount);
return AlbumApplicationResponse.FromEntity(album, trackCount);
}
private static string Truncate(string value, int maxLength) =>
value.Length <= maxLength ? value : value[..maxLength];
}
}
@@ -9,6 +9,9 @@ namespace Musify.Domain.Entities
[Required]
public required long OwnerUserId { get; set; }
[MaxLength(64)]
public string? ExternalId { get; set; }
[ForeignKey(nameof(OwnerUserId))]
public User Owner { get; set; }
}
@@ -111,6 +111,10 @@ namespace Musify.Infrastructure.Persistence
modelBuilder.Entity<UserAlbum>()
.ToTable("UserAlbums");
modelBuilder.Entity<UserAlbum>()
.HasIndex(album => new { album.OwnerUserId, album.ExternalId })
.IsUnique();
modelBuilder.Entity<UserAlbum>()
.HasOne(album => album.Owner)
.WithMany()
@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddUserAlbumExternalId : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums");
migrationBuilder.AddColumn<string>(
name: "ExternalId",
table: "UserAlbums",
type: "character varying(64)",
maxLength: 64,
nullable: true);
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId_ExternalId",
table: "UserAlbums",
columns: new[] { "OwnerUserId", "ExternalId" },
unique: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropIndex(
name: "IX_UserAlbums_OwnerUserId_ExternalId",
table: "UserAlbums");
migrationBuilder.DropColumn(
name: "ExternalId",
table: "UserAlbums");
migrationBuilder.CreateIndex(
name: "IX_UserAlbums_OwnerUserId",
table: "UserAlbums",
column: "OwnerUserId");
}
}
}
@@ -642,10 +642,15 @@ namespace Musify.Infrastructure.Persistence.Migrations
{
b.HasBaseType("Musify.Domain.Entities.Album");
b.Property<string>("ExternalId")
.HasMaxLength(64)
.HasColumnType("character varying(64)");
b.Property<long>("OwnerUserId")
.HasColumnType("bigint");
b.HasIndex("OwnerUserId");
b.HasIndex("OwnerUserId", "ExternalId")
.IsUnique();
b.ToTable("UserAlbums", (string)null);
});