Design Prot Merge #1
@@ -50,6 +50,15 @@ public static class AlbumEndpoints
|
|||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.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)
|
group.MapPut("/{albumId}", UpdateAlbum)
|
||||||
.WithName("UpdateAlbum")
|
.WithName("UpdateAlbum")
|
||||||
.WithSummary("Update An Album.")
|
.WithSummary("Update An Album.")
|
||||||
@@ -143,6 +152,19 @@ public static class AlbumEndpoints
|
|||||||
return result.ToCreatedResult(album => $"/albums/{album.Id}");
|
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(
|
private static async Task<IResult> UpdateAlbum(
|
||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
|
|||||||
@@ -32,16 +32,19 @@ namespace Musify.Application.Albums
|
|||||||
return Error.Unauthorized();
|
return Error.Unauthorized();
|
||||||
}
|
}
|
||||||
|
|
||||||
var track = await database.LocalTracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||||
if (track is null)
|
if (!trackExists)
|
||||||
{
|
{
|
||||||
logger.LogInformation("Local track {TrackId} not found", request.TrackId);
|
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
||||||
return Error.NotFound(description: "Only tracks you uploaded can be added to an album.");
|
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);
|
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
||||||
return Error.Unauthorized();
|
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]
|
[Required]
|
||||||
public required long OwnerUserId { get; set; }
|
public required long OwnerUserId { get; set; }
|
||||||
|
|
||||||
|
[MaxLength(64)]
|
||||||
|
public string? ExternalId { get; set; }
|
||||||
|
|
||||||
[ForeignKey(nameof(OwnerUserId))]
|
[ForeignKey(nameof(OwnerUserId))]
|
||||||
public User Owner { get; set; }
|
public User Owner { get; set; }
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -111,6 +111,10 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
modelBuilder.Entity<UserAlbum>()
|
modelBuilder.Entity<UserAlbum>()
|
||||||
.ToTable("UserAlbums");
|
.ToTable("UserAlbums");
|
||||||
|
|
||||||
|
modelBuilder.Entity<UserAlbum>()
|
||||||
|
.HasIndex(album => new { album.OwnerUserId, album.ExternalId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<UserAlbum>()
|
modelBuilder.Entity<UserAlbum>()
|
||||||
.HasOne(album => album.Owner)
|
.HasOne(album => album.Owner)
|
||||||
.WithMany()
|
.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.HasBaseType("Musify.Domain.Entities.Album");
|
||||||
|
|
||||||
|
b.Property<string>("ExternalId")
|
||||||
|
.HasMaxLength(64)
|
||||||
|
.HasColumnType("character varying(64)");
|
||||||
|
|
||||||
b.Property<long>("OwnerUserId")
|
b.Property<long>("OwnerUserId")
|
||||||
.HasColumnType("bigint");
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
b.HasIndex("OwnerUserId");
|
b.HasIndex("OwnerUserId", "ExternalId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("UserAlbums", (string)null);
|
b.ToTable("UserAlbums", (string)null);
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user