feat(backend): save a YouTube album with all of its tracks
POST /albums/youtube/{albumId} creates the album in your library and links
every song in it, provisioning each one as an external track without queueing
any download. The external id is kept on the album so saving it twice returns
a conflict instead of a duplicate, and albums can now hold external tracks,
not just the ones you uploaded.
This commit is contained in:
@@ -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