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,71 +2,127 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
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.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums;
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
public record CreateAlbumCommand(
long UserId,
string Title,
string? Description,
int? ReleaseYear,
Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
var title = request.Title.Trim();
var album = new Album
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = TextNormalizer.Normalize(title),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Pictures = picture.Pictures
};
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
{
logger.LogWarning("User {UserId} not found", request.UserId);
return Error.NotFound(description: $"User {request.UserId} not found");
}
await database.Albums.AddAsync(album, cancellationToken);
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
if (pictureIntent.IsError)
return pictureIntent.Errors;
await eventBus.PublishAsync(
new CreateAlbumResourcesEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
var intent = pictureIntent.Value;
var title = request.Title.Trim();
await database.SaveChangesAsync(cancellationToken);
var album = new Album
{
OwnerUserId = request.UserId,
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
Description = request.Description,
ReleaseYear = request.ReleaseYear,
Pictures = new AlbumPictures
{
OriginalName = intent.ObjectName,
SmallName = intent.ObjectName,
MediumName = intent.ObjectName,
LargeName = intent.ObjectName
}
};
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
await database.Albums.AddAsync(album, cancellationToken);
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
if (publishResult.IsError)
return publishResult.Errors;
try
{
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);
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 Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums;
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler(
IDatabase database,
ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
var album = found.Value;
album.LifeCycleStatus = LifeCycleStatus.Removing;
if (album.OwnerUserId != request.UserId)
{
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);
await database.SaveChangesAsync(cancellationToken);
database.Albums.Remove(album);
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
try
{
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}");
}
return Result.Success;
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
return new Success();
}
}
}
@@ -3,27 +3,36 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.Active()
.Where(a => a.Id == request.Id)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>;
return album == null
? AppErrors.NotFound("Album", request.Id)
: album;
public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{
var album = await database.Albums
.AsNoTracking()
.Where(a => a.Id == request.Id)
.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);
if (album == null)
return Error.NotFound();
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 Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums;
public record GetAlbumTracksQuery(
Guid AlbumId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
{
var albumExists = await database.Albums
.AsNoTracking()
.Active()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return AppErrors.NotFound("Album", request.AlbumId);
public record GetAlbumTracksQuery(
Guid AlbumId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
return await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ThenBy(albumTrack => albumTrack.Id)
.Select(albumTrack => albumTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
{
var albumExists = await database.Albums
.AsNoTracking()
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
if (!albumExists)
return Error.NotFound();
var tracksQuery = database.AlbumHasTracks
.AsNoTracking()
.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)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, 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 Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
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>>
namespace Musify.Application.Albums
{
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>>
{
var albumsQuery = database.Albums
.AsNoTracking()
.Active();
if (!string.IsNullOrWhiteSpace(request.Title))
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{
var normalizedTitle = TextNormalizer.Normalize(request.Title);
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
}
var albumsQuery = database.Albums
.AsNoTracking()
.AsQueryable();
return await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
if (!string.IsNullOrEmpty(request.Title))
{
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
}
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
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.Contracts;
using Musify.Application.Shared;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Albums;
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
return await database.Albums
.AsNoTracking()
.Active()
.Where(album => album.OwnerUserId == request.UserId)
.OrderByDescending(album => album.CreatedAt)
.ThenBy(album => album.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
if (!userExists)
return Error.NotFound(description: $"User {request.UserId} not found");
var albumsQuery = database.Albums
.AsNoTracking()
.Where(album => album.OwnerUserId == request.UserId);
var totalCount = await albumsQuery.CountAsync(cancellationToken);
var pagedEntities = await albumsQuery
.OrderByDescending(album => album.CreatedAt)
.Select(album => new
{
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 Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses;
using Musify.Application.Contracts;
namespace Musify.Application.Albums;
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
namespace Musify.Application.Albums
{
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
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
.Join(
listens,
albumTrack => albumTrack.TrackId,
history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt)
.ThenBy(entry => entry.AlbumId)
.Take(request.Limit)
.ToListAsync(cancellationToken);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
.Join(
listenedTrackIds,
albumTrack => albumTrack.TrackId,
history => history.TrackId,
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
.GroupBy(entry => entry.AlbumId)
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
.OrderByDescending(entry => entry.LastListenedAt)
.Take(request.Limit)
.ToListAsync(cancellationToken);
if (ranked.Count == 0)
return [];
if (ranked.Count == 0)
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
.AsNoTracking()
.Where(album => albumIds.Contains(album.Id))
.SelectResponse()
.ToDictionaryAsync(album => album.Id, cancellationToken);
var albums = await database.Albums
.AsNoTracking()
.Where(album => albumIds.Contains(album.Id))
.Select(album => new
{
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
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
.Select(entry => albumsById[entry.AlbumId])
.ToList();
return ranked
.Where(entry => albums.ContainsKey(entry.AlbumId))
.Select(entry => albums[entry.AlbumId])
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
.ToList();
}
}
}
@@ -3,43 +3,57 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.Albums;
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (album.IsError)
return album.Errors;
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand 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();
}
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ToListAsync(cancellationToken);
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
return Error.Unauthorized();
}
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
var albumTracks = await database.AlbumHasTracks
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
.OrderBy(albumTrack => albumTrack.TrackNumber)
.ToListAsync(cancellationToken);
database.AlbumHasTracks.Remove(link);
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
if (link == null)
{
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
return Error.NotFound(description: "Track is not in the album.");
}
var trackNumber = 1;
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
remaining.TrackNumber = trackNumber++;
database.AlbumHasTracks.Remove(link);
await database.SaveChangesAsync(cancellationToken);
var trackNumber = 1;
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
remaining.TrackNumber = trackNumber++;
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
await database.SaveChangesAsync(cancellationToken);
return Result.Success;
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
return new Success();
}
}
}
@@ -1,24 +1,105 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Pictures.Responses;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums;
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
public record RequestAlbumPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
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,40 +1,43 @@
using System.Text.Json.Serialization;
using Musify.Domain.Entities;
using Musify.Application.Serialization;
namespace Musify.Application.Albums.Responses;
public record AlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
namespace Musify.Application.Albums.Responses
{
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public static AlbumApplicationResponse FromEntity(
Album album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
public record AlbumApplicationResponse(
Guid Id,
string Title,
string? Description,
int? ReleaseYear,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
int TrackCount,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
return new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
coverTrackIds ?? []);
public const int CoverTrackCount = 4;
public static AlbumApplicationResponse FromEntity(
Album album,
int trackCount,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new AlbumApplicationResponse(
album.Id,
album.Title,
album.Description,
album.ReleaseYear,
album.OwnerUserId,
trackCount,
album.Pictures?.SmallName,
album.Pictures?.MediumName,
album.Pictures?.LargeName,
album.CreatedAt,
album.UpdatedAt,
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,72 +2,149 @@ using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Albums.Responses;
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.Events;
using Musify.Application.Albums.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.Albums;
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
namespace Musify.Application.Albums
{
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
public record UpdateAlbumCommand(
long UserId,
Guid AlbumId,
string Title,
string? Description,
int? ReleaseYear,
Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
AlbumConfiguration albumConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var album = found.Value;
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = TextNormalizer.Normalize(title);
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
if (request.NewPictureIntentId is { } intentId)
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var album = await database.Albums
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
if (album == null)
{
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
return Error.NotFound();
}
var picture = prepared.Value;
album.Pictures = picture.Pictures;
if (album.OwnerUserId != request.UserId)
{
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
return Error.Unauthorized();
}
await eventBus.PublishAsync(
new UpdateAlbumPictureSourceEvent(
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
var title = request.Title.Trim();
album.Title = title;
album.NormalizedTitle = title.ToUpperInvariant();
album.Description = request.Description;
album.ReleaseYear = request.ReleaseYear;
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;
album.Pictures = new AlbumPictures
{
OriginalName = pictureIntent.ObjectName,
SmallName = pictureIntent.ObjectName,
MediumName = pictureIntent.ObjectName,
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);
}
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);
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Updated album {AlbumId}", album.Id);
return await database.Albums
.AsNoTracking()
.Where(a => a.Id == album.Id)
.SelectResponse()
.SingleAsync(cancellationToken);
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
Guid albumId,
UploadIntent pictureIntent,
string finalPictureKey,
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.DataAnnotations" />
<PackageReference Include="MimeMapping" />
<PackageReference Include="X.PagedList" />
<PackageReference Include="X.PagedList.EF" />
</ItemGroup>
<ItemGroup>
@@ -11,17 +11,16 @@ public static class ApplicationDependencyInjection
{
services.AddMediator();
services.AddScoped<UploadIntentValidator>();
services.AddScoped<UploadIntentIssuer>();
services.AddScoped<TrackStreamIssuer>();
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
services.AddValidatedOptions<PlayListConfiguration>(configuration);
services.AddValidatedOptions<AlbumConfiguration>(configuration);
services.AddValidatedOptions<TrackConfiguration>(configuration);
services.AddValidatedOptions<MixConfiguration>(configuration);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
return services;
}
@@ -1,4 +1,4 @@
using Mediator;
using Mediator;
using Microsoft.Extensions.DependencyInjection;
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
@@ -1,14 +1,79 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration;
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
namespace Musify.Application.Configuration
{
public static string SectionName => "Album";
public class AlbumConfiguration
{
public const string SectionName = "Album";
[Required]
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
[Required]
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
[Required]
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;
namespace Musify.Application.Configuration;
public class ApplicationStorageConfiguration : IConfigurationOptions
namespace Musify.Application.Configuration
{
public static string SectionName => "ApplicationStorage";
public class ApplicationStorageConfiguration
{
public const string SectionName = "ApplicationStorage";
[Required]
[StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; }
[Required]
[StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; }
}
}
@@ -6,10 +6,6 @@ namespace Musify.Application.Configuration;
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>(
this IServiceCollection services,
IConfiguration configuration,
@@ -1,16 +1,17 @@
using Mediator;
using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration;
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
namespace Musify.Application.Configuration
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds));
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening,
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;
namespace Musify.Application.Configuration;
public class MixConfiguration : IConfigurationOptions
namespace Musify.Application.Configuration
{
public static string SectionName => "Mix";
public class MixConfiguration
{
public const string SectionName = "Mix";
[Range(4, 100)]
public int ItemsPerMix { get; set; } = 12;
[Range(4, 100)]
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 Musify.Application.Shared;
namespace Musify.Application.Configuration;
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
namespace Musify.Application.Configuration
{
public static string SectionName => "PlayList";
public class PlayListConfiguration
{
public const string SectionName = "PlayList";
[Required]
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
[Required]
public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
}
[Required]
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,17 +1,18 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
public sealed class PlaybackConfiguration : IConfigurationOptions
namespace Musify.Application.Configuration
{
public static string SectionName => "Playback";
public bool AllowAnonymousListening { get; set; }
public sealed class PlaybackConfiguration
{
public const string SectionName = "Playback";
[Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
public bool AllowAnonymousListening { get; set; }
[Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
[Range(0, 3600)]
public int AnonymousFragmentSeconds { get; set; }
[Range(1, int.MaxValue)]
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,15 +1,16 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
public class StreamGatewayConfiguration : IConfigurationOptions
namespace Musify.Application.Configuration
{
public static string SectionName => "StreamGateway";
public class StreamGatewayConfiguration
{
public const string SectionName = "StreamGateway";
[Url]
[Required]
public required string PublicBaseUrl { get; set; }
[Url]
[Required]
public required string PublicBaseUrl { get; set; }
[Required]
public string AudioFileName { get; set; } = "audio.m4a";
[Required]
public string AudioFileName { get; set; } = "audio.m4a";
}
}
@@ -1,55 +1,116 @@
using System.ComponentModel.DataAnnotations;
using Musify.Application.Shared;
namespace Musify.Application.Configuration;
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
namespace Musify.Application.Configuration
{
public static string SectionName => "Track";
[Required]
public TrackRoutes Routes { get; set; } = new();
[Required]
public PictureSizes PicturesSizes { get; set; } = new();
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
}
public class TrackRoutes : PictureRoutes
{
public TrackRoutes()
public class TrackConfiguration
{
ParentFolder = "Tracks";
public const string SectionName = "Track";
[Required]
public TrackRoutes Routes { get; set; } = new TrackRoutes();
[Required]
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
}
[Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
public class TrackRoutes
{
[Required]
public string UploadsFolder { get; set; } = "uploads";
[Required]
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
[Required]
public string ParentFolder { get; set; } = "Tracks";
[Required]
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
[Required]
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
[Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
[Required]
public string SmallPicturesFolder { get; set; } = "SmallPictures";
[Required]
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required]
public string MediumPicturesFolder { get; set; } = "MediumPictures";
[Required]
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
[Required]
public string LargePicturesFolder { get; set; } = "LargePictures";
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
[Required]
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
[Required]
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
[Required]
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
public string BuildOriginalAudioPath(long userId, string audioName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName);
[Required]
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
public string BuildProcessedAudioPath(string folderName) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
}
[Required]
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
[Required]
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 PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
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,41 +1,42 @@
using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration;
public class UploadIntentConfiguration : IConfigurationOptions
namespace Musify.Application.Configuration
{
public static string SectionName => "UploadIntent";
public class UploadIntentConfiguration
{
public const string SectionName = "UploadIntent";
[Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(10, 3600)]
public int UploadUrlExpiresInSeconds { get; set; } = 120;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
[Range(1, 100)]
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
[Range(1, long.MaxValue)]
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
[Range(30, 86400)]
public int ExpirationJobIntervalSeconds { get; set; } = 300;
[Range(30, 86400)]
public int ExpirationJobIntervalSeconds { get; set; } = 300;
[Range(0, 365)]
public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Range(0, 365)]
public int ExpiredIntentsRetentionDays { get; set; } = 7;
[Required, MinLength(1)]
public string TempRootPrefix { get; set; } = "temp";
[Required, MinLength(1)]
public string TemporalRootPrefix { get; set; } = "temporal";
[Range(1, 30)]
public int TempUploadsRetentionDays { get; set; } = 3;
[Range(1, 30)]
public int TemporalUploadsRetentionDays { get; set; } = 3;
[Range(60, 86400)]
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
[Range(60, 86400)]
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
}
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts;
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
namespace Musify.Application.Contracts
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,6 +1,9 @@
namespace Musify.Application.Contracts;
public interface IAudioTranscoderService
namespace Musify.Application.Contracts
{
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 Musify.Domain.Entities;
namespace Musify.Application.Contracts;
public interface IDatabase
namespace Musify.Application.Contracts
{
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;
public interface IDatabaseTransaction : IAsyncDisposable
namespace Musify.Application.Contracts
{
public Task CommitAsync(CancellationToken cancellationToken = default);
public interface IDatabaseTransaction : IAsyncDisposable
{
Task CommitAsync(CancellationToken cancellationToken = default);
}
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts;
public interface IEventBus
namespace Musify.Application.Contracts
{
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;
public interface IPictureService
namespace Musify.Application.Contracts
{
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;
public interface IStorageService
namespace Musify.Application.Contracts
{
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
public interface IStorageService
{
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,
CancellationToken cancellationToken);
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
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;
public interface IStreamTicketService
namespace Musify.Application.Contracts
{
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;
public record ObjectMetaData(string ContentType, long ContentLength);
namespace Musify.Application.Contracts
{
public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts;
public record StorageObject(string Key, DateTime LastModifiedUtc);
namespace Musify.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record AlbumPictureProcessed(Guid AlbumId);
namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record AlbumPictureProcessingFailed(Guid AlbumId);
namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events;
public record AlbumProcessingFailed(
Guid AlbumId,
string? Bucket,
string? PictureKey);
namespace Musify.Application.Events
{
public record AlbumProcessingFailed(
Guid AlbumId,
string? Bucket,
string? PictureKey);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record CreateAlbumResourcesEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record CreatePlayListResourcesEvent(
Guid PlayListId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record CreatePlayListResourcesEvent(
Guid PlayListId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,15 +1,18 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent(
Guid TrackId,
Guid PictureIntentId,
Guid AudioIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
string AudioSourceKey,
string AudioDestinationKey,
string AudioProcessedFolderKey,
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;
public record DeletePlayListEvent(
Guid PlayListId,
long UserId);
namespace Musify.Application.Events
{
public record DeletePlayListEvent(
Guid PlayListId,
long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events;
public record DeleteTrackEvent(
Guid TrackId,
long UserId);
namespace Musify.Application.Events
{
public record DeleteTrackEvent(
Guid TrackId,
long UserId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record PlayListPictureProcessed(Guid PlayListId);
namespace Musify.Application.Events
{
public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record PlayListPictureProcessingFailed(Guid PlayListId);
namespace Musify.Application.Events
{
public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events;
public record PlayListProcessingFailed(
Guid PlayListId,
string? Bucket,
string? PictureKey);
namespace Musify.Application.Events
{
public record PlayListProcessingFailed(
Guid PlayListId,
string? Bucket,
string? PictureKey);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record TrackAudioProcessed(Guid TrackId);
namespace Musify.Application.Events
{
public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record TrackAudioProcessingFailed(Guid TrackId);
namespace Musify.Application.Events
{
public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record TrackPictureProcessed(Guid TrackId);
namespace Musify.Application.Events
{
public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events;
public record TrackPictureProcessingFailed(Guid TrackId);
namespace Musify.Application.Events
{
public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,7 +1,8 @@
namespace Musify.Application.Events;
public record TrackProcessingFailed(
Guid TrackId,
string? Bucket,
string? PictureKey,
string? AudioKey);
namespace Musify.Application.Events
{
public record TrackProcessingFailed(
Guid TrackId,
string? Bucket,
string? PictureKey,
string? AudioKey);
}
@@ -1,9 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record UpdateAlbumPictureEvent(
Guid AlbumId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent(
Guid AlbumId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record UpdateAlbumPictureSourceEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent(
Guid AlbumId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,9 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record UpdatePlayListPictureEvent(
Guid PlayListId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record UpdatePlayListPictureEvent(
Guid PlayListId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,11 +1,14 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record UpdatePlayListPictureSourceEvent(
Guid PlayListId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record UpdatePlayListPictureSourceEvent(
Guid PlayListId,
Guid PictureIntentId,
string Bucket,
string PictureSourceKey,
string PictureDestinationKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Events;
public record UpdateTrackAudioEvent(
Guid TrackId,
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationFolderKey);
namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent(
Guid TrackId,
string SourceBucket,
string SourceKey,
string DestinationBucket,
string DestinationFolderKey);
}
@@ -1,9 +1,12 @@
using Musify.Application.Shared;
namespace Musify.Application.Events;
public record UpdateTrackPictureEvent(
Guid TrackId,
string Bucket,
string SourceKey,
ImageSizes Sizes);
namespace Musify.Application.Events
{
public record UpdateTrackPictureEvent(
Guid TrackId,
string Bucket,
string SourceKey,
ImageSize Small,
ImageSize Medium,
ImageSize Large);
}
@@ -1,24 +1,26 @@
using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
namespace Musify.Application.Genres
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
var all = Enum.GetValues<Genre>();
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
return ValueTask.FromResult(result);
}
}
}
@@ -2,28 +2,30 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses;
namespace Musify.Application.Genres;
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
namespace Musify.Application.Genres
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
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)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.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 Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.Likes;
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
namespace Musify.Application.Likes
{
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
public record GetLikedTracksQuery(
long UserId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
return await database.TrackLikes
.AsNoTracking()
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderByDescending(like => like.CreatedAt)
.ThenBy(like => like.Id)
.Select(like => like.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{
var likesQuery = database.TrackLikes
.AsNoTracking()
.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)
.Select(like => new { like.Track, ListensCount = like.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);
}
}
}
@@ -3,56 +3,54 @@ 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.Likes;
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
namespace Musify.Application.Likes
{
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler(
IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
if (!trackExists)
return AppErrors.NotFound("Track", request.TrackId);
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
var trackExists = await database.Tracks
.AsNoTracking()
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
if (!trackExists)
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
logger.LogInformation("Track {TrackId} not found", request.TrackId);
return Error.NotFound(description: $"Track {request.TrackId} not found");
}
var existingLike = await database.TrackLikes
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
bool nowLiked;
if (existingLike != null)
{
database.TrackLikes.Remove(existingLike);
nowLiked = false;
}
else
{
await database.TrackLikes.AddAsync(new TrackLike
{
UserId = request.UserId,
TrackId = request.TrackId
}, cancellationToken);
nowLiked = true;
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
}
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
var saved = await database.TrySaveChangesAsync(
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
if (saved.IsError)
return true;
logger.LogInformation("User {UserId} {Action} track {TrackId}",
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
return nowLiked;
}
}
@@ -1,3 +1,4 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
@@ -6,124 +7,146 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes;
public record GenerateMixesForUserCommand(long UserId)
: ICommand;
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand>
namespace Musify.Application.Mixes
{
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
public record GenerateMixesForUserCommand(long UserId)
: ICommand<ErrorOr<Success>>;
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
public class GenerateMixesForUserCommandHandler(
IDatabase database,
MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
{
var userId = request.UserId;
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
var libraryTrackIds = await database.Tracks
.AsNoTracking()
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => track.Id)
.ToListAsync(cancellationToken);
if (libraryTrackIds.Count == 0)
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
{
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return Unit.Value;
}
var userId = request.UserId;
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
var libraryTrackIds = await database.UserHasTracks
.AsNoTracking()
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(userTrack => userTrack.TrackId)
.ToListAsync(cancellationToken);
var historySet = new HashSet<Guid>(historyTrackIds);
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
}
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(MixKind.Daily, daily));
}
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return Unit.Value;
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
return Unit.Value;
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
{
var merged = new List<Guid>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
{
if (index < first.Count)
merged.Add(first[index]);
if (index < second.Count)
merged.Add(second[index]);
}
return merged;
}
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
{
var previousMixes = await database.Mixes
.Where(mix => mix.UserId == userId)
.ToListAsync(cancellationToken);
if (previousMixes.Count > 0)
{
database.Mixes.RemoveRange(previousMixes);
}
for (var position = 0; position < drafts.Count; position++)
{
var draft = drafts[position];
var mix = new Mix
if (libraryTrackIds.Count == 0)
{
UserId = userId,
Kind = draft.Kind,
Position = position
};
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
return new Success();
}
await database.Mixes.AddAsync(mix, cancellationToken);
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
var historySet = new HashSet<Guid>(historyTrackIds);
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
var drafts = new List<MixDraft>();
var discovery = Take(Shuffle(unheardTrackIds));
if (discovery.Count > 0)
{
await database.MixItems.AddAsync(new MixItem
drafts.Add(new MixDraft(
"Descubrimiento",
"Canciones de tu biblioteca que todavía no has escuchado.",
discovery));
}
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
if (daily.Count > 0)
{
drafts.Add(new MixDraft(
"Tu mezcla diaria",
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
daily));
}
if (drafts.Count == 0)
{
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
return new Success();
}
await ReplaceMixesAsync(userId, drafts, cancellationToken);
try
{
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);
return new Success();
}
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
{
var merged = new List<Guid>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
{
if (index < first.Count)
merged.Add(first[index]);
if (index < second.Count)
merged.Add(second[index]);
}
return merged;
}
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
{
var previousMixes = await database.Mixes
.Where(mix => mix.UserId == userId)
.ToListAsync(cancellationToken);
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);
}
for (var position = 0; position < drafts.Count; position++)
{
var draft = drafts[position];
var mix = new Mix
{
MixId = mix.Id,
Position = itemPosition,
TrackId = draft.TrackIds[itemPosition]
}, cancellationToken);
UserId = userId,
Title = draft.Title,
Subtitle = draft.Subtitle,
Position = position
};
await database.Mixes.AddAsync(mix, cancellationToken);
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
{
await database.MixItems.AddAsync(new MixItem
{
MixId = mix.Id,
Position = itemPosition,
TrackId = draft.TrackIds[itemPosition]
}, cancellationToken);
}
}
}
}
@@ -3,29 +3,29 @@ using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Application.Shared;
namespace Musify.Application.Mixes;
public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
namespace Musify.Application.Mixes
{
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{
var mix = await database.Mixes
.AsNoTracking()
.Include(mix => mix.Items)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
{
var mix = await database.Mixes
.AsNoTracking()
.Include(mix => mix.Items)
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
if (mix == null)
return AppErrors.NotFound("Mix", request.MixId);
if (mix == null)
return Error.NotFound(description: $"Mix {request.MixId} was not found");
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
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 Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes;
public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
namespace Musify.Application.Mixes
{
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>>>
{
var mixes = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ThenBy(mix => mix.Id)
.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);
foreach (var mix in mixes)
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{
var orderedItems = orderedItemsByMix[mix.Id];
var covers = orderedItems
.Take(MixApplicationResponse.CoverItemCount)
.Where(item => coverResponses.ContainsKey(item.TrackId))
.Select(item => coverResponses[item.TrackId])
.ToList();
var mixes = await database.Mixes
.AsNoTracking()
.Where(mix => mix.UserId == request.UserId)
.Include(mix => mix.Items)
.OrderBy(mix => mix.Position)
.ToListAsync(cancellationToken);
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
var responses = new List<MixApplicationResponse>(mixes.Count);
foreach (var mix in mixes)
{
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
var coverItems = await MixItemMapper.ToResponsesAsync(
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
}
return responses;
}
return responses;
}
}
@@ -2,39 +2,39 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes;
internal static class MixItemMapper
namespace Musify.Application.Mixes
{
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
internal static class MixItemMapper
{
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database,
IReadOnlyList<MixItem> orderedItems,
CancellationToken cancellationToken)
{
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
var tracksById = await database.Tracks
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
var tracksById = await database.Tracks
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item =>
{
var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse(
item.TrackId,
entry.Track.Title,
entry.Track.Owner.Name,
entry.Track.DurationSeconds,
entry.ListensCount);
})
.ToList();
return orderedItems
.Where(item => tracksById.ContainsKey(item.TrackId))
.Select(item =>
{
var entry = tracksById[item.TrackId];
return new MixItemApplicationResponse(
item.TrackId,
entry.Track.Title,
entry.Track.Owner.Name,
entry.Track.DurationSeconds,
entry.ListensCount);
})
.ToList();
}
}
}
@@ -1,12 +1,12 @@
using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses;
public record MixApplicationResponse(
Guid Id,
MixKind Kind,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
namespace Musify.Application.Mixes.Responses
{
public const int CoverItemCount = 4;
public record MixApplicationResponse(
Guid Id,
string Title,
string? Subtitle,
int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items)
{
public const int CoverItemCount = 4;
}
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Mixes.Responses;
public record MixItemApplicationResponse(
Guid TrackId,
string Title,
string? Artist,
double DurationSeconds,
int ListensCount);
namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse(
Guid TrackId,
string Title,
string? Artist,
double DurationSeconds,
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.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists;
public record CreatePlayListCommand(
long UserId,
string Name,
string? Description,
Guid? PictureIntentId,
PlayListVisibility Visibility = PlayListVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
public record CreatePlayListCommand(
long UserId,
string Name,
string? Description,
Guid? PictureIntentId,
PlaylistVisibility Visibility = PlaylistVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
var userExists = await database.Users
.AsNoTracking()
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
if (!userExists)
return AppErrors.NotFound("User", request.UserId);
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
PreparedPicture? picture = null;
if (request.PictureIntentId is { } intentId)
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
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");
}
picture = prepared.Value;
var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
if (picturesResult.IsError)
return picturesResult.Errors;
var resolvedPictures = picturesResult.Value;
var playList = new PlayList
{
UserId = request.UserId,
Name = request.Name,
NormalizedName = request.Name.Trim().ToUpperInvariant(),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = resolvedPictures.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
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);
}
var name = request.Name.Trim();
var playList = new PlayList
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
CreatePlayListCommand request, CancellationToken cancellationToken)
{
OwnerUserId = request.UserId,
Name = name,
NormalizedName = TextNormalizer.Normalize(name),
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
Pictures = picture?.Pictures,
Visibility = request.Visibility
};
await database.PlayLists.AddAsync(playList, cancellationToken);
if (!request.PictureIntentId.HasValue)
{
return new ResolvedPlayListPictures(null, null);
}
if (picture != null)
{
await eventBus.PublishAsync(
new CreatePlayListResourcesEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
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);
}
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
return PlayListApplicationResponse.FromEntity(playList);
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
try
{
await eventBus.PublishAsync(
new CreatePlayListResourcesEvent(
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);
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}");
}
}
}
}
@@ -1,36 +1,62 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Events;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler(
IDatabase database,
IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
if (playList == null)
{
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
var playList = found.Value;
playList.LifeCycleStatus = LifeCycleStatus.Removing;
if (playList.UserId != request.UserId)
{
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
return Error.Unauthorized();
}
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
await database.SaveChangesAsync(cancellationToken);
playList.LifeCycleStatus = LifeCycleStatus.Removing;
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
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}");
}
return Result.Success;
try
{
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}");
}
return new Success();
}
}
}
@@ -1,30 +1,44 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Mediator;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists;
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{
var playList = await database.PlayLists
.AsNoTracking()
.Active()
.VisibleTo(request.RequestingUserId)
.Where(p => p.Id == request.Id)
.SelectResponse()
.SingleOrDefaultAsync(cancellationToken);
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>;
return playList == null
? AppErrors.NotFound("PlayList", request.Id)
: playList;
public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{
var entry = await database.PlayLists
.AsNoTracking()
.Where(p => p.Id == request.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()
})
.SingleOrDefaultAsync(cancellationToken);
if (entry == null)
return Error.NotFound();
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 Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists;
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize,
long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{
var playListVisible = await database.PlayLists
.AsNoTracking()
.Active()
.VisibleTo(request.ViewerId)
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
if (!playListVisible)
return AppErrors.NotFound("PlayList", request.PlayListId);
public record GetPlayListTracksQuery(
Guid PlayListId,
int PageNumber,
int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
return await database.PlayListHasTracks
.AsNoTracking()
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.OrderBy(playListTrack => playListTrack.Position)
.ThenBy(playListTrack => playListTrack.Id)
.Select(playListTrack => playListTrack.Track)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{
var playListExists = await database.PlayLists
.AsNoTracking()
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
if (!playListExists)
return Error.NotFound();
var tracksQuery = database.PlayListHasTracks
.AsNoTracking()
.Include(plt => plt.Track.Owner)
.Include(plt => plt.Track.Tags)
.Where(plt => plt.PlayListId == request.PlayListId);
var totalCount = await tracksQuery.CountAsync(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 Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared;
using Musify.Domain.ValueObjects;
using X.PagedList;
using X.PagedList.EF;
namespace Musify.Application.PlayLists;
public record GetPlayListsByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize,
long? ViewerId = null)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
namespace Musify.Application.PlayLists
{
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
public record GetPlayListsByUserIdQuery(
long UserId,
string? Name,
int PageNumber,
int PageSize,
bool OnlyPublic = false)
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
{
var playListsQuery = database.PlayLists
.AsNoTracking()
.Active()
.VisibleTo(request.ViewerId)
.Where(playList => playList.OwnerUserId == request.UserId);
if (!string.IsNullOrWhiteSpace(request.Name))
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{
var normalizedName = TextNormalizer.Normalize(request.Name);
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
}
var playListsQuery = database.PlayLists
.AsNoTracking()
.Where(p => p.UserId == request.UserId);
return await playListsQuery
.OrderBy(playList => playList.CreatedAt)
.ThenBy(playList => playList.Id)
.SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
if (request.OnlyPublic)
{
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
}
if (!string.IsNullOrEmpty(request.Name))
{
var normalizedName = request.Name.Trim().ToUpperInvariant();
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
}
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.Extensions.Logging;
using Musify.Application.Contracts;
using Musify.Application.Shared;
namespace Musify.Application.PlayLists;
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler(
IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (playList.IsError)
return playList.Errors;
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand 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();
}
var playListTracks = await database.PlayListHasTracks
.Where(plt => plt.PlayListId == request.PlayListId)
.OrderBy(plt => plt.Position)
.ToListAsync(cancellationToken);
if (playList.UserId != request.UserId)
{
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
return Error.Unauthorized();
}
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
if (link == null)
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
var link = await database.PlayListHasTracks
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
if (link == null)
{
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);
await database.SaveChangesAsync(cancellationToken);
var position = 0;
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
remaining.Position = position++;
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
await database.SaveChangesAsync(cancellationToken);
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 Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Pictures.Responses;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists;
public record RequestPlayListPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
public record RequestPlayListPictureUploadCommand(
long UserId,
string FileType,
string ContentType,
long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(
IDatabase database,
UploadIntentValidator uploadIntentValidator,
IStorageService storageService,
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
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,45 +1,47 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses;
public record PlayListApplicationResponse(
Guid Id,
string Name,
string? Description,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
PlayListVisibility Visibility,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds,
long OwnerUserId,
int TrackCount,
double DurationSeconds)
namespace Musify.Application.PlayLists.Responses
{
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
IReadOnlyList<Guid>? coverTrackIds = null,
int trackCount = 0,
double durationSeconds = 0)
public record PlayListApplicationResponse(
Guid Id,
string Name,
string? Description,
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
PlaylistVisibility Visibility,
int TrackCount,
double DurationSeconds,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
{
return new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.Visibility,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? [],
playList.OwnerUserId,
trackCount,
durationSeconds);
public const int CoverTrackCount = 4;
public static PlayListApplicationResponse FromEntity(
PlayList playList,
int trackCount = 0,
double durationSeconds = 0,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
playList.Id,
playList.Name,
playList.Description,
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.UserId,
playList.Visibility,
trackCount,
durationSeconds,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
}
}
}
@@ -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.Extensions.Logging;
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events;
using Musify.Application.Pictures;
using Musify.Application.PlayLists.Responses;
using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Application.Contracts;
namespace Musify.Application.PlayLists;
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlayListVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
namespace Musify.Application.PlayLists
{
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
public record UpdatePlayListCommand(
long UserId,
Guid PlayListId,
string? NewName,
string? NewDescription,
Guid? NewPictureIntentId,
PlaylistVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler(
IEventBus eventBus,
IDatabase database,
UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger,
ApplicationStorageConfiguration storageConfiguration,
PlayListConfiguration playListConfiguration,
UploadIntentConfiguration uploadIntentConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
if (found.IsError)
return found.Errors;
var playList = found.Value;
if (!string.IsNullOrWhiteSpace(request.NewName))
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{
playList.Name = request.NewName.Trim();
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
if (playListEntity == null)
{
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
return Error.NotFound();
}
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))
{
playListEntity.Name = request.NewName;
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
}
if (!string.IsNullOrWhiteSpace(request.NewDescription))
{
playListEntity.Description = request.NewDescription;
}
if (request.NewVisibility.HasValue)
{
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);
}
// null means "unchanged"; a blank description clears it.
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)
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
Guid playListId,
UploadIntent pictureIntent,
string finalPictureKey,
CancellationToken cancellationToken)
{
var prepared = await PictureSourceChange.PrepareAsync(
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
if (prepared.IsError)
return prepared.Errors;
var picture = prepared.Value;
playList.Pictures = picture.Pictures;
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
cancellationToken);
try
{
await eventBus.PublishAsync(
new UpdatePlayListPictureSourceEvent(
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);
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