Author SHA1 Message Date
Admin 35d6314ce8 Point the IDE dev settings at the shared Infrastructure stack
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 41s
CI/CD / Deploy (push) Skipped
appsettings.Development.json still targeted Musify's old own Postgres
(port 59000) with the superuser, the default RabbitMQ vhost as guest and
the admin S3 keys. They now use Infrastructure's dev ports and Musify's
own dev credentials (role, vhost and S3 identity), the same defaults as
deploy/.env.example.

The API's non-secret Zitadel settings (issuer, metadata, endpoints) move
there too, so user-secrets only need the per-machine client id.
2026-09-24 17:11:50 +02:00
Admin f99f74f4a1 Use Musify's own credentials on the shared services
Infrastructure now gives each project its own Postgres role, RabbitMQ
user and vhost, S3 identity limited to its bucket and a Zitadel service
account that owns only its project, instead of handing out the admin
accounts.

- Postgres, RabbitMQ and S3 settings default to Musify's own (musify).
- MassTransit connects to the `musify` vhost (RABBITMQ_VHOST, taken from
  the address path); RABBITMQ_DEFAULT_USER/PASS become RABBITMQ_USER and
  RABBITMQ_PASSWORD.
- zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's
  zitadel/.output/musify) and uses that PAT. It no longer creates the
  Zitadel project, which that account can't do: Infrastructure does.
2026-09-24 17:05:23 +02:00
Admin f2765e2e7b Call the API by container name from the web player
In prod every project shares infra-net, where the `api` service name
can resolve to another project's container as well.
2026-09-24 15:06:25 +02:00
Admin 7b8f3f1222 Use a reverse-domain redirect scheme for the native app
- Android/iOS now return to cat.ikerdemo.musify:/callback instead of
  musify://callback: a private-use scheme based on a domain we own
  (RFC 8252 section 7.1), so another app is unlikely to claim it.
- The Musify Native app no longer needs devMode: loopback and custom
  scheme redirects are both valid for a native app.
- zitadel-init also updates an existing app's config, so the change
  reaches instances created before it (the client id is kept).
- iOS takes the scheme from everything before the first ':', since the
  new URI has a single slash.
2026-09-24 15:06:25 +02:00
Admin c33b0e8bfd Stop disabling Zitadel Login V2 from zitadel-init
The shared Infrastructure stack now runs the Login V2 container, and
turning the feature off here would switch every project on the shared
instance back to the old login.
2026-09-24 15:06:25 +02:00
490 changed files with 14145 additions and 17436 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,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,42 @@
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,
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.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,43 @@
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,
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.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
}
}
}
@@ -1,40 +1,64 @@
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,
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.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");
}
}
}
}
@@ -1,45 +1,37 @@
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,
PlaylistVisibility Visibility,
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,
IReadOnlyList<Guid>? coverTrackIds = null)
{
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 ?? []);
}
}
}
@@ -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,153 @@ 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);
return PlayListApplicationResponse.FromEntity(playListEntity, 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);
}
}
@@ -2,7 +2,7 @@ using System.Globalization;
using System.Text.Json;
using System.Text.Json.Serialization;
namespace Musify.Api.Serialization;
namespace Musify.Application.Serialization;
public sealed class LongAsStringConverter : JsonConverter<long>
{
@@ -1,20 +1,28 @@
using Musify.Application.Configuration;
using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
namespace Musify.Application.Services;
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
public sealed class TrackStreamIssuer(
IDatabase database,
IStreamTicketService ticketService,
TrackConfiguration trackConfiguration,
StreamGatewayConfiguration streamGatewayConfiguration,
PlaybackConfiguration playbackConfiguration)
{
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
public async Task<TrackStreamResponse> IssueAsync(
Guid trackId,
string audioFolderName,
long? userId,
CancellationToken cancellationToken)
{
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
var keyPrefix = $"{folderPath}/";
var maxBytes = AnonymousFragmentBytes(userId);
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
var manifestUrl = string.Join('/',
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
@@ -22,6 +30,19 @@ public sealed class TrackStreamIssuer(
folderPath,
streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
if (userId != null)
{
var newListeningHistory = new ListeningHistory
{
UserId = userId.Value,
TrackId = trackId,
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
listenId = newListeningHistory.Id;
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
}

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