Design Prot Merge #1
@@ -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()
|
||||
|
||||
+1063
File diff suppressed because it is too large
Load Diff
+48
@@ -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);
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user