Compare commits
84
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a081b85efc | ||
|
|
76b62ec384 | ||
|
|
2a7e805480 | ||
|
|
dddb57c98b | ||
|
|
058a902a81 | ||
|
|
153c33bf51 | ||
|
|
2b2f4093fb | ||
|
|
8a5b1bd353 | ||
|
|
03f5936944 | ||
|
|
a888a44b41 | ||
|
|
d874e06d64 | ||
|
|
f3ed68856c | ||
|
|
3eccf6dc53 | ||
|
|
e19aba777d | ||
|
|
493cc74cfb | ||
|
|
9f592cfdb8 | ||
|
|
93d7a23c53 | ||
|
|
202164aa50 | ||
|
|
ebfd41b160 | ||
|
|
1c20e106fa | ||
|
|
43928ba9b9 | ||
|
|
ebe9769d4e | ||
|
|
074ea7bf26 | ||
|
|
7cac6ff446 | ||
|
|
d839a72700 | ||
|
|
797bed8e7d | ||
|
|
9928840577 | ||
|
|
4ac73d8fa3 | ||
|
|
88487064e1 | ||
|
|
e4e4617f6e | ||
|
|
c2100c189f | ||
|
|
1a302491d1 | ||
|
|
e84c16cf92 | ||
|
|
ece6ce2a43 | ||
|
|
ecf9faf640 | ||
|
|
35d6314ce8 | ||
|
|
f99f74f4a1 | ||
|
|
f2765e2e7b | ||
|
|
7b8f3f1222 | ||
|
|
c33b0e8bfd | ||
|
|
8e85af2b3b | ||
|
|
2d23eb87c6 | ||
|
|
e5ce63a192 | ||
|
|
b5c9658d65 | ||
|
|
ba0a2373b5 | ||
|
|
b0163f6a4d | ||
|
|
0a192645d6 | ||
|
|
351b333529 | ||
|
|
155f0e2b6a | ||
|
|
a54d1ef077 | ||
|
|
2361b88e0a | ||
|
|
e7c2fd5571 | ||
|
|
b8524aa5a8 | ||
|
|
73e37dbecd | ||
|
|
3126cb6dd6 | ||
|
|
16478678b5 | ||
|
|
03afcc8a90 | ||
|
|
7f51145915 | ||
|
|
28a7c4a98c | ||
|
|
2d0867e68a | ||
|
|
bc6ca3651f | ||
|
|
c4cf5af7f1 | ||
|
|
fcf3093ad8 | ||
|
|
405df082cc | ||
|
|
f210fa4702 | ||
|
|
4b708aa987 | ||
|
|
bd07b1134e | ||
|
|
c041daf649 | ||
|
|
975fa7e4fd | ||
|
|
1a2ae37f49 | ||
|
|
1cad36b929 | ||
|
|
8cb9386447 | ||
|
|
5cda7cb4c1 | ||
|
|
e89463fb83 | ||
|
|
b011ccdd5b | ||
|
|
1df7f6ccf6 | ||
|
|
2915737f03 | ||
|
|
5b2a0e4d46 | ||
|
|
37fc229bf6 | ||
|
|
bc43b4b6eb | ||
|
|
dda8c30d45 | ||
|
|
906f38140a | ||
|
|
3e546d4a1e | ||
|
|
b2f450dcf7 |
@@ -14,6 +14,12 @@
|
|||||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
||||||
"port": 5111
|
"port": 5111
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "musify-worker",
|
||||||
|
"runtimeExecutable": "dotnet",
|
||||||
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Worker"],
|
||||||
|
"port": 5112
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "musify-streaming-gateway",
|
"name": "musify-streaming-gateway",
|
||||||
"runtimeExecutable": "dotnet",
|
"runtimeExecutable": "dotnet",
|
||||||
|
|||||||
+34
-20
@@ -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
|
||||||
|
|||||||
@@ -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,71 +7,70 @@ 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>
|
|
||||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTracksToAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTracksToAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class AddTracksToAlbumCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddTracksToAlbumCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
||||||
if (album.IsError)
|
|
||||||
return album.Errors;
|
|
||||||
|
|
||||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
|
||||||
|
|
||||||
var tracks = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => new { track.Id, track.OwnerUserId })
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
|
||||||
if (missing != Guid.Empty)
|
|
||||||
return AppErrors.NotFound("Track", missing);
|
|
||||||
|
|
||||||
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
|
||||||
if (foreign != null)
|
|
||||||
return AppErrors.Forbidden("Track", foreign.Id);
|
|
||||||
|
|
||||||
var alreadyInAlbum = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
|
||||||
.Select(link => link.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
|
||||||
if (toAdd.Count == 0)
|
|
||||||
return Result.Success;
|
|
||||||
|
|
||||||
// Album track numbers are user-visible, so they start at 1.
|
|
||||||
var nextTrackNumber = (await database.AlbumHasTracks
|
|
||||||
.Where(link => link.AlbumId == request.AlbumId)
|
|
||||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
|
||||||
|
|
||||||
foreach (var trackId in toAdd)
|
|
||||||
{
|
{
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (album.IsError)
|
||||||
|
return album.Errors;
|
||||||
|
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => new { track.Id, track.OwnerUserId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||||
|
if (missing != Guid.Empty)
|
||||||
|
return AppErrors.NotFound("Track", missing);
|
||||||
|
|
||||||
|
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||||
|
if (foreign != null)
|
||||||
|
return AppErrors.Forbidden("Track", foreign.Id);
|
||||||
|
|
||||||
|
var alreadyInAlbum = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
var nextTrackNumber = (await database.AlbumHasTracks
|
||||||
|
.Where(link => link.AlbumId == request.AlbumId)
|
||||||
|
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||||
|
|
||||||
|
foreach (var trackId in toAdd)
|
||||||
{
|
{
|
||||||
AlbumId = request.AlbumId,
|
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||||
TrackId = trackId,
|
{
|
||||||
TrackNumber = nextTrackNumber++
|
AlbumId = request.AlbumId,
|
||||||
}, cancellationToken);
|
TrackId = trackId,
|
||||||
|
TrackNumber = nextTrackNumber++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return saved;
|
|
||||||
|
|
||||||
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,61 +12,93 @@ 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(
|
|
||||||
long UserId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
Guid PictureIntentId)
|
|
||||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class CreateAlbumCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<CreateAlbumCommandHandler> logger,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
public record CreateAlbumCommand(
|
||||||
|
long UserId,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
int? ReleaseYear,
|
||||||
|
Guid PictureIntentId,
|
||||||
|
AlbumType Type = AlbumType.Album,
|
||||||
|
IReadOnlyList<Guid>? TrackIds = null)
|
||||||
|
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
|
public class CreateAlbumCommandHandler(
|
||||||
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ILogger<CreateAlbumCommandHandler> logger,
|
||||||
|
AlbumConfiguration albumConfiguration)
|
||||||
|
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
var userExists = await database.Users
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
|
||||||
var title = request.Title.Trim();
|
|
||||||
|
|
||||||
var album = new Album
|
|
||||||
{
|
{
|
||||||
OwnerUserId = request.UserId,
|
var userExists = await database.Users
|
||||||
Title = title,
|
.AsNoTracking()
|
||||||
NormalizedTitle = TextNormalizer.Normalize(title),
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
Description = request.Description,
|
if (!userExists)
|
||||||
ReleaseYear = request.ReleaseYear,
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
Pictures = picture.Pictures
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
var trackIds = (request.TrackIds ?? []).Distinct().ToList();
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
var missing = trackIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||||
new CreateAlbumResourcesEvent(
|
if (missing != Guid.Empty)
|
||||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
return AppErrors.NotFound("Track", missing);
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||||
|
if (foreign != null)
|
||||||
|
return AppErrors.Forbidden("Track", foreign.Id);
|
||||||
|
|
||||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
var picture = prepared.Value;
|
||||||
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
|
var album = new Album
|
||||||
|
{
|
||||||
|
OwnerUserId = request.UserId,
|
||||||
|
Title = title,
|
||||||
|
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||||
|
Description = request.Description,
|
||||||
|
ReleaseYear = request.ReleaseYear,
|
||||||
|
Type = request.Type,
|
||||||
|
Pictures = picture.Pictures
|
||||||
|
};
|
||||||
|
|
||||||
|
await database.Albums.AddAsync(album, cancellationToken);
|
||||||
|
|
||||||
|
for (var index = 0; index < trackIds.Count; index++)
|
||||||
|
{
|
||||||
|
await database.AlbumHasTracks.AddAsync(
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await eventBus.PublishAsync(
|
||||||
|
new CreateAlbumResourcesEvent(
|
||||||
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
|
||||||
|
|
||||||
|
var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet();
|
||||||
|
var visibleTrackIds = trackIds.Where(published.Contains).ToList();
|
||||||
|
|
||||||
|
return AlbumApplicationResponse.FromEntity(
|
||||||
|
album,
|
||||||
|
visibleTrackIds.Count,
|
||||||
|
[.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,31 +6,32 @@ 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)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class DeleteAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
ILogger<DeleteAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
public record DeleteAlbumCommand(long UserId, Guid AlbumId)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class DeleteAlbumCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
|
ILogger<DeleteAlbumCommandHandler> logger)
|
||||||
|
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||||
if (found.IsError)
|
{
|
||||||
return found.Errors;
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
var album = found.Value;
|
var album = found.Value;
|
||||||
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
||||||
|
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,25 +5,26 @@ 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)
|
|
||||||
: IQuery<ErrorOr<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetAlbumByIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
public record GetAlbumByIdQuery(Guid Id)
|
||||||
{
|
: IQuery<ErrorOr<AlbumApplicationResponse>>;
|
||||||
var album = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.Where(a => a.Id == request.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return album == null
|
public class GetAlbumByIdQueryHandler(IDatabase database)
|
||||||
? AppErrors.NotFound("Album", request.Id)
|
: IQueryHandler<GetAlbumByIdQuery, ErrorOr<AlbumApplicationResponse>>
|
||||||
: album;
|
{
|
||||||
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(GetAlbumByIdQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var album = await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.Where(a => a.Id == request.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return album == null
|
||||||
|
? AppErrors.NotFound("Album", request.Id)
|
||||||
|
: album;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId)
|
||||||
|
: IQuery<ErrorOr<AlbumTrackIdsResponse>>;
|
||||||
|
|
||||||
|
public class GetAlbumTrackIdsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetAlbumTrackIdsQuery, ErrorOr<AlbumTrackIdsResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AlbumTrackIdsResponse>> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var trackIds = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId
|
||||||
|
&& albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new AlbumTrackIdsResponse(trackIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -6,33 +6,34 @@ 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(
|
|
||||||
Guid AlbumId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetAlbumTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
|
public record GetAlbumTracksQuery(
|
||||||
{
|
Guid AlbumId,
|
||||||
var albumExists = await database.Albums
|
int PageNumber,
|
||||||
.AsNoTracking()
|
int PageSize)
|
||||||
.Active()
|
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (!albumExists)
|
|
||||||
return AppErrors.NotFound("Album", request.AlbumId);
|
|
||||||
|
|
||||||
return await database.AlbumHasTracks
|
public class GetAlbumTracksQueryHandler(IDatabase database)
|
||||||
.AsNoTracking()
|
: IQueryHandler<GetAlbumTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
{
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetAlbumTracksQuery request, CancellationToken cancellationToken)
|
||||||
.ThenBy(albumTrack => albumTrack.Id)
|
{
|
||||||
.Select(albumTrack => albumTrack.Track)
|
var albumExists = await database.Albums
|
||||||
.SelectResponse()
|
.AsNoTracking()
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
.Active()
|
||||||
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
|
if (!albumExists)
|
||||||
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
|
|
||||||
|
return await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
|
.Select(albumTrack => albumTrack.Track)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,30 +4,31 @@ 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)
|
|
||||||
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||||
|
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetAlbumsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
var albumsQuery = database.Albums
|
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.Active();
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Title))
|
|
||||||
{
|
{
|
||||||
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
var albumsQuery = database.Albums
|
||||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
.AsNoTracking()
|
||||||
}
|
.Active();
|
||||||
|
|
||||||
return await albumsQuery
|
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
{
|
||||||
.ThenBy(album => album.Id)
|
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
||||||
.SelectResponse()
|
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
}
|
||||||
|
|
||||||
|
return await albumsQuery
|
||||||
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
|
.ThenBy(album => album.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,29 +5,30 @@ 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)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
|
public record GetAlbumsByUserIdQuery(long UserId, int PageNumber, int PageSize)
|
||||||
{
|
: IQuery<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>;
|
||||||
var userExists = await database.Users
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
return await database.Albums
|
public class GetAlbumsByUserIdQueryHandler(IDatabase database)
|
||||||
.AsNoTracking()
|
: IQueryHandler<GetAlbumsByUserIdQuery, ErrorOr<PaginatedResponse<AlbumApplicationResponse>>>
|
||||||
.Active()
|
{
|
||||||
.Where(album => album.OwnerUserId == request.UserId)
|
public async ValueTask<ErrorOr<PaginatedResponse<AlbumApplicationResponse>>> Handle(GetAlbumsByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
{
|
||||||
.ThenBy(album => album.Id)
|
var userExists = await database.Users
|
||||||
.SelectResponse()
|
.AsNoTracking()
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
|
if (!userExists)
|
||||||
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
|
return await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.Where(album => album.OwnerUserId == request.UserId)
|
||||||
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
|
.ThenBy(album => album.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,49 +3,51 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums
|
||||||
|
|
||||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
|
||||||
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||||
|
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
var listens = database.ListeningHistories
|
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
{
|
||||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
var listens = database.ListeningHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||||
|
|
||||||
var ranked = await database.AlbumHasTracks
|
var ranked = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.Join(
|
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
listens,
|
.Join(
|
||||||
albumTrack => albumTrack.TrackId,
|
listens,
|
||||||
history => history.TrackId,
|
albumTrack => albumTrack.TrackId,
|
||||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
history => history.TrackId,
|
||||||
.GroupBy(entry => entry.AlbumId)
|
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
.GroupBy(entry => entry.AlbumId)
|
||||||
.OrderByDescending(entry => entry.LastListenedAt)
|
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||||
.ThenBy(entry => entry.AlbumId)
|
.OrderByDescending(entry => entry.LastListenedAt)
|
||||||
.Take(request.Limit)
|
.ThenBy(entry => entry.AlbumId)
|
||||||
.ToListAsync(cancellationToken);
|
.Take(request.Limit)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (ranked.Count == 0)
|
if (ranked.Count == 0)
|
||||||
return [];
|
return [];
|
||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||||
|
|
||||||
var albumsById = await database.Albums
|
var albumsById = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
.Where(album => albumIds.Contains(album.Id))
|
||||||
.SelectResponse()
|
.SelectResponse()
|
||||||
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
||||||
.Select(entry => albumsById[entry.AlbumId])
|
.Select(entry => albumsById[entry.AlbumId])
|
||||||
.ToList();
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,41 +5,42 @@ 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)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class RemoveTrackFromAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
public record RemoveTrackFromAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class RemoveTrackFromAlbumCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<RemoveTrackFromAlbumCommandHandler> logger)
|
||||||
|
: ICommandHandler<RemoveTrackFromAlbumCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||||
if (album.IsError)
|
{
|
||||||
return album.Errors;
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (album.IsError)
|
||||||
|
return album.Errors;
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
var albumTracks = await database.AlbumHasTracks
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
||||||
if (link == null)
|
if (link == null)
|
||||||
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
||||||
|
|
||||||
database.AlbumHasTracks.Remove(link);
|
database.AlbumHasTracks.Remove(link);
|
||||||
|
|
||||||
var trackNumber = 1;
|
var trackNumber = 1;
|
||||||
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
|
foreach (var remaining in albumTracks.Where(albumTrack => albumTrack.Id != link.Id))
|
||||||
remaining.TrackNumber = trackNumber++;
|
remaining.TrackNumber = trackNumber++;
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
logger.LogInformation("Removed track {TrackId} from album {AlbumId}", request.TrackId, request.AlbumId);
|
||||||
|
|
||||||
return Result.Success;
|
return 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(
|
|
||||||
long UserId,
|
|
||||||
string FileType,
|
|
||||||
string ContentType,
|
|
||||||
long? ExpectedSizeBytes = null)
|
|
||||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
|
||||||
|
|
||||||
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
public record RequestAlbumPictureUploadCommand(
|
||||||
await issuer.IssuePictureAsync(
|
long UserId,
|
||||||
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
string FileType,
|
||||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
string ContentType,
|
||||||
|
long? ExpectedSizeBytes = null)
|
||||||
|
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||||
|
|
||||||
|
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
||||||
|
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||||
|
await issuer.IssuePictureAsync(
|
||||||
|
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
||||||
|
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,40 +1,44 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses;
|
namespace Musify.Application.Albums.Responses
|
||||||
|
|
||||||
public record AlbumApplicationResponse(
|
|
||||||
Guid Id,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
long OwnerUserId,
|
|
||||||
int TrackCount,
|
|
||||||
string? SmallImageKeyName,
|
|
||||||
string? MediumImageKeyName,
|
|
||||||
string? LargeImageKeyName,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
public record AlbumApplicationResponse(
|
||||||
|
Guid Id,
|
||||||
public static AlbumApplicationResponse FromEntity(
|
string Title,
|
||||||
Album album,
|
string? Description,
|
||||||
int trackCount,
|
int? ReleaseYear,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
long OwnerUserId,
|
||||||
|
int TrackCount,
|
||||||
|
string? SmallImageKeyName,
|
||||||
|
string? MediumImageKeyName,
|
||||||
|
string? LargeImageKeyName,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt,
|
||||||
|
IReadOnlyList<Guid> CoverTrackIds,
|
||||||
|
AlbumType Type)
|
||||||
{
|
{
|
||||||
return new AlbumApplicationResponse(
|
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||||
album.Id,
|
|
||||||
album.Title,
|
public static AlbumApplicationResponse FromEntity(
|
||||||
album.Description,
|
Album album,
|
||||||
album.ReleaseYear,
|
int trackCount,
|
||||||
album.OwnerUserId,
|
IReadOnlyList<Guid>? coverTrackIds = null)
|
||||||
trackCount,
|
{
|
||||||
album.Pictures?.SmallName,
|
return new AlbumApplicationResponse(
|
||||||
album.Pictures?.MediumName,
|
album.Id,
|
||||||
album.Pictures?.LargeName,
|
album.Title,
|
||||||
album.CreatedAt,
|
album.Description,
|
||||||
album.UpdatedAt,
|
album.ReleaseYear,
|
||||||
coverTrackIds ?? []);
|
album.OwnerUserId,
|
||||||
|
trackCount,
|
||||||
|
album.Pictures?.SmallName,
|
||||||
|
album.Pictures?.MediumName,
|
||||||
|
album.Pictures?.LargeName,
|
||||||
|
album.CreatedAt,
|
||||||
|
album.UpdatedAt,
|
||||||
|
coverTrackIds ?? [],
|
||||||
|
album.Type);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,31 +1,34 @@
|
|||||||
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 const int CoverTrackCount = 4;
|
public static class AlbumProjections
|
||||||
|
{
|
||||||
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
||||||
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
||||||
query.Select(album => new AlbumApplicationResponse(
|
query.Select(album => new AlbumApplicationResponse(
|
||||||
album.Id,
|
album.Id,
|
||||||
album.Title,
|
album.Title,
|
||||||
album.Description,
|
album.Description,
|
||||||
album.ReleaseYear,
|
album.ReleaseYear,
|
||||||
album.OwnerUserId,
|
album.OwnerUserId,
|
||||||
album.AlbumTracks.Count,
|
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
|
||||||
album.Pictures!.SmallName,
|
album.Pictures!.SmallName,
|
||||||
album.Pictures!.MediumName,
|
album.Pictures!.MediumName,
|
||||||
album.Pictures!.LargeName,
|
album.Pictures!.LargeName,
|
||||||
album.CreatedAt,
|
album.CreatedAt,
|
||||||
album.UpdatedAt,
|
album.UpdatedAt,
|
||||||
album.AlbumTracks
|
album.AlbumTracks
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
.Take(CoverTrackCount)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
.Take(CoverTrackCount)
|
||||||
.ToList()));
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToList(),
|
||||||
|
album.Type));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.Albums.Responses
|
||||||
|
{
|
||||||
|
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
|
||||||
|
}
|
||||||
@@ -3,71 +3,50 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums;
|
namespace Musify.Application.Albums
|
||||||
|
|
||||||
public record UpdateAlbumCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid AlbumId,
|
|
||||||
string Title,
|
|
||||||
string? Description,
|
|
||||||
int? ReleaseYear,
|
|
||||||
Guid? NewPictureIntentId)
|
|
||||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
|
||||||
|
|
||||||
public class UpdateAlbumCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<UpdateAlbumCommandHandler> logger,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
public record UpdateAlbumCommand(
|
||||||
|
long UserId,
|
||||||
|
Guid AlbumId,
|
||||||
|
string Title,
|
||||||
|
string? Description,
|
||||||
|
int? ReleaseYear,
|
||||||
|
AlbumType? Type = null)
|
||||||
|
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
|
public class UpdateAlbumCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<UpdateAlbumCommandHandler> logger)
|
||||||
|
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var album = found.Value;
|
|
||||||
var title = request.Title.Trim();
|
|
||||||
|
|
||||||
album.Title = title;
|
|
||||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
|
||||||
album.Description = request.Description;
|
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId is { } intentId)
|
|
||||||
{
|
{
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
if (found.IsError)
|
||||||
if (prepared.IsError)
|
return found.Errors;
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
var album = found.Value;
|
||||||
album.Pictures = picture.Pictures;
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
album.Title = title;
|
||||||
new UpdateAlbumPictureSourceEvent(
|
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
|
||||||
cancellationToken);
|
album.ReleaseYear = request.ReleaseYear;
|
||||||
|
album.Type = request.Type ?? album.Type;
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
||||||
|
|
||||||
|
return await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Id == album.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
|
||||||
|
|
||||||
return await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(a => a.Id == album.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
|
||||||
|
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
|
public class UpdateAlbumPictureCommandHandler(
|
||||||
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ILogger<UpdateAlbumPictureCommandHandler> logger,
|
||||||
|
AlbumConfiguration albumConfiguration)
|
||||||
|
: ICommandHandler<UpdateAlbumPictureCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumPictureCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var album = found.Value;
|
||||||
|
|
||||||
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
var picture = prepared.Value;
|
||||||
|
album.Pictures = picture.Pictures;
|
||||||
|
|
||||||
|
await eventBus.PublishAsync(
|
||||||
|
new UpdateAlbumPictureSourceEvent(
|
||||||
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated picture of album {AlbumId}", album.Id);
|
||||||
|
|
||||||
|
return await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Id == album.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,26 +3,27 @@ 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 IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
public static class ApplicationDependencyInjection
|
||||||
{
|
{
|
||||||
services.AddMediator();
|
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||||
services.AddScoped<UploadIntentValidator>();
|
{
|
||||||
services.AddScoped<UploadIntentIssuer>();
|
services.AddMediator();
|
||||||
services.AddScoped<TrackStreamIssuer>();
|
services.AddScoped<UploadIntentValidator>();
|
||||||
|
services.AddScoped<UploadIntentIssuer>();
|
||||||
|
services.AddScoped<TrackStreamIssuer>();
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<MixConfiguration>(configuration);
|
services.AddValidatedOptions<MixConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Coauthors.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record GetCoauthorInvitationsQuery(long UserId, int PageNumber, int PageSize)
|
||||||
|
: IQuery<PaginatedResponse<CoauthorInvitationResponse>>;
|
||||||
|
|
||||||
|
public class GetCoauthorInvitationsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetCoauthorInvitationsQuery, PaginatedResponse<CoauthorInvitationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<PaginatedResponse<CoauthorInvitationResponse>> Handle(GetCoauthorInvitationsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await database.TrackCoauthors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(coauthor => coauthor.UserId == request.UserId
|
||||||
|
&& coauthor.Status == CoauthorStatus.Pending
|
||||||
|
&& coauthor.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.OrderByDescending(coauthor => coauthor.InvitedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.Id)
|
||||||
|
.Select(coauthor => new CoauthorInvitationResponse(
|
||||||
|
coauthor.Id,
|
||||||
|
coauthor.TrackId,
|
||||||
|
coauthor.Track.Title,
|
||||||
|
coauthor.Track.Owner.Name,
|
||||||
|
coauthor.Track.OwnerUserId,
|
||||||
|
coauthor.InvitedAt))
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record InviteCoauthorsCommand(long OwnerId, Guid TrackId, IReadOnlyList<long> UserIds)
|
||||||
|
: ICommand<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>;
|
||||||
|
|
||||||
|
public class InviteCoauthorsCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<InviteCoauthorsCommandHandler> logger)
|
||||||
|
: ICommandHandler<InviteCoauthorsCommand, ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>> Handle(InviteCoauthorsCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.OwnerId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var userIds = request.UserIds.Distinct().ToList();
|
||||||
|
if (userIds.Contains(request.OwnerId))
|
||||||
|
return Error.Validation("Coauthor.Self", "You cannot invite yourself as a coauthor.");
|
||||||
|
|
||||||
|
var existingUserIds = await database.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(user => userIds.Contains(user.Id))
|
||||||
|
.Select(user => user.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = userIds.Where(id => !existingUserIds.Contains(id)).Select(id => (long?)id).FirstOrDefault();
|
||||||
|
if (missing != null)
|
||||||
|
return AppErrors.NotFound("User", missing.Value);
|
||||||
|
|
||||||
|
var existing = await database.TrackCoauthors
|
||||||
|
.Where(coauthor => coauthor.TrackId == request.TrackId && userIds.Contains(coauthor.UserId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var alreadyInvited = existing.FirstOrDefault(coauthor => coauthor.Status != CoauthorStatus.Rejected);
|
||||||
|
if (alreadyInvited != null)
|
||||||
|
return AppErrors.Conflict("Coauthor.AlreadyInvited", $"User {alreadyInvited.UserId} was already invited to this track.");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var rejected in existing)
|
||||||
|
{
|
||||||
|
rejected.Status = CoauthorStatus.Pending;
|
||||||
|
rejected.InvitedAt = now;
|
||||||
|
rejected.RespondedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reinvitedIds = existing.Select(coauthor => coauthor.UserId).ToHashSet();
|
||||||
|
foreach (var userId in userIds.Where(id => !reinvitedIds.Contains(id)))
|
||||||
|
{
|
||||||
|
await database.TrackCoauthors.AddAsync(new TrackCoauthor
|
||||||
|
{
|
||||||
|
TrackId = request.TrackId,
|
||||||
|
UserId = userId,
|
||||||
|
InvitedAt = now
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("Coauthor.AlreadyInvited", "A user was already invited to this track."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved.Errors;
|
||||||
|
|
||||||
|
logger.LogInformation("Owner {OwnerId} invited {Count} coauthors to track {TrackId}", request.OwnerId, userIds.Count, request.TrackId);
|
||||||
|
|
||||||
|
return await database.TrackCoauthors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(coauthor => coauthor.TrackId == request.TrackId)
|
||||||
|
.OrderBy(coauthor => coauthor.InvitedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.UserId)
|
||||||
|
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record RemoveCoauthorCommand(long UserId, Guid TrackId, long CoauthorUserId)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class RemoveCoauthorCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<RemoveCoauthorCommandHandler> logger)
|
||||||
|
: ICommandHandler<RemoveCoauthorCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveCoauthorCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ownerUserId = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => track.Id == request.TrackId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => (long?)track.OwnerUserId)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (ownerUserId == null)
|
||||||
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
|
|
||||||
|
if (ownerUserId != request.UserId && request.CoauthorUserId != request.UserId)
|
||||||
|
return AppErrors.Forbidden("Track", request.TrackId);
|
||||||
|
|
||||||
|
var coauthor = await database.TrackCoauthors
|
||||||
|
.SingleOrDefaultAsync(c => c.TrackId == request.TrackId && c.UserId == request.CoauthorUserId, cancellationToken);
|
||||||
|
if (coauthor == null)
|
||||||
|
return AppErrors.NotFound("Coauthor", request.CoauthorUserId);
|
||||||
|
|
||||||
|
database.TrackCoauthors.Remove(coauthor);
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("User {UserId} removed coauthor {CoauthorUserId} from track {TrackId}",
|
||||||
|
request.UserId, request.CoauthorUserId, request.TrackId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record RespondToCoauthorInvitationCommand(long UserId, Guid InvitationId, bool Accept)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class RespondToCoauthorInvitationCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<RespondToCoauthorInvitationCommandHandler> logger)
|
||||||
|
: ICommandHandler<RespondToCoauthorInvitationCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(RespondToCoauthorInvitationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var invitation = await database.TrackCoauthors
|
||||||
|
.Include(coauthor => coauthor.Track)
|
||||||
|
.SingleOrDefaultAsync(coauthor => coauthor.Id == request.InvitationId, cancellationToken);
|
||||||
|
|
||||||
|
if (invitation == null || invitation.Track.LifeCycleStatus != LifeCycleStatus.Active)
|
||||||
|
return AppErrors.NotFound("CoauthorInvitation", request.InvitationId);
|
||||||
|
|
||||||
|
if (invitation.UserId != request.UserId)
|
||||||
|
return AppErrors.Forbidden("CoauthorInvitation", request.InvitationId);
|
||||||
|
|
||||||
|
if (invitation.Status != CoauthorStatus.Pending)
|
||||||
|
return AppErrors.Conflict("CoauthorInvitation.NotPending", "The invitation was already answered.");
|
||||||
|
|
||||||
|
invitation.Status = request.Accept ? CoauthorStatus.Accepted : CoauthorStatus.Rejected;
|
||||||
|
invitation.RespondedAt = DateTime.UtcNow;
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("User {UserId} {Action} coauthor invitation {InvitationId}",
|
||||||
|
request.UserId, request.Accept ? "accepted" : "rejected", request.InvitationId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Musify.Application.Coauthors.Responses
|
||||||
|
{
|
||||||
|
public record CoauthorInvitationResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid TrackId,
|
||||||
|
string TrackTitle,
|
||||||
|
string? Artist,
|
||||||
|
long OwnerUserId,
|
||||||
|
DateTime InvitedAt);
|
||||||
|
}
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
|
||||||
{
|
{
|
||||||
public static string SectionName => "Album";
|
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
|
{
|
||||||
|
public static string SectionName => "Album";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
||||||
|
|
||||||
[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 static string SectionName => "ApplicationStorage";
|
public class ApplicationStorageConfiguration : IConfigurationOptions
|
||||||
|
{
|
||||||
|
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,28 +2,29 @@ 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 IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
public static class ConfigurationDependencyInjection
|
||||||
where T : class, IConfigurationOptions =>
|
|
||||||
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
|
||||||
|
|
||||||
public static IServiceCollection AddValidatedOptions<T>(
|
|
||||||
this IServiceCollection services,
|
|
||||||
IConfiguration configuration,
|
|
||||||
string sectionName)
|
|
||||||
where T : class
|
|
||||||
{
|
{
|
||||||
services
|
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
||||||
.AddOptionsWithValidateOnStart<T>()
|
where T : class, IConfigurationOptions =>
|
||||||
.Bind(configuration.GetRequiredSection(sectionName))
|
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
||||||
.ValidateDataAnnotations();
|
|
||||||
|
|
||||||
services.AddSingleton(serviceProvider =>
|
public static IServiceCollection AddValidatedOptions<T>(
|
||||||
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
this IServiceCollection services,
|
||||||
|
IConfiguration configuration,
|
||||||
|
string sectionName)
|
||||||
|
where T : class
|
||||||
|
{
|
||||||
|
services
|
||||||
|
.AddOptionsWithValidateOnStart<T>()
|
||||||
|
.Bind(configuration.GetRequiredSection(sectionName))
|
||||||
|
.ValidateDataAnnotations();
|
||||||
|
|
||||||
return services;
|
services.AddSingleton(serviceProvider =>
|
||||||
|
serviceProvider.GetRequiredService<IOptions<T>>().Value);
|
||||||
|
|
||||||
|
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 ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
|
||||||
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
public record GetPlaybackPublicConfigQuery : IQuery<PlaybackPublicConfigResponse>;
|
||||||
playbackConfiguration.AllowAnonymousListening,
|
|
||||||
playbackConfiguration.AnonymousFragmentSeconds));
|
public class GetPlaybackPublicConfigQueryHandler(PlaybackConfiguration playbackConfiguration)
|
||||||
|
: IQueryHandler<GetPlaybackPublicConfigQuery, PlaybackPublicConfigResponse>
|
||||||
|
{
|
||||||
|
public ValueTask<PlaybackPublicConfigResponse> Handle(GetPlaybackPublicConfigQuery query, CancellationToken cancellationToken) =>
|
||||||
|
ValueTask.FromResult(new PlaybackPublicConfigResponse(
|
||||||
|
playbackConfiguration.AllowAnonymousListening,
|
||||||
|
playbackConfiguration.AnonymousFragmentSeconds));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public interface IConfigurationOptions
|
|
||||||
{
|
{
|
||||||
public static abstract string SectionName { get; }
|
public interface IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static abstract string SectionName { get; }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public class MixConfiguration : IConfigurationOptions
|
|
||||||
{
|
{
|
||||||
public static string SectionName => "Mix";
|
public class MixConfiguration : IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static string SectionName => "Mix";
|
||||||
|
|
||||||
[Range(4, 100)]
|
[Range(4, 100)]
|
||||||
public int ItemsPerMix { get; set; } = 12;
|
public int ItemsPerMix { get; set; } = 12;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,90 +1,91 @@
|
|||||||
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
|
|
||||||
{
|
{
|
||||||
Small,
|
public enum PictureSize
|
||||||
Medium,
|
|
||||||
Large
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PictureRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolder { get; set; } = string.Empty;
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
|
||||||
{
|
{
|
||||||
PictureSize.Small => SmallPicturesFolder,
|
Small,
|
||||||
PictureSize.Medium => MediumPicturesFolder,
|
Medium,
|
||||||
PictureSize.Large => LargePicturesFolder,
|
Large
|
||||||
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
}
|
||||||
});
|
|
||||||
|
|
||||||
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
public class PictureRoutes
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string UploadsFolder { get; set; } = "uploads";
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
[Required]
|
||||||
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalPicturesFolder, pictureName);
|
public string ParentFolder { get; set; } = string.Empty;
|
||||||
|
|
||||||
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
[Required]
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||||
}
|
|
||||||
|
[Required]
|
||||||
public class PictureSizes
|
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
[Required]
|
||||||
public int SmallWidth { get; set; } = 128;
|
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||||
|
|
||||||
[Range(1, 1024)]
|
[Required]
|
||||||
public int SmallHeight { get; set; } = 128;
|
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||||
|
|
||||||
[Range(1, 1024)]
|
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
||||||
public int MediumWidth { get; set; } = 256;
|
{
|
||||||
|
PictureSize.Small => SmallPicturesFolder,
|
||||||
[Range(1, 1024)]
|
PictureSize.Medium => MediumPicturesFolder,
|
||||||
public int MediumHeight { get; set; } = 256;
|
PictureSize.Large => LargePicturesFolder,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
||||||
[Range(1, 1024)]
|
});
|
||||||
public int LargeWidth { get; set; } = 512;
|
|
||||||
|
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargeHeight { get; set; } = 512;
|
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
||||||
|
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
|
||||||
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
|
||||||
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
||||||
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
|
||||||
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
}
|
||||||
}
|
|
||||||
|
public class PictureSizes
|
||||||
public interface IPictureOwnerConfiguration
|
{
|
||||||
{
|
[Range(1, 1024)]
|
||||||
public PictureRoutes Routes { get; }
|
public int SmallWidth { get; set; } = 128;
|
||||||
|
|
||||||
public PictureSizes PicturesSizes { get; }
|
[Range(1, 1024)]
|
||||||
}
|
public int SmallHeight { get; set; } = 128;
|
||||||
|
|
||||||
public static class PictureSizeParser
|
[Range(1, 1024)]
|
||||||
{
|
public int MediumWidth { get; set; } = 256;
|
||||||
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
|
||||||
public static PictureSize Parse(string? value) =>
|
[Range(1, 1024)]
|
||||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
public int MediumHeight { get; set; } = 256;
|
||||||
? size
|
|
||||||
: PictureSize.Medium;
|
[Range(1, 1024)]
|
||||||
|
public int LargeWidth { get; set; } = 512;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int LargeHeight { get; set; } = 512;
|
||||||
|
|
||||||
|
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPictureOwnerConfiguration
|
||||||
|
{
|
||||||
|
public PictureRoutes Routes { get; }
|
||||||
|
|
||||||
|
public PictureSizes PicturesSizes { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PictureSizeParser
|
||||||
|
{
|
||||||
|
public static PictureSize Parse(string? value) =>
|
||||||
|
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||||
|
? size
|
||||||
|
: PictureSize.Medium;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,14 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
|
||||||
{
|
{
|
||||||
public static string SectionName => "PlayList";
|
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
|
{
|
||||||
|
public static string SectionName => "PlayList";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PictureSizes PicturesSizes { get; set; } = new();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,17 +1,18 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
|
|
||||||
public sealed class PlaybackConfiguration : IConfigurationOptions
|
|
||||||
{
|
{
|
||||||
public static string SectionName => "Playback";
|
|
||||||
|
|
||||||
public bool AllowAnonymousListening { get; set; }
|
public sealed class PlaybackConfiguration : IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static string SectionName => "Playback";
|
||||||
|
|
||||||
[Range(0, 3600)]
|
public bool AllowAnonymousListening { get; set; }
|
||||||
public int AnonymousFragmentSeconds { get; set; }
|
|
||||||
|
|
||||||
[Range(1, int.MaxValue)]
|
[Range(0, 3600)]
|
||||||
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
public int AnonymousFragmentSeconds { get; set; }
|
||||||
|
|
||||||
|
[Range(1, int.MaxValue)]
|
||||||
|
public int EstimatedAudioBytesPerSecond { get; set; } = 16_000;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-3
@@ -1,4 +1,5 @@
|
|||||||
namespace Musify.Application.Configuration.Responses;
|
namespace Musify.Application.Configuration.Responses
|
||||||
|
{
|
||||||
|
|
||||||
|
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
||||||
public record PlaybackPublicConfigResponse(bool AllowAnonymousListening, int AnonymousFragmentSeconds);
|
}
|
||||||
|
|||||||
@@ -1,15 +1,16 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public class StreamGatewayConfiguration : IConfigurationOptions
|
|
||||||
{
|
{
|
||||||
public static string SectionName => "StreamGateway";
|
public class StreamGatewayConfiguration : IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static string SectionName => "StreamGateway";
|
||||||
|
|
||||||
[Url]
|
[Url]
|
||||||
[Required]
|
[Required]
|
||||||
public required string PublicBaseUrl { get; set; }
|
public required string PublicBaseUrl { get; set; }
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string AudioFileName { get; set; } = "audio.m4a";
|
public string AudioFileName { get; set; } = "audio.m4a";
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,55 +1,57 @@
|
|||||||
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 static string SectionName => "Track";
|
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
|
|
||||||
[Required]
|
|
||||||
public TrackRoutes Routes { get; set; } = new();
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public PictureSizes PicturesSizes { get; set; } = new();
|
|
||||||
|
|
||||||
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TrackRoutes : PictureRoutes
|
|
||||||
{
|
|
||||||
public TrackRoutes()
|
|
||||||
{
|
{
|
||||||
ParentFolder = "Tracks";
|
public static string SectionName => "Track";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public TrackRoutes Routes { get; set; } = new();
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
|
|
||||||
|
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
[Required]
|
public class TrackRoutes : PictureRoutes
|
||||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
{
|
||||||
|
public TrackRoutes()
|
||||||
|
{
|
||||||
|
ParentFolder = "Tracks";
|
||||||
|
}
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
public string PresetSmallPicture { get; set; } = "PresetSmallPicture.webp";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
public string PresetMediumPicture { get; set; } = "PresetMediumPicture.webp";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
public string PresetLargePicture { get; set; } = "PresetLargePicture.webp";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
||||||
|
|
||||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
[Required]
|
||||||
|
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
||||||
|
|
||||||
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
|
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
||||||
|
|
||||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
public string PresetMediumPicturePath => StorageKey.Combine(ParentFolder, PresetMediumPicture);
|
||||||
|
|
||||||
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
||||||
StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolder, OriginalAudiosFolder, audioName);
|
|
||||||
|
|
||||||
public string BuildProcessedAudioPath(string folderName) =>
|
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
||||||
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
|
||||||
|
|
||||||
|
public string BuildProcessedAudioPath(string folderName) =>
|
||||||
|
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,41 +1,42 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
|
||||||
public class UploadIntentConfiguration : IConfigurationOptions
|
|
||||||
{
|
{
|
||||||
public static string SectionName => "UploadIntent";
|
public class UploadIntentConfiguration : IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static string SectionName => "UploadIntent";
|
||||||
|
|
||||||
[Range(10, 3600)]
|
[Range(10, 3600)]
|
||||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||||
|
|
||||||
[Range(1, 100)]
|
[Range(1, 100)]
|
||||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
|
||||||
|
|
||||||
[Range(1, long.MaxValue)]
|
[Range(1, long.MaxValue)]
|
||||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
|
||||||
|
|
||||||
[Range(1, long.MaxValue)]
|
[Range(1, long.MaxValue)]
|
||||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||||
|
|
||||||
[Range(1, long.MaxValue)]
|
[Range(1, long.MaxValue)]
|
||||||
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
|
public long DefaultExpectedPictureSizeBytes { get; set; } = 10L * 1024 * 1024;
|
||||||
|
|
||||||
[Range(1, long.MaxValue)]
|
[Range(1, long.MaxValue)]
|
||||||
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
|
public long DefaultExpectedAudioSizeBytes { get; set; } = 100L * 1024 * 1024;
|
||||||
|
|
||||||
[Range(30, 86400)]
|
[Range(30, 86400)]
|
||||||
public int ExpirationJobIntervalSeconds { get; set; } = 300;
|
public int ExpirationJobIntervalSeconds { get; set; } = 300;
|
||||||
|
|
||||||
[Range(0, 365)]
|
[Range(0, 365)]
|
||||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||||
|
|
||||||
[Required, MinLength(1)]
|
[Required, MinLength(1)]
|
||||||
public string TempRootPrefix { get; set; } = "temp";
|
public string TempRootPrefix { get; set; } = "temp";
|
||||||
|
|
||||||
[Range(1, 30)]
|
[Range(1, 30)]
|
||||||
public int TempUploadsRetentionDays { get; set; } = 3;
|
public int TempUploadsRetentionDays { get; set; } = 3;
|
||||||
|
|
||||||
[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 Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
public interface IAudioTranscoderService
|
||||||
|
{
|
||||||
|
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,39 +2,44 @@ using ErrorOr;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.Contracts;
|
namespace Musify.Application.Contracts
|
||||||
|
|
||||||
public interface IDatabase
|
|
||||||
{
|
{
|
||||||
public DbSet<User> Users { get; }
|
public interface IDatabase
|
||||||
|
{
|
||||||
|
public DbSet<User> Users { get; }
|
||||||
|
|
||||||
public DbSet<PlayList> PlayLists { get; }
|
public DbSet<PlayList> PlayLists { get; }
|
||||||
|
|
||||||
public DbSet<Album> Albums { get; }
|
public DbSet<Album> Albums { get; }
|
||||||
|
|
||||||
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||||
|
|
||||||
public DbSet<Track> Tracks { get; }
|
public DbSet<Track> Tracks { get; }
|
||||||
|
|
||||||
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||||
|
|
||||||
public DbSet<Mix> Mixes { get; }
|
public DbSet<Mix> Mixes { get; }
|
||||||
|
|
||||||
public DbSet<MixItem> MixItems { get; }
|
public DbSet<MixItem> MixItems { get; }
|
||||||
|
|
||||||
public DbSet<UploadIntent> UploadIntents { get; }
|
public DbSet<UploadIntent> UploadIntents { get; }
|
||||||
|
|
||||||
public DbSet<ListeningHistory> ListeningHistories { get; }
|
public DbSet<ListeningHistory> ListeningHistories { get; }
|
||||||
|
|
||||||
public DbSet<TrackLike> TrackLikes { get; }
|
public DbSet<TrackLike> TrackLikes { get; }
|
||||||
|
|
||||||
public DbSet<TrackTag> TrackTags { get; }
|
public DbSet<TrackTag> TrackTags { get; }
|
||||||
|
|
||||||
public DbSet<UserFollow> UserFollows { get; }
|
public DbSet<UserFollow> UserFollows { get; }
|
||||||
|
|
||||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
public DbSet<TrackCoauthor> TrackCoauthors { get; }
|
||||||
|
|
||||||
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||||
|
|
||||||
|
public Task<int> SaveChangesAsync(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 Task CommitAsync(CancellationToken cancellationToken = default);
|
public interface IDatabaseTransaction : IAsyncDisposable
|
||||||
|
{
|
||||||
|
public Task CommitAsync(CancellationToken cancellationToken = default);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
namespace Musify.Application.Contracts;
|
namespace Musify.Application.Contracts
|
||||||
|
|
||||||
public interface IEventBus
|
|
||||||
{
|
{
|
||||||
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
public interface IEventBus
|
||||||
|
{
|
||||||
|
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 Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
public interface IPictureService
|
||||||
|
{
|
||||||
|
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,24 +1,24 @@
|
|||||||
namespace Musify.Application.Contracts;
|
namespace Musify.Application.Contracts
|
||||||
|
|
||||||
public interface IStorageService
|
|
||||||
{
|
{
|
||||||
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
public interface IStorageService
|
||||||
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
{
|
||||||
|
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
public Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||||
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 StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
public interface IStreamTicketService
|
||||||
}
|
{
|
||||||
|
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,15 +1,16 @@
|
|||||||
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,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string? PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string? PictureDestinationKey,
|
||||||
string AudioSourceKey,
|
string AudioSourceKey,
|
||||||
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,24 +1,25 @@
|
|||||||
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 ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||||
|
|
||||||
|
public class GetAvailableGenresQueryHandler
|
||||||
|
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||||
{
|
{
|
||||||
var all = Enum.GetValues<Genre>();
|
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
|
||||||
|
|
||||||
IReadOnlyList<AvailableGenreResponse> result = all
|
IReadOnlyList<AvailableGenreResponse> result = all
|
||||||
.Select(genre => new AvailableGenreResponse(
|
.Select(genre => new AvailableGenreResponse(
|
||||||
genre,
|
genre,
|
||||||
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
|
||||||
return ValueTask.FromResult(result);
|
return ValueTask.FromResult(result);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,29 +1,30 @@
|
|||||||
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 async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||||
{
|
|
||||||
var rows = await database.TrackTags
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.GroupBy(tag => tag.Tag)
|
|
||||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
return rows
|
public class GetGenresQueryHandler(IDatabase database)
|
||||||
.OrderByDescending(row => row.TrackCount)
|
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
||||||
.ThenBy(row => row.Genre)
|
{
|
||||||
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
||||||
.ToList();
|
{
|
||||||
|
var rows = await database.TrackTags
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
|
||||||
|
.GroupBy(tag => tag.Tag)
|
||||||
|
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return rows
|
||||||
|
.OrderByDescending(row => row.TrackCount)
|
||||||
|
.ThenBy(row => row.Genre)
|
||||||
|
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +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,26 +5,27 @@ 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(
|
|
||||||
long UserId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize)
|
|
||||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
public record GetLikedTracksQuery(
|
||||||
|
long UserId,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize)
|
||||||
|
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetLikedTracksQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||||
{
|
{
|
||||||
return await database.TrackLikes
|
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
{
|
||||||
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
return await database.TrackLikes
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
.AsNoTracking()
|
||||||
.ThenBy(like => like.Id)
|
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
|
||||||
.Select(like => like.Track)
|
.OrderByDescending(like => like.CreatedAt)
|
||||||
.SelectResponse()
|
.ThenBy(like => like.Id)
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
.Select(like => like.Track)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,52 +7,52 @@ 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)
|
|
||||||
: ICommand<ErrorOr<bool>>;
|
|
||||||
|
|
||||||
public class ToggleTrackLikeCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<ToggleTrackLikeCommandHandler> logger)
|
|
||||||
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
public record ToggleTrackLikeCommand(long UserId, Guid TrackId)
|
||||||
|
: ICommand<ErrorOr<bool>>;
|
||||||
|
|
||||||
|
public class ToggleTrackLikeCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<ToggleTrackLikeCommandHandler> logger)
|
||||||
|
: ICommandHandler<ToggleTrackLikeCommand, ErrorOr<bool>>
|
||||||
{
|
{
|
||||||
var trackExists = await database.Tracks
|
public async ValueTask<ErrorOr<bool>> Handle(ToggleTrackLikeCommand request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
return AppErrors.NotFound("Track", request.TrackId);
|
|
||||||
|
|
||||||
var existingLike = await database.TrackLikes
|
|
||||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
|
||||||
|
|
||||||
bool nowLiked;
|
|
||||||
if (existingLike != null)
|
|
||||||
{
|
{
|
||||||
database.TrackLikes.Remove(existingLike);
|
var trackExists = await database.Tracks
|
||||||
nowLiked = false;
|
.AsNoTracking()
|
||||||
}
|
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
|
||||||
else
|
if (!trackExists)
|
||||||
{
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
await database.TrackLikes.AddAsync(new TrackLike
|
|
||||||
|
var existingLike = await database.TrackLikes
|
||||||
|
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||||
|
|
||||||
|
bool nowLiked;
|
||||||
|
if (existingLike != null)
|
||||||
{
|
{
|
||||||
UserId = request.UserId,
|
database.TrackLikes.Remove(existingLike);
|
||||||
TrackId = request.TrackId
|
nowLiked = false;
|
||||||
}, cancellationToken);
|
}
|
||||||
nowLiked = true;
|
else
|
||||||
|
{
|
||||||
|
await database.TrackLikes.AddAsync(new TrackLike
|
||||||
|
{
|
||||||
|
UserId = request.UserId,
|
||||||
|
TrackId = request.TrackId
|
||||||
|
}, cancellationToken);
|
||||||
|
nowLiked = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return true;
|
||||||
|
|
||||||
|
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||||
|
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||||
|
|
||||||
|
return nowLiked;
|
||||||
}
|
}
|
||||||
|
|
||||||
// A concurrent double click can hit the unique index; the outcome is already in place, so treat it as a no-op.
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return true;
|
|
||||||
|
|
||||||
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
|
||||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
|
||||||
|
|
||||||
return nowLiked;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,124 +6,125 @@ using Musify.Application.Contracts;
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes;
|
namespace Musify.Application.Mixes
|
||||||
|
|
||||||
public record GenerateMixesForUserCommand(long UserId)
|
|
||||||
: ICommand;
|
|
||||||
|
|
||||||
public class GenerateMixesForUserCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
MixConfiguration mixConfiguration,
|
|
||||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
|
||||||
: ICommandHandler<GenerateMixesForUserCommand>
|
|
||||||
{
|
{
|
||||||
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
public record GenerateMixesForUserCommand(long UserId)
|
||||||
|
: ICommand;
|
||||||
|
|
||||||
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
public class GenerateMixesForUserCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
MixConfiguration mixConfiguration,
|
||||||
|
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||||
|
: ICommandHandler<GenerateMixesForUserCommand>
|
||||||
{
|
{
|
||||||
var userId = request.UserId;
|
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
||||||
|
|
||||||
var libraryTrackIds = await database.Tracks
|
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => track.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (libraryTrackIds.Count == 0)
|
|
||||||
{
|
{
|
||||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
var userId = request.UserId;
|
||||||
|
|
||||||
|
var libraryTrackIds = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
|
.Select(track => track.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (libraryTrackIds.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var historyTrackIds = await database.ListeningHistories
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(history => history.UserId == userId && history.IsCounted)
|
||||||
|
.Select(history => history.TrackId)
|
||||||
|
.Distinct()
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var historySet = new HashSet<Guid>(historyTrackIds);
|
||||||
|
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
||||||
|
|
||||||
|
var drafts = new List<MixDraft>();
|
||||||
|
|
||||||
|
var discovery = Take(Shuffle(unheardTrackIds));
|
||||||
|
if (discovery.Count > 0)
|
||||||
|
{
|
||||||
|
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
||||||
|
}
|
||||||
|
|
||||||
|
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||||
|
if (daily.Count > 0)
|
||||||
|
{
|
||||||
|
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (drafts.Count == 0)
|
||||||
|
{
|
||||||
|
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||||
|
return Unit.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||||
return Unit.Value;
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||||
.AsNoTracking()
|
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
||||||
.Where(history => history.UserId == userId && history.IsCounted)
|
|
||||||
.Select(history => history.TrackId)
|
|
||||||
.Distinct()
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var historySet = new HashSet<Guid>(historyTrackIds);
|
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||||
var unheardTrackIds = libraryTrackIds.Where(id => !historySet.Contains(id)).ToList();
|
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||||
|
|
||||||
var drafts = new List<MixDraft>();
|
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
|
||||||
|
|
||||||
var discovery = Take(Shuffle(unheardTrackIds));
|
|
||||||
if (discovery.Count > 0)
|
|
||||||
{
|
{
|
||||||
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
var merged = new List<Guid>(first.Count + second.Count);
|
||||||
}
|
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
||||||
|
|
||||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
|
||||||
if (daily.Count > 0)
|
|
||||||
{
|
|
||||||
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
|
||||||
}
|
|
||||||
|
|
||||||
if (drafts.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
|
||||||
return Unit.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
|
||||||
[.. trackIds.Take(mixConfiguration.ItemsPerMix)];
|
|
||||||
|
|
||||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
|
||||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
|
||||||
|
|
||||||
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
|
|
||||||
{
|
|
||||||
var merged = new List<Guid>(first.Count + second.Count);
|
|
||||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
|
||||||
{
|
|
||||||
if (index < first.Count)
|
|
||||||
merged.Add(first[index]);
|
|
||||||
if (index < second.Count)
|
|
||||||
merged.Add(second[index]);
|
|
||||||
}
|
|
||||||
|
|
||||||
return merged;
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var previousMixes = await database.Mixes
|
|
||||||
.Where(mix => mix.UserId == userId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (previousMixes.Count > 0)
|
|
||||||
{
|
|
||||||
database.Mixes.RemoveRange(previousMixes);
|
|
||||||
}
|
|
||||||
|
|
||||||
for (var position = 0; position < drafts.Count; position++)
|
|
||||||
{
|
|
||||||
var draft = drafts[position];
|
|
||||||
var mix = new Mix
|
|
||||||
{
|
{
|
||||||
UserId = userId,
|
if (index < first.Count)
|
||||||
Kind = draft.Kind,
|
merged.Add(first[index]);
|
||||||
Position = position
|
if (index < second.Count)
|
||||||
};
|
merged.Add(second[index]);
|
||||||
|
}
|
||||||
|
|
||||||
await database.Mixes.AddAsync(mix, cancellationToken);
|
return merged;
|
||||||
|
}
|
||||||
|
|
||||||
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
private async Task ReplaceMixesAsync(long userId, IReadOnlyList<MixDraft> drafts, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var previousMixes = await database.Mixes
|
||||||
|
.Where(mix => mix.UserId == userId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (previousMixes.Count > 0)
|
||||||
{
|
{
|
||||||
await database.MixItems.AddAsync(new MixItem
|
database.Mixes.RemoveRange(previousMixes);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (var position = 0; position < drafts.Count; position++)
|
||||||
|
{
|
||||||
|
var draft = drafts[position];
|
||||||
|
var mix = new Mix
|
||||||
{
|
{
|
||||||
MixId = mix.Id,
|
UserId = userId,
|
||||||
Position = itemPosition,
|
Kind = draft.Kind,
|
||||||
TrackId = draft.TrackIds[itemPosition]
|
Position = position
|
||||||
}, cancellationToken);
|
};
|
||||||
|
|
||||||
|
await database.Mixes.AddAsync(mix, cancellationToken);
|
||||||
|
|
||||||
|
for (var itemPosition = 0; itemPosition < draft.TrackIds.Count; itemPosition++)
|
||||||
|
{
|
||||||
|
await database.MixItems.AddAsync(new MixItem
|
||||||
|
{
|
||||||
|
MixId = mix.Id,
|
||||||
|
Position = itemPosition,
|
||||||
|
TrackId = draft.TrackIds[itemPosition]
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,27 +5,28 @@ 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)
|
|
||||||
: IQuery<ErrorOr<MixApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetMixByIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
|
public record GetMixByIdQuery(long UserId, Guid MixId)
|
||||||
|
: IQuery<ErrorOr<MixApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetMixByIdQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetMixByIdQuery, ErrorOr<MixApplicationResponse>>
|
||||||
{
|
{
|
||||||
var mix = await database.Mixes
|
public async ValueTask<ErrorOr<MixApplicationResponse>> Handle(GetMixByIdQuery request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
{
|
||||||
.Include(mix => mix.Items)
|
var mix = await database.Mixes
|
||||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
.AsNoTracking()
|
||||||
|
.Include(mix => mix.Items)
|
||||||
|
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
if (mix == null)
|
if (mix == null)
|
||||||
return AppErrors.NotFound("Mix", request.MixId);
|
return AppErrors.NotFound("Mix", request.MixId);
|
||||||
|
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||||
|
|
||||||
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,48 +3,55 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Mixes.Responses;
|
using Musify.Application.Mixes.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes;
|
namespace Musify.Application.Mixes
|
||||||
|
|
||||||
public record GetMixesByUserIdQuery(long UserId)
|
|
||||||
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
public record GetMixesByUserIdQuery(long UserId)
|
||||||
|
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
||||||
{
|
{
|
||||||
var mixes = await database.Mixes
|
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.Where(mix => mix.UserId == request.UserId)
|
|
||||||
.Include(mix => mix.Items)
|
|
||||||
.OrderBy(mix => mix.Position)
|
|
||||||
.ThenBy(mix => mix.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var orderedItemsByMix = mixes.ToDictionary(
|
|
||||||
mix => mix.Id,
|
|
||||||
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
|
||||||
|
|
||||||
var coverItems = orderedItemsByMix.Values
|
|
||||||
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
|
||||||
.ToList();
|
|
||||||
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
|
||||||
.GroupBy(response => response.TrackId)
|
|
||||||
.ToDictionary(group => group.Key, group => group.First());
|
|
||||||
|
|
||||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
|
||||||
foreach (var mix in mixes)
|
|
||||||
{
|
{
|
||||||
var orderedItems = orderedItemsByMix[mix.Id];
|
var mixes = await database.Mixes
|
||||||
var covers = orderedItems
|
.AsNoTracking()
|
||||||
.Take(MixApplicationResponse.CoverItemCount)
|
.Where(mix => mix.UserId == request.UserId)
|
||||||
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
.Include(mix => mix.Items)
|
||||||
.Select(item => coverResponses[item.TrackId])
|
.OrderBy(mix => mix.Position)
|
||||||
|
.ThenBy(mix => mix.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var orderedItemsByMix = mixes.ToDictionary(
|
||||||
|
mix => mix.Id,
|
||||||
|
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||||
|
|
||||||
|
var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync(
|
||||||
|
database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken);
|
||||||
|
var visibleItemsByMix = orderedItemsByMix.ToDictionary(
|
||||||
|
entry => entry.Key,
|
||||||
|
entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList());
|
||||||
|
|
||||||
|
var coverItems = visibleItemsByMix.Values
|
||||||
|
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||||
.ToList();
|
.ToList();
|
||||||
|
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||||
|
.GroupBy(response => response.TrackId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First());
|
||||||
|
|
||||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||||
|
foreach (var mix in mixes)
|
||||||
|
{
|
||||||
|
var orderedItems = visibleItemsByMix[mix.Id];
|
||||||
|
var covers = orderedItems
|
||||||
|
.Take(MixApplicationResponse.CoverItemCount)
|
||||||
|
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||||
|
.Select(item => coverResponses[item.TrackId])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
||||||
|
}
|
||||||
|
|
||||||
|
return responses;
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,54 @@ 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
|
|
||||||
{
|
{
|
||||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
|
||||||
IDatabase database,
|
internal static class MixItemMapper
|
||||||
IReadOnlyList<MixItem> orderedItems,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
|
||||||
|
IDatabase database,
|
||||||
|
IEnumerable<MixItem> items,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
var visible = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(track => track.Owner)
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Select(track => track.Id)
|
||||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
.ToListAsync(cancellationToken);
|
||||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
|
||||||
|
|
||||||
return orderedItems
|
return [.. visible];
|
||||||
.Where(item => tracksById.ContainsKey(item.TrackId))
|
}
|
||||||
.Select(item =>
|
|
||||||
{
|
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||||
var entry = tracksById[item.TrackId];
|
IDatabase database,
|
||||||
return new MixItemApplicationResponse(
|
IReadOnlyList<MixItem> orderedItems,
|
||||||
item.TrackId,
|
CancellationToken cancellationToken)
|
||||||
entry.Track.Title,
|
{
|
||||||
entry.Track.Owner.Name,
|
var trackIds = orderedItems.Select(item => item.TrackId).Distinct().ToList();
|
||||||
entry.Track.DurationSeconds,
|
|
||||||
entry.ListensCount);
|
var tracksById = await database.Tracks
|
||||||
})
|
.AsNoTracking()
|
||||||
.ToList();
|
.Include(track => track.Owner)
|
||||||
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
|
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
|
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||||
|
|
||||||
|
return orderedItems
|
||||||
|
.Where(item => tracksById.ContainsKey(item.TrackId))
|
||||||
|
.Select(item =>
|
||||||
|
{
|
||||||
|
var entry = tracksById[item.TrackId];
|
||||||
|
return new MixItemApplicationResponse(
|
||||||
|
item.TrackId,
|
||||||
|
entry.Track.Title,
|
||||||
|
entry.Track.Owner.Name,
|
||||||
|
entry.Track.DurationSeconds,
|
||||||
|
entry.ListensCount);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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(
|
|
||||||
Guid Id,
|
|
||||||
MixKind Kind,
|
|
||||||
int ItemCount,
|
|
||||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
|
||||||
{
|
{
|
||||||
public const int CoverItemCount = 4;
|
public record MixApplicationResponse(
|
||||||
|
Guid Id,
|
||||||
|
MixKind Kind,
|
||||||
|
int ItemCount,
|
||||||
|
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||||
|
{
|
||||||
|
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,69 +7,95 @@ 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
|
|
||||||
{
|
{
|
||||||
Track,
|
public enum CoverOwner
|
||||||
Album,
|
|
||||||
PlayList
|
|
||||||
}
|
|
||||||
|
|
||||||
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
|
||||||
: IQuery<ErrorOr<CoverLocation>>;
|
|
||||||
|
|
||||||
public record CoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
|
||||||
{
|
|
||||||
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var (source, routes) = request.Owner switch
|
Track,
|
||||||
|
Album,
|
||||||
|
PlayList
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
||||||
|
: IQuery<ErrorOr<CoverLocation>>;
|
||||||
|
|
||||||
|
public record CoverLocation(string Bucket, string Key, string ContentType);
|
||||||
|
|
||||||
|
public class GetCoverQueryHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
TrackConfiguration trackConfiguration,
|
||||||
|
AlbumConfiguration albumConfiguration,
|
||||||
|
PlayListConfiguration playListConfiguration)
|
||||||
|
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
||||||
|
{
|
||||||
|
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
||||||
|
|
||||||
|
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
var (source, routes) = request.Owner switch
|
||||||
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
{
|
||||||
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
|
||||||
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||||
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||||
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
||||||
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
_ => (await database.PlayLists.AsNoTracking()
|
||||||
_ => (await database.PlayLists.AsNoTracking()
|
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
||||||
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
||||||
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
||||||
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
};
|
||||||
};
|
|
||||||
|
|
||||||
var entity = request.Owner.ToString();
|
var entity = request.Owner.ToString();
|
||||||
if (source == null)
|
if (source == null)
|
||||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||||
|
|
||||||
var resizedName = request.Size switch
|
var resizedName = request.Size switch
|
||||||
|
{
|
||||||
|
PictureSize.Small => source.Small,
|
||||||
|
PictureSize.Large => source.Large,
|
||||||
|
_ => source.Medium
|
||||||
|
};
|
||||||
|
|
||||||
|
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||||
|
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||||
|
: !string.IsNullOrEmpty(source.Original)
|
||||||
|
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
||||||
|
: (null, null);
|
||||||
|
|
||||||
|
if (name == null || key == null)
|
||||||
|
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||||
|
|
||||||
|
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
||||||
|
}
|
||||||
|
|
||||||
|
private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
PictureSize.Small => source.Small,
|
var track = await database.Tracks.AsNoTracking()
|
||||||
PictureSize.Large => source.Large,
|
.Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
_ => source.Medium
|
.Select(t => new
|
||||||
};
|
{
|
||||||
|
Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName),
|
||||||
|
Album = t.AlbumTracks
|
||||||
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null)
|
||||||
|
.OrderBy(albumTrack => albumTrack.AddedAt)
|
||||||
|
.ThenBy(albumTrack => albumTrack.AlbumId)
|
||||||
|
.Select(albumTrack => new CoverSource(
|
||||||
|
albumTrack.Album.OwnerUserId,
|
||||||
|
albumTrack.Album.Pictures!.OriginalName,
|
||||||
|
albumTrack.Album.Pictures.SmallName,
|
||||||
|
albumTrack.Album.Pictures.MediumName,
|
||||||
|
albumTrack.Album.Pictures.LargeName))
|
||||||
|
.FirstOrDefault()
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
// The resized files only exist once processing has finished; until then serve the original.
|
if (track == null)
|
||||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
return (null, trackConfiguration.Routes);
|
||||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
|
||||||
: !string.IsNullOrEmpty(source.Original)
|
|
||||||
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
|
||||||
: (null, null);
|
|
||||||
|
|
||||||
if (name == null || key == null)
|
return string.IsNullOrEmpty(track.Own.Original) && track.Album != null
|
||||||
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
? (track.Album, albumConfiguration.Routes)
|
||||||
|
: (track.Own, trackConfiguration.Routes);
|
||||||
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,31 +5,31 @@ 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
|
|
||||||
{
|
{
|
||||||
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
Guid intentId,
|
public static class PictureSourceChange
|
||||||
long userId,
|
|
||||||
UploadIntentPurpose purpose,
|
|
||||||
IPictureOwnerConfiguration owner,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
||||||
if (validation.IsError)
|
UploadIntentValidator uploadIntentValidator,
|
||||||
return validation.Errors;
|
Guid intentId,
|
||||||
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
|
IPictureOwnerConfiguration owner,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
||||||
|
if (validation.IsError)
|
||||||
|
return validation.Errors;
|
||||||
|
|
||||||
var intent = validation.Value;
|
var intent = validation.Value;
|
||||||
|
|
||||||
return new PreparedPicture(
|
return new PreparedPicture(
|
||||||
intent,
|
intent,
|
||||||
EntityPictures.Pending(intent.ObjectName),
|
EntityPictures.Pending(intent.ObjectName),
|
||||||
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
||||||
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,10 +1,11 @@
|
|||||||
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,
|
||||||
string PictureName,
|
string PictureName,
|
||||||
string ContentType,
|
string ContentType,
|
||||||
int ExpiresInSeconds,
|
int ExpiresInSeconds,
|
||||||
string UploadUrl);
|
string UploadUrl);
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||||
|
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||||
|
|
||||||
|
public class AddAlbumToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddAlbumToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (playList.IsError)
|
||||||
|
return playList.Errors;
|
||||||
|
|
||||||
|
var albumExists = await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
|
if (!albumExists)
|
||||||
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
|
|
||||||
|
var albumTrackIds = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
|
var existingTrackIds = (await playListTracksQuery
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken)).ToHashSet();
|
||||||
|
|
||||||
|
var newTrackIds = albumTrackIds
|
||||||
|
.Where(trackId => !existingTrackIds.Contains(trackId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newTrackIds.Count == 0)
|
||||||
|
return new AddAlbumToPlayListResponse(0);
|
||||||
|
|
||||||
|
var nextPosition = existingTrackIds.Count > 0
|
||||||
|
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
foreach (var trackId in newTrackIds)
|
||||||
|
{
|
||||||
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
|
{
|
||||||
|
PlayListId = request.PlayListId,
|
||||||
|
TrackId = trackId,
|
||||||
|
Position = nextPosition++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved.Errors;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
|
||||||
|
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
||||||
|
|
||||||
|
return new AddAlbumToPlayListResponse(newTrackIds.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,67 +7,69 @@ 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>
|
|
||||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTracksToPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTracksToPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class AddTracksToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddTracksToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
if (playList.IsError)
|
|
||||||
return playList.Errors;
|
|
||||||
|
|
||||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
|
||||||
|
|
||||||
var existingIds = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(track => track.Id)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
|
||||||
if (missing != Guid.Empty)
|
|
||||||
return AppErrors.NotFound("Track", missing);
|
|
||||||
|
|
||||||
var alreadyInPlayList = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
|
||||||
.Select(link => link.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
|
||||||
if (toAdd.Count == 0)
|
|
||||||
return Result.Success;
|
|
||||||
|
|
||||||
// Playlist positions are internal indexes, so they start at 0.
|
|
||||||
var nextPosition = (await database.PlayListHasTracks
|
|
||||||
.Where(link => link.PlayListId == request.PlayListId)
|
|
||||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
|
||||||
|
|
||||||
foreach (var trackId in toAdd)
|
|
||||||
{
|
{
|
||||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (playList.IsError)
|
||||||
|
return playList.Errors;
|
||||||
|
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var existingIds = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
|
.Select(track => track.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
||||||
|
if (missing != Guid.Empty)
|
||||||
|
return AppErrors.NotFound("Track", missing);
|
||||||
|
|
||||||
|
var alreadyInPlayList = await database.PlayListHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
|
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
|
||||||
|
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
foreach (var trackId in toAdd)
|
||||||
{
|
{
|
||||||
PlayListId = request.PlayListId,
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
TrackId = trackId,
|
{
|
||||||
Position = nextPosition++
|
PlayListId = request.PlayListId,
|
||||||
}, cancellationToken);
|
TrackId = trackId,
|
||||||
|
Position = nextPosition++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
|
||||||
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
|
||||||
if (saved.IsError)
|
|
||||||
return saved;
|
|
||||||
|
|
||||||
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
|
||||||
|
|
||||||
return Result.Success;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,67 +12,68 @@ 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(
|
|
||||||
long UserId,
|
|
||||||
string Name,
|
|
||||||
string? Description,
|
|
||||||
Guid? PictureIntentId,
|
|
||||||
PlayListVisibility Visibility = PlayListVisibility.Private)
|
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class CreatePlayListCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<CreatePlayListCommandHandler> logger,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
public record CreatePlayListCommand(
|
||||||
|
long UserId,
|
||||||
|
string Name,
|
||||||
|
string? Description,
|
||||||
|
Guid? PictureIntentId,
|
||||||
|
PlayListVisibility Visibility = PlayListVisibility.Private)
|
||||||
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
|
public class CreatePlayListCommandHandler(
|
||||||
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ILogger<CreatePlayListCommandHandler> logger,
|
||||||
|
PlayListConfiguration playListConfiguration)
|
||||||
|
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
var userExists = await database.Users
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
|
||||||
|
|
||||||
PreparedPicture? picture = null;
|
|
||||||
if (request.PictureIntentId is { } intentId)
|
|
||||||
{
|
{
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
var userExists = await database.Users
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
.AsNoTracking()
|
||||||
if (prepared.IsError)
|
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
return prepared.Errors;
|
if (!userExists)
|
||||||
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
picture = prepared.Value;
|
PreparedPicture? picture = null;
|
||||||
|
if (request.PictureIntentId.HasValue)
|
||||||
|
{
|
||||||
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
|
uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
picture = prepared.Value;
|
||||||
|
}
|
||||||
|
|
||||||
|
var name = request.Name.Trim();
|
||||||
|
var playList = new PlayList
|
||||||
|
{
|
||||||
|
OwnerUserId = request.UserId,
|
||||||
|
Name = name,
|
||||||
|
NormalizedName = TextNormalizer.Normalize(name),
|
||||||
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
|
Pictures = picture?.Pictures,
|
||||||
|
Visibility = request.Visibility
|
||||||
|
};
|
||||||
|
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||||
|
|
||||||
|
if (picture != null)
|
||||||
|
{
|
||||||
|
await eventBus.PublishAsync(
|
||||||
|
new CreatePlayListResourcesEvent(
|
||||||
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||||
|
|
||||||
|
return PlayListApplicationResponse.FromEntity(playList);
|
||||||
}
|
}
|
||||||
|
|
||||||
var name = request.Name.Trim();
|
|
||||||
var playList = new PlayList
|
|
||||||
{
|
|
||||||
OwnerUserId = request.UserId,
|
|
||||||
Name = name,
|
|
||||||
NormalizedName = TextNormalizer.Normalize(name),
|
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
|
||||||
Pictures = picture?.Pictures,
|
|
||||||
Visibility = request.Visibility
|
|
||||||
};
|
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
|
||||||
|
|
||||||
if (picture != null)
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreatePlayListResourcesEvent(
|
|
||||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(playList);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,31 +6,32 @@ 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)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class DeletePlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
IEventBus eventBus,
|
|
||||||
ILogger<DeletePlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
public record DeletePlayListCommand(long UserId, Guid PlayListId)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class DeletePlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
|
ILogger<DeletePlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<DeletePlayListCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||||
if (found.IsError)
|
{
|
||||||
return found.Errors;
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
var playList = found.Value;
|
var playList = found.Value;
|
||||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
||||||
|
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,26 +5,27 @@ 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)
|
|
||||||
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetPlayListByIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
public record GetPlayListByIdQuery(Guid Id, long? RequestingUserId = null)
|
||||||
{
|
: IQuery<ErrorOr<PlayListApplicationResponse>>;
|
||||||
var playList = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.VisibleTo(request.RequestingUserId)
|
|
||||||
.Where(p => p.Id == request.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return playList == null
|
public class GetPlayListByIdQueryHandler(IDatabase database)
|
||||||
? AppErrors.NotFound("PlayList", request.Id)
|
: IQueryHandler<GetPlayListByIdQuery, ErrorOr<PlayListApplicationResponse>>
|
||||||
: playList;
|
{
|
||||||
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.VisibleTo(request.RequestingUserId)
|
||||||
|
.Where(p => p.Id == request.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
return playList == null
|
||||||
|
? AppErrors.NotFound("PlayList", request.Id)
|
||||||
|
: playList;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,35 +7,36 @@ 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(
|
|
||||||
Guid PlayListId,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
long? ViewerId = null)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
public record GetPlayListTracksQuery(
|
||||||
{
|
Guid PlayListId,
|
||||||
var playListVisible = await database.PlayLists
|
int PageNumber,
|
||||||
.AsNoTracking()
|
int PageSize,
|
||||||
.Active()
|
long? ViewerId = null)
|
||||||
.VisibleTo(request.ViewerId)
|
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||||
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
|
||||||
if (!playListVisible)
|
|
||||||
return AppErrors.NotFound("PlayList", request.PlayListId);
|
|
||||||
|
|
||||||
return await database.PlayListHasTracks
|
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||||
.AsNoTracking()
|
: IQueryHandler<GetPlayListTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||||
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
{
|
||||||
.OrderBy(playListTrack => playListTrack.Position)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||||
.ThenBy(playListTrack => playListTrack.Id)
|
{
|
||||||
.Select(playListTrack => playListTrack.Track)
|
var playListVisible = await database.PlayLists
|
||||||
.SelectResponse()
|
.AsNoTracking()
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
.Active()
|
||||||
|
.VisibleTo(request.ViewerId)
|
||||||
|
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
||||||
|
if (!playListVisible)
|
||||||
|
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||||
|
|
||||||
|
return await database.PlayListHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||||
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
|
.ThenBy(playListTrack => playListTrack.Id)
|
||||||
|
.Select(playListTrack => playListTrack.Track)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,37 +4,38 @@ 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(
|
|
||||||
long UserId,
|
|
||||||
string? Name,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
long? ViewerId = null)
|
|
||||||
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
public record GetPlayListsByUserIdQuery(
|
||||||
|
long UserId,
|
||||||
|
string? Name,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize,
|
||||||
|
long? ViewerId = null)
|
||||||
|
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
|
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
var playListsQuery = database.PlayLists
|
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
.AsNoTracking()
|
|
||||||
.Active()
|
|
||||||
.VisibleTo(request.ViewerId)
|
|
||||||
.Where(playList => playList.OwnerUserId == request.UserId);
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
|
||||||
{
|
{
|
||||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
var playListsQuery = database.PlayLists
|
||||||
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
.AsNoTracking()
|
||||||
}
|
.Active()
|
||||||
|
.VisibleTo(request.ViewerId)
|
||||||
|
.Where(playList => playList.OwnerUserId == request.UserId);
|
||||||
|
|
||||||
return await playListsQuery
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
.OrderBy(playList => playList.CreatedAt)
|
{
|
||||||
.ThenBy(playList => playList.Id)
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
.SelectResponse()
|
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
}
|
||||||
|
|
||||||
|
return await playListsQuery
|
||||||
|
.OrderBy(playList => playList.CreatedAt)
|
||||||
|
.ThenBy(playList => playList.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -5,41 +5,42 @@ 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)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class RemoveTrackFromPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
public record RemoveTrackFromPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class RemoveTrackFromPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<RemoveTrackFromPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<RemoveTrackFromPlayListCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||||
if (playList.IsError)
|
{
|
||||||
return playList.Errors;
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (playList.IsError)
|
||||||
|
return playList.Errors;
|
||||||
|
|
||||||
var playListTracks = await database.PlayListHasTracks
|
var playListTracks = await database.PlayListHasTracks
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||||
.OrderBy(plt => plt.Position)
|
.OrderBy(plt => plt.Position)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
||||||
if (link == null)
|
if (link == null)
|
||||||
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
||||||
|
|
||||||
database.PlayListHasTracks.Remove(link);
|
database.PlayListHasTracks.Remove(link);
|
||||||
|
|
||||||
var position = 0;
|
var position = 0;
|
||||||
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
||||||
remaining.Position = position++;
|
remaining.Position = position++;
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||||
|
|
||||||
return Result.Success;
|
return 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(
|
|
||||||
long UserId,
|
|
||||||
string FileType,
|
|
||||||
string ContentType,
|
|
||||||
long? ExpectedSizeBytes = null)
|
|
||||||
: ICommand<ErrorOr<PictureUploadResponse>>;
|
|
||||||
|
|
||||||
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
public record RequestPlayListPictureUploadCommand(
|
||||||
await issuer.IssuePictureAsync(
|
long UserId,
|
||||||
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
string FileType,
|
||||||
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
string ContentType,
|
||||||
|
long? ExpectedSizeBytes = null)
|
||||||
|
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||||
|
|
||||||
|
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
||||||
|
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||||
|
await issuer.IssuePictureAsync(
|
||||||
|
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
||||||
|
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.PlayLists.Responses
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListResponse(int AddedCount);
|
||||||
|
}
|
||||||
+38
-37
@@ -2,44 +2,45 @@ 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(
|
|
||||||
Guid Id,
|
|
||||||
string Name,
|
|
||||||
string? Description,
|
|
||||||
string? SmallImageKeyName,
|
|
||||||
string? MediumImageKeyName,
|
|
||||||
string? LargeImageKeyName,
|
|
||||||
PlayListVisibility Visibility,
|
|
||||||
DateTime CreatedAt,
|
|
||||||
DateTime UpdatedAt,
|
|
||||||
IReadOnlyList<Guid> CoverTrackIds,
|
|
||||||
long OwnerUserId,
|
|
||||||
int TrackCount,
|
|
||||||
double DurationSeconds)
|
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
public record PlayListApplicationResponse(
|
||||||
|
Guid Id,
|
||||||
public static PlayListApplicationResponse FromEntity(
|
string Name,
|
||||||
PlayList playList,
|
string? Description,
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null,
|
string? SmallImageKeyName,
|
||||||
int trackCount = 0,
|
string? MediumImageKeyName,
|
||||||
double durationSeconds = 0)
|
string? LargeImageKeyName,
|
||||||
|
PlayListVisibility Visibility,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt,
|
||||||
|
IReadOnlyList<Guid> CoverTrackIds,
|
||||||
|
long OwnerUserId,
|
||||||
|
int TrackCount,
|
||||||
|
double DurationSeconds)
|
||||||
{
|
{
|
||||||
return new PlayListApplicationResponse(
|
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
||||||
playList.Id,
|
|
||||||
playList.Name,
|
public static PlayListApplicationResponse FromEntity(
|
||||||
playList.Description,
|
PlayList playList,
|
||||||
playList.Pictures?.SmallName,
|
IReadOnlyList<Guid>? coverTrackIds = null,
|
||||||
playList.Pictures?.MediumName,
|
int trackCount = 0,
|
||||||
playList.Pictures?.LargeName,
|
double durationSeconds = 0)
|
||||||
playList.Visibility,
|
{
|
||||||
playList.CreatedAt,
|
return new PlayListApplicationResponse(
|
||||||
playList.UpdatedAt,
|
playList.Id,
|
||||||
coverTrackIds ?? [],
|
playList.Name,
|
||||||
playList.OwnerUserId,
|
playList.Description,
|
||||||
trackCount,
|
playList.Pictures?.SmallName,
|
||||||
durationSeconds);
|
playList.Pictures?.MediumName,
|
||||||
|
playList.Pictures?.LargeName,
|
||||||
|
playList.Visibility,
|
||||||
|
playList.CreatedAt,
|
||||||
|
playList.UpdatedAt,
|
||||||
|
coverTrackIds ?? [],
|
||||||
|
playList.OwnerUserId,
|
||||||
|
trackCount,
|
||||||
|
durationSeconds);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +1,39 @@
|
|||||||
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 const int CoverTrackCount = 4;
|
public static class PlayListProjections
|
||||||
|
{
|
||||||
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
||||||
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
||||||
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
||||||
|
|
||||||
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
||||||
query.Select(playList => new PlayListApplicationResponse(
|
query.Select(playList => new PlayListApplicationResponse(
|
||||||
playList.Id,
|
playList.Id,
|
||||||
playList.Name,
|
playList.Name,
|
||||||
playList.Description,
|
playList.Description,
|
||||||
playList.Pictures!.SmallName,
|
playList.Pictures!.SmallName,
|
||||||
playList.Pictures!.MediumName,
|
playList.Pictures!.MediumName,
|
||||||
playList.Pictures!.LargeName,
|
playList.Pictures!.LargeName,
|
||||||
playList.Visibility,
|
playList.Visibility,
|
||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
playList.UpdatedAt,
|
||||||
playList.PlayListTracks
|
playList.PlayListTracks
|
||||||
.OrderBy(playListTrack => playListTrack.Position)
|
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||||
.Take(CoverTrackCount)
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
.Select(playListTrack => playListTrack.TrackId)
|
.Take(CoverTrackCount)
|
||||||
.ToList(),
|
.Select(playListTrack => playListTrack.TrackId)
|
||||||
playList.OwnerUserId,
|
.ToList(),
|
||||||
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
playList.OwnerUserId,
|
||||||
playList.PlayListTracks
|
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
|
||||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
playList.PlayListTracks
|
||||||
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||||
|
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,79 +2,48 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists;
|
namespace Musify.Application.PlayLists
|
||||||
|
|
||||||
public record UpdatePlayListCommand(
|
|
||||||
long UserId,
|
|
||||||
Guid PlayListId,
|
|
||||||
string? NewName,
|
|
||||||
string? NewDescription,
|
|
||||||
Guid? NewPictureIntentId,
|
|
||||||
PlayListVisibility? NewVisibility = null)
|
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
|
||||||
|
|
||||||
public class UpdatePlayListCommandHandler(
|
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
ILogger<UpdatePlayListCommandHandler> logger,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
public record UpdatePlayListCommand(
|
||||||
|
long UserId,
|
||||||
|
Guid PlayListId,
|
||||||
|
string NewName,
|
||||||
|
string? NewDescription,
|
||||||
|
PlayListVisibility NewVisibility)
|
||||||
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
|
public class UpdatePlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<UpdatePlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
if (found.IsError)
|
|
||||||
return found.Errors;
|
|
||||||
|
|
||||||
var playList = found.Value;
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
|
||||||
{
|
{
|
||||||
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var playList = found.Value;
|
||||||
|
|
||||||
playList.Name = request.NewName.Trim();
|
playList.Name = request.NewName.Trim();
|
||||||
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||||
}
|
|
||||||
|
|
||||||
// null means "unchanged"; a blank description clears it.
|
|
||||||
if (request.NewDescription != null)
|
|
||||||
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||||
|
playList.Visibility = request.NewVisibility;
|
||||||
|
|
||||||
if (request.NewVisibility is { } visibility)
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
playList.Visibility = visibility;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId is { } intentId)
|
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
||||||
{
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
return await database.PlayLists
|
||||||
playList.Pictures = picture.Pictures;
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == playList.Id)
|
||||||
await eventBus.PublishAsync(
|
.SelectResponse()
|
||||||
new UpdatePlayListPictureSourceEvent(
|
.SingleAsync(cancellationToken);
|
||||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
|
||||||
|
|
||||||
return await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(p => p.Id == playList.Id)
|
|
||||||
.SelectResponse()
|
|
||||||
.SingleAsync(cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
|
||||||
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
|
public class UpdatePlayListPictureCommandHandler(
|
||||||
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ILogger<UpdatePlayListPictureCommandHandler> logger,
|
||||||
|
PlayListConfiguration playListConfiguration)
|
||||||
|
: ICommandHandler<UpdatePlayListPictureCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListPictureCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var playList = found.Value;
|
||||||
|
|
||||||
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
var picture = prepared.Value;
|
||||||
|
playList.Pictures = picture.Pictures;
|
||||||
|
|
||||||
|
await eventBus.PublishAsync(
|
||||||
|
new UpdatePlayListPictureSourceEvent(
|
||||||
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated picture of playlist {PlayListId}", playList.Id);
|
||||||
|
|
||||||
|
return await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == playList.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user