Author SHA1 Message Date
Admin 8e85af2b3b Render unknown URLs inside the app shell and move ListeningStats to the domain types
CI/CD / BackEnd (push) Successful in 1m19s
CI/CD / WebPlayer (push) Successful in 46s
CI/CD / Deploy (push) Skipped
Add catch-all routes so unknown pages 404 inside the (app) layout and unknown
/api URLs return a JSON 404 instead of redirecting to the login page. Restore
the full root error page.
2026-09-24 14:29:45 +02:00
Admin 2d23eb87c6 Update frontend docs for the new structure and fix session test typing 2026-09-24 14:06:46 +02:00
Admin e5ce63a192 Add tests for API endpoints, liked store, listen tracker and paged list 2026-09-24 14:04:43 +02:00
Admin b5c9658d65 Add tests for sessions, guards, API error details and the app layout load 2026-09-24 14:03:35 +02:00
Admin ba0a2373b5 Normalize API counters in the server mappers instead of route loaders 2026-09-24 14:02:23 +02:00
Admin b0163f6a4d Split the explore page into genre grid, top result and search results components 2026-09-24 14:01:33 +02:00
Admin 0a192645d6 Drive the theme accent from the app layout instead of the player 2026-09-24 13:59:54 +02:00
Admin 351b333529 Split the server API module into client, guards, form fields and action helpers 2026-09-24 13:59:21 +02:00
Admin 155f0e2b6a Move liked tracks state to state/ and recently played merge to player/ 2026-09-24 13:58:43 +02:00
Admin a54d1ef077 Move store-connected components out of ui/ into components/music and lint the ui boundary 2026-09-24 13:58:13 +02:00
Admin 2361b88e0a Rename the app shell components folder and move AccountMenu next to its consumers 2026-09-24 13:57:35 +02:00
Admin e7c2fd5571 Move the app shell and its data loading into the (app) route group layout 2026-09-24 13:57:02 +02:00
Admin 3126cb6dd6 web-player: regenerate package-lock with npm 10 so npm ci works on Node 22
CI/CD / BackEnd (push) Successful in 1m19s
CI/CD / WebPlayer (push) Successful in 45s
CI/CD / Deploy (push) Skipped
2026-09-24 13:16:43 +02:00
Admin 03afcc8a90 web-player: sync package-lock with missing emnapi optional dependencies
CI/CD / BackEnd (push) Successful in 1m24s
CI/CD / WebPlayer (push) Failing after 8s
CI/CD / Deploy (push) Skipped
2026-09-24 13:02:38 +02:00
Admin f210fa4702 web-player: add albums to playlists through the bulk endpoint
CI/CD / BackEnd (push) Successful in 1m29s
CI/CD / WebPlayer (push) Failing after 7s
CI/CD / Deploy (push) Skipped
2026-09-24 12:05:34 +02:00
Admin 4b708aa987 backend: add endpoint to add a whole album to a playlist 2026-09-24 12:04:28 +02:00
Admin bd07b1134e web-player: fix icon size guardrail and add larger icon tokens 2026-09-24 11:56:09 +02:00
Admin c041daf649 web-player: add lint guardrails for imports, non-null assertions, unused props and icon sizes; refresh docs 2026-09-24 11:54:38 +02:00
Admin 975fa7e4fd web-player: consistent imports, tokens, glossary and app route grouping 2026-09-24 11:52:57 +02:00
Admin 1a2ae37f49 web-player: show only real upload steps 2026-09-24 11:51:47 +02:00
Admin 1cad36b929 web-player: extract menu, paged list and play-all components from pages 2026-09-24 11:51:14 +02:00
Admin 8cb9386447 web-player: unify primitive link/button rendering, add onEscape action and self-contained AccountMenu 2026-09-24 05:27:25 +02:00
Admin 5cda7cb4c1 web-player: split player, navigation and accent state into focused modules 2026-09-24 05:25:35 +02:00
Admin e89463fb83 web-player: use playlist totals from the API, cache genres and parallelize explore requests 2026-09-24 05:21:29 +02:00
Admin b011ccdd5b backend: expose track count and duration on playlist responses 2026-09-24 05:20:20 +02:00
Admin 1df7f6ccf6 web-player: share form limits between client and server and extract form parsers 2026-09-24 05:19:41 +02:00
Admin 2915737f03 web-player: centralize server helpers (apiFor, authedAction, form readers) and unify page sizes 2026-09-24 05:13:51 +02:00
Admin 5b2a0e4d46 web-player: normalize tracks, albums and playlists into single domain types at the server boundary 2026-09-24 05:09:37 +02:00
Admin 37fc229bf6 web-player: remove dead code and unused props 2026-09-24 05:04:09 +02:00
Admin bc43b4b6eb web-player: fix playlist ownership, sidebar counts, home spotlight and assorted page bugs 2026-09-24 05:02:26 +02:00
Admin dda8c30d45 backend: expose owner user id on playlist responses 2026-09-24 04:59:50 +02:00
Admin 906f38140a web-player: dedupe queue, stop at end of queue, open queue once and harden localStorage 2026-09-24 04:59:28 +02:00
Admin 3e546d4a1e web-player: fix open redirect via returnTo, require SESSION_SECRET and validate API inputs 2026-09-24 04:58:08 +02:00
Admin b2f450dcf7 web-player: add Vitest, unit tests and pure queue operations 2026-09-24 04:54:09 +02:00
639 changed files with 19238 additions and 20247 deletions
-65
View File
@@ -1,65 +0,0 @@
root = true
[*]
charset = utf-8
insert_final_newline = true
trim_trailing_whitespace = true
indent_style = space
[*.{csproj,props,targets,slnx,xml}]
indent_size = 2
[*.json]
indent_size = 4
[**/Migrations/**.cs]
generated_code = true
[*.cs]
indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning
csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false
dotnet_diagnostic.IDE0005.severity = warning
dotnet_style_require_accessibility_modifiers = always:warning
csharp_style_var_for_built_in_types = true:suggestion
csharp_style_var_when_type_is_apparent = true:suggestion
csharp_style_var_elsewhere = true:suggestion
csharp_prefer_braces = when_multiline:warning
csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case
dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers =
dotnet_naming_style.camel_case.capitalization = camel_case
dotnet_naming_rule.private_constants.symbols = private_constants
dotnet_naming_rule.private_constants.style = pascal_case
dotnet_naming_rule.private_constants.severity = warning
dotnet_naming_symbols.private_constants.applicable_kinds = field
dotnet_naming_symbols.private_constants.required_modifiers = const
dotnet_naming_style.pascal_case.capitalization = pascal_case
# Analyzer rules that would need behaviour or API changes; downgraded in P0, revisit in P15.
dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.severity = suggestion
dotnet_diagnostic.CA1305.severity = suggestion
dotnet_diagnostic.CA1707.severity = suggestion
dotnet_diagnostic.CA1859.severity = suggestion
dotnet_diagnostic.CA1716.severity = suggestion
dotnet_diagnostic.CA1000.severity = suggestion
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion
dotnet_diagnostic.CA1711.severity = suggestion
dotnet_diagnostic.CA1861.severity = suggestion
@@ -0,0 +1,82 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.Albums
{
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToAlbumCommandHandler(
IDatabase database,
ILogger<AddTrackToAlbumCommandHandler> logger)
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var track = await database.Tracks
.AsNoTracking()
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
if (track == null)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
if (track.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
return Error.Unauthorized();
}
var alreadyAdded = await database.AlbumHasTracks
.AsNoTracking()
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
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
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
var nextTrackNumber = hasTracks
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
: 1;
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = request.TrackId,
TrackNumber = nextTrackNumber
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
request.TrackId, request.AlbumId, nextTrackNumber);
return new Success();
}
}
}
@@ -1,77 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToAlbumCommandHandler(
IDatabase database,
ILogger<AddTracksToAlbumCommandHandler> logger)
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var tracks = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { track.Id, track.OwnerUserId })
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
if (foreign != null)
return AppErrors.Forbidden("Track", foreign.Id);
var alreadyInAlbum = await database.AlbumHasTracks
.AsNoTracking()
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
// Album track numbers are user-visible, so they start at 1.
var nextTrackNumber = (await database.AlbumHasTracks
.Where(link => link.AlbumId == request.AlbumId)
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
foreach (var trackId in toAdd)
{
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
{
AlbumId = request.AlbumId,
TrackId = trackId,
TrackNumber = nextTrackNumber++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
return Result.Success;
}
}
@@ -2,19 +2,18 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record CreateAlbumCommand( public record CreateAlbumCommand(
long UserId, long UserId,
string Title, string Title,
string? Description, string? Description,
@@ -22,51 +21,108 @@ public record CreateAlbumCommand(
Guid PictureIntentId) Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler( public class CreateAlbumCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger, ILogger<CreateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration) ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
.AsNoTracking() .AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken); .AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId); {
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var prepared = await PictureSourceChange.PrepareAsync( var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (prepared.IsError) if (pictureIntent.IsError)
return prepared.Errors; return pictureIntent.Errors;
var picture = prepared.Value; var intent = pictureIntent.Value;
var title = request.Title.Trim(); var title = request.Title.Trim();
var album = new Album var album = new Album
{ {
OwnerUserId = request.UserId, OwnerUserId = request.UserId,
Title = title, Title = title,
NormalizedTitle = TextNormalizer.Normalize(title), NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description, Description = request.Description,
ReleaseYear = request.ReleaseYear, ReleaseYear = request.ReleaseYear,
Pictures = picture.Pictures Pictures = new AlbumPictures
{
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
}; };
await database.Albums.AddAsync(album, cancellationToken); await database.Albums.AddAsync(album, cancellationToken);
await eventBus.PublishAsync( var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
new CreateAlbumResourcesEvent( var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), if (publishResult.IsError)
cancellationToken); return publishResult.Errors;
try
{
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
}
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId); logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0); return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
} }
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
}
}
}
} }
@@ -1,36 +1,50 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
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
{
public record DeleteAlbumCommand(long UserId, Guid AlbumId) public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
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 found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums
if (found.IsError) .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
return found.Errors; if (album == null)
{
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
var album = found.Value; if (album.OwnerUserId != request.UserId)
album.LifeCycleStatus = LifeCycleStatus.Removing; {
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken); database.Albums.Remove(album);
try
{
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("Marked album {AlbumId} as removing", request.AlbumId); logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return Result.Success; return new Success();
}
} }
} }
@@ -3,27 +3,36 @@ 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.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumByIdQuery(Guid Id) public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>; : IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database) public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>> : IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{ {
var album = await database.Albums var album = await database.Albums
.AsNoTracking() .AsNoTracking()
.Active()
.Where(a => a.Id == request.Id) .Where(a => a.Id == request.Id)
.SelectResponse() .Select(a => new
{
Album = a,
TrackCount = a.AlbumTracks.Count,
CoverTrackIds = a.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
return album == null if (album == null)
? AppErrors.NotFound("Album", request.Id) return Error.NotFound();
: album;
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
}
} }
} }
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.Albums
{
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
: IQuery<ErrorOr<AlbumCoverLocation>>;
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
public class GetAlbumCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration)
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
{
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.Albums.AsNoTracking()
.Where(a => a.Id == request.AlbumId)
.Select(a => a.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = albumConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -1,38 +1,49 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumTracksQuery( public record GetAlbumTracksQuery(
Guid AlbumId, Guid AlbumId,
int PageNumber, int PageNumber,
int PageSize) int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database) public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>> : IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
{ {
var albumExists = await database.Albums var albumExists = await database.Albums
.AsNoTracking() .AsNoTracking()
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken); .AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists) if (!albumExists)
return AppErrors.NotFound("Album", request.AlbumId); return Error.NotFound();
return await database.AlbumHasTracks var tracksQuery = database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active) .Include(albumTrack => albumTrack.Track.Owner)
.Include(albumTrack => albumTrack.Track.Tags)
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber) .OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id) .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(albumTrack => albumTrack.Track) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -1,33 +1,60 @@
using ErrorOr;
using Mediator; 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.Application.Shared; using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
{ {
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<ErrorOr<AlbumsSearchResponse>>;
public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
{
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{ {
var albumsQuery = database.Albums var albumsQuery = database.Albums
.AsNoTracking() .AsNoTracking()
.Active(); .AsQueryable();
if (!string.IsNullOrWhiteSpace(request.Title)) if (!string.IsNullOrEmpty(request.Title))
{ {
var normalizedTitle = TextNormalizer.Normalize(request.Title); var normalizedTitle = request.Title.Trim().ToUpperInvariant();
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle)); albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
} }
return await albumsQuery var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt) .OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id) .Select(album => new
.SelectResponse() {
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
.Select(entry => AlbumSearchItemResponse.FromAlbum(
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
.ToList();
return new AlbumsSearchResponse(
items,
pagedEntities.PageNumber,
pagedEntities.PageSize,
pagedEntities.PageCount,
pagedEntities.TotalItemCount,
pagedEntities.HasPreviousPage,
pagedEntities.HasNextPage);
}
} }
} }
@@ -4,30 +4,50 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database) public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> : IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
.AsNoTracking() .AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken); .AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId); return Error.NotFound(description: $"User {request.UserId} not found");
return await database.Albums var albumsQuery = database.Albums
.AsNoTracking() .AsNoTracking()
.Active() .Where(album => album.OwnerUserId == request.UserId);
.Where(album => album.OwnerUserId == request.UserId)
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt) .OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id) .Select(album => new
.SelectResponse() {
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
}
} }
} }
@@ -1,51 +1,61 @@
using ErrorOr;
using Mediator; 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;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{ {
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
{ {
var listens = database.ListeningHistories public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted); .Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Join( .Join(
listens, listenedTrackIds,
albumTrack => albumTrack.TrackId, albumTrack => albumTrack.TrackId,
history => history.TrackId, history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt }) (albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId) .GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) }) .Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt) .OrderByDescending(entry => entry.LastListenedAt)
.ThenBy(entry => entry.AlbumId)
.Take(request.Limit) .Take(request.Limit)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (ranked.Count == 0) if (ranked.Count == 0)
return []; return new List<AlbumApplicationResponse>();
var albumIds = ranked.Select(entry => entry.AlbumId).ToList(); var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
var albumsById = await database.Albums var albums = await database.Albums
.AsNoTracking() .AsNoTracking()
.Where(album => albumIds.Contains(album.Id)) .Where(album => albumIds.Contains(album.Id))
.SelectResponse() .Select(album => new
.ToDictionaryAsync(album => album.Id, cancellationToken); {
Album = album,
TrackCount = album.AlbumTracks.Count,
CoverTrackIds = album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()
})
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
return ranked return ranked
.Where(entry => albumsById.ContainsKey(entry.AlbumId)) .Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albumsById[entry.AlbumId]) .Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList(); .ToList();
} }
}
} }
@@ -3,23 +3,33 @@ 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
{
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler( public class RemoveTrackFromAlbumCommandHandler(
IDatabase database, IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger) ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>> : ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums
if (album.IsError) .AsNoTracking()
return album.Errors; .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
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)
@@ -28,7 +38,10 @@ public class RemoveTrackFromAlbumCommandHandler(
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId); var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null) if (link == null)
return Error.NotFound("Album.TrackNotFound", "Track is not in the album."); {
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
database.AlbumHasTracks.Remove(link); database.AlbumHasTracks.Remove(link);
@@ -40,6 +53,7 @@ public class RemoveTrackFromAlbumCommandHandler(
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId); logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return Result.Success; return new Success();
}
} }
} }
@@ -1,24 +1,105 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Pictures.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record RequestAlbumPictureUploadCommand( public record RequestAlbumPictureUploadCommand(
long UserId, long UserId,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>; : ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) public class RequestAlbumPictureUploadCommandHandler(
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>> IDatabase database,
{ UploadIntentValidator uploadIntentValidator,
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => IStorageService storageService,
await issuer.IssuePictureAsync( ILogger<RequestAlbumPictureUploadCommandHandler> logger,
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, ApplicationStorageConfiguration storageConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
{
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.AlbumPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new AlbumPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
} }
@@ -1,13 +1,15 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses; namespace Musify.Application.Albums.Responses
{
public record AlbumApplicationResponse( public record AlbumApplicationResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Description, string? Description,
int? ReleaseYear, int? ReleaseYear,
long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount, int TrackCount,
string? SmallImageKeyName, string? SmallImageKeyName,
string? MediumImageKeyName, string? MediumImageKeyName,
@@ -15,8 +17,8 @@ public record AlbumApplicationResponse(
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds)
{ {
public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public const int CoverTrackCount = 4;
public static AlbumApplicationResponse FromEntity( public static AlbumApplicationResponse FromEntity(
Album album, Album album,
@@ -37,4 +39,5 @@ public record AlbumApplicationResponse(
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? []);
} }
}
} }
@@ -0,0 +1,11 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -1,31 +0,0 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses;
public static class AlbumProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
query.Select(album => new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
album.AlbumTracks.Count,
album.Pictures!.SmallName,
album.Pictures!.MediumName,
album.Pictures!.LargeName,
album.CreatedAt,
album.UpdatedAt,
album.AlbumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToList()));
}
@@ -0,0 +1,7 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
{
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
}
}
@@ -0,0 +1,12 @@
namespace Musify.Application.Albums.Responses
{
public record AlbumsSearchResponse(
IReadOnlyList<AlbumSearchItemResponse> Items,
int PageNumber,
int PageSize,
int PageCount,
int TotalItemCount,
bool HasPreviousPage,
bool HasNextPage);
}
@@ -2,18 +2,18 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record UpdateAlbumCommand( public record UpdateAlbumCommand(
long UserId, long UserId,
Guid AlbumId, Guid AlbumId,
string Title, string Title,
@@ -22,52 +22,129 @@ public record UpdateAlbumCommand(
Guid? NewPictureIntentId) Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler( public class UpdateAlbumCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger, ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration) ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{ {
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums
if (found.IsError) .SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
return found.Errors; if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
var album = found.Value;
var title = request.Title.Trim(); var title = request.Title.Trim();
album.Title = title; album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title); album.NormalizedTitle = title.ToUpperInvariant();
album.Description = request.Description; album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear; album.ReleaseYear = request.ReleaseYear;
if (request.NewPictureIntentId is { } intentId) UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{ {
var prepared = await PictureSourceChange.PrepareAsync( var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken); uploadIntentConfiguration,
if (prepared.IsError) request.NewPictureIntentId.Value, request.UserId, cancellationToken);
return prepared.Errors; if (validation.IsError)
return validation.Errors;
var picture = prepared.Value; pictureIntent = validation.Value;
album.Pictures = picture.Pictures; album.Pictures = new AlbumPictures
{
await eventBus.PublishAsync( OriginalName = pictureIntent.ObjectName,
new UpdateAlbumPictureSourceEvent( SmallName = pictureIntent.ObjectName,
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), MediumName = pictureIntent.ObjectName,
cancellationToken); LargeName = pictureIntent.ObjectName
};
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
} }
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
album.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
}
var albumTracks = database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == album.Id);
var trackCount = await albumTracks.CountAsync(cancellationToken);
var coverTrackIds = await albumTracks
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Take(AlbumApplicationResponse.CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id); logger.LogInformation("Updated album {AlbumId}", album.Id);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
return await database.Albums private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
.AsNoTracking() Guid albumId,
.Where(a => a.Id == album.Id) UploadIntent pictureIntent,
.SelectResponse() string finalPictureKey,
.SingleAsync(cancellationToken); CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
albumId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
albumConfiguration.Routes.SmallPicturesPath,
albumConfiguration.PicturesSizes.SmallPictureWidth,
albumConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
albumConfiguration.Routes.MediumPicturesPath,
albumConfiguration.PicturesSizes.MediumPictureWidth,
albumConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
albumConfiguration.Routes.LargePicturesPath,
albumConfiguration.PicturesSizes.LargePictureWidth,
albumConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
}
}
} }
} }
@@ -19,6 +19,8 @@
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" /> <PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" /> <PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
<PackageReference Include="MimeMapping" /> <PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -11,17 +11,16 @@ public static class ApplicationDependencyInjection
{ {
services.AddMediator(); services.AddMediator();
services.AddScoped<UploadIntentValidator>(); services.AddScoped<UploadIntentValidator>();
services.AddScoped<UploadIntentIssuer>();
services.AddScoped<TrackStreamIssuer>(); services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration); services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration); services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<AlbumConfiguration>(configuration); services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration); services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration); services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration); services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration); services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
services.AddValidatedOptions<PlaybackConfiguration>(configuration); services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
return services; return services;
} }
@@ -1,4 +1,4 @@
using Mediator; using Mediator;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)] [assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -1,14 +1,79 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public static string SectionName => "Album"; public class AlbumConfiguration
{
public const string SectionName = "Album";
[Required] [Required]
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" }; public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
[Required] [Required]
public PictureSizes PicturesSizes { get; set; } = new(); public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
}
public class AlbumRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "Albums";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class AlbumPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,12 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class ApplicationStorageConfiguration : IConfigurationOptions
{ {
public static string SectionName => "ApplicationStorage"; public class ApplicationStorageConfiguration
{
public const string SectionName = "ApplicationStorage";
[Required] [Required]
[StringLength(128, MinimumLength = 1)] [StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; } public required string Bucket { get; set; }
}
} }
@@ -6,10 +6,6 @@ namespace Musify.Application.Configuration;
public static class ConfigurationDependencyInjection public static class ConfigurationDependencyInjection
{ {
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
where T : class, IConfigurationOptions =>
services.AddValidatedOptions<T>(configuration, T.SectionName);
public static IServiceCollection AddValidatedOptions<T>( public static IServiceCollection AddValidatedOptions<T>(
this IServiceCollection services, this IServiceCollection services,
IConfiguration configuration, IConfiguration configuration,
@@ -1,16 +1,17 @@
using Mediator; using Mediator;
using Musify.Application.Configuration.Responses; using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{ {
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse( ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening, playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds)); playbackConfiguration.AnonymousFragmentSeconds));
}
} }
@@ -1,6 +0,0 @@
namespace Musify.Application.Configuration;
public interface IConfigurationOptions
{
public static abstract string SectionName { get; }
}
@@ -1,11 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class MixConfiguration : IConfigurationOptions
{ {
public static string SectionName => "Mix"; public class MixConfiguration
{
public const string SectionName = "Mix";
[Range(4, 100)] [Range(4, 100)]
public int ItemsPerMix { get; set; } = 12; public int ItemsPerMix { get; set; } = 12;
}
} }
@@ -1,90 +0,0 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration;
public enum PictureSize
{
Small,
Medium,
Large
}
public class PictureRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = string.Empty;
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
{
PictureSize.Small => SmallPicturesFolder,
PictureSize.Medium => MediumPicturesFolder,
PictureSize.Large => LargePicturesFolder,
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
});
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName);
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
}
public class PictureSizes
{
[Range(1, 1024)]
public int SmallWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargeWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargeHeight { get; set; } = 512;
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
}
public interface IPictureOwnerConfiguration
{
public PictureRoutes Routes { get; }
public PictureSizes PicturesSizes { get; }
}
public static class PictureSizeParser
{
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
public static PictureSize Parse(string? value) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size
: PictureSize.Medium;
}
@@ -1,14 +1,79 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public static string SectionName => "PlayList"; public class PlayListConfiguration
{
public const string SectionName = "PlayList";
[Required] [Required]
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" }; public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
[Required] [Required]
public PictureSizes PicturesSizes { get; set; } = new(); public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
}
public class PlayListRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolders { get; set; } = "PlayLists";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
}
public class PlayListPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,11 +1,11 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public sealed class PlaybackConfiguration : IConfigurationOptions
{ {
public static string SectionName => "Playback";
public sealed class PlaybackConfiguration
{
public const string SectionName = "Playback";
public bool AllowAnonymousListening { get; set; } public bool AllowAnonymousListening { get; set; }
@@ -14,4 +14,5 @@ public sealed class PlaybackConfiguration : IConfigurationOptions
[Range(1, int.MaxValue)] [Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
} }
@@ -1,4 +1,5 @@
namespace Musify.Application.Configuration.Responses; namespace Musify.Application.Configuration.Responses
{
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); }
@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class StreamGatewayConfiguration : IConfigurationOptions
{ {
public static string SectionName => "StreamGateway"; public class StreamGatewayConfiguration
{
public const string SectionName = "StreamGateway";
[Url] [Url]
[Required] [Required]
@@ -12,4 +12,5 @@ public class StreamGatewayConfiguration : IConfigurationOptions
[Required] [Required]
public string AudioFileName { get; set; } = "audio.m4a"; public string AudioFileName { get; set; } = "audio.m4a";
}
} }
@@ -1,28 +1,39 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public static string SectionName => "Track"; public class TrackConfiguration
[Required]
public TrackRoutes Routes { get; set; } = new();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
}
public class TrackRoutes : PictureRoutes
{
public TrackRoutes()
{ {
ParentFolder = "Tracks"; public const string SectionName = "Track";
[Required]
public TrackRoutes Routes { get; set; } = new TrackRoutes();
[Required]
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
} }
public class TrackRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string ParentFolder { get; set; } = "Tracks";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
[Required] [Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp"; public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
@@ -39,7 +50,13 @@ public class TrackRoutes : PictureRoutes
public string OriginalAudiosFolder { get; set; } = "OriginalAudios"; public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required] [Required]
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios"; public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture); public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
@@ -47,9 +64,53 @@ public class TrackRoutes : PictureRoutes
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string BuildOriginalAudioPath(long userId, string audioName) => public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName);
public string BuildProcessedAudioPath(string folderName) => public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
}
public class TrackPicturesSizes
{
[Range(1, 1024)]
public int SmallPictureWidth { get; set; } = 128;
[Range(1, 1024)]
public int SmallPictureHeight { get; set; } = 128;
[Range(1, 1024)]
public int MediumPictureWidth { get; set; } = 256;
[Range(1, 1024)]
public int MediumPictureHeight { get; set; } = 256;
[Range(1, 1024)]
public int LargePictureWidth { get; set; } = 512;
[Range(1, 1024)]
public int LargePictureHeight { get; set; } = 512;
}
} }
@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class UploadIntentConfiguration : IConfigurationOptions
{ {
public static string SectionName => "UploadIntent"; public class UploadIntentConfiguration
{
public const string SectionName = "UploadIntent";
[Range(10, 3600)] [Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120; public int UploadUrlExpiresInSeconds { get; set; } = 120;
@@ -31,11 +31,12 @@ public class UploadIntentConfiguration : IConfigurationOptions
public int ExpiredIntentsRetentionDays { get; set; } = 7; public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)] [Required, MinLength(1)]
public string TempRootPrefix { get; set; } = "temp"; public string TemporalRootPrefix { get; set; } = "temporal";
[Range(1, 30)] [Range(1, 30)]
public int TempUploadsRetentionDays { get; set; } = 3; public int TemporalUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)] [Range(60, 86400)]
public int TempCleanupJobIntervalSeconds { get; set; } = 3600; public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
}
} }
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,6 +1,9 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IAudioTranscoderService
{ {
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); public interface IAudioTranscoderService
{
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
}
} }
@@ -1,40 +1,42 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IDatabase
{ {
public DbSet<User> Users { get; } public interface IDatabase
{
DbSet<User> Users { get; }
public DbSet<PlayList> PlayLists { get; } DbSet<PlayList> PlayLists { get; }
public DbSet<Album> Albums { get; } DbSet<Album> Albums { get; }
public DbSet<AlbumHasTrack> AlbumHasTracks { get; } DbSet<AlbumHasTrack> AlbumHasTracks { get; }
public DbSet<Track> Tracks { get; } DbSet<Track> Tracks { get; }
public DbSet<PlayListHasTrack> PlayListHasTracks { get; } DbSet<UserHasTrack> UserHasTracks { get; }
public DbSet<Mix> Mixes { get; } DbSet<PlayListHasTrack> PlayListHasTracks { get; }
public DbSet<MixItem> MixItems { get; } DbSet<Mix> Mixes { get; }
public DbSet<UploadIntent> UploadIntents { get; } DbSet<MixItem> MixItems { get; }
public DbSet<ListeningHistory> ListeningHistories { get; } DbSet<Upload> Uploads { get; }
public DbSet<TrackLike> TrackLikes { get; } DbSet<UploadIntent> UploadIntents { get; }
public DbSet<TrackTag> TrackTags { get; } DbSet<ListeningHistory> ListeningHistories { get; }
public DbSet<UserFollow> UserFollows { get; } DbSet<TrackLike> TrackLikes { get; }
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); DbSet<TrackTag> TrackTags { get; }
public Task<int> SaveChangesAsync(CancellationToken cancellationToken); DbSet<UserFollow> UserFollows { get; }
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IDatabaseTransaction : IAsyncDisposable
{ {
public Task CommitAsync(CancellationToken cancellationToken = default); public interface IDatabaseTransaction : IAsyncDisposable
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IEventBus
{ {
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class; public interface IEventBus
{
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IPictureService
{ {
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); public interface IPictureService
{
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
} }
@@ -1,24 +1,28 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IStorageService
{ {
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary> public interface IStorageService
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken); {
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken); Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default); Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken); Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken); Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken); Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken); Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey, Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
CancellationToken cancellationToken); CancellationToken cancellationToken);
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
} }
@@ -1,8 +1,10 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IStreamTicketService
{ {
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); public interface IStreamTicketService
} {
public record StreamTicket(string Token, int ExpiresInSeconds); StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
}
public record StreamTicket(string Token, int ExpiresInSeconds);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record ObjectMetaData(string ContentType, long ContentLength); public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModifiedUtc); public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId); public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId); public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumProcessingFailed( public record AlbumProcessingFailed(
Guid AlbumId, Guid AlbumId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent( public record CreateAlbumResourcesEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreatePlayListResourcesEvent( public record CreatePlayListResourcesEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,8 +1,8 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent( public record CreateTrackResourcesEvent(
Guid TrackId, Guid TrackId,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
@@ -12,4 +12,7 @@ public record CreateTrackResourcesEvent(
string AudioSourceKey, string AudioSourceKey,
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,5 +0,0 @@
namespace Musify.Application.Events;
public record DeleteAlbumEvent(
Guid AlbumId,
long UserId);
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeletePlayListEvent( public record DeletePlayListEvent(
Guid PlayListId, Guid PlayListId,
long UserId); long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeleteTrackEvent( public record DeleteTrackEvent(
Guid TrackId, Guid TrackId,
long UserId); long UserId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListPictureProcessed(Guid PlayListId); public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListPictureProcessingFailed(Guid PlayListId); public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListProcessingFailed( public record PlayListProcessingFailed(
Guid PlayListId, Guid PlayListId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackAudioProcessed(Guid TrackId); public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackAudioProcessingFailed(Guid TrackId); public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackPictureProcessed(Guid TrackId); public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackPictureProcessingFailed(Guid TrackId); public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,7 +1,8 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackProcessingFailed( public record TrackProcessingFailed(
Guid TrackId, Guid TrackId,
string? Bucket, string? Bucket,
string? PictureKey, string? PictureKey,
string? AudioKey); string? AudioKey);
}
@@ -1,9 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent( public record UpdateAlbumPictureEvent(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent( public record UpdateAlbumPictureSourceEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,9 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdatePlayListPictureEvent( public record UpdatePlayListPictureEvent(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdatePlayListPictureSourceEvent( public record UpdatePlayListPictureSourceEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent( public record UpdateTrackAudioEvent(
Guid TrackId, Guid TrackId,
string SourceBucket, string SourceBucket,
string SourceKey, string SourceKey,
string DestinationBucket, string DestinationBucket,
string DestinationFolderKey); string DestinationFolderKey);
}
@@ -1,9 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateTrackPictureEvent( public record UpdateTrackPictureEvent(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,14 +1,15 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres; namespace Musify.Application.Genres
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{ {
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{ {
var all = Enum.GetValues<Genre>(); var all = Enum.GetValues<Genre>();
@@ -21,4 +22,5 @@ public class GetAvailableGenresQueryHandler
return ValueTask.FromResult(result); return ValueTask.FromResult(result);
} }
}
} }
@@ -2,20 +2,21 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres; namespace Musify.Application.Genres
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{ {
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{ {
var rows = await database.TrackTags var rows = await database.TrackTags
.AsNoTracking() .AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.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);
@@ -26,4 +27,5 @@ public class GetGenresQueryHandler(IDatabase database)
.Select(row => new GenreResponse(row.Genre, row.TrackCount)) .Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList(); .ToList();
} }
}
} }
@@ -1,5 +0,0 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
@@ -1,5 +0,0 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses;
public record GenreResponse(Genre Genre, int TrackCount);
@@ -1,30 +1,43 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Likes; namespace Musify.Application.Likes
{
public record GetLikedTracksQuery( public record GetLikedTracksQuery(
long UserId, long UserId,
int PageNumber, int PageNumber,
int PageSize) int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database) public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>> : IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{ {
return await database.TrackLikes public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
var likesQuery = database.TrackLikes
.AsNoTracking() .AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active) .Include(like => like.Track.Owner)
.Include(like => like.Track.Tags)
.Where(like => like.UserId == request.UserId);
var totalCount = await likesQuery.CountAsync(cancellationToken);
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt) .OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id) .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(like => like.Track) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -3,27 +3,28 @@ 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;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes; namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(long UserId, Guid TrackId) public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>; : ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler( public class ToggleTrackLikeCommandHandler(
IDatabase database, IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger) ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>> : ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{ {
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{ {
var trackExists = await database.Tracks var trackExists = await database.Tracks
.AsNoTracking() .AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, 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 existingLike = await database.TrackLikes var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken); .SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
@@ -44,15 +45,12 @@ public class ToggleTrackLikeCommandHandler(
nowLiked = true; nowLiked = true;
} }
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op. await database.SaveChangesAsync(cancellationToken);
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);
return nowLiked; return nowLiked;
} }
}
} }
@@ -1,3 +1,4 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
@@ -6,33 +7,33 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>;
public record GenerateMixesForUserCommand(long UserId) public class GenerateMixesForUserCommandHandler(
: ICommand;
public class GenerateMixesForUserCommandHandler(
IDatabase database, IDatabase database,
MixConfiguration mixConfiguration, MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger) ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand> : ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{ {
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds); private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
{ {
var userId = request.UserId; var userId = request.UserId;
var libraryTrackIds = await database.Tracks var libraryTrackIds = await database.UserHasTracks
.AsNoTracking() .AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => track.Id) .Select(userTrack => userTrack.TrackId)
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0) if (libraryTrackIds.Count == 0)
{ {
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId); logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return Unit.Value; return new Success();
} }
var historyTrackIds = await database.ListeningHistories var historyTrackIds = await database.ListeningHistories
@@ -50,27 +51,41 @@ public class GenerateMixesForUserCommandHandler(
var discovery = Take(Shuffle(unheardTrackIds)); var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0) if (discovery.Count > 0)
{ {
drafts.Add(new MixDraft(MixKind.Discovery, discovery)); drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones de tu biblioteca que todavía no has escuchado.",
discovery));
} }
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds))); var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0) if (daily.Count > 0)
{ {
drafts.Add(new MixDraft(MixKind.Daily, daily)); drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
daily));
} }
if (drafts.Count == 0) if (drafts.Count == 0)
{ {
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId); logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return Unit.Value; return new Success();
} }
await ReplaceMixesAsync(userId, drafts, cancellationToken); await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
}
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId); logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return Unit.Value; return new Success();
} }
private List<Guid> Take(IEnumerable<Guid> trackIds) => private List<Guid> Take(IEnumerable<Guid> trackIds) =>
@@ -101,6 +116,12 @@ public class GenerateMixesForUserCommandHandler(
if (previousMixes.Count > 0) if (previousMixes.Count > 0)
{ {
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
var previousItems = await database.MixItems
.Where(item => previousIds.Contains(item.MixId))
.ToListAsync(cancellationToken);
database.MixItems.RemoveRange(previousItems);
database.Mixes.RemoveRange(previousMixes); database.Mixes.RemoveRange(previousMixes);
} }
@@ -110,7 +131,8 @@ public class GenerateMixesForUserCommandHandler(
var mix = new Mix var mix = new Mix
{ {
UserId = userId, UserId = userId,
Kind = draft.Kind, Title = draft.Title,
Subtitle = draft.Subtitle,
Position = position Position = position
}; };
@@ -127,4 +149,5 @@ public class GenerateMixesForUserCommandHandler(
} }
} }
} }
}
} }
@@ -3,16 +3,15 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GetMixByIdQuery(long UserId, Guid MixId) public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>; : IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database) public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>> : IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{ {
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
{ {
var mix = await database.Mixes var mix = await database.Mixes
@@ -21,11 +20,12 @@ public class GetMixByIdQueryHandler(IDatabase database)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken); .SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null) if (mix == null)
return AppErrors.NotFound("Mix", request.MixId); return Error.NotFound(description: $"Mix {request.MixId} was not found");
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList(); var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken); var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
}
} }
} }
@@ -1,50 +1,37 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{ {
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) public record GetMixesByUserIdQuery(long UserId)
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
{
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{ {
var mixes = await database.Mixes var mixes = await database.Mixes
.AsNoTracking() .AsNoTracking()
.Where(mix => mix.UserId == request.UserId) .Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items) .Include(mix => mix.Items)
.OrderBy(mix => mix.Position) .OrderBy(mix => mix.Position)
.ThenBy(mix => mix.Id)
.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 = orderedItemsByMix[mix.Id]; var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var covers = orderedItems var coverItems = await MixItemMapper.ToResponsesAsync(
.Take(MixApplicationResponse.CoverItemCount) database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
.Where(item => coverResponses.ContainsKey(item.TrackId))
.Select(item => coverResponses[item.TrackId])
.ToList();
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers)); responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
} }
return responses; return responses;
} }
}
} }
@@ -2,13 +2,12 @@ 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
internal static class MixItemMapper
{ {
internal static class MixItemMapper
{
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database, IDatabase database,
IReadOnlyList<MixItem> orderedItems, IReadOnlyList<MixItem> orderedItems,
@@ -19,7 +18,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) && track.LifeCycleStatus == LifeCycleStatus.Active) .Where(track => trackIds.Contains(track.Id))
.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);
@@ -37,4 +36,5 @@ internal static class MixItemMapper
}) })
.ToList(); .ToList();
} }
}
} }
@@ -1,12 +1,12 @@
using Musify.Domain.ValueObjects; namespace Musify.Application.Mixes.Responses
{
namespace Musify.Application.Mixes.Responses; public record MixApplicationResponse(
public record MixApplicationResponse(
Guid Id, Guid Id,
MixKind Kind, string Title,
string? Subtitle,
int ItemCount, int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items) IReadOnlyList<MixItemApplicationResponse> Items)
{ {
public const int CoverItemCount = 4; public const int CoverItemCount = 4;
}
} }
@@ -1,8 +1,9 @@
namespace Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse( public record MixItemApplicationResponse(
Guid TrackId, Guid TrackId,
string Title, string Title,
string? Artist, string? Artist,
double DurationSeconds, double DurationSeconds,
int ListensCount); int ListensCount);
}
@@ -1,75 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
public enum CoverOwner
{
Track,
Album,
PlayList
}
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
{
var (source, routes) = request.Owner switch
{
CoverOwner.Track => (await database.Tracks.AsNoTracking()
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
CoverOwner.Album => (await database.Albums.AsNoTracking()
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
_ => (await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
};
var entity = request.Owner.ToString();
if (source == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
var resizedName = request.Size switch
{
PictureSize.Small => source.Small,
PictureSize.Large => source.Large,
_ => source.Medium
};
// The resized files only exist once processing has finished; until then serve the original.
var (name, key) = !string.IsNullOrEmpty(resizedName)
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
: !string.IsNullOrEmpty(source.Original)
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
: (null, null);
if (name == null || key == null)
return AppErrors.NotFound($"{entity}Cover", request.Id);
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
}
}
@@ -1,35 +0,0 @@
using ErrorOr;
using Musify.Application.Configuration;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures;
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
public static class PictureSourceChange
{
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator,
Guid intentId,
long userId,
UploadIntentPurpose purpose,
IPictureOwnerConfiguration owner,
CancellationToken cancellationToken)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
if (validation.IsError)
return validation.Errors;
var intent = validation.Value;
return new PreparedPicture(
intent,
EntityPictures.Pending(intent.ObjectName),
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes));
}
}
@@ -1,10 +0,0 @@
namespace Musify.Application.Pictures.Responses;
public record PictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
@@ -0,0 +1,86 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
public class AddAlbumToPlayListCommandHandler(
IDatabase database,
ILogger<AddAlbumToPlayListCommandHandler> logger)
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
{
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound(description: $"Album {request.AlbumId} not found");
}
var albumTrackIds = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => albumTrack.TrackId)
.ToListAsync(cancellationToken);
var playListTracksQuery = database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId);
var existingTrackIds = (await playListTracksQuery
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken)).ToHashSet();
var newTrackIds = albumTrackIds
.Where(trackId => !existingTrackIds.Contains(trackId))
.ToList();
if (newTrackIds.Count == 0)
return new AddAlbumToPlayListResponse(0);
var nextPosition = existingTrackIds.Count > 0
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddRangeAsync(
newTrackIds.Select((trackId, index) => new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition + index
}),
cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
newTrackIds.Count, request.AlbumId, request.PlayListId);
return new AddAlbumToPlayListResponse(newTrackIds.Count);
}
}
}
@@ -0,0 +1,75 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Domain.Entities;
namespace Musify.Application.PlayLists
{
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class AddTrackToPlayListCommandHandler(
IDatabase database,
ILogger<AddTrackToPlayListCommandHandler> logger)
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var alreadyAdded = await database.PlayListHasTracks
.AsNoTracking()
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (alreadyAdded)
{
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
.Where(plt => plt.PlayListId == request.PlayListId);
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
var nextPosition = hasTracks
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
: 0;
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = request.TrackId,
Position = nextPosition
}, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
request.TrackId, request.PlayListId, nextPosition);
return new Success();
}
}
}
@@ -1,73 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>;
public class AddTracksToPlayListCommandHandler(
IDatabase database,
ILogger<AddTracksToPlayListCommandHandler> logger)
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
var requestedIds = request.TrackIds.Distinct().ToList();
var existingIds = await database.Tracks
.AsNoTracking()
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
if (missing != Guid.Empty)
return AppErrors.NotFound("Track", missing);
var alreadyInPlayList = await database.PlayListHasTracks
.AsNoTracking()
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
.Select(link => link.TrackId)
.ToListAsync(cancellationToken);
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
if (toAdd.Count == 0)
return Result.Success;
// Playlist positions are internal indexes, so they start at 0.
var nextPosition = (await database.PlayListHasTracks
.Where(link => link.PlayListId == request.PlayListId)
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
foreach (var trackId in toAdd)
{
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
{
PlayListId = request.PlayListId,
TrackId = trackId,
Position = nextPosition++
}, cancellationToken);
}
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
if (saved.IsError)
return saved;
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
return Result.Success;
}
}
@@ -3,76 +3,144 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Shared;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record CreatePlayListCommand( public record CreatePlayListCommand(
long UserId, long UserId,
string Name, string Name,
string? Description, string? Description,
Guid? PictureIntentId, Guid? PictureIntentId,
PlayListVisibility Visibility = PlayListVisibility.Private) PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler( public class CreatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger, ILogger<CreatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration) ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
.AsNoTracking() .AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken); .AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists) if (!userExists)
return AppErrors.NotFound("User", request.UserId);
PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId)
{ {
var prepared = await PictureSourceChange.PrepareAsync( logger.LogWarning("User {UserId} not found", request.UserId);
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); return Error.NotFound(description: $"User {request.UserId} not found");
if (prepared.IsError)
return prepared.Errors;
picture = prepared.Value;
} }
var name = request.Name.Trim(); var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList var playList = new PlayList
{ {
OwnerUserId = request.UserId, UserId = request.UserId,
Name = name, Name = request.Name,
NormalizedName = TextNormalizer.Normalize(name), NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(), Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = picture?.Pictures, Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility Visibility = request.Visibility
}; };
await database.PlayLists.AddAsync(playList, cancellationToken); await database.PlayLists.AddAsync(playList, cancellationToken);
if (picture != null) if (resolvedPictures.Intent != null)
{
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
}
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
}
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
CreatePlayListCommand request, CancellationToken cancellationToken)
{
if (!request.PictureIntentId.HasValue)
{
return new ResolvedPlayListPictures(null, null);
}
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
return new ResolvedPlayListPictures(new PlayListPictures
{
OriginalName = validation.Value.ObjectName,
SmallName = validation.Value.ObjectName,
MediumName = validation.Value.ObjectName,
LargeName = validation.Value.ObjectName
}, validation.Value);
}
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{ {
await eventBus.PublishAsync( await eventBus.PublishAsync(
new CreatePlayListResourcesEvent( new CreatePlayListResourcesEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken); cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
}
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
} }
} }
@@ -1,36 +1,62 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
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.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record DeletePlayListCommand(long UserId, Guid PlayListId) public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler( public class DeletePlayListCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger) ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>> : ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{ {
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
if (found.IsError) if (playList == null)
return found.Errors; {
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
}
var playList = found.Value;
playList.LifeCycleStatus = LifeCycleStatus.Removing; playList.LifeCycleStatus = LifeCycleStatus.Removing;
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken); try
{
await eventBus.PublishAsync(
new DeletePlayListEvent(playList.Id, request.UserId),
cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
try
{
await database.SaveChangesAsync(cancellationToken); await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
}
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId); return new Success();
}
return Result.Success;
} }
} }
@@ -1,30 +1,44 @@
using ErrorOr; using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Mediator;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>; : IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database) public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>> : IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists var entry = await database.PlayLists
.AsNoTracking() .AsNoTracking()
.Active()
.VisibleTo(request.RequestingUserId)
.Where(p => p.Id == request.Id) .Where(p => p.Id == request.Id)
.SelectResponse() .Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.SingleOrDefaultAsync(cancellationToken); .SingleOrDefaultAsync(cancellationToken);
return playList == null if (entry == null)
? AppErrors.NotFound("PlayList", request.Id) return Error.NotFound();
: playList;
if (entry.PlayList.Visibility == PlaylistVisibility.Private
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
}
} }
} }
@@ -0,0 +1,49 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using MimeMapping;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
: IQuery<ErrorOr<PlayListCoverLocation>>;
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
public class GetPlayListCoverQueryHandler(
IDatabase database,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration)
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
{
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
{
var pictures = await database.PlayLists.AsNoTracking()
.Where(p => p.Id == request.PlayListId)
.Select(p => p.Pictures)
.SingleOrDefaultAsync(cancellationToken);
if (pictures == null)
return Error.NotFound();
var routes = playListConfiguration.Routes;
var (name, key) = request.Size.ToLowerInvariant() switch
{
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
};
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
return Error.NotFound();
var contentType = MimeUtility.GetMimeMapping(name);
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
}
private static string? BuildKey(Func<string, string> builder, string? name)
=> string.IsNullOrEmpty(name) ? null : builder(name);
}
}
@@ -2,40 +2,48 @@ using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListTracksQuery( public record GetPlayListTracksQuery(
Guid PlayListId, Guid PlayListId,
int PageNumber, int PageNumber,
int PageSize, int PageSize)
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database) public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>> : IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{ {
var playListVisible = await database.PlayLists var playListExists = await database.PlayLists
.AsNoTracking() .AsNoTracking()
.Active() .AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
.VisibleTo(request.ViewerId) if (!playListExists)
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken); return Error.NotFound();
if (!playListVisible)
return AppErrors.NotFound("PlayList", request.PlayListId);
return await database.PlayListHasTracks var tracksQuery = database.PlayListHasTracks
.AsNoTracking() .AsNoTracking()
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Include(plt => plt.Track.Owner)
.OrderBy(playListTrack => playListTrack.Position) .Include(plt => plt.Track.Tags)
.ThenBy(playListTrack => playListTrack.Id) .Where(plt => plt.PlayListId == request.PlayListId);
.Select(playListTrack => playListTrack.Track)
.SelectResponse() var totalCount = await tracksQuery.CountAsync(cancellationToken);
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
}
} }
} }
@@ -0,0 +1,45 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Shared;
using Musify.Application.PlayLists.Responses;
using X.PagedList;
using X.PagedList.EF;
using Mediator;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists
{
public record GetPlayListsQuery(int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
{
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
var pagedEntities = await database.PlayLists
.AsNoTracking()
.OrderBy(p => p.Id)
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,40 +1,66 @@
using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListsByUserIdQuery( public record GetPlayListsByUserIdQuery(
long UserId, long UserId,
string? Name, string? Name,
int PageNumber, int PageNumber,
int PageSize, int PageSize,
long? ViewerId = null) bool OnlyPublic = false)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>; : IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database) public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>> : IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{ {
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{ {
var playListsQuery = database.PlayLists var playListsQuery = database.PlayLists
.AsNoTracking() .AsNoTracking()
.Active() .Where(p => p.UserId == request.UserId);
.VisibleTo(request.ViewerId)
.Where(playList => playList.OwnerUserId == request.UserId);
if (!string.IsNullOrWhiteSpace(request.Name)) if (request.OnlyPublic)
{ {
var normalizedName = TextNormalizer.Normalize(request.Name); playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
} }
return await playListsQuery if (!string.IsNullOrEmpty(request.Name))
.OrderBy(playList => playList.CreatedAt) {
.ThenBy(playList => playList.Id) var normalizedName = request.Name.Trim().ToUpperInvariant();
.SelectResponse() playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); }
var totalCount = await playListsQuery.CountAsync(cancellationToken);
var pagedEntities = await playListsQuery
.OrderBy(p => p.CreatedAt)
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToList()
})
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
} }
} }
@@ -3,43 +3,47 @@ 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
{
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler( public class RemoveTrackFromPlayListCommandHandler(
IDatabase database, IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger) ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>> : ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
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.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playList = await database.PlayLists
if (playList.IsError) .SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
return playList.Errors; if (playList == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
var playListTracks = await database.PlayListHasTracks if (playList.UserId != request.UserId)
.Where(plt => plt.PlayListId == request.PlayListId) {
.OrderBy(plt => plt.Position) logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
.ToListAsync(cancellationToken); return Error.Unauthorized();
}
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId); var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
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 Result.Success; return new Success();
}
} }
} }
@@ -1,24 +1,105 @@
using ErrorOr; using ErrorOr;
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Pictures.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record RequestPlayListPictureUploadCommand( public record RequestPlayListPictureUploadCommand(
long UserId, long UserId,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>; : ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) public class RequestPlayListPictureUploadCommandHandler(
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>> IDatabase database,
{ UploadIntentValidator uploadIntentValidator,
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => IStorageService storageService,
await issuer.IssuePictureAsync( ILogger<RequestPlayListPictureUploadCommandHandler> logger,
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, ApplicationStorageConfiguration storageConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
{
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
var effectiveSizeBytes = request.ExpectedSizeBytes
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
try
{
var uploadUrl = await storageService.GetUploadUrlAsync(
storageConfiguration.Bucket,
tempKey,
request.ContentType,
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
cancellationToken);
await using var transaction = await database.BeginTransactionAsync(
System.Data.IsolationLevel.Serializable, cancellationToken);
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
uploadIntentConfiguration,
request.UserId, effectiveSizeBytes, 1, cancellationToken);
if (quotaCheck.IsError)
{
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
return quotaCheck.Errors;
}
var intent = new UploadIntent
{
UserId = request.UserId,
Bucket = storageConfiguration.Bucket,
Key = tempKey,
ObjectName = objectName,
ContentType = request.ContentType,
ExpectedSizeBytes = effectiveSizeBytes,
Purpose = UploadIntentPurpose.PlayListPicture,
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
};
await database.UploadIntents.AddAsync(intent, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
await transaction.CommitAsync(cancellationToken);
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
return new PlayListPictureUploadResponse(
intent.Id,
storageConfiguration.Bucket,
tempKey,
objectName,
request.ContentType,
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
uploadUrl);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
return Error.Failure(description: "Failed to generate upload URL");
}
}
}
} }
@@ -0,0 +1,4 @@
namespace Musify.Application.PlayLists.Responses
{
public record AddAlbumToPlayListResponse(int AddedCount);
}
@@ -1,31 +1,32 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses
namespace Musify.Application.PlayLists.Responses; {
public record PlayListApplicationResponse(
public record PlayListApplicationResponse(
Guid Id, Guid Id,
string Name, string Name,
string? Description, string? Description,
string? SmallImageKeyName, string? SmallImageKeyName,
string? MediumImageKeyName, string? MediumImageKeyName,
string? LargeImageKeyName, string? LargeImageKeyName,
PlayListVisibility Visibility, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
PlaylistVisibility Visibility,
int TrackCount,
double DurationSeconds,
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds, IReadOnlyList<Guid> CoverTrackIds)
long OwnerUserId, {
int TrackCount, public const int CoverTrackCount = 4;
double DurationSeconds)
{
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity( public static PlayListApplicationResponse FromEntity(
PlayList playList, PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null,
int trackCount = 0, int trackCount = 0,
double durationSeconds = 0) double durationSeconds = 0,
IReadOnlyList<Guid>? coverTrackIds = null)
{ {
return new PlayListApplicationResponse( return new PlayListApplicationResponse(
playList.Id, playList.Id,
@@ -34,12 +35,13 @@ public record PlayListApplicationResponse(
playList.Pictures?.SmallName, playList.Pictures?.SmallName,
playList.Pictures?.MediumName, playList.Pictures?.MediumName,
playList.Pictures?.LargeName, playList.Pictures?.LargeName,
playList.UserId,
playList.Visibility, playList.Visibility,
trackCount,
durationSeconds,
playList.CreatedAt, playList.CreatedAt,
playList.UpdatedAt, playList.UpdatedAt,
coverTrackIds ?? [], coverTrackIds ?? []);
playList.OwnerUserId, }
trackCount,
durationSeconds);
} }
} }
@@ -0,0 +1,11 @@
namespace Musify.Application.PlayLists.Responses
{
public record PlayListPictureUploadResponse(
Guid IntentId,
string Bucket,
string Key,
string PictureName,
string ContentType,
int ExpiresInSeconds,
string UploadUrl);
}
@@ -1,37 +0,0 @@
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses;
public static class PlayListProjections
{
public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
query.Select(playList => new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures!.SmallName,
playList.Pictures!.MediumName,
playList.Pictures!.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
playList.PlayListTracks
.OrderBy(playListTrack => playListTrack.Position)
.Take(CoverTrackCount)
.Select(playListTrack => playListTrack.TrackId)
.ToList(),
playList.OwnerUserId,
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
@@ -3,78 +3,168 @@ using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Shared;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record UpdatePlayListCommand( public record UpdatePlayListCommand(
long UserId, long UserId,
Guid PlayListId, Guid PlayListId,
string? NewName, string? NewName,
string? NewDescription, string? NewDescription,
Guid? NewPictureIntentId, Guid? NewPictureIntentId,
PlayListVisibility? NewVisibility = null) PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler( public class UpdatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger, ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration) ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{ {
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (found.IsError) if (playListEntity == null)
return found.Errors; {
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
var playList = found.Value; if (playListEntity.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
if (!string.IsNullOrWhiteSpace(request.NewName)) if (!string.IsNullOrWhiteSpace(request.NewName))
{ {
playList.Name = request.NewName.Trim(); playListEntity.Name = request.NewName;
playList.NormalizedName = TextNormalizer.Normalize(request.NewName); playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
} }
// null means "unchanged"; a blank description clears it. if (!string.IsNullOrWhiteSpace(request.NewDescription))
if (request.NewDescription != null)
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
if (request.NewVisibility is { } visibility)
playList.Visibility = visibility;
if (request.NewPictureIntentId is { } intentId)
{ {
var prepared = await PictureSourceChange.PrepareAsync( playListEntity.Description = request.NewDescription;
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken); }
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value; if (request.NewVisibility.HasValue)
playList.Pictures = picture.Pictures; {
playListEntity.Visibility = request.NewVisibility.Value;
}
UploadIntent? pictureIntent = null;
string? finalPictureKey = null;
if (request.NewPictureIntentId.HasValue)
{
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration,
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
if (validation.IsError)
return validation.Errors;
pictureIntent = validation.Value;
playListEntity.Pictures = new PlayListPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
LargeName = pictureIntent.ObjectName
};
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
}
if (pictureIntent != null && finalPictureKey != null)
{
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
}
try
{
await database.SaveChangesAsync(cancellationToken);
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
}
var coverTrackIds = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
.Select(plt => plt.TrackId)
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
var totals = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.GroupBy(plt => plt.PlayListId)
.Select(group => new
{
TrackCount = group.Count(),
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
})
.SingleOrDefaultAsync(cancellationToken);
return PlayListApplicationResponse.FromEntity(
playListEntity,
totals?.TrackCount ?? 0,
totals?.DurationSeconds ?? 0,
coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync( await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent( new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes), playListId,
pictureIntent.Id,
storageConfiguration.Bucket,
pictureIntent.Key,
finalPictureKey,
new ImageSize(
playListConfiguration.Routes.SmallPicturesPath,
playListConfiguration.PicturesSizes.SmallPictureWidth,
playListConfiguration.PicturesSizes.SmallPictureHeight),
new ImageSize(
playListConfiguration.Routes.MediumPicturesPath,
playListConfiguration.PicturesSizes.MediumPictureWidth,
playListConfiguration.PicturesSizes.MediumPictureHeight),
new ImageSize(
playListConfiguration.Routes.LargePicturesPath,
playListConfiguration.PicturesSizes.LargePictureWidth,
playListConfiguration.PicturesSizes.LargePictureHeight)),
cancellationToken); cancellationToken);
return new Success();
}
catch (Exception exception)
{
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
}
} }
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
return await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == playList.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
} }
} }

Some files were not shown because too many files have changed in this diff Show More