10 Commits
Author SHA1 Message Date
Admin b8524aa5a8 Update naming conventions in .editorconfig and docs.editorc
CI/CD / BackEnd (push) Successful in 1m34s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Skipped
2026-09-24 13:31:31 +02:00
Admin 73e37dbecd Change editor rule 2026-09-24 13:24:43 +02:00
Admin 16478678b5 Use System.Text.Json number handling for long values 2026-09-24 13:14:05 +02:00
Admin 7f51145915 Update docs for the refactored backend and add backend conventions guide 2026-09-24 13:01:32 +02:00
Admin 28a7c4a98c Enable more analyzers, drop log-and-rethrow in file activities and apply dotnet format
Use invariant culture when formatting ids, put cancellation tokens last in endpoint handlers, remove the
CS8618 pragmas and keep only the analyzer overrides that still apply.
2026-09-24 12:57:53 +02:00
Admin 2d0867e68a Add tests for MassTransit activities, routing slips, sagas, fault handling and playlist visibility
Infrastructure exposes its internals to the test project so the activities and slip builders can be
exercised directly, and the fault consumer and state machines run on the MassTransit test harness.
2026-09-24 12:53:33 +02:00
Admin bc6ca3651f Share the entity model between Database and the test database via ModelConfiguration 2026-09-24 12:41:05 +02:00
Admin c4cf5af7f1 Make the Worker validate its service container and align Worker and Gateway hosts
The Worker now registers the Application layer, observability and stream tickets, validates the
container on build and ships a complete appsettings. The Gateway drops underscore fields and uses a
placeholder bucket in its default YARP transform.
2026-09-24 12:40:18 +02:00
Admin fcf3093ad8 Unify API route parameters, return IResult everywhere, merge picture upload DTOs and centralize validation limits
Guid and long route constraints, id/trackId parameter names, Update*Request without the New prefix,
sentence-case operation summaries, shared security scheme id and a single Limits class.
2026-09-24 12:36:48 +02:00
Admin 405df082cc Deduplicate MassTransit routing slips, activities and consumers 2026-09-24 12:25:55 +02:00
466 changed files with 15780 additions and 13895 deletions
+34 -20
View File
@@ -18,7 +18,7 @@ generated_code = true
[*.cs] [*.cs]
indent_size = 4 indent_size = 4
csharp_style_namespace_declarations = file_scoped:warning csharp_style_namespace_declarations = block_scoped:warning
csharp_using_directive_placement = outside_namespace:warning csharp_using_directive_placement = outside_namespace:warning
dotnet_sort_system_directives_first = true dotnet_sort_system_directives_first = true
dotnet_separate_import_directive_groups = false dotnet_separate_import_directive_groups = false
@@ -33,33 +33,47 @@ csharp_style_prefer_primary_constructors = true:suggestion
csharp_style_expression_bodied_methods = when_on_single_line:suggestion csharp_style_expression_bodied_methods = when_on_single_line:suggestion
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
# Naming. Roslyn applies the first matching rule in file order, so the specific rules come first.
# Private fields are _camelCase whatever their modifiers (static, readonly, const).
dotnet_naming_rule.private_fields.symbols = private_fields dotnet_naming_rule.private_fields.symbols = private_fields
dotnet_naming_rule.private_fields.style = camel_case dotnet_naming_rule.private_fields.style = underscore_camel_case
dotnet_naming_rule.private_fields.severity = warning dotnet_naming_rule.private_fields.severity = warning
dotnet_naming_symbols.private_fields.applicable_kinds = field dotnet_naming_symbols.private_fields.applicable_kinds = field
dotnet_naming_symbols.private_fields.applicable_accessibilities = private dotnet_naming_symbols.private_fields.applicable_accessibilities = private
dotnet_naming_symbols.private_fields.required_modifiers = dotnet_naming_style.underscore_camel_case.capitalization = camel_case
dotnet_naming_style.camel_case.capitalization = camel_case dotnet_naming_style.underscore_camel_case.required_prefix = _
dotnet_naming_rule.private_constants.symbols = private_constants # Non-private fields (constants, static readonly, etc.) are PascalCase.
dotnet_naming_rule.private_constants.style = pascal_case dotnet_naming_rule.other_fields.symbols = other_fields
dotnet_naming_rule.private_constants.severity = warning dotnet_naming_rule.other_fields.style = pascal_case
dotnet_naming_symbols.private_constants.applicable_kinds = field dotnet_naming_rule.other_fields.severity = warning
dotnet_naming_symbols.private_constants.required_modifiers = const dotnet_naming_symbols.other_fields.applicable_kinds = field
dotnet_naming_symbols.other_fields.applicable_accessibilities = public, internal, protected, protected_internal, private_protected
dotnet_naming_style.pascal_case.capitalization = pascal_case dotnet_naming_style.pascal_case.capitalization = pascal_case
# Analyzer rules that would need behaviour or API changes; downgraded in P0, revisit in P15. dotnet_naming_rule.interfaces.symbols = interfaces
dotnet_naming_rule.interfaces.style = interface_style
dotnet_naming_rule.interfaces.severity = warning
dotnet_naming_symbols.interfaces.applicable_kinds = interface
dotnet_naming_style.interface_style.capitalization = pascal_case
dotnet_naming_style.interface_style.required_prefix = I
dotnet_naming_rule.types_and_members.symbols = types_and_members
dotnet_naming_rule.types_and_members.style = pascal_case
dotnet_naming_rule.types_and_members.severity = warning
dotnet_naming_symbols.types_and_members.applicable_kinds = class, struct, enum, delegate, property, method, event
dotnet_naming_symbols.types_and_members.applicable_accessibilities = *
# CA1848/CA1873 would turn every log call into a [LoggerMessage] source-generated method; CA1716 flags the
# EF-style IDatabase.Set<T>() and the Musify.Application.Shared namespace, whose names are deliberate.
dotnet_diagnostic.CA1848.severity = suggestion dotnet_diagnostic.CA1848.severity = suggestion
dotnet_diagnostic.CA1873.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.CA1716.severity = suggestion
dotnet_diagnostic.CA1000.severity = suggestion # The routing slip builders are stateless services resolved from DI; they stay instance members.
dotnet_diagnostic.CA1725.severity = suggestion
dotnet_diagnostic.CA1816.severity = suggestion
dotnet_diagnostic.CA1822.severity = suggestion dotnet_diagnostic.CA1822.severity = suggestion
dotnet_diagnostic.CA1852.severity = suggestion
dotnet_diagnostic.CA1068.severity = suggestion # Test names follow Method_Scenario_Expected.
dotnet_diagnostic.CA1711.severity = suggestion [Tests/**.cs]
dotnet_diagnostic.CA1861.severity = suggestion dotnet_diagnostic.CA1707.severity = none
# xUnit collection fixtures are conventionally named ...Collection.
dotnet_diagnostic.CA1711.severity = none
+58
View File
@@ -0,0 +1,58 @@
# Musify: backend
.NET 10 solution (`Musify.slnx`): `Core/` (Domain, Application, Infrastructure), `Hosts/` (Api, Worker, StreamingGateway) and `Tests/`. Architecture and flows are in `../docs/`.
## Commands
Run from `backend/`.
```sh
dotnet build Musify.slnx # warnings are errors, including unused usings (IDE0005)
dotnet test Musify.slnx # Infrastructure and Api tests need Docker (Testcontainers)
dotnet format Musify.slnx --verify-no-changes
```
- Read the totals of `dotnet test` (`total:`, `succeeded:`) before trusting it; don't chain it with `&&` into a commit.
- EF migrations: `dotnet ef migrations add <Name> --project Core/Musify.Infrastructure --startup-project Core/Musify.Infrastructure` (set `Database__ConnectionString` to any value). A model change needs a migration; the entity model lives in `ModelConfiguration.Apply`, which the in-memory test database reuses.
- An HTTP contract change (routes, DTOs, responses) needs the web-player schema regenerated (`web-player/src/lib/api/schema.d.ts`, from `/openapi/v1.json`) and `npm run check` clean.
- A MassTransit message change (events, routing slip arguments, process kinds) means the queues must be drained before deploying; note it in the commit.
## Style
- Block-scoped namespaces (`namespace X { }`; file-scoped `namespace X;` is not allowed), usings outside the namespace and sorted (`.editorconfig` enforces it).
- Private fields are `_camelCase` whatever their modifiers (including `static readonly` and `const`); non-private constants and static fields, types, methods and properties are PascalCase; interfaces start with `I`; explicit accessibility. Enforced by `.editorconfig` (IDE1006).
- `ArgumentNullException.ThrowIfNull(x)` without `nameof`.
- Use `PlayList` (not `Playlist`) in code; configuration POCOs end in `Configuration`; injected services are named after their type (`storageService`).
- User-facing strings are in English (the web-player owns translation).
- Trim stored names; normalized columns go through `TextNormalizer.Normalize`.
- Format ids and numbers with `CultureInfo.InvariantCulture`; put `CancellationToken` last.
## Application layer
- One file per use case, `<Feature>/<UseCase>.cs`, holding the record and its handler; responses go in `Responses/`.
- Errors come from `AppErrors` (`NotFound`, `Forbidden` = 403 for "not the owner", `Conflict`). `Error.Unauthorized` (401) only means "not signed in". The success value is `Result.Success`; the API maps it to 204.
- Handlers that cannot fail return plain types. Lists are paginated through `PageRequest` / `ToPaginatedAsync`, ordered with an `Id` tiebreak, and public reads return only `LifeCycleStatus.Active` rows.
- Owned-entity operations go through `FindOwnedAsync` (non-active entities are not found).
- No try/catch around `SaveChangesAsync` or `PublishAsync` in handlers: the outbox makes publish + save atomic. Infrastructure either handles an error or lets it propagate; never log and rethrow.
- List query records keep `int PageNumber, int PageSize`; the API binds them through `PageQuery` and validates them with `PageQueryValidator`.
## API layer
- Routes: `{id:guid}` for the resource, `{trackId:guid}` for sub-resources, `{id:long}` / `{userId:long}` for user ids.
- Every endpoint returns `IResult` (`Results.Ok` when the handler cannot fail, `ToHttpResult()` otherwise).
- Validation limits live in `Validators/Limits.cs`; the database column length wins when a limit disagrees with the web-player. Upload allow-lists are in `Validators/Uploads.cs`.
- `.WithSummary(...)` is sentence case without a trailing period.
## Infrastructure and hosts
- MassTransit routing slips are built from `RoutingSlips.Create` / `AddStep` / `TrackFaults`; activities for the three owner types share generic bases (`MarkLifeCycleActivity<T>`, `DeleteEntityActivity<T>`, `UpdatePicturesActivity<T>`).
- Recurring work is Hangfire (`RecurringJobsRegistrar`); intervals are in seconds and rounded up to whole minutes.
- The Worker calls `AddApplication` and validates its service container on build, so any handler dependency must be registered there. Shared config sections in its `appsettings.json` mirror the API's.
- The StreamingGateway references neither Application nor Infrastructure and keeps its own options.
## Tests
- xUnit v3; pass `TestContext.Current.CancellationToken` to methods that take a token (analyzer xUnit1051).
- Test names are `Method_Scenario_Expected`.
- Application tests use the in-memory SQLite `TestDatabase`; Infrastructure and Api tests use Testcontainers (Postgres, SeaweedFS). MassTransit sagas and consumers are tested on the MassTransit test harness.
- `Infrastructure` exposes its internals to `Musify.Infrastructure.Tests`.
@@ -7,17 +7,17 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary> /// <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) public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class AddTracksToAlbumCommandHandler( public class AddTracksToAlbumCommandHandler(
IDatabase database, IDatabase database,
ILogger<AddTracksToAlbumCommandHandler> logger) ILogger<AddTracksToAlbumCommandHandler> logger)
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>> : ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -74,4 +74,5 @@ public class AddTracksToAlbumCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -12,9 +12,9 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record CreateAlbumCommand( public record CreateAlbumCommand(
long UserId, long UserId,
string Title, string Title,
string? Description, string? Description,
@@ -22,14 +22,14 @@ public record CreateAlbumCommand(
Guid PictureIntentId) Guid PictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class CreateAlbumCommandHandler( public class CreateAlbumCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateAlbumCommandHandler> logger, ILogger<CreateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration) AlbumConfiguration albumConfiguration)
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
@@ -69,4 +69,5 @@ public class CreateAlbumCommandHandler(
return AlbumApplicationResponse.FromEntity(album, trackCount: 0); return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
} }
}
} }
@@ -6,17 +6,17 @@ using Musify.Application.Events;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record DeleteAlbumCommand(long UserId, Guid AlbumId) public record DeleteAlbumCommand(long UserId, Guid AlbumId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class DeleteAlbumCommandHandler( public class DeleteAlbumCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
ILogger<DeleteAlbumCommandHandler> logger) ILogger<DeleteAlbumCommandHandler> logger)
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>> : ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
{ {
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -33,4 +33,5 @@ public class DeleteAlbumCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumByIdQuery(Guid Id) public record GetAlbumByIdQuery(Guid Id)
: IQuery<ErrorOr<AlbumApplicationResponse>>; : IQuery<ErrorOr<AlbumApplicationResponse>>;
public class GetAlbumByIdQueryHandler(IDatabase database) public class GetAlbumByIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>> : IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
{ {
var album = await database.Albums var album = await database.Albums
@@ -26,4 +26,5 @@ public class GetAlbumByIdQueryHandler(IDatabase database)
? AppErrors.NotFound("Album", request.Id) ? AppErrors.NotFound("Album", request.Id)
: album; : album;
} }
}
} }
@@ -6,17 +6,17 @@ using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumTracksQuery( public record GetAlbumTracksQuery(
Guid AlbumId, Guid AlbumId,
int PageNumber, int PageNumber,
int PageSize) int PageSize)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetAlbumTracksQueryHandler(IDatabase database) public class GetAlbumTracksQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>> : IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
{ {
var albumExists = await database.Albums var albumExists = await database.Albums
@@ -35,4 +35,5 @@ public class GetAlbumTracksQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -4,14 +4,14 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize) public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
: IQuery<PaginatedResponse<AlbumApplicationResponse>>; : IQuery<PaginatedResponse<AlbumApplicationResponse>>;
public class GetAlbumsQueryHandler(IDatabase database) public class GetAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>> : IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
{ {
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken) public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
{ {
var albumsQuery = database.Albums var albumsQuery = database.Albums
@@ -30,4 +30,5 @@ public class GetAlbumsQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize) public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
public class GetAlbumsByUserIdQueryHandler(IDatabase database) public class GetAlbumsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> : IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
@@ -30,4 +30,5 @@ public class GetAlbumsByUserIdQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums.Responses; using Musify.Application.Albums.Responses;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit) public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
: IQuery<IReadOnlyList<AlbumApplicationResponse>>; : IQuery<IReadOnlyList<AlbumApplicationResponse>>;
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database) public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>> : IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
{ {
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
{ {
var listens = database.ListeningHistories var listens = database.ListeningHistories
@@ -48,4 +48,5 @@ public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
.Select(entry => albumsById[entry.AlbumId]) .Select(entry => albumsById[entry.AlbumId])
.ToList(); .ToList();
} }
}
} }
@@ -5,16 +5,16 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId) public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class RemoveTrackFromAlbumCommandHandler( public class RemoveTrackFromAlbumCommandHandler(
IDatabase database, IDatabase database,
ILogger<RemoveTrackFromAlbumCommandHandler> logger) ILogger<RemoveTrackFromAlbumCommandHandler> logger)
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>> : ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
{ {
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -42,4 +42,5 @@ public class RemoveTrackFromAlbumCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record RequestAlbumPictureUploadCommand( public record RequestAlbumPictureUploadCommand(
long UserId, long UserId,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration) public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>> : ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) => public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync( await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
}
} }
@@ -1,8 +1,8 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Albums.Responses; namespace Musify.Application.Albums.Responses
{
public record AlbumApplicationResponse( public record AlbumApplicationResponse(
Guid Id, Guid Id,
string Title, string Title,
string? Description, string? Description,
@@ -15,7 +15,7 @@ public record AlbumApplicationResponse(
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds) IReadOnlyList<Guid> CoverTrackIds)
{ {
public const int CoverTrackCount = AlbumProjections.CoverTrackCount; public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
public static AlbumApplicationResponse FromEntity( public static AlbumApplicationResponse FromEntity(
@@ -37,4 +37,5 @@ public record AlbumApplicationResponse(
album.UpdatedAt, album.UpdatedAt,
coverTrackIds ?? []); coverTrackIds ?? []);
} }
}
} }
@@ -1,10 +1,10 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums.Responses; namespace Musify.Application.Albums.Responses
public static class AlbumProjections
{ {
public static class AlbumProjections
{
public const int CoverTrackCount = 4; public const int CoverTrackCount = 4;
public static IQueryable<Album> Active(this IQueryable<Album> query) => public static IQueryable<Album> Active(this IQueryable<Album> query) =>
@@ -28,4 +28,5 @@ public static class AlbumProjections
.Take(CoverTrackCount) .Take(CoverTrackCount)
.Select(albumTrack => albumTrack.TrackId) .Select(albumTrack => albumTrack.TrackId)
.ToList())); .ToList()));
}
} }
@@ -11,9 +11,9 @@ using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Albums; namespace Musify.Application.Albums
{
public record UpdateAlbumCommand( public record UpdateAlbumCommand(
long UserId, long UserId,
Guid AlbumId, Guid AlbumId,
string Title, string Title,
@@ -22,14 +22,14 @@ public record UpdateAlbumCommand(
Guid? NewPictureIntentId) Guid? NewPictureIntentId)
: ICommand<ErrorOr<AlbumApplicationResponse>>; : ICommand<ErrorOr<AlbumApplicationResponse>>;
public class UpdateAlbumCommandHandler( public class UpdateAlbumCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<UpdateAlbumCommandHandler> logger, ILogger<UpdateAlbumCommandHandler> logger,
AlbumConfiguration albumConfiguration) AlbumConfiguration albumConfiguration)
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>> : ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
{ {
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
{ {
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken); var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
@@ -70,4 +70,5 @@ public class UpdateAlbumCommandHandler(
.SelectResponse() .SelectResponse()
.SingleAsync(cancellationToken); .SingleAsync(cancellationToken);
} }
}
} }
@@ -3,10 +3,10 @@ using Microsoft.Extensions.DependencyInjection;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Services; using Musify.Application.Services;
namespace Musify.Application; namespace Musify.Application
public static class ApplicationDependencyInjection
{ {
public static class ApplicationDependencyInjection
{
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
{ {
services.AddMediator(); services.AddMediator();
@@ -25,4 +25,5 @@ public static class ApplicationDependencyInjection
return services; return services;
} }
}
} }
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "Album"; public static string SectionName => "Album";
[Required] [Required]
@@ -11,4 +11,5 @@ public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfigurat
[Required] [Required]
public PictureSizes PicturesSizes { get; set; } = new(); public PictureSizes PicturesSizes { get; set; } = new();
}
} }
@@ -1,12 +1,13 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class ApplicationStorageConfiguration : IConfigurationOptions
{ {
public class ApplicationStorageConfiguration : IConfigurationOptions
{
public static string SectionName => "ApplicationStorage"; public static string SectionName => "ApplicationStorage";
[Required] [Required]
[StringLength(128, MinimumLength = 1)] [StringLength(128, MinimumLength = 1)]
public required string Bucket { get; set; } public required string Bucket { get; set; }
}
} }
@@ -2,10 +2,10 @@ using Microsoft.Extensions.Configuration;
using Microsoft.Extensions.DependencyInjection; using Microsoft.Extensions.DependencyInjection;
using Microsoft.Extensions.Options; using Microsoft.Extensions.Options;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public static class ConfigurationDependencyInjection
{ {
public static class ConfigurationDependencyInjection
{
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration) public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
where T : class, IConfigurationOptions => where T : class, IConfigurationOptions =>
services.AddValidatedOptions<T>(configuration, T.SectionName); services.AddValidatedOptions<T>(configuration, T.SectionName);
@@ -26,4 +26,5 @@ public static class ConfigurationDependencyInjection
return services; return services;
} }
}
} }
@@ -1,16 +1,17 @@
using Mediator; using Mediator;
using Musify.Application.Configuration.Responses; using Musify.Application.Configuration.Responses;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{ {
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
{
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) => public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
ValueTask.FromResult(new PlaybackPublicConfigResponse( ValueTask.FromResult(new PlaybackPublicConfigResponse(
playbackConfiguration.AllowAnonymousListening, playbackConfiguration.AllowAnonymousListening,
playbackConfiguration.AnonymousFragmentSeconds)); playbackConfiguration.AnonymousFragmentSeconds));
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public interface IConfigurationOptions
{ {
public interface IConfigurationOptions
{
public static abstract string SectionName { get; } public static abstract string SectionName { get; }
}
} }
@@ -1,11 +1,12 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class MixConfiguration : IConfigurationOptions
{ {
public class MixConfiguration : IConfigurationOptions
{
public static string SectionName => "Mix"; public static string SectionName => "Mix";
[Range(4, 100)] [Range(4, 100)]
public int ItemsPerMix { get; set; } = 12; public int ItemsPerMix { get; set; } = 12;
}
} }
@@ -1,17 +1,18 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public enum PictureSize
{ {
public enum PictureSize
{
Small, Small,
Medium, Medium,
Large Large
} }
public class PictureRoutes public class PictureRoutes
{ {
[Required] [Required]
public string UploadsFolder { get; set; } = "uploads"; public string UploadsFolder { get; set; } = "uploads";
@@ -41,14 +42,14 @@ public class PictureRoutes
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName); public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
public string BuildOriginalPicturePath(long userId, string pictureName) => public string BuildOriginalPicturePath(long userId, string pictureName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName); StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) => public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName); StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
} }
public class PictureSizes public class PictureSizes
{ {
[Range(1, 1024)] [Range(1, 1024)]
public int SmallWidth { get; set; } = 128; public int SmallWidth { get; set; } = 128;
@@ -71,20 +72,21 @@ public class PictureSizes
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight), new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight), new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight)); new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
} }
public interface IPictureOwnerConfiguration public interface IPictureOwnerConfiguration
{ {
public PictureRoutes Routes { get; } public PictureRoutes Routes { get; }
public PictureSizes PicturesSizes { get; } public PictureSizes PicturesSizes { get; }
} }
public static class PictureSizeParser public static class PictureSizeParser
{ {
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary> /// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
public static PictureSize Parse(string? value) => public static PictureSize Parse(string? value) =>
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size) Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
? size ? size
: PictureSize.Medium; : PictureSize.Medium;
}
} }
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "PlayList"; public static string SectionName => "PlayList";
[Required] [Required]
@@ -11,4 +11,5 @@ public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfigu
[Required] [Required]
public PictureSizes PicturesSizes { get; set; } = new(); public PictureSizes PicturesSizes { get; set; } = new();
}
} }
@@ -1,10 +1,10 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public sealed class PlaybackConfiguration : IConfigurationOptions
{ {
public sealed class PlaybackConfiguration : IConfigurationOptions
{
public static string SectionName => "Playback"; public static string SectionName => "Playback";
public bool AllowAnonymousListening { get; set; } public bool AllowAnonymousListening { get; set; }
@@ -14,4 +14,5 @@ public sealed class PlaybackConfiguration : IConfigurationOptions
[Range(1, int.MaxValue)] [Range(1, int.MaxValue)]
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000; public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
}
} }
@@ -1,4 +1,5 @@
namespace Musify.Application.Configuration.Responses; namespace Musify.Application.Configuration.Responses
{
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds); }
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class StreamGatewayConfiguration : IConfigurationOptions
{ {
public class StreamGatewayConfiguration : IConfigurationOptions
{
public static string SectionName => "StreamGateway"; public static string SectionName => "StreamGateway";
[Url] [Url]
@@ -12,4 +12,5 @@ public class StreamGatewayConfiguration : IConfigurationOptions
[Required] [Required]
public string AudioFileName { get; set; } = "audio.m4a"; public string AudioFileName { get; set; } = "audio.m4a";
}
} }
@@ -1,10 +1,11 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
using System.Globalization;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{ {
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
{
public static string SectionName => "Track"; public static string SectionName => "Track";
[Required] [Required]
@@ -14,10 +15,10 @@ public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfigurat
public PictureSizes PicturesSizes { get; set; } = new(); public PictureSizes PicturesSizes { get; set; } = new();
PictureRoutes IPictureOwnerConfiguration.Routes => Routes; PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
} }
public class TrackRoutes : PictureRoutes public class TrackRoutes : PictureRoutes
{ {
public TrackRoutes() public TrackRoutes()
{ {
ParentFolder = "Tracks"; ParentFolder = "Tracks";
@@ -48,8 +49,9 @@ public class TrackRoutes : PictureRoutes
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture); public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
public string BuildOriginalAudioPath(long userId, string audioName) => public string BuildOriginalAudioPath(long userId, string audioName) =>
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName); StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
public string BuildProcessedAudioPath(string folderName) => public string BuildProcessedAudioPath(string folderName) =>
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName); StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
}
} }
@@ -1,9 +1,9 @@
using System.ComponentModel.DataAnnotations; using System.ComponentModel.DataAnnotations;
namespace Musify.Application.Configuration; namespace Musify.Application.Configuration
public class UploadIntentConfiguration : IConfigurationOptions
{ {
public class UploadIntentConfiguration : IConfigurationOptions
{
public static string SectionName => "UploadIntent"; public static string SectionName => "UploadIntent";
[Range(10, 3600)] [Range(10, 3600)]
@@ -38,4 +38,5 @@ public class UploadIntentConfiguration : IConfigurationOptions
[Range(60, 86400)] [Range(60, 86400)]
public int TempCleanupJobIntervalSeconds { get; set; } = 3600; public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
}
} }
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record AudioTranscodeResult(int StatusCode, TimeSpan Duration); public record AudioTranscodeResult(int StatusCode, TimeSpan Duration);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IAudioTranscoderService
{ {
public interface IAudioTranscoderService
{
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken); public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
}
} }
@@ -2,10 +2,10 @@ using ErrorOr;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IDatabase
{ {
public interface IDatabase
{
public DbSet<User> Users { get; } public DbSet<User> Users { get; }
public DbSet<PlayList> PlayLists { get; } public DbSet<PlayList> PlayLists { get; }
@@ -34,7 +34,10 @@ public interface IDatabase
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken); public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
public Task<int> SaveChangesAsync(CancellationToken cancellationToken); public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken); public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IDatabaseTransaction : IAsyncDisposable
{ {
public interface IDatabaseTransaction : IAsyncDisposable
{
public Task CommitAsync(CancellationToken cancellationToken = default); public Task CommitAsync(CancellationToken cancellationToken = default);
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IEventBus
{ {
public interface IEventBus
{
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class; public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IPictureService
{ {
public interface IPictureService
{
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken); public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
}
} }
@@ -1,7 +1,7 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IStorageService
{ {
public interface IStorageService
{
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary> /// <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 Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
@@ -21,4 +21,5 @@ public interface IStorageService
CancellationToken cancellationToken); CancellationToken cancellationToken);
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken); public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
}
} }
@@ -1,8 +1,9 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
public interface IStreamTicketService
{ {
public interface IStreamTicketService
{
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null); public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
} }
public record StreamTicket(string Token, int ExpiresInSeconds); public record StreamTicket(string Token, int ExpiresInSeconds);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record ObjectMetaData(string ContentType, long ContentLength); public record ObjectMetaData(string ContentType, long ContentLength);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Contracts; namespace Musify.Application.Contracts
{
public record StorageObject(string Key, DateTime LastModifiedUtc); public record StorageObject(string Key, DateTime LastModifiedUtc);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumPictureProcessed(Guid AlbumId); public record AlbumPictureProcessed(Guid AlbumId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumPictureProcessingFailed(Guid AlbumId); public record AlbumPictureProcessingFailed(Guid AlbumId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record AlbumProcessingFailed( public record AlbumProcessingFailed(
Guid AlbumId, Guid AlbumId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreateAlbumResourcesEvent( public record CreateAlbumResourcesEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreatePlayListResourcesEvent( public record CreatePlayListResourcesEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,8 +1,8 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record CreateTrackResourcesEvent( public record CreateTrackResourcesEvent(
Guid TrackId, Guid TrackId,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
@@ -13,3 +13,4 @@ public record CreateTrackResourcesEvent(
string AudioDestinationKey, string AudioDestinationKey,
string AudioProcessedFolderKey, string AudioProcessedFolderKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeleteAlbumEvent( public record DeleteAlbumEvent(
Guid AlbumId, Guid AlbumId,
long UserId); long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeletePlayListEvent( public record DeletePlayListEvent(
Guid PlayListId, Guid PlayListId,
long UserId); long UserId);
}
@@ -1,5 +1,6 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record DeleteTrackEvent( public record DeleteTrackEvent(
Guid TrackId, Guid TrackId,
long UserId); long UserId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListPictureProcessed(Guid PlayListId); public record PlayListPictureProcessed(Guid PlayListId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListPictureProcessingFailed(Guid PlayListId); public record PlayListPictureProcessingFailed(Guid PlayListId);
}
@@ -1,6 +1,7 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record PlayListProcessingFailed( public record PlayListProcessingFailed(
Guid PlayListId, Guid PlayListId,
string? Bucket, string? Bucket,
string? PictureKey); string? PictureKey);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackAudioProcessed(Guid TrackId); public record TrackAudioProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackAudioProcessingFailed(Guid TrackId); public record TrackAudioProcessingFailed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackPictureProcessed(Guid TrackId); public record TrackPictureProcessed(Guid TrackId);
}
@@ -1,3 +1,4 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackPictureProcessingFailed(Guid TrackId); public record TrackPictureProcessingFailed(Guid TrackId);
}
@@ -1,7 +1,8 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record TrackProcessingFailed( public record TrackProcessingFailed(
Guid TrackId, Guid TrackId,
string? Bucket, string? Bucket,
string? PictureKey, string? PictureKey,
string? AudioKey); string? AudioKey);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateAlbumPictureEvent( public record UpdateAlbumPictureEvent(
Guid AlbumId, Guid AlbumId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateAlbumPictureSourceEvent( public record UpdateAlbumPictureSourceEvent(
Guid AlbumId, Guid AlbumId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdatePlayListPictureEvent( public record UpdatePlayListPictureEvent(
Guid PlayListId, Guid PlayListId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,11 +1,12 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdatePlayListPictureSourceEvent( public record UpdatePlayListPictureSourceEvent(
Guid PlayListId, Guid PlayListId,
Guid PictureIntentId, Guid PictureIntentId,
string Bucket, string Bucket,
string PictureSourceKey, string PictureSourceKey,
string PictureDestinationKey, string PictureDestinationKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,8 +1,9 @@
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateTrackAudioEvent( public record UpdateTrackAudioEvent(
Guid TrackId, Guid TrackId,
string SourceBucket, string SourceBucket,
string SourceKey, string SourceKey,
string DestinationBucket, string DestinationBucket,
string DestinationFolderKey); string DestinationFolderKey);
}
@@ -1,9 +1,10 @@
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Events; namespace Musify.Application.Events
{
public record UpdateTrackPictureEvent( public record UpdateTrackPictureEvent(
Guid TrackId, Guid TrackId,
string Bucket, string Bucket,
string SourceKey, string SourceKey,
ImageSizes Sizes); ImageSizes Sizes);
}
@@ -1,14 +1,14 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses; using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres; namespace Musify.Application.Genres
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{ {
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{ {
var all = Enum.GetValues<Genre>(); var all = Enum.GetValues<Genre>();
@@ -21,4 +21,5 @@ public class GetAvailableGenresQueryHandler
return ValueTask.FromResult(result); return ValueTask.FromResult(result);
} }
}
} }
@@ -1,16 +1,16 @@
using Mediator; using Mediator;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
using Musify.Application.Genres.Responses; using Musify.Application.Genres.Responses;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres; namespace Musify.Application.Genres
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{ {
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{ {
var rows = await database.TrackTags var rows = await database.TrackTags
@@ -26,4 +26,5 @@ public class GetGenresQueryHandler(IDatabase database)
.Select(row => new GenreResponse(row.Genre, row.TrackCount)) .Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList(); .ToList();
} }
}
} }
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses; namespace Musify.Application.Genres.Responses
{
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith); public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
}
@@ -1,5 +1,6 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres.Responses; namespace Musify.Application.Genres.Responses
{
public record GenreResponse(Genre Genre, int TrackCount); public record GenreResponse(Genre Genre, int TrackCount);
}
@@ -5,17 +5,17 @@ using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes; namespace Musify.Application.Likes
{
public record GetLikedTracksQuery( public record GetLikedTracksQuery(
long UserId, long UserId,
int PageNumber, int PageNumber,
int PageSize) int PageSize)
: IQuery<PaginatedResponse<TrackApplicationResponse>>; : IQuery<PaginatedResponse<TrackApplicationResponse>>;
public class GetLikedTracksQueryHandler(IDatabase database) public class GetLikedTracksQueryHandler(IDatabase database)
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>> : IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
{ {
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken) public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
{ {
return await database.TrackLikes return await database.TrackLikes
@@ -27,4 +27,5 @@ public class GetLikedTracksQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -7,16 +7,16 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Likes; namespace Musify.Application.Likes
{
public record ToggleTrackLikeCommand(long UserId, Guid TrackId) public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
: ICommand<ErrorOr<bool>>; : ICommand<ErrorOr<bool>>;
public class ToggleTrackLikeCommandHandler( public class ToggleTrackLikeCommandHandler(
IDatabase database, IDatabase database,
ILogger<ToggleTrackLikeCommandHandler> logger) ILogger<ToggleTrackLikeCommandHandler> logger)
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>> : ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
{ {
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
{ {
var trackExists = await database.Tracks var trackExists = await database.Tracks
@@ -55,4 +55,5 @@ public class ToggleTrackLikeCommandHandler(
return nowLiked; return nowLiked;
} }
}
} }
@@ -6,17 +6,17 @@ using Musify.Application.Contracts;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GenerateMixesForUserCommand(long UserId) public record GenerateMixesForUserCommand(long UserId)
: ICommand; : ICommand;
public class GenerateMixesForUserCommandHandler( public class GenerateMixesForUserCommandHandler(
IDatabase database, IDatabase database,
MixConfiguration mixConfiguration, MixConfiguration mixConfiguration,
ILogger<GenerateMixesForUserCommandHandler> logger) ILogger<GenerateMixesForUserCommandHandler> logger)
: ICommandHandler<GenerateMixesForUserCommand> : ICommandHandler<GenerateMixesForUserCommand>
{ {
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds); private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken) public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
@@ -79,7 +79,7 @@ public class GenerateMixesForUserCommandHandler(
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) => private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
[.. trackIds.OrderBy(_ => Guid.NewGuid())]; [.. trackIds.OrderBy(_ => Guid.NewGuid())];
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second) private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
{ {
var merged = new List<Guid>(first.Count + second.Count); var merged = new List<Guid>(first.Count + second.Count);
for (var index = 0; index < Math.Max(first.Count, second.Count); index++) for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
@@ -127,4 +127,5 @@ public class GenerateMixesForUserCommandHandler(
} }
} }
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GetMixByIdQuery(long UserId, Guid MixId) public record GetMixByIdQuery(long UserId, Guid MixId)
: IQuery<ErrorOr<MixApplicationResponse>>; : IQuery<ErrorOr<MixApplicationResponse>>;
public class GetMixByIdQueryHandler(IDatabase database) public class GetMixByIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>> : IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
{ {
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
{ {
var mix = await database.Mixes var mix = await database.Mixes
@@ -28,4 +28,5 @@ public class GetMixByIdQueryHandler(IDatabase database)
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items); return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
} }
}
} }
@@ -3,14 +3,14 @@ using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Mixes.Responses; using Musify.Application.Mixes.Responses;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
{
public record GetMixesByUserIdQuery(long UserId) public record GetMixesByUserIdQuery(long UserId)
: IQuery<IReadOnlyList<MixApplicationResponse>>; : IQuery<IReadOnlyList<MixApplicationResponse>>;
public class GetMixesByUserIdQueryHandler(IDatabase database) public class GetMixesByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>> : IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
{ {
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
{ {
var mixes = await database.Mixes var mixes = await database.Mixes
@@ -47,4 +47,5 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
return responses; return responses;
} }
}
} }
@@ -4,11 +4,11 @@ using Musify.Application.Mixes.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes; namespace Musify.Application.Mixes
internal static class MixItemMapper
{ {
internal static class MixItemMapper
{
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync( public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
IDatabase database, IDatabase database,
IReadOnlyList<MixItem> orderedItems, IReadOnlyList<MixItem> orderedItems,
@@ -37,4 +37,5 @@ internal static class MixItemMapper
}) })
.ToList(); .ToList();
} }
}
} }
@@ -1,12 +1,13 @@
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes.Responses
{
public record MixApplicationResponse( public record MixApplicationResponse(
Guid Id, Guid Id,
MixKind Kind, MixKind Kind,
int ItemCount, int ItemCount,
IReadOnlyList<MixItemApplicationResponse> Items) IReadOnlyList<MixItemApplicationResponse> Items)
{ {
public const int CoverItemCount = 4; public const int CoverItemCount = 4;
}
} }
@@ -1,8 +1,9 @@
namespace Musify.Application.Mixes.Responses; namespace Musify.Application.Mixes.Responses
{
public record MixItemApplicationResponse( public record MixItemApplicationResponse(
Guid TrackId, Guid TrackId,
string Title, string Title,
string? Artist, string? Artist,
double DurationSeconds, double DurationSeconds,
int ListensCount); int ListensCount);
}
@@ -7,28 +7,28 @@ using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures; namespace Musify.Application.Pictures
public enum CoverOwner
{ {
public enum CoverOwner
{
Track, Track,
Album, Album,
PlayList PlayList
} }
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium) public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
: IQuery<ErrorOr<CoverLocation>>; : IQuery<ErrorOr<CoverLocation>>;
public record CoverLocation(string Bucket, string Key, string ContentType); public record CoverLocation(string Bucket, string Key, string ContentType);
public class GetCoverQueryHandler( public class GetCoverQueryHandler(
IDatabase database, IDatabase database,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
TrackConfiguration trackConfiguration, TrackConfiguration trackConfiguration,
AlbumConfiguration albumConfiguration, AlbumConfiguration albumConfiguration,
PlayListConfiguration playListConfiguration) PlayListConfiguration playListConfiguration)
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>> : IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
{ {
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large); private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
@@ -72,4 +72,5 @@ public class GetCoverQueryHandler(
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name)); return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
} }
}
} }
@@ -5,13 +5,13 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Pictures; 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
{ {
/// <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( public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
Guid intentId, Guid intentId,
@@ -32,4 +32,5 @@ public static class PictureSourceChange
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName), owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
owner.PicturesSizes.ToImageSizes(owner.Routes)); owner.PicturesSizes.ToImageSizes(owner.Routes));
} }
}
} }
@@ -1,6 +1,6 @@
namespace Musify.Application.Pictures.Responses; namespace Musify.Application.Pictures.Responses
{
public record PictureUploadResponse( public record PictureUploadResponse(
Guid IntentId, Guid IntentId,
string Bucket, string Bucket,
string Key, string Key,
@@ -8,3 +8,4 @@ public record PictureUploadResponse(
string ContentType, string ContentType,
int ExpiresInSeconds, int ExpiresInSeconds,
string UploadUrl); string UploadUrl);
}
@@ -7,17 +7,17 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary> /// <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) public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class AddTracksToPlayListCommandHandler( public class AddTracksToPlayListCommandHandler(
IDatabase database, IDatabase database,
ILogger<AddTracksToPlayListCommandHandler> logger) ILogger<AddTracksToPlayListCommandHandler> logger)
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>> : ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -70,4 +70,5 @@ public class AddTracksToPlayListCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -12,9 +12,9 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record CreatePlayListCommand( public record CreatePlayListCommand(
long UserId, long UserId,
string Name, string Name,
string? Description, string? Description,
@@ -22,14 +22,14 @@ public record CreatePlayListCommand(
PlayListVisibility Visibility = PlayListVisibility.Private) PlayListVisibility Visibility = PlayListVisibility.Private)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class CreatePlayListCommandHandler( public class CreatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreatePlayListCommandHandler> logger, ILogger<CreatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration) PlayListConfiguration playListConfiguration)
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
{ {
var userExists = await database.Users var userExists = await database.Users
@@ -75,4 +75,5 @@ public class CreatePlayListCommandHandler(
return PlayListApplicationResponse.FromEntity(playList); return PlayListApplicationResponse.FromEntity(playList);
} }
}
} }
@@ -6,17 +6,17 @@ using Musify.Application.Events;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record DeletePlayListCommand(long UserId, Guid PlayListId) public record DeletePlayListCommand(long UserId, Guid PlayListId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class DeletePlayListCommandHandler( public class DeletePlayListCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
ILogger<DeletePlayListCommandHandler> logger) ILogger<DeletePlayListCommandHandler> logger)
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>> : ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
{ {
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -33,4 +33,5 @@ public class DeletePlayListCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,14 +5,14 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null) public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
: IQuery<ErrorOr<PlayListApplicationResponse>>; : IQuery<ErrorOr<PlayListApplicationResponse>>;
public class GetPlayListByIdQueryHandler(IDatabase database) public class GetPlayListByIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>> : IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists var playList = await database.PlayLists
@@ -27,4 +27,5 @@ public class GetPlayListByIdQueryHandler(IDatabase database)
? AppErrors.NotFound("PlayList", request.Id) ? AppErrors.NotFound("PlayList", request.Id)
: playList; : playList;
} }
}
} }
@@ -7,18 +7,18 @@ using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListTracksQuery( public record GetPlayListTracksQuery(
Guid PlayListId, Guid PlayListId,
int PageNumber, int PageNumber,
int PageSize, int PageSize,
long? ViewerId = null) long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>; : IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
public class GetPlayListTracksQueryHandler(IDatabase database) public class GetPlayListTracksQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>> : IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
{ {
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
{ {
var playListVisible = await database.PlayLists var playListVisible = await database.PlayLists
@@ -38,4 +38,5 @@ public class GetPlayListTracksQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -4,9 +4,9 @@ using Musify.Application.Contracts;
using Musify.Application.PlayLists.Responses; using Musify.Application.PlayLists.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record GetPlayListsByUserIdQuery( public record GetPlayListsByUserIdQuery(
long UserId, long UserId,
string? Name, string? Name,
int PageNumber, int PageNumber,
@@ -14,9 +14,9 @@ public record GetPlayListsByUserIdQuery(
long? ViewerId = null) long? ViewerId = null)
: IQuery<PaginatedResponse<PlayListApplicationResponse>>; : IQuery<PaginatedResponse<PlayListApplicationResponse>>;
public class GetPlayListsByUserIdQueryHandler(IDatabase database) public class GetPlayListsByUserIdQueryHandler(IDatabase database)
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>> : IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
{ {
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken) public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
{ {
var playListsQuery = database.PlayLists var playListsQuery = database.PlayLists
@@ -37,4 +37,5 @@ public class GetPlayListsByUserIdQueryHandler(IDatabase database)
.SelectResponse() .SelectResponse()
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken); .ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
} }
}
} }
@@ -5,16 +5,16 @@ using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared; using Musify.Application.Shared;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId) public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class RemoveTrackFromPlayListCommandHandler( public class RemoveTrackFromPlayListCommandHandler(
IDatabase database, IDatabase database,
ILogger<RemoveTrackFromPlayListCommandHandler> logger) ILogger<RemoveTrackFromPlayListCommandHandler> logger)
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>> : ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
{ {
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -42,4 +42,5 @@ public class RemoveTrackFromPlayListCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }
@@ -5,20 +5,21 @@ using Musify.Application.Pictures.Responses;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record RequestPlayListPictureUploadCommand( public record RequestPlayListPictureUploadCommand(
long UserId, long UserId,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes = null) long? ExpectedSizeBytes = null)
: ICommand<ErrorOr<PictureUploadResponse>>; : ICommand<ErrorOr<PictureUploadResponse>>;
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration) public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>> : ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
{ {
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) => public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
await issuer.IssuePictureAsync( await issuer.IssuePictureAsync(
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken); request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
}
} }
@@ -2,9 +2,9 @@ using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses; namespace Musify.Application.PlayLists.Responses
{
public record PlayListApplicationResponse( public record PlayListApplicationResponse(
Guid Id, Guid Id,
string Name, string Name,
string? Description, string? Description,
@@ -18,7 +18,7 @@ public record PlayListApplicationResponse(
long OwnerUserId, long OwnerUserId,
int TrackCount, int TrackCount,
double DurationSeconds) double DurationSeconds)
{ {
public const int CoverTrackCount = PlayListProjections.CoverTrackCount; public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
public static PlayListApplicationResponse FromEntity( public static PlayListApplicationResponse FromEntity(
@@ -42,4 +42,5 @@ public record PlayListApplicationResponse(
trackCount, trackCount,
durationSeconds); durationSeconds);
} }
}
} }
@@ -1,10 +1,10 @@
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists.Responses; namespace Musify.Application.PlayLists.Responses
public static class PlayListProjections
{ {
public static class PlayListProjections
{
public const int CoverTrackCount = 4; public const int CoverTrackCount = 4;
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) => public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
@@ -34,4 +34,5 @@ public static class PlayListProjections
playList.PlayListTracks playList.PlayListTracks
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active) .Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
.Sum(playListTrack => playListTrack.Track.DurationSeconds))); .Sum(playListTrack => playListTrack.Track.DurationSeconds)));
}
} }
@@ -11,9 +11,9 @@ using Musify.Application.Services;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.PlayLists; namespace Musify.Application.PlayLists
{
public record UpdatePlayListCommand( public record UpdatePlayListCommand(
long UserId, long UserId,
Guid PlayListId, Guid PlayListId,
string? NewName, string? NewName,
@@ -22,14 +22,14 @@ public record UpdatePlayListCommand(
PlayListVisibility? NewVisibility = null) PlayListVisibility? NewVisibility = null)
: ICommand<ErrorOr<PlayListApplicationResponse>>; : ICommand<ErrorOr<PlayListApplicationResponse>>;
public class UpdatePlayListCommandHandler( public class UpdatePlayListCommandHandler(
IEventBus eventBus, IEventBus eventBus,
IDatabase database, IDatabase database,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<UpdatePlayListCommandHandler> logger, ILogger<UpdatePlayListCommandHandler> logger,
PlayListConfiguration playListConfiguration) PlayListConfiguration playListConfiguration)
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>> : ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
{ {
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
{ {
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken); var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
@@ -77,4 +77,5 @@ public class UpdatePlayListCommandHandler(
.SelectResponse() .SelectResponse()
.SingleAsync(cancellationToken); .SingleAsync(cancellationToken);
} }
}
} }
@@ -2,15 +2,15 @@ using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
namespace Musify.Application.Services; namespace Musify.Application.Services
{
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary> /// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
public sealed class TrackStreamIssuer( public sealed class TrackStreamIssuer(
IStreamTicketService ticketService, IStreamTicketService ticketService,
TrackConfiguration trackConfiguration, TrackConfiguration trackConfiguration,
StreamGatewayConfiguration streamGatewayConfiguration, StreamGatewayConfiguration streamGatewayConfiguration,
PlaybackConfiguration playbackConfiguration) PlaybackConfiguration playbackConfiguration)
{ {
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId) public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
{ {
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName); var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
@@ -32,4 +32,5 @@ public sealed class TrackStreamIssuer(
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond; return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
} }
}
} }
@@ -8,31 +8,31 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Services; namespace Musify.Application.Services
{
public sealed record UploadRequest( public sealed record UploadRequest(
UploadIntentPurpose Purpose, UploadIntentPurpose Purpose,
PictureRoutes Routes, PictureRoutes Routes,
string FileType, string FileType,
string ContentType, string ContentType,
long? ExpectedSizeBytes); long? ExpectedSizeBytes);
public sealed record IssuedUpload( public sealed record IssuedUpload(
Guid IntentId, Guid IntentId,
string Key, string Key,
string ObjectName, string ObjectName,
string ContentType, string ContentType,
string UploadUrl); string UploadUrl);
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary> /// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
public sealed class UploadIntentIssuer( public sealed class UploadIntentIssuer(
IDatabase database, IDatabase database,
IStorageService storageService, IStorageService storageService,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ApplicationStorageConfiguration storageConfiguration, ApplicationStorageConfiguration storageConfiguration,
UploadIntentConfiguration uploadIntentConfiguration, UploadIntentConfiguration uploadIntentConfiguration,
ILogger<UploadIntentIssuer> logger) ILogger<UploadIntentIssuer> logger)
{ {
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync( public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
long userId, long userId,
IReadOnlyList<UploadRequest> requests, IReadOnlyList<UploadRequest> requests,
@@ -130,4 +130,5 @@ public sealed class UploadIntentIssuer(
purpose == UploadIntentPurpose.TrackAudio purpose == UploadIntentPurpose.TrackAudio
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes ? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes; : uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
}
} }
@@ -6,13 +6,13 @@ using Musify.Application.Shared;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Services; namespace Musify.Application.Services
{
public sealed class UploadIntentValidator( public sealed class UploadIntentValidator(
IDatabase database, IDatabase database,
IStorageService storageService, IStorageService storageService,
UploadIntentConfiguration config) UploadIntentConfiguration config)
{ {
public async Task<ErrorOr<Success>> CheckQuotaAsync( public async Task<ErrorOr<Success>> CheckQuotaAsync(
long userId, long userId,
long requiredBytes, long requiredBytes,
@@ -74,4 +74,5 @@ public sealed class UploadIntentValidator(
return intent; return intent;
} }
}
} }
@@ -1,9 +1,9 @@
using ErrorOr; using ErrorOr;
namespace Musify.Application.Shared; namespace Musify.Application.Shared
public static class AppErrors
{ {
public static class AppErrors
{
public static Error NotFound(string entity, object id) => public static Error NotFound(string entity, object id) =>
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found."); Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
@@ -12,4 +12,5 @@ public static class AppErrors
public static Error Conflict(string code, string description) => public static Error Conflict(string code, string description) =>
Error.Conflict(code, description); Error.Conflict(code, description);
}
} }
@@ -1,11 +1,12 @@
namespace Musify.Application.Shared; namespace Musify.Application.Shared
{
public record ImageSize( public record ImageSize(
string SavePath, string SavePath,
int Width, int Width,
int Height); int Height);
public record ImageSizes( public record ImageSizes(
ImageSize Small, ImageSize Small,
ImageSize Medium, ImageSize Medium,
ImageSize Large); ImageSize Large);
}
@@ -3,10 +3,10 @@ using Microsoft.EntityFrameworkCore;
using Musify.Domain.Abstractions; using Musify.Domain.Abstractions;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Shared; namespace Musify.Application.Shared
public static class OwnershipExtensions
{ {
public static class OwnershipExtensions
{
/// <summary> /// <summary>
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing /// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
/// or not active, Forbidden when someone else owns it. /// or not active, Forbidden when someone else owns it.
@@ -25,4 +25,5 @@ public static class OwnershipExtensions
? entity ? entity
: AppErrors.Forbidden(typeof(T).Name, id); : AppErrors.Forbidden(typeof(T).Name, id);
} }
}
} }
@@ -1,13 +1,13 @@
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
namespace Musify.Application.Shared; namespace Musify.Application.Shared
public record PageRequest(int PageNumber = 1, int PageSize = 20)
{ {
public record PageRequest(int PageNumber = 1, int PageSize = 20)
{
public const int MaxPageSize = 100; public const int MaxPageSize = 100;
} }
public record PaginatedResponse<T>( public record PaginatedResponse<T>(
IReadOnlyList<T> Items, IReadOnlyList<T> Items,
int PageNumber, int PageNumber,
int PageSize, int PageSize,
@@ -16,8 +16,8 @@ public record PaginatedResponse<T>(
bool HasNextPage, bool HasNextPage,
bool HasPreviousPage); bool HasPreviousPage);
public static class PaginationExtensions public static class PaginationExtensions
{ {
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary> /// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>( public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken) this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
@@ -42,4 +42,5 @@ public static class PaginationExtensions
HasNextPage: pageNumber < pageCount, HasNextPage: pageNumber < pageCount,
HasPreviousPage: pageNumber > 1 && pageCount > 0); HasPreviousPage: pageNumber > 1 && pageCount > 0);
} }
}
} }
@@ -1,11 +1,12 @@
namespace Musify.Application.Shared; namespace Musify.Application.Shared
public static class StorageKey
{ {
public static class StorageKey
{
public static string Combine(params string[] segments) public static string Combine(params string[] segments)
{ {
return string.Join('/', segments return string.Join('/', segments
.Where(static s => !string.IsNullOrWhiteSpace(s)) .Where(static s => !string.IsNullOrWhiteSpace(s))
.Select(static s => s.Trim().Trim('/', '\\'))); .Select(static s => s.Trim().Trim('/', '\\')));
} }
}
} }
@@ -1,6 +1,7 @@
namespace Musify.Application.Shared; namespace Musify.Application.Shared
public static class TextNormalizer
{ {
public static class TextNormalizer
{
public static string Normalize(string value) => value.Trim().ToUpperInvariant(); public static string Normalize(string value) => value.Trim().ToUpperInvariant();
}
} }
@@ -4,16 +4,16 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Configuration; using Musify.Application.Configuration;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Events; using Musify.Application.Events;
using Musify.Application.Services; using Musify.Application.Services;
using Musify.Application.Shared;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks; namespace Musify.Application.Tracks
{
public record CreateTrackCommand( public record CreateTrackCommand(
long UserId, long UserId,
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
@@ -22,14 +22,14 @@ public record CreateTrackCommand(
bool IsExplicit = false) bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>; : ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
UploadIntentValidator uploadIntentValidator, UploadIntentValidator uploadIntentValidator,
ILogger<CreateTrackCommandHandler> logger, ILogger<CreateTrackCommandHandler> logger,
TrackConfiguration trackConfiguration) TrackConfiguration trackConfiguration)
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>> : ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
{ {
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
{ {
var user = await database.Users var user = await database.Users
@@ -110,4 +110,5 @@ public class CreateTrackCommandHandler(
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0); return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
} }
}
} }
@@ -3,15 +3,15 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging; using Microsoft.Extensions.Logging;
using Musify.Application.Contracts; using Musify.Application.Contracts;
namespace Musify.Application.Tracks; namespace Musify.Application.Tracks
{
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public record DeleteStaleUncountedListensCommand : ICommand<int>; public class DeleteStaleUncountedListensCommandHandler(
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database, IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger) ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int> : ICommandHandler<DeleteStaleUncountedListensCommand, int>
{ {
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken) public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{ {
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays); var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
@@ -29,4 +29,5 @@ public class DeleteStaleUncountedListensCommandHandler(
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays); logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count; return stale.Count;
} }
}
} }
@@ -6,19 +6,19 @@ using Musify.Application.Events;
using Musify.Application.Shared; using Musify.Application.Shared;
using Musify.Domain.ValueObjects; using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks; namespace Musify.Application.Tracks
{
public record DeleteTrackCommand( public record DeleteTrackCommand(
long UserId, long UserId,
Guid TrackId) Guid TrackId)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
public class DeleteTrackCommandHandler( public class DeleteTrackCommandHandler(
IDatabase database, IDatabase database,
IEventBus eventBus, IEventBus eventBus,
ILogger<DeleteTrackCommandHandler> logger) ILogger<DeleteTrackCommandHandler> logger)
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>> : ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
{ {
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken) public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
{ {
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken); var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
@@ -38,4 +38,5 @@ public class DeleteTrackCommandHandler(
return Result.Success; return Result.Success;
} }
}
} }

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