Fix query and API bugs: 403 for non-owners, page bounds, lifecycle filters, cover fallback, race-safe inserts, async album delete
This commit is contained in:
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
@@ -21,48 +22,30 @@ public class AddTrackToAlbumCommandHandler(
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
if (album.OwnerUserId != request.UserId)
|
||||||
{
|
return AppErrors.Forbidden("Album", request.AlbumId);
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var track = await database.Tracks
|
var track = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||||
if (track == null)
|
if (track == null)
|
||||||
{
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (track.OwnerUserId != request.UserId)
|
if (track.OwnerUserId != request.UserId)
|
||||||
{
|
return AppErrors.Forbidden("Track", request.TrackId);
|
||||||
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.AlbumHasTracks
|
var alreadyAdded = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
|
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
|
||||||
if (alreadyAdded)
|
if (alreadyAdded)
|
||||||
{
|
return AlreadyInAlbum;
|
||||||
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.Conflict(description: "Track is already in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracksQuery = database.AlbumHasTracks
|
// Album track numbers are user-visible, so they start at 1.
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
var nextTrackNumber = (await database.AlbumHasTracks
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
|
.MaxAsync(albumTrack => (int?)albumTrack.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||||
var nextTrackNumber = hasTracks
|
|
||||||
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||||
{
|
{
|
||||||
@@ -71,11 +54,15 @@ public class AddTrackToAlbumCommandHandler(
|
|||||||
TrackNumber = nextTrackNumber
|
TrackNumber = nextTrackNumber
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
var saved = await database.TrySaveChangesAsync(AlreadyInAlbum, cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
|
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
|
||||||
request.TrackId, request.AlbumId, nextTrackNumber);
|
request.TrackId, request.AlbumId, nextTrackNumber);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Error AlreadyInAlbum => AppErrors.Conflict("Album.TrackAlreadyAdded", "Track is already in the album.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -57,13 +57,7 @@ public class CreateAlbumCommandHandler(
|
|||||||
NormalizedTitle = title.ToUpperInvariant(),
|
NormalizedTitle = title.ToUpperInvariant(),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
ReleaseYear = request.ReleaseYear,
|
ReleaseYear = request.ReleaseYear,
|
||||||
Pictures = new AlbumPictures
|
Pictures = AlbumPictures.Pending(intent.ObjectName)
|
||||||
{
|
|
||||||
OriginalName = intent.ObjectName,
|
|
||||||
SmallName = intent.ObjectName,
|
|
||||||
MediumName = intent.ObjectName,
|
|
||||||
LargeName = intent.ObjectName
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
await database.Albums.AddAsync(album, cancellationToken);
|
||||||
|
|||||||
@@ -3,6 +3,9 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -11,39 +14,27 @@ public record DeleteAlbumCommand(long UserId, Guid AlbumId)
|
|||||||
|
|
||||||
public class DeleteAlbumCommandHandler(
|
public class DeleteAlbumCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
ILogger<DeleteAlbumCommandHandler> logger)
|
ILogger<DeleteAlbumCommandHandler> logger)
|
||||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId && a.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
if (album.OwnerUserId != request.UserId)
|
||||||
{
|
return AppErrors.Forbidden("Album", request.AlbumId);
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
database.Albums.Remove(album);
|
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
try
|
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
|
logger.LogInformation("Marked album {AlbumId} of user {UserId} as removing", request.AlbumId, request.UserId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -16,7 +17,7 @@ public class GetAlbumByIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(a => a.Id == request.Id)
|
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(a => new
|
.Select(a => new
|
||||||
{
|
{
|
||||||
Album = a,
|
Album = a,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using MimeMapping;
|
using MimeMapping;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -20,14 +22,15 @@ public class GetAlbumCoverQueryHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pictures = await database.Albums.AsNoTracking()
|
var owned = await database.Albums.AsNoTracking()
|
||||||
.Where(a => a.Id == request.AlbumId)
|
.Where(e => e.Id == request.AlbumId && e.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(a => a.Pictures)
|
.Select(e => new { OwnerUserId = e.OwnerUserId, e.Pictures })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (pictures == null)
|
if (owned?.Pictures == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("AlbumCover", request.AlbumId);
|
||||||
|
|
||||||
|
var pictures = owned.Pictures;
|
||||||
var routes = albumConfiguration.Routes;
|
var routes = albumConfiguration.Routes;
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
var (name, key) = request.Size.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
@@ -36,8 +39,15 @@ public class GetAlbumCoverQueryHandler(
|
|||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The resized files only exist once processing has finished; until then serve the original.
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
||||||
return Error.NotFound();
|
{
|
||||||
|
name = pictures.OriginalName;
|
||||||
|
key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
||||||
|
return AppErrors.NotFound("AlbumCover", request.AlbumId);
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
var contentType = MimeUtility.GetMimeMapping(name);
|
||||||
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
|
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Application.Shared;
|
|||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -22,7 +23,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var albumExists = await database.Albums
|
var albumExists = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
.AnyAsync(album => album.Id == request.AlbumId && album.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
if (!albumExists)
|
if (!albumExists)
|
||||||
return Error.NotFound();
|
return Error.NotFound();
|
||||||
|
|
||||||
@@ -30,7 +31,7 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(albumTrack => albumTrack.Track.Owner)
|
.Include(albumTrack => albumTrack.Track.Owner)
|
||||||
.Include(albumTrack => albumTrack.Track.Tags)
|
.Include(albumTrack => albumTrack.Track.Tags)
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ public class GetAlbumsQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var albumsQuery = database.Albums
|
var albumsQuery = database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Title))
|
if (!string.IsNullOrEmpty(request.Title))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Application.Contracts;
|
|||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -25,7 +26,7 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database)
|
|||||||
|
|
||||||
var albumsQuery = database.Albums
|
var albumsQuery = database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => album.OwnerUserId == request.UserId);
|
.Where(album => album.OwnerUserId == request.UserId && album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -38,7 +39,7 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
|||||||
|
|
||||||
var albums = await database.Albums
|
var albums = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
.Where(album => albumIds.Contains(album.Id) && album.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(album => new
|
.Select(album => new
|
||||||
{
|
{
|
||||||
Album = album,
|
Album = album,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums;
|
||||||
|
|
||||||
@@ -20,16 +21,10 @@ public class RemoveTrackFromAlbumCommandHandler(
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
||||||
if (album == null)
|
if (album == null)
|
||||||
{
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
if (album.OwnerUserId != request.UserId)
|
||||||
{
|
return AppErrors.Forbidden("Album", request.AlbumId);
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
var albumTracks = await database.AlbumHasTracks
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ public class UpdateAlbumCommandHandler(
|
|||||||
if (album.OwnerUserId != request.UserId)
|
if (album.OwnerUserId != request.UserId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
||||||
return Error.Unauthorized();
|
return AppErrors.Forbidden("Album", request.AlbumId);
|
||||||
}
|
}
|
||||||
|
|
||||||
var title = request.Title.Trim();
|
var title = request.Title.Trim();
|
||||||
@@ -67,13 +67,7 @@ public class UpdateAlbumCommandHandler(
|
|||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
pictureIntent = validation.Value;
|
||||||
album.Pictures = new AlbumPictures
|
album.Pictures = AlbumPictures.Pending(pictureIntent.ObjectName);
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = pictureIntent.ObjectName,
|
|
||||||
MediumName = pictureIntent.ObjectName,
|
|
||||||
LargeName = pictureIntent.ObjectName
|
|
||||||
};
|
|
||||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
@@ -38,4 +39,6 @@ public interface IDatabase
|
|||||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ namespace Musify.Application.Contracts;
|
|||||||
|
|
||||||
public interface IStorageService
|
public interface IStorageService
|
||||||
{
|
{
|
||||||
public Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
namespace Musify.Application.Events;
|
||||||
|
|
||||||
|
public record DeleteAlbumEvent(
|
||||||
|
Guid AlbumId,
|
||||||
|
long UserId);
|
||||||
@@ -16,7 +16,7 @@ public class GetGenresQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var rows = await database.TrackTags
|
var rows = await database.TrackTags
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
|
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active))
|
||||||
.GroupBy(tag => tag.Tag)
|
.GroupBy(tag => tag.Tag)
|
||||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Application.Shared;
|
|||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Likes;
|
namespace Musify.Application.Likes;
|
||||||
|
|
||||||
@@ -24,7 +25,7 @@ public class GetLikedTracksQueryHandler(IDatabase database)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(like => like.Track.Owner)
|
.Include(like => like.Track.Owner)
|
||||||
.Include(like => like.Track.Tags)
|
.Include(like => like.Track.Tags)
|
||||||
.Where(like => like.UserId == request.UserId);
|
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Likes;
|
namespace Musify.Application.Likes;
|
||||||
@@ -45,7 +46,11 @@ public class ToggleTrackLikeCommandHandler(
|
|||||||
nowLiked = true;
|
nowLiked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return true;
|
||||||
|
|
||||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||||
|
|||||||
@@ -21,14 +21,28 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
|
|||||||
.OrderBy(mix => mix.Position)
|
.OrderBy(mix => mix.Position)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var orderedItemsByMix = mixes.ToDictionary(
|
||||||
|
mix => mix.Id,
|
||||||
|
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||||
|
|
||||||
|
var coverItems = orderedItemsByMix.Values
|
||||||
|
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||||
|
.ToList();
|
||||||
|
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||||
|
.GroupBy(response => response.TrackId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First());
|
||||||
|
|
||||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||||
foreach (var mix in mixes)
|
foreach (var mix in mixes)
|
||||||
{
|
{
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
var orderedItems = orderedItemsByMix[mix.Id];
|
||||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
var covers = orderedItems
|
||||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
.Take(MixApplicationResponse.CoverItemCount)
|
||||||
|
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||||
|
.Select(item => coverResponses[item.TrackId])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, covers));
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses;
|
return responses;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Mixes.Responses;
|
using Musify.Application.Mixes.Responses;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes;
|
namespace Musify.Application.Mixes;
|
||||||
|
|
||||||
@@ -18,7 +19,7 @@ internal static class MixItemMapper
|
|||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(track => track.Owner)
|
.Include(track => track.Owner)
|
||||||
.Where(track => trackIds.Contains(track.Id))
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
@@ -18,44 +19,30 @@ public class AddTrackToPlayListCommandHandler(
|
|||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
if (playList == null)
|
if (playList == null)
|
||||||
{
|
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
if (playList.UserId != request.UserId)
|
||||||
{
|
return AppErrors.Forbidden("PlayList", request.PlayListId);
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackExists = await database.Tracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
||||||
if (!trackExists)
|
if (!trackExists)
|
||||||
{
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.PlayListHasTracks
|
var alreadyAdded = await database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
||||||
if (alreadyAdded)
|
if (alreadyAdded)
|
||||||
{
|
return AlreadyInPlayList;
|
||||||
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
return Error.Conflict(description: "Track is already in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var playListTracksQuery = database.PlayListHasTracks
|
// Playlist positions are internal indexes, so they start at 0.
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
var nextPosition = (await database.PlayListHasTracks
|
||||||
|
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||||
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
|
.MaxAsync(plt => (int?)plt.Position, cancellationToken) ?? -1) + 1;
|
||||||
var nextPosition = hasTracks
|
|
||||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
{
|
{
|
||||||
@@ -64,11 +51,15 @@ public class AddTrackToPlayListCommandHandler(
|
|||||||
Position = nextPosition
|
Position = nextPosition
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
var saved = await database.TrySaveChangesAsync(AlreadyInPlayList, cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
|
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
|
||||||
request.TrackId, request.PlayListId, nextPosition);
|
request.TrackId, request.PlayListId, nextPosition);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Error AlreadyInPlayList => AppErrors.Conflict("PlayList.TrackAlreadyAdded", "Track is already in the playlist.");
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,7 +53,7 @@ public class CreatePlayListCommandHandler(
|
|||||||
var playList = new PlayList
|
var playList = new PlayList
|
||||||
{
|
{
|
||||||
UserId = request.UserId,
|
UserId = request.UserId,
|
||||||
Name = request.Name,
|
Name = request.Name.Trim(),
|
||||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
Pictures = resolvedPictures.Pictures,
|
Pictures = resolvedPictures.Pictures,
|
||||||
@@ -97,13 +97,7 @@ public class CreatePlayListCommandHandler(
|
|||||||
if (validation.IsError)
|
if (validation.IsError)
|
||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
return new ResolvedPlayListPictures(PlayListPictures.Pending(validation.Value.ObjectName), validation.Value);
|
||||||
{
|
|
||||||
OriginalName = validation.Value.ObjectName,
|
|
||||||
SmallName = validation.Value.ObjectName,
|
|
||||||
MediumName = validation.Value.ObjectName,
|
|
||||||
LargeName = validation.Value.ObjectName
|
|
||||||
}, validation.Value);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
|
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -29,7 +30,7 @@ public class DeletePlayListCommandHandler(
|
|||||||
if (playList.UserId != request.UserId)
|
if (playList.UserId != request.UserId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
|
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
|
||||||
return Error.Unauthorized();
|
return AppErrors.Forbidden("PlayList", request.PlayListId);
|
||||||
}
|
}
|
||||||
|
|
||||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
var entry = await database.PlayLists
|
var entry = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.Id == request.Id)
|
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
|||||||
@@ -4,6 +4,8 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using MimeMapping;
|
using MimeMapping;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
|
|
||||||
@@ -20,14 +22,15 @@ public class GetPlayListCoverQueryHandler(
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pictures = await database.PlayLists.AsNoTracking()
|
var owned = await database.PlayLists.AsNoTracking()
|
||||||
.Where(p => p.Id == request.PlayListId)
|
.Where(e => e.Id == request.PlayListId && e.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(p => p.Pictures)
|
.Select(e => new { OwnerUserId = e.UserId, e.Pictures })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (pictures == null)
|
if (owned?.Pictures == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("PlayListCover", request.PlayListId);
|
||||||
|
|
||||||
|
var pictures = owned.Pictures;
|
||||||
var routes = playListConfiguration.Routes;
|
var routes = playListConfiguration.Routes;
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
var (name, key) = request.Size.ToLowerInvariant() switch
|
||||||
{
|
{
|
||||||
@@ -36,8 +39,15 @@ public class GetPlayListCoverQueryHandler(
|
|||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// The resized files only exist once processing has finished; until then serve the original.
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
||||||
return Error.NotFound();
|
{
|
||||||
|
name = pictures.OriginalName;
|
||||||
|
key = string.IsNullOrEmpty(name) ? null : routes.BuildOriginalPicturePath(owned.OwnerUserId, name);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
||||||
|
return AppErrors.NotFound("PlayListCover", request.PlayListId);
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
var contentType = MimeUtility.GetMimeMapping(name);
|
||||||
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
|
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(
|
.AnyAsync(
|
||||||
p => p.Id == request.PlayListId
|
p => p.Id == request.PlayListId
|
||||||
|
&& p.LifeCycleStatus == LifeCycleStatus.Active
|
||||||
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId),
|
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId),
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
if (!playListExists)
|
if (!playListExists)
|
||||||
@@ -35,7 +36,7 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(plt => plt.Track.Owner)
|
.Include(plt => plt.Track.Owner)
|
||||||
.Include(plt => plt.Track.Tags)
|
.Include(plt => plt.Track.Tags)
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
.Where(plt => plt.PlayListId == request.PlayListId && plt.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
|||||||
var playListsQuery = database.PlayLists
|
var playListsQuery = database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.UserId == request.UserId
|
.Where(p => p.UserId == request.UserId
|
||||||
|
&& p.LifeCycleStatus == LifeCycleStatus.Active
|
||||||
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId));
|
&& (p.Visibility == PlaylistVisibility.Public || p.UserId == request.ViewerId));
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
@@ -38,7 +39,7 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
|||||||
|
|
||||||
var pagedEntities = await playListsQuery
|
var pagedEntities = await playListsQuery
|
||||||
.OrderBy(p => p.CreatedAt)
|
.OrderBy(p => p.CreatedAt)
|
||||||
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
|
.ThenBy(p => p.Id)
|
||||||
.Select(p => new
|
.Select(p => new
|
||||||
{
|
{
|
||||||
PlayList = p,
|
PlayList = p,
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists;
|
||||||
|
|
||||||
@@ -17,32 +18,33 @@ public class RemoveTrackFromPlayListCommandHandler(
|
|||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
||||||
if (playList == null)
|
if (playList == null)
|
||||||
{
|
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
if (playList.UserId != request.UserId)
|
||||||
{
|
return AppErrors.Forbidden("PlayList", request.PlayListId);
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var link = await database.PlayListHasTracks
|
var playListTracks = await database.PlayListHasTracks
|
||||||
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||||
|
.OrderBy(plt => plt.Position)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
||||||
if (link == null)
|
if (link == null)
|
||||||
{
|
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
||||||
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
return Error.NotFound(description: "Track is not in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.PlayListHasTracks.Remove(link);
|
database.PlayListHasTracks.Remove(link);
|
||||||
|
|
||||||
|
var position = 0;
|
||||||
|
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
||||||
|
remaining.Position = position++;
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -44,18 +44,18 @@ public class UpdatePlayListCommandHandler(
|
|||||||
if (playListEntity.UserId != request.UserId)
|
if (playListEntity.UserId != request.UserId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
||||||
return Error.Unauthorized();
|
return AppErrors.Forbidden("PlayList", request.PlayListId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||||
{
|
{
|
||||||
playListEntity.Name = request.NewName;
|
playListEntity.Name = request.NewName.Trim();
|
||||||
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
|
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewDescription))
|
if (request.NewDescription != null)
|
||||||
{
|
{
|
||||||
playListEntity.Description = request.NewDescription;
|
playListEntity.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.NewVisibility.HasValue)
|
if (request.NewVisibility.HasValue)
|
||||||
@@ -75,13 +75,7 @@ public class UpdatePlayListCommandHandler(
|
|||||||
return validation.Errors;
|
return validation.Errors;
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
pictureIntent = validation.Value;
|
||||||
playListEntity.Pictures = new PlayListPictures
|
playListEntity.Pictures = PlayListPictures.Pending(pictureIntent.ObjectName);
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = pictureIntent.ObjectName,
|
|
||||||
MediumName = pictureIntent.ObjectName,
|
|
||||||
LargeName = pictureIntent.ObjectName
|
|
||||||
};
|
|
||||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,15 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared;
|
||||||
|
|
||||||
|
public static class AppErrors
|
||||||
|
{
|
||||||
|
public static Error NotFound(string entity, object id) =>
|
||||||
|
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
|
||||||
|
|
||||||
|
public static Error Forbidden(string entity, object id) =>
|
||||||
|
Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}.");
|
||||||
|
|
||||||
|
public static Error Conflict(string code, string description) =>
|
||||||
|
Error.Conflict(code, description);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Shared;
|
||||||
|
|
||||||
|
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
||||||
|
{
|
||||||
|
public const int MaxPageSize = 100;
|
||||||
|
}
|
||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -31,7 +32,7 @@ public class DeleteTrackCommandHandler(
|
|||||||
if (track.OwnerUserId != request.UserId)
|
if (track.OwnerUserId != request.UserId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
||||||
return Error.Unauthorized();
|
return AppErrors.Forbidden("Track", request.TrackId);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.Audio.IsInProgress)
|
if (track.Audio.IsInProgress)
|
||||||
|
|||||||
@@ -1,29 +1,26 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
|
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
||||||
|
|
||||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
|
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.ListeningHistories
|
return await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Track.Owner)
|
.Include(t => t.Track.Owner)
|
||||||
.Include(t => t.Track.Tags)
|
.Include(t => t.Track.Tags)
|
||||||
.Where(t => t.UserId == query.UserId)
|
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.OrderByDescending(t => t.ListenedAt)
|
.OrderByDescending(t => t.ListenedAt)
|
||||||
|
.ThenBy(t => t.Id)
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
return track == null
|
|
||||||
? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
|
|
||||||
: track;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -28,11 +29,12 @@ public class GetListeningHistoryQueryHandler(
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Owner)
|
.Include(t => t.Owner)
|
||||||
.Include(t => t.Tags)
|
.Include(t => t.Tags)
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
.Where(t => recentTrackIds.Contains(t.Id) && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|
||||||
return recentTrackIds
|
return recentTrackIds
|
||||||
|
.Where(id => tracksById.ContainsKey(id))
|
||||||
.Select(id => tracksById[id])
|
.Select(id => tracksById[id])
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -17,7 +18,7 @@ public class GetTrackByIdQueryHandler(IDatabase database)
|
|||||||
var entity = await database.Tracks.AsNoTracking()
|
var entity = await database.Tracks.AsNoTracking()
|
||||||
.Include(t => t.Owner)
|
.Include(t => t.Owner)
|
||||||
.Include(t => t.Tags)
|
.Include(t => t.Tags)
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Application.Configuration;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -26,7 +27,7 @@ public class GetTrackStreamQueryHandler(
|
|||||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||||
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
var track = await database.Tracks.AsNoTracking()
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(t => new { t.Id, t.Audio })
|
.Select(t => new { t.Id, t.Audio })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ public class GetTracksQueryHandler(IDatabase database)
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Include(ut => ut.Track.Owner)
|
||||||
.Include(ut => ut.Track.Tags)
|
.Include(ut => ut.Track.Tags)
|
||||||
.AsQueryable();
|
.Where(ut => ut.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Application.Shared;
|
|||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
using X.PagedList;
|
||||||
using X.PagedList.EF;
|
using X.PagedList.EF;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks;
|
namespace Musify.Application.Tracks;
|
||||||
|
|
||||||
@@ -21,14 +22,11 @@ public class GetTracksByUserIdQueryHandler(IDatabase database)
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
|
|
||||||
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
|
|
||||||
|
|
||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.UserHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Include(ut => ut.Track.Owner)
|
||||||
.Include(ut => ut.Track.Tags)
|
.Include(ut => ut.Track.Tags)
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Where(p => p.UserId == request.UserId && p.Track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrEmpty(request.Name))
|
||||||
{
|
{
|
||||||
@@ -41,7 +39,7 @@ public class GetTracksByUserIdQueryHandler(IDatabase database)
|
|||||||
var pagedEntities = await tracksQuery
|
var pagedEntities = await tracksQuery
|
||||||
.OrderBy(t => t.Track.CreatedAt)
|
.OrderBy(t => t.Track.CreatedAt)
|
||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
|
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||||
|
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Users;
|
namespace Musify.Application.Users;
|
||||||
@@ -40,7 +41,11 @@ public class FollowUserCommandHandler(
|
|||||||
FollowedId = request.FollowedId
|
FollowedId = request.FollowedId
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
// A concurrent double click can hit the unique index; already following is a successful no-op.
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
logger.LogInformation("User {FollowerId} followed user {FollowedId}", request.FollowerId, request.FollowedId);
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ public class GetListeningStatsQueryHandler(IDatabase database)
|
|||||||
var listenedDateSet = listenedDates.ToHashSet();
|
var listenedDateSet = listenedDates.ToHashSet();
|
||||||
var streakDays = 0;
|
var streakDays = 0;
|
||||||
var day = DateTime.UtcNow.Date;
|
var day = DateTime.UtcNow.Date;
|
||||||
|
if (!listenedDateSet.Contains(day))
|
||||||
|
day = day.AddDays(-1);
|
||||||
|
|
||||||
while (listenedDateSet.Contains(day))
|
while (listenedDateSet.Contains(day))
|
||||||
{
|
{
|
||||||
streakDays++;
|
streakDays++;
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ public class AlbumPictures
|
|||||||
[Required]
|
[Required]
|
||||||
public required string OriginalName { get; set; }
|
public required string OriginalName { get; set; }
|
||||||
|
|
||||||
[Required]
|
public string? SmallName { get; set; }
|
||||||
public required string SmallName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
public string? MediumName { get; set; }
|
||||||
public required string MediumName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
public string? LargeName { get; set; }
|
||||||
public required string LargeName { get; set; }
|
|
||||||
|
public static AlbumPictures Pending(string originalName) => new() { OriginalName = originalName };
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,12 +7,11 @@ public class PlayListPictures
|
|||||||
[Required]
|
[Required]
|
||||||
public required string OriginalName { get; set; }
|
public required string OriginalName { get; set; }
|
||||||
|
|
||||||
[Required]
|
public string? SmallName { get; set; }
|
||||||
public required string SmallName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
public string? MediumName { get; set; }
|
||||||
public required string MediumName { get; set; }
|
|
||||||
|
|
||||||
[Required]
|
public string? LargeName { get; set; }
|
||||||
public required string LargeName { get; set; }
|
|
||||||
|
public static PlayListPictures Pending(string originalName) => new() { OriginalName = originalName };
|
||||||
}
|
}
|
||||||
|
|||||||
+35
@@ -0,0 +1,35 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Infrastructure.MassTransit.Arguments;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.MassTransit.Activities.Albums;
|
||||||
|
|
||||||
|
internal class DeleteAlbumFromDbActivity(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<DeleteAlbumFromDbActivity> logger)
|
||||||
|
: IExecuteActivity<DeleteAlbumFromDbArguments>
|
||||||
|
{
|
||||||
|
public const string ExecuteEndpointName = "delete-album-from-db";
|
||||||
|
|
||||||
|
public async Task<ExecutionResult> Execute(ExecuteContext<DeleteAlbumFromDbArguments> executeContext)
|
||||||
|
{
|
||||||
|
var album = await database.Albums
|
||||||
|
.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
|
||||||
|
|
||||||
|
if (album == null)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Album {AlbumId} not found while deleting from DB, skipping",
|
||||||
|
executeContext.Arguments.AlbumId);
|
||||||
|
return executeContext.Completed();
|
||||||
|
}
|
||||||
|
|
||||||
|
database.Albums.Remove(album);
|
||||||
|
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Deleted album {AlbumId} from DB", album.Id);
|
||||||
|
return executeContext.Completed();
|
||||||
|
}
|
||||||
|
}
|
||||||
+36
@@ -0,0 +1,36 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
using Musify.Infrastructure.MassTransit.Arguments;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.MassTransit.Activities.Albums;
|
||||||
|
|
||||||
|
internal class MarkAlbumAsRemovingActivity(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<MarkAlbumAsRemovingActivity> logger)
|
||||||
|
: IExecuteActivity<MarkAlbumAsRemovingArguments>
|
||||||
|
{
|
||||||
|
public const string ExecuteEndpointName = "mark-album-as-removing";
|
||||||
|
|
||||||
|
public async Task<ExecutionResult> Execute(ExecuteContext<MarkAlbumAsRemovingArguments> executeContext)
|
||||||
|
{
|
||||||
|
var album = await database.Albums
|
||||||
|
.SingleOrDefaultAsync(p => p.Id == executeContext.Arguments.AlbumId, executeContext.CancellationToken);
|
||||||
|
|
||||||
|
if (album == null)
|
||||||
|
{
|
||||||
|
logger.LogInformation(
|
||||||
|
"Album {AlbumId} not found while marking as removing, skipping",
|
||||||
|
executeContext.Arguments.AlbumId);
|
||||||
|
return executeContext.Completed();
|
||||||
|
}
|
||||||
|
|
||||||
|
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
await database.SaveChangesAsync(executeContext.CancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Marked album {AlbumId} as removing", album.Id);
|
||||||
|
return executeContext.Completed();
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-1
@@ -23,7 +23,8 @@ internal class DownloadFileFromBucketActivity(
|
|||||||
using var fileStream = await storageHandler.GetFileAsync(
|
using var fileStream = await storageHandler.GetFileAsync(
|
||||||
executeContext.Arguments.Bucket,
|
executeContext.Arguments.Bucket,
|
||||||
executeContext.Arguments.Key,
|
executeContext.Arguments.Key,
|
||||||
executeContext.CancellationToken);
|
executeContext.CancellationToken)
|
||||||
|
?? throw new FileNotFoundException($"Object {executeContext.Arguments.Bucket}/{executeContext.Arguments.Key} was not found.");
|
||||||
|
|
||||||
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
var destinationDirectory = Path.GetDirectoryName(destinationPath);
|
||||||
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
if (!string.IsNullOrWhiteSpace(destinationDirectory))
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Infrastructure.MassTransit.Arguments;
|
||||||
|
|
||||||
|
internal record DeleteAlbumFromDbArguments(
|
||||||
|
Guid AlbumId);
|
||||||
+4
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Infrastructure.MassTransit.Arguments;
|
||||||
|
|
||||||
|
internal record MarkAlbumAsRemovingArguments(
|
||||||
|
Guid AlbumId);
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.MassTransit.Consumers;
|
||||||
|
|
||||||
|
public class DeleteAlbumConsumer(
|
||||||
|
IBus bus,
|
||||||
|
DeleteAlbumRoutingSlipBuilder routingSlipBuilder)
|
||||||
|
: IConsumer<DeleteAlbumEvent>
|
||||||
|
{
|
||||||
|
public async Task Consume(ConsumeContext<DeleteAlbumEvent> consumeContext)
|
||||||
|
{
|
||||||
|
var routingSlip = (await routingSlipBuilder
|
||||||
|
.BuildAsync(
|
||||||
|
consumeContext.Message.AlbumId,
|
||||||
|
consumeContext.Message.UserId,
|
||||||
|
consumeContext.CorrelationId,
|
||||||
|
consumeContext.CancellationToken))
|
||||||
|
.Build();
|
||||||
|
|
||||||
|
await bus.Execute(routingSlip);
|
||||||
|
}
|
||||||
|
}
|
||||||
+4
@@ -22,6 +22,7 @@ public static partial class MassTransitDependencyInjection
|
|||||||
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
|
services.AddScoped<PictureWorkflowRoutingSlipBuilder>();
|
||||||
services.AddScoped<DeleteTrackRoutingSlipBuilder>();
|
services.AddScoped<DeleteTrackRoutingSlipBuilder>();
|
||||||
services.AddScoped<DeletePlayListRoutingSlipBuilder>();
|
services.AddScoped<DeletePlayListRoutingSlipBuilder>();
|
||||||
|
services.AddScoped<DeleteAlbumRoutingSlipBuilder>();
|
||||||
services.AddScoped<CreateTrackRoutingSlipBuilder>();
|
services.AddScoped<CreateTrackRoutingSlipBuilder>();
|
||||||
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
|
services.AddScoped<PlayListPictureSourceRoutingSlipBuilder>();
|
||||||
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
|
services.AddScoped<AlbumPictureSourceRoutingSlipBuilder>();
|
||||||
@@ -35,6 +36,7 @@ public static partial class MassTransitDependencyInjection
|
|||||||
options.AddConsumer<UpdateTrackAudioConsumer>();
|
options.AddConsumer<UpdateTrackAudioConsumer>();
|
||||||
options.AddConsumer<DeleteTrackConsumer>();
|
options.AddConsumer<DeleteTrackConsumer>();
|
||||||
options.AddConsumer<DeletePlayListConsumer>();
|
options.AddConsumer<DeletePlayListConsumer>();
|
||||||
|
options.AddConsumer<DeleteAlbumConsumer>();
|
||||||
options.AddConsumer<CreateTrackConsumer>();
|
options.AddConsumer<CreateTrackConsumer>();
|
||||||
options.AddConsumer<CreatePlayListConsumer>();
|
options.AddConsumer<CreatePlayListConsumer>();
|
||||||
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
|
options.AddConsumer<UpdatePlayListPictureSourceConsumer>();
|
||||||
@@ -58,6 +60,8 @@ public static partial class MassTransitDependencyInjection
|
|||||||
options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>();
|
options.AddExecuteActivity<MarkTrackAsFailedActivity, MarkTrackAsFailedArguments>();
|
||||||
options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>();
|
options.AddExecuteActivity<MarkPlayListAsFailedActivity, MarkPlayListAsFailedArguments>();
|
||||||
options.AddExecuteActivity<MarkAlbumAsFailedActivity, MarkAlbumAsFailedArguments>();
|
options.AddExecuteActivity<MarkAlbumAsFailedActivity, MarkAlbumAsFailedArguments>();
|
||||||
|
options.AddExecuteActivity<MarkAlbumAsRemovingActivity, MarkAlbumAsRemovingArguments>();
|
||||||
|
options.AddExecuteActivity<DeleteAlbumFromDbActivity, DeleteAlbumFromDbArguments>();
|
||||||
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
|
options.AddExecuteActivity<PublishAlbumPictureProcessingEventActivity, PublishAlbumPictureProcessingEventArguments>();
|
||||||
|
|
||||||
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
|
options.AddActivity<ResizePictureActivity, ResizePictureLocalArguments, ResizePictureLog>();
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs;
|
|||||||
public record UpdateAlbumPictureLog(
|
public record UpdateAlbumPictureLog(
|
||||||
Guid AlbumId,
|
Guid AlbumId,
|
||||||
string PreviousOriginalPictureKey,
|
string PreviousOriginalPictureKey,
|
||||||
string PreviousSmallPictureKey,
|
string? PreviousSmallPictureKey,
|
||||||
string PreviousMediumPictureKey,
|
string? PreviousMediumPictureKey,
|
||||||
string PreviousLargePictureKey);
|
string? PreviousLargePictureKey);
|
||||||
|
|||||||
@@ -3,6 +3,6 @@ namespace Musify.Infrastructure.MassTransit.Logs;
|
|||||||
public record UpdatePlayListPictureLog(
|
public record UpdatePlayListPictureLog(
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string PreviousOriginalPictureKey,
|
string PreviousOriginalPictureKey,
|
||||||
string PreviousSmallPictureKey,
|
string? PreviousSmallPictureKey,
|
||||||
string PreviousMediumPictureKey,
|
string? PreviousMediumPictureKey,
|
||||||
string PreviousLargePictureKey);
|
string? PreviousLargePictureKey);
|
||||||
|
|||||||
@@ -9,6 +9,8 @@ internal static class ActivityNames
|
|||||||
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
|
internal const string MarkPlayListAsFailed = "MarkPlayListAsFailed";
|
||||||
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
|
internal const string DeletePlayListFromDb = "DeletePlayListFromDb";
|
||||||
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
|
internal const string MarkAlbumAsFailed = "MarkAlbumAsFailed";
|
||||||
|
internal const string MarkAlbumAsRemoving = "MarkAlbumAsRemoving";
|
||||||
|
internal const string DeleteAlbumFromDb = "DeleteAlbumFromDb";
|
||||||
|
|
||||||
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
|
internal const string GenerateAudioWorkflowPaths = "GenerateAudioWorkflowPaths";
|
||||||
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
|
internal const string GeneratePictureWorkflowPaths = "GeneratePictureWorkflowPaths";
|
||||||
@@ -55,4 +57,7 @@ internal static class ActivityNames
|
|||||||
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture";
|
internal const string RemovePlayListLargePicture = "RemovePlayListLargePicture";
|
||||||
|
|
||||||
internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture";
|
internal const string RemoveAlbumOriginalPicture = "RemoveAlbumOriginalPicture";
|
||||||
|
internal const string RemoveAlbumSmallPicture = "RemoveAlbumSmallPicture";
|
||||||
|
internal const string RemoveAlbumMediumPicture = "RemoveAlbumMediumPicture";
|
||||||
|
internal const string RemoveAlbumLargePicture = "RemoveAlbumLargePicture";
|
||||||
}
|
}
|
||||||
|
|||||||
+93
@@ -0,0 +1,93 @@
|
|||||||
|
using MassTransit;
|
||||||
|
using MassTransit.Courier.Contracts;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Infrastructure.MassTransit.Activities.Files;
|
||||||
|
using Musify.Infrastructure.MassTransit.Activities.Albums;
|
||||||
|
using Musify.Infrastructure.MassTransit.Arguments;
|
||||||
|
using Musify.Infrastructure.MassTransit.Consumers;
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders;
|
||||||
|
|
||||||
|
public class DeleteAlbumRoutingSlipBuilder(
|
||||||
|
IDatabase database,
|
||||||
|
AlbumConfiguration albumConfiguration,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration)
|
||||||
|
{
|
||||||
|
public async Task<RoutingSlipBuilder> BuildAsync(
|
||||||
|
Guid albumId,
|
||||||
|
long userId,
|
||||||
|
Guid? correlationId,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var routingSlipBuilder = new RoutingSlipBuilder(NewId.NextGuid());
|
||||||
|
routingSlipBuilder.AddVariable(RoutingSlipVariableNames.Workflow.CorrelationId, correlationId ?? Guid.Empty);
|
||||||
|
|
||||||
|
routingSlipBuilder.AddSubscription(
|
||||||
|
EndpointHelper.BuildConsumerUri(RoutingSlipCleanUpConsumer.QueueName),
|
||||||
|
RoutingSlipEvents.Completed | RoutingSlipEvents.Faulted);
|
||||||
|
|
||||||
|
var album = await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.SingleOrDefaultAsync(p => p.Id == albumId, cancellationToken);
|
||||||
|
|
||||||
|
routingSlipBuilder.AddActivity(
|
||||||
|
ActivityNames.MarkAlbumAsRemoving,
|
||||||
|
EndpointHelper.BuildExecuteActivityUri(MarkAlbumAsRemovingActivity.ExecuteEndpointName),
|
||||||
|
new MarkAlbumAsRemovingArguments(albumId));
|
||||||
|
|
||||||
|
if (album?.Pictures != null)
|
||||||
|
{
|
||||||
|
var bucket = storageConfiguration.Bucket;
|
||||||
|
var routes = albumConfiguration.Routes;
|
||||||
|
|
||||||
|
if (album.Pictures.OriginalName is { } originalName)
|
||||||
|
{
|
||||||
|
routingSlipBuilder.AddActivity(
|
||||||
|
ActivityNames.RemoveAlbumOriginalPicture,
|
||||||
|
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||||
|
new RemoveFileFromBucketArguments(
|
||||||
|
bucket,
|
||||||
|
routes.BuildOriginalPicturePath(userId, originalName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (album.Pictures.SmallName is { } smallName)
|
||||||
|
{
|
||||||
|
routingSlipBuilder.AddActivity(
|
||||||
|
ActivityNames.RemoveAlbumSmallPicture,
|
||||||
|
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||||
|
new RemoveFileFromBucketArguments(
|
||||||
|
bucket,
|
||||||
|
routes.BuildSmallPicturePath(smallName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (album.Pictures.MediumName is { } mediumName)
|
||||||
|
{
|
||||||
|
routingSlipBuilder.AddActivity(
|
||||||
|
ActivityNames.RemoveAlbumMediumPicture,
|
||||||
|
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||||
|
new RemoveFileFromBucketArguments(
|
||||||
|
bucket,
|
||||||
|
routes.BuildMediumPicturePath(mediumName)));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (album.Pictures.LargeName is { } largeName)
|
||||||
|
{
|
||||||
|
routingSlipBuilder.AddActivity(
|
||||||
|
ActivityNames.RemoveAlbumLargePicture,
|
||||||
|
EndpointHelper.BuildExecuteActivityUri(RemoveFileFromBucketActivity.ExecuteEndpointName),
|
||||||
|
new RemoveFileFromBucketArguments(
|
||||||
|
bucket,
|
||||||
|
routes.BuildLargePicturePath(largeName)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
routingSlipBuilder.AddActivity(
|
||||||
|
ActivityNames.DeleteAlbumFromDb,
|
||||||
|
EndpointHelper.BuildExecuteActivityUri(DeleteAlbumFromDbActivity.ExecuteEndpointName),
|
||||||
|
new DeleteAlbumFromDbArguments(albumId));
|
||||||
|
|
||||||
|
return routingSlipBuilder;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ErrorOr;
|
||||||
using MassTransit;
|
using MassTransit;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.EntityFrameworkCore.Storage;
|
using Microsoft.EntityFrameworkCore.Storage;
|
||||||
@@ -6,6 +7,7 @@ using Musify.Domain.Entities;
|
|||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Infrastructure.Configuration;
|
using Musify.Infrastructure.Configuration;
|
||||||
using Musify.Infrastructure.MassTransit.Sagas;
|
using Musify.Infrastructure.MassTransit.Sagas;
|
||||||
|
using Npgsql;
|
||||||
using AppIDatabase = Musify.Application.Contracts.IDatabase;
|
using AppIDatabase = Musify.Application.Contracts.IDatabase;
|
||||||
|
|
||||||
namespace Musify.Infrastructure.Persistence;
|
namespace Musify.Infrastructure.Persistence;
|
||||||
@@ -82,9 +84,9 @@ public class Database(DatabaseConfiguration configuration)
|
|||||||
.OwnsOne(album => album.Pictures, pictures =>
|
.OwnsOne(album => album.Pictures, pictures =>
|
||||||
{
|
{
|
||||||
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
|
pictures.Property(p => p.OriginalName).HasColumnName("OriginalPictureName").HasMaxLength(64);
|
||||||
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64);
|
pictures.Property(p => p.SmallName).HasColumnName("SmallPictureName").HasMaxLength(64).IsRequired(false);
|
||||||
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64);
|
pictures.Property(p => p.MediumName).HasColumnName("MediumPictureName").HasMaxLength(64).IsRequired(false);
|
||||||
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64);
|
pictures.Property(p => p.LargeName).HasColumnName("LargePictureName").HasMaxLength(64).IsRequired(false);
|
||||||
});
|
});
|
||||||
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
modelBuilder.Entity<Album>().Navigation(album => album.Pictures).IsRequired(false);
|
||||||
|
|
||||||
@@ -110,6 +112,10 @@ public class Database(DatabaseConfiguration configuration)
|
|||||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<PlayListHasTrack>()
|
||||||
|
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<Mix>()
|
modelBuilder.Entity<Mix>()
|
||||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
.HasIndex(mix => new { mix.UserId, mix.Position });
|
||||||
|
|
||||||
@@ -205,6 +211,19 @@ public class Database(DatabaseConfiguration configuration)
|
|||||||
return new DatabaseTransaction(transaction);
|
return new DatabaseTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SaveChangesAsync(cancellationToken);
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception) when (exception.InnerException is PostgresException { SqlState: PostgresErrorCodes.UniqueViolation })
|
||||||
|
{
|
||||||
|
return onUniqueViolation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
|
private sealed class DatabaseTransaction(IDbContextTransaction inner) : IDatabaseTransaction
|
||||||
{
|
{
|
||||||
public Task CommitAsync(CancellationToken cancellationToken) =>
|
public Task CommitAsync(CancellationToken cancellationToken) =>
|
||||||
|
|||||||
+1174
File diff suppressed because it is too large
Load Diff
+46
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class PlayListTrackUnique : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
// Remove duplicate rows (keeping the lowest position) before enforcing uniqueness.
|
||||||
|
migrationBuilder.Sql("""
|
||||||
|
DELETE FROM "PlayListHasTrack" a
|
||||||
|
USING "PlayListHasTrack" b
|
||||||
|
WHERE a."PlayListId" = b."PlayListId"
|
||||||
|
AND a."TrackId" = b."TrackId"
|
||||||
|
AND (a."Position", a."Id") > (b."Position", b."Id");
|
||||||
|
""");
|
||||||
|
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_PlayListHasTrack_PlayListId",
|
||||||
|
table: "PlayListHasTrack");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PlayListHasTrack_PlayListId_TrackId",
|
||||||
|
table: "PlayListHasTrack",
|
||||||
|
columns: new[] { "PlayListId", "TrackId" },
|
||||||
|
unique: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropIndex(
|
||||||
|
name: "IX_PlayListHasTrack_PlayListId_TrackId",
|
||||||
|
table: "PlayListHasTrack");
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_PlayListHasTrack_PlayListId",
|
||||||
|
table: "PlayListHasTrack",
|
||||||
|
column: "PlayListId");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -417,10 +417,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
|
|
||||||
b.HasKey("Id");
|
b.HasKey("Id");
|
||||||
|
|
||||||
b.HasIndex("PlayListId");
|
|
||||||
|
|
||||||
b.HasIndex("TrackId");
|
b.HasIndex("TrackId");
|
||||||
|
|
||||||
|
b.HasIndex("PlayListId", "TrackId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
b.ToTable("PlayListHasTrack");
|
b.ToTable("PlayListHasTrack");
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -790,13 +791,11 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b1.Property<string>("LargeName")
|
b1.Property<string>("LargeName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("LargePictureName");
|
.HasColumnName("LargePictureName");
|
||||||
|
|
||||||
b1.Property<string>("MediumName")
|
b1.Property<string>("MediumName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("MediumPictureName");
|
.HasColumnName("MediumPictureName");
|
||||||
@@ -808,7 +807,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.HasColumnName("OriginalPictureName");
|
.HasColumnName("OriginalPictureName");
|
||||||
|
|
||||||
b1.Property<string>("SmallName")
|
b1.Property<string>("SmallName")
|
||||||
.IsRequired()
|
|
||||||
.HasMaxLength(64)
|
.HasMaxLength(64)
|
||||||
.HasColumnType("character varying(64)")
|
.HasColumnType("character varying(64)")
|
||||||
.HasColumnName("SmallPictureName");
|
.HasColumnName("SmallPictureName");
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
|||||||
InfrastructureStorageConfiguration storageClientConfiguration)
|
InfrastructureStorageConfiguration storageClientConfiguration)
|
||||||
: IStorageService
|
: IStorageService
|
||||||
{
|
{
|
||||||
public async Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
|
public async Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var request = new GetObjectRequest
|
var request = new GetObjectRequest
|
||||||
{
|
{
|
||||||
@@ -29,6 +29,11 @@ public class StorageService(IDatabase database, IAmazonS3 amazonS3, ILogger<Stor
|
|||||||
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
|
logger.LogDebug("Retrieved file from S3 {Bucket}/{Key}", bucket, key);
|
||||||
return response.ResponseStream;
|
return response.ResponseStream;
|
||||||
}
|
}
|
||||||
|
catch (AmazonS3Exception exception) when (exception.ErrorCode == "NoSuchKey")
|
||||||
|
{
|
||||||
|
logger.LogDebug("File not found in S3 {Bucket}/{Key}", bucket, key);
|
||||||
|
return null;
|
||||||
|
}
|
||||||
catch (Exception exception)
|
catch (Exception exception)
|
||||||
{
|
{
|
||||||
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
|
logger.LogError(exception, "Failed to get file from S3 {Bucket}/{Key}", bucket, key);
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Api.Filters;
|
|||||||
using Musify.Application.Albums;
|
using Musify.Application.Albums;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Api.Models;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
|
||||||
@@ -13,12 +14,15 @@ namespace Musify.Api.Endpoints;
|
|||||||
|
|
||||||
public static class AlbumEndpoints
|
public static class AlbumEndpoints
|
||||||
{
|
{
|
||||||
|
private const int MaxRecentAlbumsLimit = 50;
|
||||||
|
|
||||||
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
|
public static IEndpointRouteBuilder MapAlbumEndpoints(this IEndpointRouteBuilder app)
|
||||||
{
|
{
|
||||||
var group = app.MapGroup("/albums")
|
var group = app.MapGroup("/albums")
|
||||||
.WithTags("Albums");
|
.WithTags("Albums");
|
||||||
|
|
||||||
group.MapGet("/", GetAlbums)
|
group.MapGet("/", GetAlbums)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetAlbums")
|
.WithName("GetAlbums")
|
||||||
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
|
.WithSummary("Get Paginated Albums, Optionally Filtered By Title.")
|
||||||
.Produces<AlbumsSearchResponse>();
|
.Produces<AlbumsSearchResponse>();
|
||||||
@@ -30,6 +34,7 @@ public static class AlbumEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/users/{userId}", GetAlbumsByUserId)
|
group.MapGet("/users/{userId}", GetAlbumsByUserId)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetAlbumsByUserId")
|
.WithName("GetAlbumsByUserId")
|
||||||
.WithSummary("Get Paginated Albums For A User.")
|
.WithSummary("Get Paginated Albums For A User.")
|
||||||
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
.Produces<PaginatedResponse<AlbumApplicationResponse>>()
|
||||||
@@ -43,6 +48,7 @@ public static class AlbumEndpoints
|
|||||||
.Produces(StatusCodes.Status401Unauthorized);
|
.Produces(StatusCodes.Status401Unauthorized);
|
||||||
|
|
||||||
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
|
group.MapGet("/{albumId}/tracks", GetAlbumTracks)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetAlbumTracks")
|
.WithName("GetAlbumTracks")
|
||||||
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
|
.WithSummary("Get The Tracks Of An Album Ordered By Track Number.")
|
||||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||||
@@ -82,6 +88,7 @@ public static class AlbumEndpoints
|
|||||||
.Produces<AlbumApplicationResponse>()
|
.Produces<AlbumApplicationResponse>()
|
||||||
.ProducesValidationProblem()
|
.ProducesValidationProblem()
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapDelete("/{albumId}", DeleteAlbum)
|
group.MapDelete("/{albumId}", DeleteAlbum)
|
||||||
@@ -90,6 +97,7 @@ public static class AlbumEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum)
|
group.MapPost("/{albumId}/tracks/{trackId}", AddTrackToAlbum)
|
||||||
@@ -98,6 +106,7 @@ public static class AlbumEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.Produces(StatusCodes.Status409Conflict);
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
@@ -107,6 +116,7 @@ public static class AlbumEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -116,10 +126,9 @@ public static class AlbumEndpoints
|
|||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? title,
|
string? title,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetAlbumsQuery(title, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetAlbumsQuery(title, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -136,10 +145,9 @@ public static class AlbumEndpoints
|
|||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
long userId,
|
long userId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetAlbumsByUserIdQuery(userId, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +157,14 @@ public static class AlbumEndpoints
|
|||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int limit = 12)
|
int limit = 12)
|
||||||
{
|
{
|
||||||
|
if (limit is < 1 or > MaxRecentAlbumsLimit)
|
||||||
|
{
|
||||||
|
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||||
|
{
|
||||||
|
[nameof(limit)] = [$"'{nameof(limit)}' must be between 1 and {MaxRecentAlbumsLimit}."]
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
var result = await mediator.Send(new GetRecentlyListenedAlbumsQuery(currentUser.RequiredId, limit), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
@@ -157,10 +173,9 @@ public static class AlbumEndpoints
|
|||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
Guid albumId,
|
Guid albumId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 50)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetAlbumTracksQuery(albumId, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetAlbumTracksQuery(albumId, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -178,6 +193,9 @@ public static class AlbumEndpoints
|
|||||||
|
|
||||||
var location = result.Value;
|
var location = result.Value;
|
||||||
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
||||||
|
if (stream == null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
return Results.Stream(stream, location.ContentType);
|
return Results.Stream(stream, location.ContentType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,8 +3,10 @@ using Musify.Api.Authentication;
|
|||||||
using Musify.Api.DataTransferObjects.Likes;
|
using Musify.Api.DataTransferObjects.Likes;
|
||||||
using Musify.Api.Extensions;
|
using Musify.Api.Extensions;
|
||||||
using Musify.Application.Likes;
|
using Musify.Application.Likes;
|
||||||
|
using Musify.Api.Models;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Api.Filters;
|
||||||
|
|
||||||
namespace Musify.Api.Endpoints;
|
namespace Musify.Api.Endpoints;
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ public static class LikeEndpoints
|
|||||||
.RequireAuthorization();
|
.RequireAuthorization();
|
||||||
|
|
||||||
group.MapGet("/", GetLikedTracks)
|
group.MapGet("/", GetLikedTracks)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetLikedTracks")
|
.WithName("GetLikedTracks")
|
||||||
.WithSummary("Get The Current User'S Liked Tracks.")
|
.WithSummary("Get The Current User'S Liked Tracks.")
|
||||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||||
@@ -36,10 +39,9 @@ public static class LikeEndpoints
|
|||||||
IMediator mediator,
|
IMediator mediator,
|
||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetLikedTracksQuery(currentUser.RequiredId, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ using Musify.Api.Filters;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists;
|
using Musify.Application.PlayLists;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Api.Models;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
@@ -26,11 +27,13 @@ public static class PlayListEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/users/{userId}", GetPlayListsByUserId)
|
group.MapGet("/users/{userId}", GetPlayListsByUserId)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetPlayListsByUserId")
|
.WithName("GetPlayListsByUserId")
|
||||||
.WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.")
|
.WithSummary("Get Paginated PlayLists For A User. Private PlayLists Are Only Returned To Their Owner.")
|
||||||
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
|
.Produces<PaginatedResponse<PlayListApplicationResponse>>();
|
||||||
|
|
||||||
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
|
group.MapGet("/{playlistId}/tracks", GetPlayListTracks)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetPlayListTracks")
|
.WithName("GetPlayListTracks")
|
||||||
.WithSummary("Get Paginated Tracks Of A PlayList.")
|
.WithSummary("Get Paginated Tracks Of A PlayList.")
|
||||||
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
.Produces<PaginatedResponse<TrackApplicationResponse>>()
|
||||||
@@ -60,6 +63,7 @@ public static class PlayListEndpoints
|
|||||||
.Produces<PlayListApplicationResponse>()
|
.Produces<PlayListApplicationResponse>()
|
||||||
.ProducesValidationProblem()
|
.ProducesValidationProblem()
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapDelete("/{playlistId}", DeletePlayList)
|
group.MapDelete("/{playlistId}", DeletePlayList)
|
||||||
@@ -68,6 +72,7 @@ public static class PlayListEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
|
group.MapPost("/upload-picture", RequestPlayListPictureUpload)
|
||||||
@@ -86,6 +91,7 @@ public static class PlayListEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
|
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.Produces(StatusCodes.Status409Conflict);
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
@@ -95,6 +101,7 @@ public static class PlayListEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
return app;
|
return app;
|
||||||
@@ -116,10 +123,9 @@ public static class PlayListEndpoints
|
|||||||
long userId,
|
long userId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? name,
|
string? name,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, pageNumber, pageSize, currentUser.Id), cancellationToken);
|
var result = await mediator.Send(new GetPlayListsByUserIdQuery(userId, name, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -128,10 +134,9 @@ public static class PlayListEndpoints
|
|||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
Guid playlistId,
|
Guid playlistId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, pageNumber, pageSize, currentUser.Id), cancellationToken);
|
var result = await mediator.Send(new GetPlayListTracksQuery(playlistId, page.PageNumber, page.PageSize, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +154,9 @@ public static class PlayListEndpoints
|
|||||||
|
|
||||||
var location = result.Value;
|
var location = result.Value;
|
||||||
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
||||||
|
if (stream == null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
return Results.Stream(stream, location.ContentType);
|
return Results.Stream(stream, location.ContentType);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ using Musify.Api.DataTransferObjects.Tracks;
|
|||||||
using Musify.Api.Extensions;
|
using Musify.Api.Extensions;
|
||||||
using Musify.Api.Filters;
|
using Musify.Api.Filters;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Api.Models;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
@@ -19,6 +20,7 @@ public static class TrackEndpoints
|
|||||||
.WithTags("Tracks");
|
.WithTags("Tracks");
|
||||||
|
|
||||||
group.MapGet("/", GetTracks)
|
group.MapGet("/", GetTracks)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetTracks")
|
.WithName("GetTracks")
|
||||||
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
||||||
.Produces<TracksSearchResponse>()
|
.Produces<TracksSearchResponse>()
|
||||||
@@ -52,6 +54,7 @@ public static class TrackEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/users/{userId}", GetTracksByUserId)
|
group.MapGet("/users/{userId}", GetTracksByUserId)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetTracksByUserId")
|
.WithName("GetTracksByUserId")
|
||||||
.WithSummary("Get Paginated Tracks For A User.")
|
.WithSummary("Get Paginated Tracks For A User.")
|
||||||
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
||||||
@@ -73,6 +76,7 @@ public static class TrackEndpoints
|
|||||||
.RequireAuthorization()
|
.RequireAuthorization()
|
||||||
.Produces(StatusCodes.Status204NoContent)
|
.Produces(StatusCodes.Status204NoContent)
|
||||||
.Produces(StatusCodes.Status401Unauthorized)
|
.Produces(StatusCodes.Status401Unauthorized)
|
||||||
|
.Produces(StatusCodes.Status403Forbidden)
|
||||||
.Produces(StatusCodes.Status404NotFound)
|
.Produces(StatusCodes.Status404NotFound)
|
||||||
.Produces(StatusCodes.Status409Conflict);
|
.Produces(StatusCodes.Status409Conflict);
|
||||||
|
|
||||||
@@ -95,8 +99,7 @@ public static class TrackEndpoints
|
|||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? name,
|
string? name,
|
||||||
string? genre,
|
string? genre,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
Genre? parsedGenre = null;
|
Genre? parsedGenre = null;
|
||||||
if (!string.IsNullOrWhiteSpace(genre))
|
if (!string.IsNullOrWhiteSpace(genre))
|
||||||
@@ -112,7 +115,7 @@ public static class TrackEndpoints
|
|||||||
parsedGenre = value;
|
parsedGenre = value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
|
var result = await mediator.Send(new GetTracksQuery(name, page.PageNumber, page.PageSize, parsedGenre), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -139,6 +142,9 @@ public static class TrackEndpoints
|
|||||||
|
|
||||||
var location = result.Value;
|
var location = result.Value;
|
||||||
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
||||||
|
if (stream == null)
|
||||||
|
return Results.NotFound();
|
||||||
|
|
||||||
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
||||||
return Results.Stream(stream, location.ContentType);
|
return Results.Stream(stream, location.ContentType);
|
||||||
}
|
}
|
||||||
@@ -171,10 +177,9 @@ public static class TrackEndpoints
|
|||||||
long userId,
|
long userId,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? name,
|
string? name,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,13 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Musify.Api.Authentication;
|
using Musify.Api.Authentication;
|
||||||
using Musify.Api.Extensions;
|
using Musify.Api.Extensions;
|
||||||
|
using Musify.Api.Models;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Application.Users;
|
using Musify.Application.Users;
|
||||||
using Musify.Application.Users.Responses;
|
using Musify.Application.Users.Responses;
|
||||||
|
using Musify.Api.Filters;
|
||||||
|
|
||||||
namespace Musify.Api.Endpoints;
|
namespace Musify.Api.Endpoints;
|
||||||
|
|
||||||
@@ -17,6 +19,7 @@ public static class UserEndpoints
|
|||||||
.WithTags("Users");
|
.WithTags("Users");
|
||||||
|
|
||||||
group.MapGet("/", GetUsers)
|
group.MapGet("/", GetUsers)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetUsers")
|
.WithName("GetUsers")
|
||||||
.WithSummary("Get Paginated Users.")
|
.WithSummary("Get Paginated Users.")
|
||||||
.Produces<PaginatedResponse<UserSummaryResponse>>();
|
.Produces<PaginatedResponse<UserSummaryResponse>>();
|
||||||
@@ -45,7 +48,7 @@ public static class UserEndpoints
|
|||||||
.WithName("GetLastTrackListenedByUserId")
|
.WithName("GetLastTrackListenedByUserId")
|
||||||
.WithSummary("Get The Last Track A User Listened To.")
|
.WithSummary("Get The Last Track A User Listened To.")
|
||||||
.Produces<TrackApplicationResponse>()
|
.Produces<TrackApplicationResponse>()
|
||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status204NoContent);
|
||||||
|
|
||||||
group.MapGet("/{id}/profile", GetUserProfile)
|
group.MapGet("/{id}/profile", GetUserProfile)
|
||||||
.WithName("GetUserProfile")
|
.WithName("GetUserProfile")
|
||||||
@@ -54,6 +57,7 @@ public static class UserEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/{id}/followers", GetFollowers)
|
group.MapGet("/{id}/followers", GetFollowers)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetUserFollowers")
|
.WithName("GetUserFollowers")
|
||||||
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
|
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
|
||||||
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||||
@@ -61,6 +65,7 @@ public static class UserEndpoints
|
|||||||
.Produces(StatusCodes.Status404NotFound);
|
.Produces(StatusCodes.Status404NotFound);
|
||||||
|
|
||||||
group.MapGet("/{id}/following", GetFollowing)
|
group.MapGet("/{id}/following", GetFollowing)
|
||||||
|
.AddEndpointFilter<ValidationFilter<PageQuery>>()
|
||||||
.WithName("GetUserFollowing")
|
.WithName("GetUserFollowing")
|
||||||
.WithSummary("Get The Users A User Follows.")
|
.WithSummary("Get The Users A User Follows.")
|
||||||
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
.Produces<PaginatedResponse<UserSummaryResponse>>()
|
||||||
@@ -97,10 +102,9 @@ public static class UserEndpoints
|
|||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
string? usernameSearch,
|
string? usernameSearch,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 10)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
|
var result = await mediator.Send(new GetUsersQuery(page.PageNumber, page.PageSize, usernameSearch, currentUser.Id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -138,8 +142,8 @@ public static class UserEndpoints
|
|||||||
long id,
|
long id,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
|
var track = await mediator.Send(new GetLastTrackListenedByUserIdQuery(id), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return track == null ? Results.NoContent() : Results.Ok(track);
|
||||||
}
|
}
|
||||||
|
|
||||||
private static async Task<IResult> GetUserProfile(
|
private static async Task<IResult> GetUserProfile(
|
||||||
@@ -157,10 +161,9 @@ public static class UserEndpoints
|
|||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
long id,
|
long id,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 20)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -169,10 +172,9 @@ public static class UserEndpoints
|
|||||||
CurrentUser currentUser,
|
CurrentUser currentUser,
|
||||||
long id,
|
long id,
|
||||||
CancellationToken cancellationToken,
|
CancellationToken cancellationToken,
|
||||||
int pageNumber = 1,
|
[AsParameters] PageQuery page)
|
||||||
int pageSize = 20)
|
|
||||||
{
|
{
|
||||||
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
|
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, page.PageNumber, page.PageSize), cancellationToken);
|
||||||
return result.ToHttpResult();
|
return result.ToHttpResult();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -28,10 +28,10 @@ public static class ErrorOrHttpExtensions
|
|||||||
var error = errors[0];
|
var error = errors[0];
|
||||||
return error.Type switch
|
return error.Type switch
|
||||||
{
|
{
|
||||||
ErrorType.NotFound => Results.NotFound(error.Description),
|
ErrorType.NotFound => Results.Problem(error.Description, statusCode: StatusCodes.Status404NotFound),
|
||||||
ErrorType.Unauthorized => Results.Unauthorized(),
|
ErrorType.Unauthorized => Results.Unauthorized(),
|
||||||
ErrorType.Forbidden => Results.StatusCode(StatusCodes.Status403Forbidden),
|
ErrorType.Forbidden => Results.Problem(error.Description, statusCode: StatusCodes.Status403Forbidden),
|
||||||
ErrorType.Conflict => Results.Conflict(error.Description),
|
ErrorType.Conflict => Results.Problem(error.Description, statusCode: StatusCodes.Status409Conflict),
|
||||||
_ => Results.Problem(error.Description)
|
_ => Results.Problem(error.Description)
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
|
namespace Musify.Api.Models;
|
||||||
|
|
||||||
|
/// <summary>Query-string binding for <see cref="PageRequest"/>, keeping the camelCase parameter names of the API.</summary>
|
||||||
|
public record PageQuery(
|
||||||
|
[property: FromQuery(Name = "pageNumber")] int PageNumber = 1,
|
||||||
|
[property: FromQuery(Name = "pageSize")] int PageSize = 20);
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
using FluentValidation;
|
||||||
|
using Musify.Api.Models;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
|
namespace Musify.Api.Validators;
|
||||||
|
|
||||||
|
public sealed class PageQueryValidator : AbstractValidator<PageQuery>
|
||||||
|
{
|
||||||
|
public PageQueryValidator()
|
||||||
|
{
|
||||||
|
RuleFor(x => x.PageNumber)
|
||||||
|
.GreaterThanOrEqualTo(1);
|
||||||
|
|
||||||
|
RuleFor(x => x.PageSize)
|
||||||
|
.InclusiveBetween(1, PageRequest.MaxPageSize);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,5 +13,8 @@ public sealed class CreatePlayListRequestValidator : AbstractValidator<CreatePla
|
|||||||
|
|
||||||
RuleFor(x => x.Description)
|
RuleFor(x => x.Description)
|
||||||
.MaximumLength(256);
|
.MaximumLength(256);
|
||||||
|
|
||||||
|
RuleFor(x => x.Visibility)
|
||||||
|
.IsInEnum();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,5 +15,9 @@ public sealed class UpdatePlayListRequestValidator : AbstractValidator<UpdatePla
|
|||||||
RuleFor(x => x.NewDescription)
|
RuleFor(x => x.NewDescription)
|
||||||
.MaximumLength(256)
|
.MaximumLength(256)
|
||||||
.When(x => x.NewDescription != null);
|
.When(x => x.NewDescription != null);
|
||||||
|
|
||||||
|
RuleFor(x => x.NewVisibility)
|
||||||
|
.IsInEnum()
|
||||||
|
.When(x => x.NewVisibility != null);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -95,7 +95,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task DeleteAlbum_NotTheOwner_ReturnsUnauthorized()
|
public async Task DeleteAlbum_NotTheOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = await CreateUserAsync();
|
var owner = await CreateUserAsync();
|
||||||
var stranger = await CreateUserAsync();
|
var stranger = await CreateUserAsync();
|
||||||
@@ -106,7 +106,7 @@ public sealed class AlbumEndpointsTests(ApiTestFixture fixture)
|
|||||||
|
|
||||||
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
var response = await fixture.CreateAuthenticatedClient(stranger).DeleteAsync($"/albums/{created!.Id}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -38,7 +38,8 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
TrackId = track.Id,
|
TrackId = track.Id,
|
||||||
ListenedAt = listenedAt
|
ListenedAt = listenedAt,
|
||||||
|
IsCounted = true
|
||||||
});
|
});
|
||||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||||
}
|
}
|
||||||
@@ -94,7 +95,7 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNotFound()
|
public async Task GetLastTrackListenedByUserId_NoHistory_ReturnsNoContent()
|
||||||
{
|
{
|
||||||
var client = fixture.CreateAnonymousClient();
|
var client = fixture.CreateAnonymousClient();
|
||||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||||
@@ -102,6 +103,39 @@ public sealed class UserEndpointsTests(ApiTestFixture fixture)
|
|||||||
|
|
||||||
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
var response = await client.GetAsync($"/users/{userId}/last-listened-track", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Theory]
|
||||||
|
[InlineData("pageSize=0")]
|
||||||
|
[InlineData("pageSize=101")]
|
||||||
|
[InlineData("pageNumber=0")]
|
||||||
|
public async Task GetUsers_InvalidPaging_ReturnsBadRequest(string query)
|
||||||
|
{
|
||||||
|
var client = fixture.CreateAnonymousClient();
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"/users?{query}", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListeningStats_AnotherUser_ReturnsForbidden()
|
||||||
|
{
|
||||||
|
var userId = await fixture.SeedUserAsync();
|
||||||
|
var other = await fixture.SeedUserAsync();
|
||||||
|
var client = fixture.CreateAuthenticatedClient(userId);
|
||||||
|
|
||||||
|
var response = await client.GetAsync($"/users/{other}/listening-stats", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Forbidden, response.StatusCode);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetListeningStats_Anonymous_ReturnsUnauthorized()
|
||||||
|
{
|
||||||
|
var response = await fixture.CreateAnonymousClient().GetAsync("/users/1/listening-stats", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -49,7 +49,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(stranger.Id, album.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -65,7 +65,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_TrackOwnedByAnotherUser_ReturnsUnauthorized()
|
public async Task Handle_TrackOwnedByAnotherUser_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var otherOwner = TestEntities.User(2, "other");
|
var otherOwner = TestEntities.User(2, "other");
|
||||||
@@ -75,7 +75,7 @@ public sealed class AddTrackToAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new AddTrackToAlbumCommand(owner.Id, album.Id, foreignTrack.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -1,16 +1,21 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Musify.Application.Albums;
|
using Musify.Application.Albums;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
using NSubstitute;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
|
|
||||||
namespace Musify.Application.Tests.Albums;
|
namespace Musify.Application.Tests.Albums;
|
||||||
|
|
||||||
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
||||||
{
|
{
|
||||||
private DeleteAlbumCommandHandler CreateHandler() => new(Database, NoOpLogger<DeleteAlbumCommandHandler>());
|
private readonly IEventBus eventBus = Substitute.For<IEventBus>();
|
||||||
|
|
||||||
|
private DeleteAlbumCommandHandler CreateHandler() => new(Database, eventBus, NoOpLogger<DeleteAlbumCommandHandler>());
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_Owner_RemovesAlbum()
|
public async Task Handle_Owner_MarksAlbumAsRemoving()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
var owner = TestEntities.User();
|
||||||
var album = TestEntities.Album(owner.Id);
|
var album = TestEntities.Album(owner.Id);
|
||||||
@@ -19,7 +24,10 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new DeleteAlbumCommand(owner.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
Assert.Null(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
var stored = await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(stored);
|
||||||
|
Assert.Equal(LifeCycleStatus.Removing, stored.LifeCycleStatus);
|
||||||
|
await eventBus.Received(1).PublishAsync(Arg.Any<Musify.Application.Events.DeleteAlbumEvent>(), Arg.Any<CancellationToken>());
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -31,7 +39,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorizedAndKeepsAlbum()
|
public async Task Handle_NotOwner_ReturnsForbiddenAndKeepsAlbum()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -40,7 +48,7 @@ public sealed class DeleteAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new DeleteAlbumCommand(stranger.Id, album.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
Assert.NotNull(await Database.Albums.FindAsync([album.Id], TestContext.Current.CancellationToken));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,4 +34,19 @@ public sealed class GetAlbumCoverQueryHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_PicturesStillProcessing_FallsBackToTheOriginal()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var album = TestEntities.Album(owner.Id, pictures: Musify.Domain.ValueObjects.AlbumPictures.Pending("original.png"));
|
||||||
|
await SeedAsync(owner, album);
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetAlbumCoverQuery(album.Id, "small"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
Assert.Contains($"{owner.Id}", result.Value.Key);
|
||||||
|
Assert.EndsWith("original.png", result.Value.Key);
|
||||||
|
Assert.Equal("image/png", result.Value.ContentType);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -46,7 +46,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -55,7 +55,7 @@ public sealed class RemoveTrackFromAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new RemoveTrackFromAlbumCommand(stranger.Id, album.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
@@ -77,7 +77,7 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -88,6 +88,6 @@ public sealed class UpdateAlbumCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+16
-2
@@ -53,7 +53,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -62,7 +62,7 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new AddTrackToPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -89,4 +89,18 @@ public sealed class AddTrackToPlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
Assert.Equal(ErrorType.Conflict, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task TrySaveChangesAsync_DuplicatePlayListTrack_MapsTheUniqueViolationToTheGivenError()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var track = TestEntities.Track(owner);
|
||||||
|
await SeedAsync(owner, playList, track, new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 0 });
|
||||||
|
Database.PlayListHasTracks.Add(new PlayListHasTrack { PlayListId = playList.Id, TrackId = track.Id, Position = 1 });
|
||||||
|
|
||||||
|
var result = await Database.TrySaveChangesAsync(Error.Conflict("Test.Duplicate", "duplicate"), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal("Test.Duplicate", result.FirstError.Code);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -39,7 +39,7 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -48,6 +48,6 @@ public sealed class DeletePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new DeletePlayListCommand(stranger.Id, playList.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+24
-2
@@ -33,7 +33,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -42,7 +42,7 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(stranger.Id, playList.Id, Guid.NewGuid()), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -56,4 +56,26 @@ public sealed class RemoveTrackFromPlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_TrackInTheMiddle_RenumbersTheRemainingTracks()
|
||||||
|
{
|
||||||
|
var owner = TestEntities.User();
|
||||||
|
var playList = TestEntities.PlayList(owner.Id);
|
||||||
|
var first = TestEntities.Track(owner, "First");
|
||||||
|
var second = TestEntities.Track(owner, "Second");
|
||||||
|
var third = TestEntities.Track(owner, "Third");
|
||||||
|
await SeedAsync(
|
||||||
|
owner, playList, first, second, third,
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = first.Id, Position = 0 },
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = second.Id, Position = 1 },
|
||||||
|
new PlayListHasTrack { PlayListId = playList.Id, TrackId = third.Id, Position = 2 });
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new RemoveTrackFromPlayListCommand(owner.Id, playList.Id, second.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.False(result.IsError);
|
||||||
|
var remaining = Database.PlayListHasTracks.OrderBy(link => link.Position).ToList();
|
||||||
|
Assert.Equal([first.Id, third.Id], remaining.Select(link => link.TrackId));
|
||||||
|
Assert.Equal([0, 1], remaining.Select(link => link.Position));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+5
-5
@@ -48,7 +48,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -59,7 +59,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -93,7 +93,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_BlankNameAndDescription_LeavesThemUnchanged()
|
public async Task Handle_BlankName_LeavesItUnchangedAndBlankDescriptionClearsIt()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
var owner = TestEntities.User();
|
||||||
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
|
var playList = TestEntities.PlayList(owner.Id, "Original Name", "Original description");
|
||||||
@@ -105,7 +105,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal("Original Name", result.Value.Name);
|
Assert.Equal("Original Name", result.Value.Name);
|
||||||
Assert.Equal("Original description", result.Value.Description);
|
Assert.Null(result.Value.Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
@@ -126,7 +126,7 @@ public sealed class UpdatePlayListCommandHandlerTests : HandlerTestBase
|
|||||||
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.False(result.IsError);
|
||||||
Assert.Equal("new-cover.webp", result.Value.SmallImageKeyName);
|
Assert.Null(result.Value.SmallImageKeyName);
|
||||||
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
|
var stored = await Database.PlayLists.FindAsync([playList.Id], TestContext.Current.CancellationToken);
|
||||||
Assert.NotNull(stored);
|
Assert.NotNull(stored);
|
||||||
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
|
Assert.Equal("new-cover.webp", stored.Pictures?.OriginalName);
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ErrorOr;
|
||||||
using Microsoft.Data.Sqlite;
|
using Microsoft.Data.Sqlite;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
@@ -79,6 +80,10 @@ public sealed class TestDatabase : DbContext, IDatabase
|
|||||||
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
.HasIndex(albumTrack => new { albumTrack.AlbumId, albumTrack.TrackId })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<PlayListHasTrack>()
|
||||||
|
.HasIndex(playListTrack => new { playListTrack.PlayListId, playListTrack.TrackId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
modelBuilder.Entity<Mix>()
|
modelBuilder.Entity<Mix>()
|
||||||
.HasIndex(mix => new { mix.UserId, mix.Position });
|
.HasIndex(mix => new { mix.UserId, mix.Position });
|
||||||
|
|
||||||
@@ -164,6 +169,19 @@ public sealed class TestDatabase : DbContext, IDatabase
|
|||||||
return new SqliteDatabaseTransaction(transaction);
|
return new SqliteDatabaseTransaction(transaction);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
try
|
||||||
|
{
|
||||||
|
await SaveChangesAsync(cancellationToken);
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
catch (DbUpdateException exception) when (exception.InnerException is SqliteException { SqliteErrorCode: 19 })
|
||||||
|
{
|
||||||
|
return onUniqueViolation;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public override async ValueTask DisposeAsync()
|
public override async ValueTask DisposeAsync()
|
||||||
{
|
{
|
||||||
await base.DisposeAsync();
|
await base.DisposeAsync();
|
||||||
|
|||||||
@@ -39,7 +39,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NotOwner_ReturnsUnauthorized()
|
public async Task Handle_NotOwner_ReturnsForbidden()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User(1, "owner");
|
var owner = TestEntities.User(1, "owner");
|
||||||
var stranger = TestEntities.User(2, "stranger");
|
var stranger = TestEntities.User(2, "stranger");
|
||||||
@@ -48,7 +48,7 @@ public sealed class DeleteTrackCommandHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new DeleteTrackCommand(stranger.Id, track.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.Unauthorized, result.FirstError.Type);
|
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
|
|||||||
+4
-5
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Musify.Application.Tests.TestSupport;
|
using Musify.Application.Tests.TestSupport;
|
||||||
using Musify.Application.Tracks;
|
using Musify.Application.Tracks;
|
||||||
using Xunit;
|
using Xunit;
|
||||||
@@ -22,18 +21,18 @@ public sealed class GetLastTrackListenedByUserIdQueryHandlerTests : HandlerTestB
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
Assert.NotNull(result);
|
||||||
Assert.Equal("Newer listen", result.Value.Title);
|
Assert.Equal("Newer listen", result.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NoHistory_ReturnsNotFound()
|
public async Task Handle_NoHistory_ReturnsNull()
|
||||||
{
|
{
|
||||||
var owner = TestEntities.User();
|
var owner = TestEntities.User();
|
||||||
await SeedAsync(owner);
|
await SeedAsync(owner);
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetLastTrackListenedByUserIdQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
Assert.Null(result);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,19 +27,4 @@ public sealed class GetTracksByUserIdQueryHandlerTests : HandlerTestBase
|
|||||||
var track = Assert.Single(result.Value.Items);
|
var track = Assert.Single(result.Value.Items);
|
||||||
Assert.Equal("Mine", track.Title);
|
Assert.Equal("Mine", track.Title);
|
||||||
}
|
}
|
||||||
|
|
||||||
[Theory]
|
|
||||||
[InlineData(0, 1)]
|
|
||||||
[InlineData(-5, 1)]
|
|
||||||
public async Task Handle_NonPositivePageNumber_FallsBackToFirstPage(int requestedPageNumber, int expectedPageNumber)
|
|
||||||
{
|
|
||||||
var owner = TestEntities.User();
|
|
||||||
var track = TestEntities.Track(owner);
|
|
||||||
await SeedAsync(owner, track, new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
|
||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetTracksByUserIdQuery(owner.Id, Name: null, PageNumber: requestedPageNumber, PageSize: 10), TestContext.Current.CancellationToken);
|
|
||||||
|
|
||||||
Assert.False(result.IsError);
|
|
||||||
Assert.Equal(expectedPageNumber, result.Value.PageNumber);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -102,7 +102,7 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
|
|||||||
}
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task Handle_NoListenToday_StreakIsZero()
|
public async Task Handle_ListenedYesterdayButNotToday_StreakIsStillAlive()
|
||||||
{
|
{
|
||||||
var user = TestEntities.User();
|
var user = TestEntities.User();
|
||||||
var track = TestEntities.Track(user);
|
var track = TestEntities.Track(user);
|
||||||
@@ -112,6 +112,20 @@ public sealed class GetListeningStatsQueryHandlerTests : HandlerTestBase
|
|||||||
|
|
||||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Equal(1, result.StreakDays);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task Handle_NoListenTodayOrYesterday_StreakIsZero()
|
||||||
|
{
|
||||||
|
var user = TestEntities.User();
|
||||||
|
var track = TestEntities.Track(user);
|
||||||
|
await SeedAsync(
|
||||||
|
user, track,
|
||||||
|
TestEntities.ListeningHistory(user.Id, track.Id, DateTime.UtcNow.AddDays(-2)));
|
||||||
|
|
||||||
|
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal(0, result.StreakDays);
|
Assert.Equal(0, result.StreakDays);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,12 +42,21 @@ public sealed class StorageServiceTests(InfrastructureTestFixture fixture) : IAs
|
|||||||
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
await service.UploadFileAsync(new MemoryStream(content), "text/plain", InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
await using var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, key, TestContext.Current.CancellationToken);
|
||||||
|
Assert.NotNull(stream);
|
||||||
using var reader = new StreamReader(stream, Encoding.UTF8);
|
using var reader = new StreamReader(stream, Encoding.UTF8);
|
||||||
var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
|
var readBack = await reader.ReadToEndAsync(TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
Assert.Equal("hello seaweedfs", readBack);
|
Assert.Equal("hello seaweedfs", readBack);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public async Task GetFileAsync_MissingObject_ReturnsNull()
|
||||||
|
{
|
||||||
|
var stream = await service.GetFileAsync(InfrastructureTestFixture.S3Bucket, $"tests/{Guid.NewGuid():N}/missing.txt", TestContext.Current.CancellationToken);
|
||||||
|
|
||||||
|
Assert.Null(stream);
|
||||||
|
}
|
||||||
|
|
||||||
[Fact]
|
[Fact]
|
||||||
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
|
public async Task HeadObjectAsync_ExistingObject_ReturnsMetadata()
|
||||||
{
|
{
|
||||||
|
|||||||
Vendored
+65
-2
@@ -1396,6 +1396,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1430,6 +1437,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1465,6 +1479,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1507,6 +1528,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1825,6 +1853,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1957,6 +1992,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -1991,6 +2033,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2073,6 +2122,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2353,6 +2409,13 @@ export interface operations {
|
|||||||
};
|
};
|
||||||
content?: never;
|
content?: never;
|
||||||
};
|
};
|
||||||
|
/** @description Forbidden */
|
||||||
|
403: {
|
||||||
|
headers: {
|
||||||
|
[name: string]: unknown;
|
||||||
|
};
|
||||||
|
content?: never;
|
||||||
|
};
|
||||||
/** @description Not Found */
|
/** @description Not Found */
|
||||||
404: {
|
404: {
|
||||||
headers: {
|
headers: {
|
||||||
@@ -2561,8 +2624,8 @@ export interface operations {
|
|||||||
"application/json": components["schemas"]["TrackApplicationResponse"];
|
"application/json": components["schemas"]["TrackApplicationResponse"];
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
/** @description Not Found */
|
/** @description No Content */
|
||||||
404: {
|
204: {
|
||||||
headers: {
|
headers: {
|
||||||
[name: string]: unknown;
|
[name: string]: unknown;
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ export const SEARCH_DEBOUNCE_MS = 450;
|
|||||||
export const SEARCH_MIN_LENGTH = 2;
|
export const SEARCH_MIN_LENGTH = 2;
|
||||||
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
|
||||||
export const ALBUMS_PAGE_SIZE = 50;
|
export const ALBUMS_PAGE_SIZE = 50;
|
||||||
export const ALBUM_TRACKS_PAGE_SIZE = 200;
|
export const ALBUM_TRACKS_PAGE_SIZE = 100;
|
||||||
export const ALBUM_EARLIEST_YEAR = 1877;
|
export const ALBUM_EARLIEST_YEAR = 1877;
|
||||||
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
export const LIBRARY_PICKER_PAGE_SIZE = 50;
|
||||||
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
|
||||||
@@ -15,7 +15,8 @@ export const HOME_CONTINUE_LIMIT = 10;
|
|||||||
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
|
||||||
export const HOME_POPULAR_MAX = 8;
|
export const HOME_POPULAR_MAX = 8;
|
||||||
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
|
||||||
export const LIKED_TRACKS_PAGE_SIZE = 500;
|
export const LIKED_TRACKS_LIMIT = 500;
|
||||||
|
export const PLAYLIST_TRACKS_LIMIT = 500;
|
||||||
export const EXPLORE_USERS_LIMIT = 12;
|
export const EXPLORE_USERS_LIMIT = 12;
|
||||||
export const FOLLOW_LIST_PAGE_SIZE = 30;
|
export const FOLLOW_LIST_PAGE_SIZE = 30;
|
||||||
export const MENU_WIDTH = 240;
|
export const MENU_WIDTH = 240;
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
/** Largest page size the API accepts (`PageRequest.MaxPageSize` in the backend). */
|
||||||
|
export const API_MAX_PAGE_SIZE = 100;
|
||||||
|
|
||||||
|
type Page<T> = { items: T[]; hasNextPage: boolean };
|
||||||
|
|
||||||
|
/** Loads consecutive pages until the list ends or `maxItems` items were collected. */
|
||||||
|
export async function fetchAllPages<T>(
|
||||||
|
loadPage: (pageNumber: number, pageSize: number) => Promise<Page<T> | undefined>,
|
||||||
|
maxItems: number
|
||||||
|
): Promise<T[]> {
|
||||||
|
const items: T[] = [];
|
||||||
|
|
||||||
|
for (let pageNumber = 1; items.length < maxItems; pageNumber++) {
|
||||||
|
const page = await loadPage(pageNumber, API_MAX_PAGE_SIZE);
|
||||||
|
if (!page) break;
|
||||||
|
|
||||||
|
items.push(...page.items);
|
||||||
|
if (!page.hasNextPage) break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return items.slice(0, maxItems);
|
||||||
|
}
|
||||||
@@ -7,6 +7,8 @@ import {
|
|||||||
unwrapOrError
|
unwrapOrError
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
|
import { fetchAllPages } from '$lib/server/pagination';
|
||||||
|
import { PLAYLIST_TRACKS_LIMIT } from '$lib/config';
|
||||||
|
|
||||||
const MAX_NAME = 100;
|
const MAX_NAME = 100;
|
||||||
|
|
||||||
@@ -21,11 +23,15 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
|
|||||||
|
|
||||||
const tracksByPlaylist = await Promise.all(
|
const tracksByPlaylist = await Promise.all(
|
||||||
data.items.map((playlist) =>
|
data.items.map((playlist) =>
|
||||||
api
|
fetchAllPages(
|
||||||
.GET('/playlists/{playlistId}/tracks', {
|
async (pageNumber, pageSize) =>
|
||||||
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
|
(
|
||||||
|
await api.GET('/playlists/{playlistId}/tracks', {
|
||||||
|
params: { path: { playlistId: playlist.id }, query: { pageNumber, pageSize } }
|
||||||
})
|
})
|
||||||
.then((res) => res.data?.items ?? [])
|
).data,
|
||||||
|
PLAYLIST_TRACKS_LIMIT
|
||||||
|
)
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
@@ -8,6 +8,8 @@ import {
|
|||||||
unwrapOrFail
|
unwrapOrFail
|
||||||
} from '$lib/server/api';
|
} from '$lib/server/api';
|
||||||
import { uploadPresignedImage } from '$lib/server/upload';
|
import { uploadPresignedImage } from '$lib/server/upload';
|
||||||
|
import { fetchAllPages } from '$lib/server/pagination';
|
||||||
|
import { PLAYLIST_TRACKS_LIMIT } from '$lib/config';
|
||||||
|
|
||||||
const MAX_NAME = 100;
|
const MAX_NAME = 100;
|
||||||
|
|
||||||
@@ -16,15 +18,20 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
|
|||||||
optionalUser(locals, url, allowAnonymousListening);
|
optionalUser(locals, url, allowAnonymousListening);
|
||||||
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
|
||||||
|
|
||||||
const [playlistRes, tracksRes] = await Promise.all([
|
const [playlistRes, tracks] = await Promise.all([
|
||||||
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
|
||||||
api.GET('/playlists/{playlistId}/tracks', {
|
fetchAllPages(
|
||||||
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
|
async (pageNumber, pageSize) =>
|
||||||
|
(
|
||||||
|
await api.GET('/playlists/{playlistId}/tracks', {
|
||||||
|
params: { path: { playlistId: params.id }, query: { pageNumber, pageSize } }
|
||||||
})
|
})
|
||||||
|
).data,
|
||||||
|
PLAYLIST_TRACKS_LIMIT
|
||||||
|
)
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
|
||||||
const tracks = tracksRes.data?.items ?? [];
|
|
||||||
|
|
||||||
return { playlist, tracks };
|
return { playlist, tracks };
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -3,7 +3,8 @@ import type { LayoutServerLoad } from './$types';
|
|||||||
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
import { getAllowAnonymousListening } from '$lib/server/playbackConfig';
|
||||||
import { createApiClient } from '$lib/server/api';
|
import { createApiClient } from '$lib/server/api';
|
||||||
import { authConfig } from '$lib/server/config';
|
import { authConfig } from '$lib/server/config';
|
||||||
import { LIKED_TRACKS_PAGE_SIZE } from '$lib/config';
|
import { LIKED_TRACKS_LIMIT } from '$lib/config';
|
||||||
|
import { fetchAllPages } from '$lib/server/pagination';
|
||||||
|
|
||||||
const PUBLIC_PATHS = new Set(['/auth']);
|
const PUBLIC_PATHS = new Set(['/auth']);
|
||||||
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
const SIDEBAR_PLAYLISTS_LIMIT = 8;
|
||||||
@@ -30,10 +31,11 @@ async function fetchSidebarPlaylists(
|
|||||||
async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) {
|
async function fetchLikedTracks(fetchFn: typeof fetch, accessToken: string | null) {
|
||||||
try {
|
try {
|
||||||
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
const api = createApiClient({ fetch: fetchFn, accessToken: accessToken ?? undefined });
|
||||||
const { data } = await api.GET('/likes', {
|
return await fetchAllPages(
|
||||||
params: { query: { pageNumber: 1, pageSize: LIKED_TRACKS_PAGE_SIZE } }
|
async (pageNumber, pageSize) =>
|
||||||
});
|
(await api.GET('/likes', { params: { query: { pageNumber, pageSize } } })).data,
|
||||||
return data?.items ?? [];
|
LIKED_TRACKS_LIMIT
|
||||||
|
);
|
||||||
} catch {
|
} catch {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user