Merge branch 'backend-refactor-rev' into audit-merge-three
Adapt the refactored web player to the new backend API (id path params, bulk track adds, flat list items, mix kind) and port AddAlbumToPlayList to the backend conventions.
This commit is contained in:
@@ -0,0 +1,79 @@
|
|||||||
|
root = true
|
||||||
|
|
||||||
|
[*]
|
||||||
|
charset = utf-8
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
indent_style = space
|
||||||
|
|
||||||
|
[*.{csproj,props,targets,slnx,xml}]
|
||||||
|
indent_size = 2
|
||||||
|
|
||||||
|
[*.json]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
[**/Migrations/**.cs]
|
||||||
|
generated_code = true
|
||||||
|
|
||||||
|
[*.cs]
|
||||||
|
indent_size = 4
|
||||||
|
|
||||||
|
csharp_style_namespace_declarations = block_scoped:warning
|
||||||
|
csharp_using_directive_placement = outside_namespace:warning
|
||||||
|
dotnet_sort_system_directives_first = true
|
||||||
|
dotnet_separate_import_directive_groups = false
|
||||||
|
dotnet_diagnostic.IDE0005.severity = warning
|
||||||
|
|
||||||
|
dotnet_style_require_accessibility_modifiers = always:warning
|
||||||
|
csharp_style_var_for_built_in_types = true:suggestion
|
||||||
|
csharp_style_var_when_type_is_apparent = true:suggestion
|
||||||
|
csharp_style_var_elsewhere = true:suggestion
|
||||||
|
csharp_prefer_braces = when_multiline:warning
|
||||||
|
csharp_style_prefer_primary_constructors = true:suggestion
|
||||||
|
csharp_style_expression_bodied_methods = when_on_single_line:suggestion
|
||||||
|
dotnet_style_prefer_collection_expression = when_types_loosely_match:suggestion
|
||||||
|
|
||||||
|
# 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.style = underscore_camel_case
|
||||||
|
dotnet_naming_rule.private_fields.severity = warning
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_kinds = field
|
||||||
|
dotnet_naming_symbols.private_fields.applicable_accessibilities = private
|
||||||
|
dotnet_naming_style.underscore_camel_case.capitalization = camel_case
|
||||||
|
dotnet_naming_style.underscore_camel_case.required_prefix = _
|
||||||
|
|
||||||
|
# Non-private fields (constants, static readonly, etc.) are PascalCase.
|
||||||
|
dotnet_naming_rule.other_fields.symbols = other_fields
|
||||||
|
dotnet_naming_rule.other_fields.style = pascal_case
|
||||||
|
dotnet_naming_rule.other_fields.severity = warning
|
||||||
|
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_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.CA1873.severity = suggestion
|
||||||
|
dotnet_diagnostic.CA1716.severity = suggestion
|
||||||
|
# The routing slip builders are stateless services resolved from DI; they stay instance members.
|
||||||
|
dotnet_diagnostic.CA1822.severity = suggestion
|
||||||
|
|
||||||
|
# Test names follow Method_Scenario_Expected.
|
||||||
|
[Tests/**.cs]
|
||||||
|
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`.
|
||||||
@@ -1,82 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTrackToAlbumCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTrackToAlbumCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var album = await database.Albums
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var track = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (track == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (track.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} does not own track {TrackId}", request.UserId, request.TrackId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
|
|
||||||
if (alreadyAdded)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} already in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.Conflict(description: "Track is already in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracksQuery = database.AlbumHasTracks
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
|
||||||
|
|
||||||
var hasTracks = await albumTracksQuery.AnyAsync(cancellationToken);
|
|
||||||
var nextTrackNumber = hasTracks
|
|
||||||
? await albumTracksQuery.MaxAsync(albumTrack => albumTrack.TrackNumber, cancellationToken) + 1
|
|
||||||
: 1;
|
|
||||||
|
|
||||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
|
||||||
{
|
|
||||||
AlbumId = request.AlbumId,
|
|
||||||
TrackId = request.TrackId,
|
|
||||||
TrackNumber = nextTrackNumber
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
|
|
||||||
request.TrackId, request.AlbumId, nextTrackNumber);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,78 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||||
|
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class AddTracksToAlbumCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddTracksToAlbumCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (album.IsError)
|
||||||
|
return album.Errors;
|
||||||
|
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => new { track.Id, track.OwnerUserId })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||||
|
if (missing != Guid.Empty)
|
||||||
|
return AppErrors.NotFound("Track", missing);
|
||||||
|
|
||||||
|
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||||
|
if (foreign != null)
|
||||||
|
return AppErrors.Forbidden("Track", foreign.Id);
|
||||||
|
|
||||||
|
var alreadyInAlbum = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
// Album track numbers are user-visible, so they start at 1.
|
||||||
|
var nextTrackNumber = (await database.AlbumHasTracks
|
||||||
|
.Where(link => link.AlbumId == request.AlbumId)
|
||||||
|
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||||
|
|
||||||
|
foreach (var trackId in toAdd)
|
||||||
|
{
|
||||||
|
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||||
|
{
|
||||||
|
AlbumId = request.AlbumId,
|
||||||
|
TrackId = trackId,
|
||||||
|
TrackNumber = nextTrackNumber++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,14 +2,15 @@ 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.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Albums.Responses;
|
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.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -26,9 +27,7 @@ namespace Musify.Application.Albums
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<CreateAlbumCommandHandler> logger,
|
ILogger<CreateAlbumCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
AlbumConfiguration albumConfiguration)
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
: ICommandHandler<CreateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(CreateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
@@ -37,92 +36,38 @@ namespace Musify.Application.Albums
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
{
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var pictureIntent = await uploadIntentValidator.ValidateAndLoadAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentConfiguration, request.PictureIntentId, request.UserId, cancellationToken);
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
if (pictureIntent.IsError)
|
if (prepared.IsError)
|
||||||
return pictureIntent.Errors;
|
return prepared.Errors;
|
||||||
|
|
||||||
var intent = pictureIntent.Value;
|
var picture = prepared.Value;
|
||||||
var title = request.Title.Trim();
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
var album = new Album
|
var album = new Album
|
||||||
{
|
{
|
||||||
OwnerUserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Title = title,
|
Title = title,
|
||||||
NormalizedTitle = title.ToUpperInvariant(),
|
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
ReleaseYear = request.ReleaseYear,
|
ReleaseYear = request.ReleaseYear,
|
||||||
Pictures = new AlbumPictures
|
Pictures = picture.Pictures
|
||||||
{
|
|
||||||
OriginalName = intent.ObjectName,
|
|
||||||
SmallName = intent.ObjectName,
|
|
||||||
MediumName = intent.ObjectName,
|
|
||||||
LargeName = intent.ObjectName
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
await database.Albums.AddAsync(album, cancellationToken);
|
||||||
|
|
||||||
var finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, intent.ObjectName);
|
await eventBus.PublishAsync(
|
||||||
var publishResult = await PublishCreateAlbumEventAsync(album.Id, intent, finalPictureKey, cancellationToken);
|
new CreateAlbumResourcesEvent(
|
||||||
if (publishResult.IsError)
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
return publishResult.Errors;
|
cancellationToken);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to save album for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: $"Failed to create album for user {request.UserId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishCreateAlbumEventAsync(
|
|
||||||
Guid albumId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new CreateAlbumResourcesEvent(
|
|
||||||
albumId,
|
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.SmallPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.MediumPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.LargePicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish create album event for album {AlbumId}", albumId);
|
|
||||||
return Error.Failure(description: $"Failed to publish create album event for album {albumId}");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,8 +1,10 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -11,40 +13,25 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
public class DeleteAlbumCommandHandler(
|
public class DeleteAlbumCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
ILogger<DeleteAlbumCommandHandler> logger)
|
ILogger<DeleteAlbumCommandHandler> logger)
|
||||||
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
: ICommandHandler<DeleteAlbumCommand, ErrorOr<Success>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
if (found.IsError)
|
||||||
if (album == null)
|
return found.Errors;
|
||||||
{
|
|
||||||
logger.LogDebug("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
var album = found.Value;
|
||||||
{
|
album.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
database.Albums.Remove(album);
|
await eventBus.PublishAsync(new DeleteAlbumEvent(album.Id, request.UserId), cancellationToken);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to delete album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to delete album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Deleted album {AlbumId} for user {UserId}", request.AlbumId, request.UserId);
|
logger.LogInformation("Marked album {AlbumId} as removing", request.AlbumId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -16,23 +17,14 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
.Where(a => a.Id == request.Id)
|
.Where(a => a.Id == request.Id)
|
||||||
.Select(a => new
|
.SelectResponse()
|
||||||
{
|
|
||||||
Album = a,
|
|
||||||
TrackCount = a.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = a.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (album == null)
|
return album == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("Album", request.Id)
|
||||||
|
: album;
|
||||||
return AlbumApplicationResponse.FromEntity(album.Album, album.TrackCount, album.CoverTrackIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
|
||||||
{
|
|
||||||
public record GetAlbumCoverQuery(Guid AlbumId, string Size)
|
|
||||||
: IQuery<ErrorOr<AlbumCoverLocation>>;
|
|
||||||
|
|
||||||
public record AlbumCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetAlbumCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: IQueryHandler<GetAlbumCoverQuery, ErrorOr<AlbumCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<AlbumCoverLocation>> Handle(GetAlbumCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pictures = await database.Albums.AsNoTracking()
|
|
||||||
.Where(a => a.Id == request.AlbumId)
|
|
||||||
.Select(a => a.Pictures)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (pictures == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = albumConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
|
||||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
|
||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
|
||||||
return new AlbumCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,11 +1,10 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -22,28 +21,19 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
var albumExists = await database.Albums
|
var albumExists = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
if (!albumExists)
|
if (!albumExists)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
|
|
||||||
var tracksQuery = database.AlbumHasTracks
|
return await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(albumTrack => albumTrack.Track.Owner)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.Include(albumTrack => albumTrack.Track.Tags)
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId);
|
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await tracksQuery
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.Select(albumTrack => albumTrack.Track)
|
||||||
|
.SelectResponse()
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
pagedEntities.Select(entry => TrackApplicationResponse.FromEntity(entry.Track, entry.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,60 +1,34 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using X.PagedList;
|
using Musify.Application.Shared;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
public record GetAlbumsQuery(string? Title, int PageNumber, int PageSize)
|
||||||
: IQuery<ErrorOr<AlbumsSearchResponse>>;
|
: IQuery<PaginatedResponse<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class GetAlbumsQueryHandler(IDatabase database)
|
public class GetAlbumsQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetAlbumsQuery, ErrorOr<AlbumsSearchResponse>>
|
: IQueryHandler<GetAlbumsQuery, PaginatedResponse<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumsSearchResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
public async ValueTask<PaginatedResponse<AlbumApplicationResponse>> Handle(GetAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var albumsQuery = database.Albums
|
var albumsQuery = database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AsQueryable();
|
.Active();
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Title))
|
if (!string.IsNullOrWhiteSpace(request.Title))
|
||||||
{
|
{
|
||||||
var normalizedTitle = request.Title.Trim().ToUpperInvariant();
|
var normalizedTitle = TextNormalizer.Normalize(request.Title);
|
||||||
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
albumsQuery = albumsQuery.Where(album => album.NormalizedTitle.Contains(normalizedTitle));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
return await albumsQuery
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
.Select(album => new
|
.ThenBy(album => album.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = album,
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var items = pagedEntities
|
|
||||||
.Select(entry => AlbumSearchItemResponse.FromAlbum(
|
|
||||||
AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new AlbumsSearchResponse(
|
|
||||||
items,
|
|
||||||
pagedEntities.PageNumber,
|
|
||||||
pagedEntities.PageSize,
|
|
||||||
pagedEntities.PageCount,
|
|
||||||
pagedEntities.TotalItemCount,
|
|
||||||
pagedEntities.HasPreviousPage,
|
|
||||||
pagedEntities.HasNextPage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,8 +4,6 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -21,33 +19,16 @@ namespace Musify.Application.Albums
|
|||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
.AnyAsync(user => user.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
var albumsQuery = database.Albums
|
return await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => album.OwnerUserId == request.UserId);
|
.Active()
|
||||||
|
.Where(album => album.OwnerUserId == request.UserId)
|
||||||
var totalCount = await albumsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await albumsQuery
|
|
||||||
.OrderByDescending(album => album.CreatedAt)
|
.OrderByDescending(album => album.CreatedAt)
|
||||||
.Select(album => new
|
.ThenBy(album => album.Id)
|
||||||
{
|
.SelectResponse()
|
||||||
Album = album,
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedAlbums = new StaticPagedList<AlbumApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<AlbumApplicationResponse>.FromPagedList(pagedAlbums);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
@@ -7,54 +6,46 @@ using Musify.Application.Contracts;
|
|||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
public record GetRecentlyListenedAlbumsQuery(long UserId, int Limit)
|
||||||
: IQuery<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>;
|
: IQuery<IReadOnlyList<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
public class GetRecentlyListenedAlbumsQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetRecentlyListenedAlbumsQuery, ErrorOr<IReadOnlyList<AlbumApplicationResponse>>>
|
: IQueryHandler<GetRecentlyListenedAlbumsQuery, IReadOnlyList<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<IReadOnlyList<AlbumApplicationResponse>>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
public async ValueTask<IReadOnlyList<AlbumApplicationResponse>> Handle(GetRecentlyListenedAlbumsQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var listenedTrackIds = database.ListeningHistories
|
var listens = database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||||
|
|
||||||
var ranked = await database.AlbumHasTracks
|
var ranked = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.Join(
|
.Join(
|
||||||
listenedTrackIds,
|
listens,
|
||||||
albumTrack => albumTrack.TrackId,
|
albumTrack => albumTrack.TrackId,
|
||||||
history => history.TrackId,
|
history => history.TrackId,
|
||||||
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
(albumTrack, history) => new { albumTrack.AlbumId, history.ListenedAt })
|
||||||
.GroupBy(entry => entry.AlbumId)
|
.GroupBy(entry => entry.AlbumId)
|
||||||
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
.Select(group => new { AlbumId = group.Key, LastListenedAt = group.Max(entry => entry.ListenedAt) })
|
||||||
.OrderByDescending(entry => entry.LastListenedAt)
|
.OrderByDescending(entry => entry.LastListenedAt)
|
||||||
|
.ThenBy(entry => entry.AlbumId)
|
||||||
.Take(request.Limit)
|
.Take(request.Limit)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (ranked.Count == 0)
|
if (ranked.Count == 0)
|
||||||
return new List<AlbumApplicationResponse>();
|
return [];
|
||||||
|
|
||||||
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
var albumIds = ranked.Select(entry => entry.AlbumId).ToList();
|
||||||
|
|
||||||
var albums = await database.Albums
|
var albumsById = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(album => albumIds.Contains(album.Id))
|
.Where(album => albumIds.Contains(album.Id))
|
||||||
.Select(album => new
|
.SelectResponse()
|
||||||
{
|
.ToDictionaryAsync(album => album.Id, cancellationToken);
|
||||||
Album = album,
|
|
||||||
TrackCount = album.AlbumTracks.Count,
|
|
||||||
CoverTrackIds = album.AlbumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToDictionaryAsync(entry => entry.Album.Id, cancellationToken);
|
|
||||||
|
|
||||||
return ranked
|
return ranked
|
||||||
.Where(entry => albums.ContainsKey(entry.AlbumId))
|
.Where(entry => albumsById.ContainsKey(entry.AlbumId))
|
||||||
.Select(entry => albums[entry.AlbumId])
|
.Select(entry => albumsById[entry.AlbumId])
|
||||||
.Select(entry => AlbumApplicationResponse.FromEntity(entry.Album, entry.TrackCount, entry.CoverTrackIds))
|
|
||||||
.ToList();
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -16,20 +17,9 @@ namespace Musify.Application.Albums
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.AsNoTracking()
|
if (album.IsError)
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
return album.Errors;
|
||||||
if (album == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of album {AlbumId}", request.UserId, request.AlbumId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = await database.AlbumHasTracks
|
var albumTracks = await database.AlbumHasTracks
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||||
@@ -38,10 +28,7 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
var link = albumTracks.SingleOrDefault(albumTrack => albumTrack.TrackId == request.TrackId);
|
||||||
if (link == null)
|
if (link == null)
|
||||||
{
|
return Error.NotFound("Album.TrackNotFound", "Track is not in the album.");
|
||||||
logger.LogInformation("Track {TrackId} not in album {AlbumId}", request.TrackId, request.AlbumId);
|
|
||||||
return Error.NotFound(description: "Track is not in the album.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.AlbumHasTracks.Remove(link);
|
database.AlbumHasTracks.Remove(link);
|
||||||
|
|
||||||
@@ -53,7 +40,7 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
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 new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Pictures.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -16,90 +12,14 @@ namespace Musify.Application.Albums
|
|||||||
string FileType,
|
string FileType,
|
||||||
string ContentType,
|
string ContentType,
|
||||||
long? ExpectedSizeBytes = null)
|
long? ExpectedSizeBytes = null)
|
||||||
: ICommand<ErrorOr<AlbumPictureUploadResponse>>;
|
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||||
|
|
||||||
public class RequestAlbumPictureUploadCommandHandler(
|
public class RequestAlbumPictureUploadCommandHandler(UploadIntentIssuer issuer, AlbumConfiguration albumConfiguration)
|
||||||
IDatabase database,
|
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
IStorageService storageService,
|
|
||||||
ILogger<RequestAlbumPictureUploadCommandHandler> logger,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<RequestAlbumPictureUploadCommand, ErrorOr<AlbumPictureUploadResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumPictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestAlbumPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||||
{
|
await issuer.IssuePictureAsync(
|
||||||
var userExists = await database.Users
|
request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration,
|
||||||
.AsNoTracking()
|
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
|
||||||
|
|
||||||
var tempKey = albumConfiguration.Routes.BuildTempPicturePath(
|
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
request.ContentType,
|
|
||||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await using var transaction = await database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
|
||||||
|
|
||||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
|
||||||
if (quotaCheck.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
|
||||||
return quotaCheck.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
var intent = new UploadIntent
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
Bucket = storageConfiguration.Bucket,
|
|
||||||
Key = tempKey,
|
|
||||||
ObjectName = objectName,
|
|
||||||
ContentType = request.ContentType,
|
|
||||||
ExpectedSizeBytes = effectiveSizeBytes,
|
|
||||||
Purpose = UploadIntentPurpose.AlbumPicture,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (AlbumPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
|
||||||
|
|
||||||
return new AlbumPictureUploadResponse(
|
|
||||||
intent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
objectName,
|
|
||||||
request.ContentType,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
|
||||||
uploadUrl);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate album picture upload URL for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: "Failed to generate upload URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,4 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Application.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
namespace Musify.Application.Albums.Responses
|
||||||
{
|
{
|
||||||
@@ -9,7 +7,7 @@ namespace Musify.Application.Albums.Responses
|
|||||||
string Title,
|
string Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
int? ReleaseYear,
|
int? ReleaseYear,
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
long OwnerUserId,
|
||||||
int TrackCount,
|
int TrackCount,
|
||||||
string? SmallImageKeyName,
|
string? SmallImageKeyName,
|
||||||
string? MediumImageKeyName,
|
string? MediumImageKeyName,
|
||||||
@@ -18,7 +16,7 @@ namespace Musify.Application.Albums.Responses
|
|||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||||
|
|
||||||
public static AlbumApplicationResponse FromEntity(
|
public static AlbumApplicationResponse FromEntity(
|
||||||
Album album,
|
Album album,
|
||||||
|
|||||||
@@ -0,0 +1,32 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums.Responses
|
||||||
|
{
|
||||||
|
public static class AlbumProjections
|
||||||
|
{
|
||||||
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
|
public static IQueryable<Album> Active(this IQueryable<Album> query) =>
|
||||||
|
query.Where(album => album.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
|
public static IQueryable<AlbumApplicationResponse> SelectResponse(this IQueryable<Album> query) =>
|
||||||
|
query.Select(album => new AlbumApplicationResponse(
|
||||||
|
album.Id,
|
||||||
|
album.Title,
|
||||||
|
album.Description,
|
||||||
|
album.ReleaseYear,
|
||||||
|
album.OwnerUserId,
|
||||||
|
album.AlbumTracks.Count,
|
||||||
|
album.Pictures!.SmallName,
|
||||||
|
album.Pictures!.MediumName,
|
||||||
|
album.Pictures!.LargeName,
|
||||||
|
album.CreatedAt,
|
||||||
|
album.UpdatedAt,
|
||||||
|
album.AlbumTracks
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Take(CoverTrackCount)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToList()));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
public record AlbumSearchItemResponse(AlbumApplicationResponse Album)
|
|
||||||
{
|
|
||||||
public static AlbumSearchItemResponse FromAlbum(AlbumApplicationResponse album) => new(album);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +0,0 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
|
||||||
{
|
|
||||||
|
|
||||||
public record AlbumsSearchResponse(
|
|
||||||
IReadOnlyList<AlbumSearchItemResponse> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasPreviousPage,
|
|
||||||
bool HasNextPage);
|
|
||||||
}
|
|
||||||
@@ -2,14 +2,14 @@ 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.Shared;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
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.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
@@ -27,124 +27,48 @@ namespace Musify.Application.Albums
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<UpdateAlbumCommandHandler> logger,
|
ILogger<UpdateAlbumCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
AlbumConfiguration albumConfiguration)
|
||||||
AlbumConfiguration albumConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var album = await database.Albums
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(a => a.Id == request.AlbumId, cancellationToken);
|
if (found.IsError)
|
||||||
if (album == null)
|
return found.Errors;
|
||||||
{
|
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (album.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Album {AlbumId} does not belong to user {UserId}", request.AlbumId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var album = found.Value;
|
||||||
var title = request.Title.Trim();
|
var title = request.Title.Trim();
|
||||||
|
|
||||||
album.Title = title;
|
album.Title = title;
|
||||||
album.NormalizedTitle = title.ToUpperInvariant();
|
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||||
album.Description = request.Description;
|
album.Description = request.Description;
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
album.ReleaseYear = request.ReleaseYear;
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
if (request.NewPictureIntentId is { } intentId)
|
||||||
string? finalPictureKey = null;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
|
||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
if (prepared.IsError)
|
||||||
if (validation.IsError)
|
return prepared.Errors;
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
var picture = prepared.Value;
|
||||||
album.Pictures = new AlbumPictures
|
album.Pictures = picture.Pictures;
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = pictureIntent.ObjectName,
|
|
||||||
MediumName = pictureIntent.ObjectName,
|
|
||||||
LargeName = pictureIntent.ObjectName
|
|
||||||
};
|
|
||||||
finalPictureKey = albumConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pictureIntent != null && finalPictureKey != null)
|
|
||||||
{
|
|
||||||
var publishResult = await PublishUpdateAlbumPictureSourceEventAsync(
|
|
||||||
album.Id, pictureIntent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to update album {AlbumId}", request.AlbumId);
|
|
||||||
return Error.Failure(description: $"Failed to update album {request.AlbumId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTracks = database.AlbumHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(albumTrack => albumTrack.AlbumId == album.Id);
|
|
||||||
|
|
||||||
var trackCount = await albumTracks.CountAsync(cancellationToken);
|
|
||||||
var coverTrackIds = await albumTracks
|
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
|
||||||
.Take(AlbumApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated album {AlbumId}", album.Id);
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount, coverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishUpdateAlbumPictureSourceEventAsync(
|
|
||||||
Guid albumId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new UpdateAlbumPictureSourceEvent(
|
new UpdateAlbumPictureSourceEvent(
|
||||||
albumId,
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.SmallPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.MediumPicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
albumConfiguration.Routes.LargePicturesPath,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
albumConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish update album picture source event for album {AlbumId}", albumId);
|
|
||||||
return Error.Failure(description: $"Failed to publish update album picture source event for album {albumId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,8 +19,6 @@
|
|||||||
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
<PackageReference Include="Microsoft.Extensions.Options.ConfigurationExtensions" />
|
||||||
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
<PackageReference Include="Microsoft.Extensions.Options.DataAnnotations" />
|
||||||
<PackageReference Include="MimeMapping" />
|
<PackageReference Include="MimeMapping" />
|
||||||
<PackageReference Include="X.PagedList" />
|
|
||||||
<PackageReference Include="X.PagedList.EF" />
|
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
|
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
|
|||||||
@@ -3,25 +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 class ApplicationDependencyInjection
|
||||||
{
|
{
|
||||||
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
public static IServiceCollection AddApplication(this IServiceCollection services, IConfiguration configuration)
|
||||||
{
|
{
|
||||||
services.AddMediator();
|
services.AddMediator();
|
||||||
services.AddScoped<UploadIntentValidator>();
|
services.AddScoped<UploadIntentValidator>();
|
||||||
|
services.AddScoped<UploadIntentIssuer>();
|
||||||
services.AddScoped<TrackStreamIssuer>();
|
services.AddScoped<TrackStreamIssuer>();
|
||||||
|
|
||||||
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration, ApplicationStorageConfiguration.SectionName);
|
services.AddValidatedOptions<ApplicationStorageConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<PlayListConfiguration>(configuration, PlayListConfiguration.SectionName);
|
services.AddValidatedOptions<PlayListConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<AlbumConfiguration>(configuration, AlbumConfiguration.SectionName);
|
services.AddValidatedOptions<AlbumConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<TrackConfiguration>(configuration, TrackConfiguration.SectionName);
|
services.AddValidatedOptions<TrackConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<MixConfiguration>(configuration, MixConfiguration.SectionName);
|
services.AddValidatedOptions<MixConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration, StreamGatewayConfiguration.SectionName);
|
services.AddValidatedOptions<StreamGatewayConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<UploadIntentConfiguration>(configuration, UploadIntentConfiguration.SectionName);
|
services.AddValidatedOptions<UploadIntentConfiguration>(configuration);
|
||||||
services.AddValidatedOptions<PlaybackConfiguration>(configuration, PlaybackConfiguration.SectionName);
|
services.AddValidatedOptions<PlaybackConfiguration>(configuration);
|
||||||
|
|
||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
|
||||||
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
|
[assembly: MediatorOptions(ServiceLifetime = ServiceLifetime.Scoped)]
|
||||||
@@ -1,79 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class AlbumConfiguration
|
public class AlbumConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "Album";
|
public static string SectionName => "Album";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public AlbumRoutes Routes { get; set; } = new AlbumRoutes();
|
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "Albums" };
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public AlbumPicturesSizes PicturesSizes { get; set; } = new AlbumPicturesSizes();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
}
|
|
||||||
|
|
||||||
public class AlbumRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolders { get; set; } = "Albums";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class AlbumPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class ApplicationStorageConfiguration
|
public class ApplicationStorageConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "ApplicationStorage";
|
public static string SectionName => "ApplicationStorage";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
[StringLength(128, MinimumLength = 1)]
|
[StringLength(128, MinimumLength = 1)]
|
||||||
|
|||||||
@@ -2,10 +2,14 @@ using Microsoft.Extensions.Configuration;
|
|||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
using Microsoft.Extensions.Options;
|
using Microsoft.Extensions.Options;
|
||||||
|
|
||||||
namespace Musify.Application.Configuration;
|
namespace Musify.Application.Configuration
|
||||||
|
{
|
||||||
public static class ConfigurationDependencyInjection
|
public static class ConfigurationDependencyInjection
|
||||||
{
|
{
|
||||||
|
public static IServiceCollection AddValidatedOptions<T>(this IServiceCollection services, IConfiguration configuration)
|
||||||
|
where T : class, IConfigurationOptions =>
|
||||||
|
services.AddValidatedOptions<T>(configuration, T.SectionName);
|
||||||
|
|
||||||
public static IServiceCollection AddValidatedOptions<T>(
|
public static IServiceCollection AddValidatedOptions<T>(
|
||||||
this IServiceCollection services,
|
this IServiceCollection services,
|
||||||
IConfiguration configuration,
|
IConfiguration configuration,
|
||||||
@@ -23,3 +27,4 @@ public static class ConfigurationDependencyInjection
|
|||||||
return services;
|
return services;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Configuration
|
||||||
|
{
|
||||||
|
public interface IConfigurationOptions
|
||||||
|
{
|
||||||
|
public static abstract string SectionName { get; }
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class MixConfiguration
|
public class MixConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "Mix";
|
public static string SectionName => "Mix";
|
||||||
|
|
||||||
[Range(4, 100)]
|
[Range(4, 100)]
|
||||||
public int ItemsPerMix { get; set; } = 12;
|
public int ItemsPerMix { get; set; } = 12;
|
||||||
|
|||||||
@@ -0,0 +1,92 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.Globalization;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
|
namespace Musify.Application.Configuration
|
||||||
|
{
|
||||||
|
public enum PictureSize
|
||||||
|
{
|
||||||
|
Small,
|
||||||
|
Medium,
|
||||||
|
Large
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PictureRoutes
|
||||||
|
{
|
||||||
|
[Required]
|
||||||
|
public string UploadsFolder { get; set; } = "uploads";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string ParentFolder { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
||||||
|
|
||||||
|
[Required]
|
||||||
|
public string LargePicturesFolder { get; set; } = "LargePictures";
|
||||||
|
|
||||||
|
public string FolderPath(PictureSize size) => StorageKey.Combine(ParentFolder, size switch
|
||||||
|
{
|
||||||
|
PictureSize.Small => SmallPicturesFolder,
|
||||||
|
PictureSize.Medium => MediumPicturesFolder,
|
||||||
|
PictureSize.Large => LargePicturesFolder,
|
||||||
|
_ => throw new ArgumentOutOfRangeException(nameof(size), size, null)
|
||||||
|
});
|
||||||
|
|
||||||
|
public string BuildPicturePath(PictureSize size, string pictureName) => StorageKey.Combine(FolderPath(size), pictureName);
|
||||||
|
|
||||||
|
public string BuildOriginalPicturePath(long userId, string pictureName) =>
|
||||||
|
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalPicturesFolder, pictureName);
|
||||||
|
|
||||||
|
public string BuildTempPath(string tempRootPrefix, long userId, string objectName) =>
|
||||||
|
StorageKey.Combine(tempRootPrefix, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, objectName);
|
||||||
|
}
|
||||||
|
|
||||||
|
public class PictureSizes
|
||||||
|
{
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int SmallWidth { get; set; } = 128;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int SmallHeight { get; set; } = 128;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int MediumWidth { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int MediumHeight { get; set; } = 256;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int LargeWidth { get; set; } = 512;
|
||||||
|
|
||||||
|
[Range(1, 1024)]
|
||||||
|
public int LargeHeight { get; set; } = 512;
|
||||||
|
|
||||||
|
public ImageSizes ToImageSizes(PictureRoutes routes) => new(
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Small), SmallWidth, SmallHeight),
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Medium), MediumWidth, MediumHeight),
|
||||||
|
new ImageSize(routes.FolderPath(PictureSize.Large), LargeWidth, LargeHeight));
|
||||||
|
}
|
||||||
|
|
||||||
|
public interface IPictureOwnerConfiguration
|
||||||
|
{
|
||||||
|
public PictureRoutes Routes { get; }
|
||||||
|
|
||||||
|
public PictureSizes PicturesSizes { get; }
|
||||||
|
}
|
||||||
|
|
||||||
|
public static class PictureSizeParser
|
||||||
|
{
|
||||||
|
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
||||||
|
public static PictureSize Parse(string? value) =>
|
||||||
|
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||||
|
? size
|
||||||
|
: PictureSize.Medium;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,79 +1,15 @@
|
|||||||
using System.ComponentModel.DataAnnotations;
|
using System.ComponentModel.DataAnnotations;
|
||||||
using Musify.Application.Shared;
|
|
||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class PlayListConfiguration
|
public class PlayListConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "PlayList";
|
public static string SectionName => "PlayList";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListRoutes Routes { get; set; } = new PlayListRoutes();
|
public PictureRoutes Routes { get; set; } = new() { ParentFolder = "PlayLists" };
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public PlayListPicturesSizes PicturesSizes { get; set; } = new PlayListPicturesSizes();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
}
|
|
||||||
|
|
||||||
public class PlayListRoutes
|
|
||||||
{
|
|
||||||
[Required]
|
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string ParentFolders { get; set; } = "PlayLists";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolders, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolders, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolders, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolders, OriginalPicturesFolder);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), ParentFolders, OriginalPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, SmallPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(ParentFolders, MediumPicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(ParentFolders, LargePicturesFolder, pictureName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolders, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class PlayListPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,9 +3,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
|
|
||||||
public sealed class PlaybackConfiguration
|
public sealed class PlaybackConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "Playback";
|
public static string SectionName => "Playback";
|
||||||
|
|
||||||
public bool AllowAnonymousListening { get; set; }
|
public bool AllowAnonymousListening { get; set; }
|
||||||
|
|
||||||
|
|||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class StreamGatewayConfiguration
|
public class StreamGatewayConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "StreamGateway";
|
public static string SectionName => "StreamGateway";
|
||||||
|
|
||||||
[Url]
|
[Url]
|
||||||
[Required]
|
[Required]
|
||||||
|
|||||||
@@ -1,38 +1,28 @@
|
|||||||
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
|
public class TrackConfiguration : IConfigurationOptions, IPictureOwnerConfiguration
|
||||||
{
|
{
|
||||||
public const string SectionName = "Track";
|
public static string SectionName => "Track";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public TrackRoutes Routes { get; set; } = new TrackRoutes();
|
public TrackRoutes Routes { get; set; } = new();
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public TrackPicturesSizes PicturesSizes { get; set; } = new TrackPicturesSizes();
|
public PictureSizes PicturesSizes { get; set; } = new();
|
||||||
|
|
||||||
|
PictureRoutes IPictureOwnerConfiguration.Routes => Routes;
|
||||||
}
|
}
|
||||||
|
|
||||||
public class TrackRoutes
|
public class TrackRoutes : PictureRoutes
|
||||||
{
|
{
|
||||||
[Required]
|
public TrackRoutes()
|
||||||
public string UploadsFolder { get; set; } = "uploads";
|
{
|
||||||
|
ParentFolder = "Tracks";
|
||||||
[Required]
|
}
|
||||||
public string ParentFolder { get; set; } = "Tracks";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string OriginalPicturesFolder { get; set; } = "OriginalPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string SmallPicturesFolder { get; set; } = "SmallPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string MediumPicturesFolder { get; set; } = "MediumPictures";
|
|
||||||
|
|
||||||
[Required]
|
|
||||||
public string LargePicturesFolder { get; set; } = "LargePictures";
|
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
public string PresetOriginalPicture { get; set; } = "PresetOriginalPicture.webp";
|
||||||
@@ -50,13 +40,7 @@ namespace Musify.Application.Configuration
|
|||||||
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
public string OriginalAudiosFolder { get; set; } = "OriginalAudios";
|
||||||
|
|
||||||
[Required]
|
[Required]
|
||||||
public string ProcessedAudioFolder { get; set; } = "ProcessedAudios";
|
public string ProcessedAudiosFolder { get; set; } = "ProcessedAudios";
|
||||||
|
|
||||||
public string SmallPicturesPath => StorageKey.Combine(ParentFolder, SmallPicturesFolder);
|
|
||||||
|
|
||||||
public string MediumPicturesPath => StorageKey.Combine(ParentFolder, MediumPicturesFolder);
|
|
||||||
|
|
||||||
public string LargePicturesPath => StorageKey.Combine(ParentFolder, LargePicturesFolder);
|
|
||||||
|
|
||||||
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
public string PresetSmallPicturePath => StorageKey.Combine(ParentFolder, PresetSmallPicture);
|
||||||
|
|
||||||
@@ -64,53 +48,10 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
public string PresetLargePicturePath => StorageKey.Combine(ParentFolder, PresetLargePicture);
|
||||||
|
|
||||||
public string OriginalPicturesPath => StorageKey.Combine(ParentFolder, OriginalPicturesFolder);
|
public string BuildOriginalAudioPath(long userId, string audioName) =>
|
||||||
|
StorageKey.Combine(UploadsFolder, userId.ToString(CultureInfo.InvariantCulture), ParentFolder, OriginalAudiosFolder, audioName);
|
||||||
|
|
||||||
public string OriginalAudiosPath => StorageKey.Combine(ParentFolder, OriginalAudiosFolder);
|
public string BuildProcessedAudioPath(string folderName) =>
|
||||||
|
StorageKey.Combine(ParentFolder, ProcessedAudiosFolder, folderName);
|
||||||
public string ProcessedAudiosPath => StorageKey.Combine(ParentFolder, ProcessedAudioFolder);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(string pictureName) => StorageKey.Combine(OriginalPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalPicturePath(long userId, string pictureName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildSmallPicturePath(string pictureName) => StorageKey.Combine(SmallPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildMediumPicturePath(string pictureName) => StorageKey.Combine(MediumPicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildLargePicturePath(string pictureName) => StorageKey.Combine(LargePicturesPath, pictureName);
|
|
||||||
|
|
||||||
public string BuildOriginalAudioPath(string audioName) => StorageKey.Combine(OriginalAudiosPath, audioName);
|
|
||||||
|
|
||||||
public string BuildOriginalAudioPath(long userId, string audioName) => StorageKey.Combine(UploadsFolder, userId.ToString(), OriginalAudiosPath, audioName);
|
|
||||||
|
|
||||||
public string BuildProcessedAudioPath(string audioName) => StorageKey.Combine(ProcessedAudiosPath, audioName);
|
|
||||||
|
|
||||||
public string BuildTempPicturePath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
|
||||||
|
|
||||||
public string BuildTempAudioPath(string tempRootPrefix, long userId, string objectName) =>
|
|
||||||
StorageKey.Combine(tempRootPrefix, userId.ToString(), ParentFolder, objectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
public class TrackPicturesSizes
|
|
||||||
{
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureWidth { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int SmallPictureHeight { get; set; } = 128;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureWidth { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int MediumPictureHeight { get; set; } = 256;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureWidth { get; set; } = 512;
|
|
||||||
|
|
||||||
[Range(1, 1024)]
|
|
||||||
public int LargePictureHeight { get; set; } = 512;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Application.Configuration
|
namespace Musify.Application.Configuration
|
||||||
{
|
{
|
||||||
public class UploadIntentConfiguration
|
public class UploadIntentConfiguration : IConfigurationOptions
|
||||||
{
|
{
|
||||||
public const string SectionName = "UploadIntent";
|
public static string SectionName => "UploadIntent";
|
||||||
|
|
||||||
[Range(10, 3600)]
|
[Range(10, 3600)]
|
||||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||||
@@ -31,12 +31,12 @@ namespace Musify.Application.Configuration
|
|||||||
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
public int ExpiredIntentsRetentionDays { get; set; } = 7;
|
||||||
|
|
||||||
[Required, MinLength(1)]
|
[Required, MinLength(1)]
|
||||||
public string TemporalRootPrefix { get; set; } = "temporal";
|
public string TempRootPrefix { get; set; } = "temp";
|
||||||
|
|
||||||
[Range(1, 30)]
|
[Range(1, 30)]
|
||||||
public int TemporalUploadsRetentionDays { get; set; } = 3;
|
public int TempUploadsRetentionDays { get; set; } = 3;
|
||||||
|
|
||||||
[Range(60, 86400)]
|
[Range(60, 86400)]
|
||||||
public int TemporalCleanUpJobIntervalSeconds { get; set; } = 3600;
|
public int TempCleanupJobIntervalSeconds { get; set; } = 3600;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,8 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IAudioTranscoderService
|
public interface IAudioTranscoderService
|
||||||
{
|
{
|
||||||
Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
public Task<AudioTranscodeResult> TranscodeToAudioFileAsync(Stream audioStream, string destinationPath, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<bool> IsValidAudioFileAsync(string filePath, CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
@@ -5,38 +6,38 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IDatabase
|
public interface IDatabase
|
||||||
{
|
{
|
||||||
DbSet<User> Users { get; }
|
public DbSet<User> Users { get; }
|
||||||
|
|
||||||
DbSet<PlayList> PlayLists { get; }
|
public DbSet<PlayList> PlayLists { get; }
|
||||||
|
|
||||||
DbSet<Album> Albums { get; }
|
public DbSet<Album> Albums { get; }
|
||||||
|
|
||||||
DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
public DbSet<AlbumHasTrack> AlbumHasTracks { get; }
|
||||||
|
|
||||||
DbSet<Track> Tracks { get; }
|
public DbSet<Track> Tracks { get; }
|
||||||
|
|
||||||
DbSet<UserHasTrack> UserHasTracks { get; }
|
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||||
|
|
||||||
DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
public DbSet<Mix> Mixes { get; }
|
||||||
|
|
||||||
DbSet<Mix> Mixes { get; }
|
public DbSet<MixItem> MixItems { get; }
|
||||||
|
|
||||||
DbSet<MixItem> MixItems { get; }
|
public DbSet<UploadIntent> UploadIntents { get; }
|
||||||
|
|
||||||
DbSet<Upload> Uploads { get; }
|
public DbSet<ListeningHistory> ListeningHistories { get; }
|
||||||
|
|
||||||
DbSet<UploadIntent> UploadIntents { get; }
|
public DbSet<TrackLike> TrackLikes { get; }
|
||||||
|
|
||||||
DbSet<ListeningHistory> ListeningHistories { get; }
|
public DbSet<TrackTag> TrackTags { get; }
|
||||||
|
|
||||||
DbSet<TrackLike> TrackLikes { get; }
|
public DbSet<UserFollow> UserFollows { get; }
|
||||||
|
|
||||||
DbSet<TrackTag> TrackTags { get; }
|
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
DbSet<UserFollow> UserFollows { get; }
|
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||||
|
|
||||||
Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
public Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<int> SaveChangesAsync(CancellationToken cancellationToken);
|
public Task<ErrorOr<Success>> TrySaveChangesAsync(Error onUniqueViolation, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IDatabaseTransaction : IAsyncDisposable
|
public interface IDatabaseTransaction : IAsyncDisposable
|
||||||
{
|
{
|
||||||
Task CommitAsync(CancellationToken cancellationToken = default);
|
public Task CommitAsync(CancellationToken cancellationToken = default);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IEventBus
|
public interface IEventBus
|
||||||
{
|
{
|
||||||
Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
public Task PublishAsync<T>(T message, CancellationToken cancellationToken) where T : class;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,6 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IPictureService
|
public interface IPictureService
|
||||||
{
|
{
|
||||||
Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
public Task<Stream> ResizePictureAsWebpAsync(Stream pictureStream, int width, int height, CancellationToken cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,27 +2,24 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStorageService
|
public interface IStorageService
|
||||||
{
|
{
|
||||||
Task<Stream> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
||||||
|
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task<string> GetUrlAsync(string bucket, string key, TimeSpan ExpirationTime, CancellationToken cancellationToken);
|
public Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, bool preventOverwrite = true, CancellationToken cancellationToken = default);
|
||||||
|
|
||||||
Task<string> GetUploadUrlAsync(string bucket, string key, string contentType, TimeSpan expirationTime, CancellationToken cancellationToken, bool preventOverwrite = true);
|
public Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task UploadFileAsync(string filePath, string bucket, string key, CancellationToken cancellationToken);
|
public Task UploadFileAsync(Stream sourceStream, string contentType, string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
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);
|
||||||
|
|
||||||
Task<IReadOnlyList<string>> TransferFilesAsync(string sourceDirectory, string bucket, string route, CancellationToken cancellationToken);
|
public Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task RemoveFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
||||||
|
|
||||||
Task CopyFileAsync(string sourceBucket, string sourceKey, string destinationBucket, string destinationKey,
|
|
||||||
CancellationToken cancellationToken);
|
CancellationToken cancellationToken);
|
||||||
|
|
||||||
Task RemoveFolderAsync(string bucket, string folderKey, CancellationToken cancellationToken);
|
public IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
||||||
|
|
||||||
IAsyncEnumerable<StorageObject> ListObjectsAsync(string bucket, string prefix, CancellationToken cancellationToken);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,8 +2,7 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStreamTicketService
|
public interface IStreamTicketService
|
||||||
{
|
{
|
||||||
|
public StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
||||||
StreamTicket IssueTicket(long? userId, string keyPrefix, long? maxBytes = null);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public record StreamTicket(string Token, int ExpiresInSeconds);
|
public record StreamTicket(string Token, int ExpiresInSeconds);
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -12,7 +12,5 @@ namespace Musify.Application.Events
|
|||||||
string AudioSourceKey,
|
string AudioSourceKey,
|
||||||
string AudioDestinationKey,
|
string AudioDestinationKey,
|
||||||
string AudioProcessedFolderKey,
|
string AudioProcessedFolderKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Events
|
||||||
|
{
|
||||||
|
public record DeleteAlbumEvent(
|
||||||
|
Guid AlbumId,
|
||||||
|
long UserId);
|
||||||
|
}
|
||||||
@@ -6,7 +6,5 @@ namespace Musify.Application.Events
|
|||||||
Guid AlbumId,
|
Guid AlbumId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string SourceKey,
|
string SourceKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,12 +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,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
@@ -8,7 +8,5 @@ namespace Musify.Application.Events
|
|||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string PictureDestinationKey,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,4 @@
|
|||||||
namespace Musify.Application.Events
|
namespace Musify.Application.Events
|
||||||
{
|
{
|
||||||
public record UpdateTrackAudioEvent(
|
public record UpdateTrackAudioEvent(
|
||||||
Guid TrackId,
|
Guid TrackId,
|
||||||
|
|||||||
@@ -1,12 +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,
|
||||||
ImageSize Small,
|
ImageSizes Sizes);
|
||||||
ImageSize Medium,
|
|
||||||
ImageSize Large);
|
|
||||||
}
|
}
|
||||||
@@ -1,12 +1,11 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Genres
|
namespace Musify.Application.Genres
|
||||||
{
|
{
|
||||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||||
|
|
||||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
|
||||||
|
|
||||||
public class GetAvailableGenresQueryHandler
|
public class GetAvailableGenresQueryHandler
|
||||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -1,14 +1,13 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Genres.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Genres
|
namespace Musify.Application.Genres
|
||||||
{
|
{
|
||||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||||
|
|
||||||
public record GenreResponse(Genre Genre, int TrackCount);
|
|
||||||
|
|
||||||
public class GetGenresQueryHandler(IDatabase database)
|
public class GetGenresQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
||||||
{
|
{
|
||||||
@@ -16,7 +15,7 @@ namespace Musify.Application.Genres
|
|||||||
{
|
{
|
||||||
var rows = await database.TrackTags
|
var rows = await database.TrackTags
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
|
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.GroupBy(tag => tag.Tag)
|
.GroupBy(tag => tag.Tag)
|
||||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Genres.Responses
|
||||||
|
{
|
||||||
|
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Genres.Responses
|
||||||
|
{
|
||||||
|
public record GenreResponse(Genre Genre, int TrackCount);
|
||||||
|
}
|
||||||
@@ -1,11 +1,9 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
namespace Musify.Application.Likes
|
||||||
{
|
{
|
||||||
@@ -13,31 +11,21 @@ namespace Musify.Application.Likes
|
|||||||
long UserId,
|
long UserId,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetLikedTracksQueryHandler(IDatabase database)
|
public class GetLikedTracksQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetLikedTracksQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
: IQueryHandler<GetLikedTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetLikedTracksQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var likesQuery = database.TrackLikes
|
return await database.TrackLikes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(like => like.Track.Owner)
|
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Include(like => like.Track.Tags)
|
|
||||||
.Where(like => like.UserId == request.UserId);
|
|
||||||
|
|
||||||
var totalCount = await likesQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await likesQuery
|
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
.OrderByDescending(like => like.CreatedAt)
|
||||||
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.ThenBy(like => like.Id)
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
.Select(like => like.Track)
|
||||||
|
.SelectResponse()
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,9 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Likes
|
namespace Musify.Application.Likes
|
||||||
{
|
{
|
||||||
@@ -19,12 +21,9 @@ namespace Musify.Application.Likes
|
|||||||
{
|
{
|
||||||
var trackExists = await database.Tracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
if (!trackExists)
|
if (!trackExists)
|
||||||
{
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var existingLike = await database.TrackLikes
|
var existingLike = await database.TrackLikes
|
||||||
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
.SingleOrDefaultAsync(like => like.UserId == request.UserId && like.TrackId == request.TrackId, cancellationToken);
|
||||||
@@ -45,7 +44,11 @@ namespace Musify.Application.Likes
|
|||||||
nowLiked = true;
|
nowLiked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
// 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}",
|
logger.LogInformation("User {UserId} {Action} track {TrackId}",
|
||||||
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
request.UserId, nowLiked ? "liked" : "unliked", request.TrackId);
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
@@ -10,30 +9,30 @@ using Musify.Domain.ValueObjects;
|
|||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
public record GenerateMixesForUserCommand(long UserId)
|
public record GenerateMixesForUserCommand(long UserId)
|
||||||
: ICommand<ErrorOr<Success>>;
|
: ICommand;
|
||||||
|
|
||||||
public class GenerateMixesForUserCommandHandler(
|
public class GenerateMixesForUserCommandHandler(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
MixConfiguration mixConfiguration,
|
MixConfiguration mixConfiguration,
|
||||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
: ICommandHandler<GenerateMixesForUserCommand>
|
||||||
{
|
{
|
||||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
public async ValueTask<Unit> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userId = request.UserId;
|
var userId = request.UserId;
|
||||||
|
|
||||||
var libraryTrackIds = await database.UserHasTracks
|
var libraryTrackIds = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(userTrack => userTrack.TrackId)
|
.Select(track => track.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
if (libraryTrackIds.Count == 0)
|
if (libraryTrackIds.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
logger.LogInformation("User {UserId} has no tracks yet, skipping mix generation", userId);
|
||||||
return new Success();
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var historyTrackIds = await database.ListeningHistories
|
var historyTrackIds = await database.ListeningHistories
|
||||||
@@ -51,41 +50,27 @@ namespace Musify.Application.Mixes
|
|||||||
var discovery = Take(Shuffle(unheardTrackIds));
|
var discovery = Take(Shuffle(unheardTrackIds));
|
||||||
if (discovery.Count > 0)
|
if (discovery.Count > 0)
|
||||||
{
|
{
|
||||||
drafts.Add(new MixDraft(
|
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
||||||
"Descubrimiento",
|
|
||||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
|
||||||
discovery));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||||
if (daily.Count > 0)
|
if (daily.Count > 0)
|
||||||
{
|
{
|
||||||
drafts.Add(new MixDraft(
|
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
||||||
"Tu mezcla diaria",
|
|
||||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
|
||||||
daily));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (drafts.Count == 0)
|
if (drafts.Count == 0)
|
||||||
{
|
{
|
||||||
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
logger.LogInformation("No candidate songs found for user {UserId}, skipping mix generation", userId);
|
||||||
return new Success();
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
await ReplaceMixesAsync(userId, drafts, cancellationToken);
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate mixes for user {UserId}", userId);
|
|
||||||
return Error.Failure(description: $"Failed to generate mixes for user {userId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
logger.LogInformation("Generated {Count} mixes for user {UserId}", drafts.Count, userId);
|
||||||
return new Success();
|
return Unit.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
private List<Guid> Take(IEnumerable<Guid> trackIds) =>
|
||||||
@@ -94,7 +79,7 @@ namespace Musify.Application.Mixes
|
|||||||
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
private static List<Guid> Shuffle(IEnumerable<Guid> trackIds) =>
|
||||||
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
[.. trackIds.OrderBy(_ => Guid.NewGuid())];
|
||||||
|
|
||||||
private static List<Guid> Interleave(IReadOnlyList<Guid> first, IReadOnlyList<Guid> second)
|
private static List<Guid> Interleave(List<Guid> first, List<Guid> second)
|
||||||
{
|
{
|
||||||
var merged = new List<Guid>(first.Count + second.Count);
|
var merged = new List<Guid>(first.Count + second.Count);
|
||||||
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
for (var index = 0; index < Math.Max(first.Count, second.Count); index++)
|
||||||
@@ -116,12 +101,6 @@ namespace Musify.Application.Mixes
|
|||||||
|
|
||||||
if (previousMixes.Count > 0)
|
if (previousMixes.Count > 0)
|
||||||
{
|
{
|
||||||
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
|
|
||||||
var previousItems = await database.MixItems
|
|
||||||
.Where(item => previousIds.Contains(item.MixId))
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
database.MixItems.RemoveRange(previousItems);
|
|
||||||
database.Mixes.RemoveRange(previousMixes);
|
database.Mixes.RemoveRange(previousMixes);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -131,8 +110,7 @@ namespace Musify.Application.Mixes
|
|||||||
var mix = new Mix
|
var mix = new Mix
|
||||||
{
|
{
|
||||||
UserId = userId,
|
UserId = userId,
|
||||||
Title = draft.Title,
|
Kind = draft.Kind,
|
||||||
Subtitle = draft.Subtitle,
|
|
||||||
Position = position
|
Position = position
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Mixes.Responses;
|
using Musify.Application.Mixes.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
@@ -20,12 +21,12 @@ namespace Musify.Application.Mixes
|
|||||||
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
.SingleOrDefaultAsync(mix => mix.Id == request.MixId && mix.UserId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
if (mix == null)
|
if (mix == null)
|
||||||
return Error.NotFound(description: $"Mix {request.MixId} was not found");
|
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.Title, mix.Subtitle, items.Count, items);
|
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
@@ -7,28 +6,43 @@ using Musify.Application.Mixes.Responses;
|
|||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
public record GetMixesByUserIdQuery(long UserId)
|
public record GetMixesByUserIdQuery(long UserId)
|
||||||
: IQuery<ErrorOr<IReadOnlyList<MixApplicationResponse>>>;
|
: IQuery<IReadOnlyList<MixApplicationResponse>>;
|
||||||
|
|
||||||
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetMixesByUserIdQuery, ErrorOr<IReadOnlyList<MixApplicationResponse>>>
|
: IQueryHandler<GetMixesByUserIdQuery, IReadOnlyList<MixApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<IReadOnlyList<MixApplicationResponse>>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<IReadOnlyList<MixApplicationResponse>> Handle(GetMixesByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var mixes = await database.Mixes
|
var mixes = await database.Mixes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(mix => mix.UserId == request.UserId)
|
.Where(mix => mix.UserId == request.UserId)
|
||||||
.Include(mix => mix.Items)
|
.Include(mix => mix.Items)
|
||||||
.OrderBy(mix => mix.Position)
|
.OrderBy(mix => mix.Position)
|
||||||
|
.ThenBy(mix => mix.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var orderedItemsByMix = mixes.ToDictionary(
|
||||||
|
mix => mix.Id,
|
||||||
|
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||||
|
|
||||||
|
var coverItems = orderedItemsByMix.Values
|
||||||
|
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||||
|
.ToList();
|
||||||
|
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||||
|
.GroupBy(response => response.TrackId)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First());
|
||||||
|
|
||||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||||
foreach (var mix in mixes)
|
foreach (var mix in mixes)
|
||||||
{
|
{
|
||||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
var orderedItems = orderedItemsByMix[mix.Id];
|
||||||
var coverItems = await MixItemMapper.ToResponsesAsync(
|
var covers = orderedItems
|
||||||
database, orderedItems.Take(MixApplicationResponse.CoverItemCount).ToList(), cancellationToken);
|
.Take(MixApplicationResponse.CoverItemCount)
|
||||||
|
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||||
|
.Select(item => coverResponses[item.TrackId])
|
||||||
|
.ToList();
|
||||||
|
|
||||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, coverItems));
|
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
||||||
}
|
}
|
||||||
|
|
||||||
return responses;
|
return responses;
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using Microsoft.EntityFrameworkCore;
|
|||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Mixes.Responses;
|
using Musify.Application.Mixes.Responses;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes
|
namespace Musify.Application.Mixes
|
||||||
{
|
{
|
||||||
@@ -18,7 +19,7 @@ namespace Musify.Application.Mixes
|
|||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(track => track.Owner)
|
.Include(track => track.Owner)
|
||||||
.Where(track => trackIds.Contains(track.Id))
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Mixes.Responses
|
namespace Musify.Application.Mixes.Responses
|
||||||
{
|
{
|
||||||
public record MixApplicationResponse(
|
public record MixApplicationResponse(
|
||||||
Guid Id,
|
Guid Id,
|
||||||
string Title,
|
MixKind Kind,
|
||||||
string? Subtitle,
|
|
||||||
int ItemCount,
|
int ItemCount,
|
||||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using MimeMapping;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Pictures
|
||||||
|
{
|
||||||
|
public enum CoverOwner
|
||||||
|
{
|
||||||
|
Track,
|
||||||
|
Album,
|
||||||
|
PlayList
|
||||||
|
}
|
||||||
|
|
||||||
|
public record GetCoverQuery(CoverOwner Owner, Guid Id, PictureSize Size = PictureSize.Medium)
|
||||||
|
: IQuery<ErrorOr<CoverLocation>>;
|
||||||
|
|
||||||
|
public record CoverLocation(string Bucket, string Key, string ContentType);
|
||||||
|
|
||||||
|
public class GetCoverQueryHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
TrackConfiguration trackConfiguration,
|
||||||
|
AlbumConfiguration albumConfiguration,
|
||||||
|
PlayListConfiguration playListConfiguration)
|
||||||
|
: IQueryHandler<GetCoverQuery, ErrorOr<CoverLocation>>
|
||||||
|
{
|
||||||
|
private sealed record CoverSource(long OwnerUserId, string? Original, string? Small, string? Medium, string? Large);
|
||||||
|
|
||||||
|
public async ValueTask<ErrorOr<CoverLocation>> Handle(GetCoverQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var (source, routes) = request.Owner switch
|
||||||
|
{
|
||||||
|
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
||||||
|
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
||||||
|
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
||||||
|
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||||
|
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||||
|
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||||
|
.SingleOrDefaultAsync(cancellationToken), albumConfiguration.Routes),
|
||||||
|
_ => (await database.PlayLists.AsNoTracking()
|
||||||
|
.Where(p => p.Id == request.Id && p.LifeCycleStatus == LifeCycleStatus.Active && p.Pictures != null)
|
||||||
|
.Select(p => new CoverSource(p.OwnerUserId, p.Pictures!.OriginalName, p.Pictures.SmallName, p.Pictures.MediumName, p.Pictures.LargeName))
|
||||||
|
.SingleOrDefaultAsync(cancellationToken), playListConfiguration.Routes)
|
||||||
|
};
|
||||||
|
|
||||||
|
var entity = request.Owner.ToString();
|
||||||
|
if (source == null)
|
||||||
|
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||||
|
|
||||||
|
var resizedName = request.Size switch
|
||||||
|
{
|
||||||
|
PictureSize.Small => source.Small,
|
||||||
|
PictureSize.Large => source.Large,
|
||||||
|
_ => source.Medium
|
||||||
|
};
|
||||||
|
|
||||||
|
// The resized files only exist once processing has finished; until then serve the original.
|
||||||
|
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||||
|
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||||
|
: !string.IsNullOrEmpty(source.Original)
|
||||||
|
? (source.Original, routes.BuildOriginalPicturePath(source.OwnerUserId, source.Original))
|
||||||
|
: (null, null);
|
||||||
|
|
||||||
|
if (name == null || key == null)
|
||||||
|
return AppErrors.NotFound($"{entity}Cover", request.Id);
|
||||||
|
|
||||||
|
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Pictures
|
||||||
|
{
|
||||||
|
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
||||||
|
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||||
|
|
||||||
|
public static class PictureSourceChange
|
||||||
|
{
|
||||||
|
public static async Task<ErrorOr<PreparedPicture>> PrepareAsync(
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
Guid intentId,
|
||||||
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
|
IPictureOwnerConfiguration owner,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var validation = await uploadIntentValidator.ValidateAndLoadAsync(intentId, userId, purpose, cancellationToken);
|
||||||
|
if (validation.IsError)
|
||||||
|
return validation.Errors;
|
||||||
|
|
||||||
|
var intent = validation.Value;
|
||||||
|
|
||||||
|
return new PreparedPicture(
|
||||||
|
intent,
|
||||||
|
EntityPictures.Pending(intent.ObjectName),
|
||||||
|
owner.Routes.BuildOriginalPicturePath(userId, intent.ObjectName),
|
||||||
|
owner.PicturesSizes.ToImageSizes(owner.Routes));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+2
-2
@@ -1,6 +1,6 @@
|
|||||||
namespace Musify.Application.Albums.Responses
|
namespace Musify.Application.Pictures.Responses
|
||||||
{
|
{
|
||||||
public record AlbumPictureUploadResponse(
|
public record PictureUploadResponse(
|
||||||
Guid IntentId,
|
Guid IntentId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string Key,
|
string Key,
|
||||||
@@ -2,12 +2,15 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
|
/// <summary>Appends every track of an album to a playlist in album order, skipping the ones it already has.</summary>
|
||||||
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||||
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||||
|
|
||||||
@@ -18,41 +21,29 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
if (playList.IsError)
|
||||||
if (playList == null)
|
return playList.Errors;
|
||||||
{
|
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumExists = await database.Albums
|
var albumExists = await database.Albums
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
if (!albumExists)
|
if (!albumExists)
|
||||||
{
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
logger.LogInformation("Album {AlbumId} not found", request.AlbumId);
|
|
||||||
return Error.NotFound(description: $"Album {request.AlbumId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var albumTrackIds = await database.AlbumHasTracks
|
var albumTrackIds = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var playListTracksQuery = database.PlayListHasTracks
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
.Where(link => link.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
var existingTrackIds = (await playListTracksQuery
|
var existingTrackIds = (await playListTracksQuery
|
||||||
.Select(plt => plt.TrackId)
|
.Select(link => link.TrackId)
|
||||||
.ToListAsync(cancellationToken)).ToHashSet();
|
.ToListAsync(cancellationToken)).ToHashSet();
|
||||||
|
|
||||||
var newTrackIds = albumTrackIds
|
var newTrackIds = albumTrackIds
|
||||||
@@ -62,20 +53,24 @@ namespace Musify.Application.PlayLists
|
|||||||
if (newTrackIds.Count == 0)
|
if (newTrackIds.Count == 0)
|
||||||
return new AddAlbumToPlayListResponse(0);
|
return new AddAlbumToPlayListResponse(0);
|
||||||
|
|
||||||
var nextPosition = existingTrackIds.Count > 0
|
// Playlist positions are internal indexes, so they start at 0.
|
||||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
var nextPosition = (await playListTracksQuery
|
||||||
: 0;
|
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||||
|
|
||||||
await database.PlayListHasTracks.AddRangeAsync(
|
foreach (var trackId in newTrackIds)
|
||||||
newTrackIds.Select((trackId, index) => new PlayListHasTrack
|
{
|
||||||
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
{
|
{
|
||||||
PlayListId = request.PlayListId,
|
PlayListId = request.PlayListId,
|
||||||
TrackId = trackId,
|
TrackId = trackId,
|
||||||
Position = nextPosition + index
|
Position = nextPosition++
|
||||||
}),
|
}, cancellationToken);
|
||||||
cancellationToken);
|
}
|
||||||
|
|
||||||
await database.SaveChangesAsync(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}",
|
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
|
||||||
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
||||||
|
|||||||
@@ -1,75 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
|
||||||
: ICommand<ErrorOr<Success>>;
|
|
||||||
|
|
||||||
public class AddTrackToPlayListCommandHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ILogger<AddTrackToPlayListCommandHandler> logger)
|
|
||||||
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var playList = await database.PlayLists
|
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
|
||||||
if (playList == null)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
var trackExists = await database.Tracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(t => t.Id == request.TrackId, cancellationToken);
|
|
||||||
if (!trackExists)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound(description: $"Track {request.TrackId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var alreadyAdded = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
|
||||||
if (alreadyAdded)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Track {TrackId} already in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
return Error.Conflict(description: "Track is already in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var playListTracksQuery = database.PlayListHasTracks
|
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
|
||||||
|
|
||||||
var hasTracks = await playListTracksQuery.AnyAsync(cancellationToken);
|
|
||||||
var nextPosition = hasTracks
|
|
||||||
? await playListTracksQuery.MaxAsync(plt => plt.Position, cancellationToken) + 1
|
|
||||||
: 0;
|
|
||||||
|
|
||||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
|
||||||
{
|
|
||||||
PlayListId = request.PlayListId,
|
|
||||||
TrackId = request.TrackId,
|
|
||||||
Position = nextPosition
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
|
|
||||||
request.TrackId, request.PlayListId, nextPosition);
|
|
||||||
|
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,74 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||||
|
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class AddTracksToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddTracksToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (playList.IsError)
|
||||||
|
return playList.Errors;
|
||||||
|
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var existingIds = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => track.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
||||||
|
if (missing != Guid.Empty)
|
||||||
|
return AppErrors.NotFound("Track", missing);
|
||||||
|
|
||||||
|
var alreadyInPlayList = await database.PlayListHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
||||||
|
if (toAdd.Count == 0)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
// Playlist positions are internal indexes, so they start at 0.
|
||||||
|
var nextPosition = (await database.PlayListHasTracks
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId)
|
||||||
|
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||||
|
|
||||||
|
foreach (var trackId in toAdd)
|
||||||
|
{
|
||||||
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
|
{
|
||||||
|
PlayListId = request.PlayListId,
|
||||||
|
TrackId = trackId,
|
||||||
|
Position = nextPosition++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -18,7 +19,7 @@ namespace Musify.Application.PlayLists
|
|||||||
string Name,
|
string Name,
|
||||||
string? Description,
|
string? Description,
|
||||||
Guid? PictureIntentId,
|
Guid? PictureIntentId,
|
||||||
PlaylistVisibility Visibility = PlaylistVisibility.Private)
|
PlayListVisibility Visibility = PlayListVisibility.Private)
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class CreatePlayListCommandHandler(
|
public class CreatePlayListCommandHandler(
|
||||||
@@ -26,121 +27,53 @@ namespace Musify.Application.PlayLists
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<CreatePlayListCommandHandler> logger,
|
ILogger<CreatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
PlayListConfiguration playListConfiguration)
|
||||||
PlayListConfiguration playListConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<CreatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
private sealed record ResolvedPlayListPictures(PlayListPictures? Pictures, UploadIntent? Intent);
|
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(CreatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var userExists = await database.Users
|
var userExists = await database.Users
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
||||||
if (!userExists)
|
if (!userExists)
|
||||||
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
|
PreparedPicture? picture = null;
|
||||||
|
if (request.PictureIntentId is { } intentId)
|
||||||
{
|
{
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
picture = prepared.Value;
|
||||||
}
|
}
|
||||||
|
|
||||||
var picturesResult = await ResolvePicturesAsync(request, cancellationToken);
|
var name = request.Name.Trim();
|
||||||
if (picturesResult.IsError)
|
|
||||||
return picturesResult.Errors;
|
|
||||||
|
|
||||||
var resolvedPictures = picturesResult.Value;
|
|
||||||
|
|
||||||
var playList = new PlayList
|
var playList = new PlayList
|
||||||
{
|
{
|
||||||
UserId = request.UserId,
|
OwnerUserId = request.UserId,
|
||||||
Name = request.Name,
|
Name = name,
|
||||||
NormalizedName = request.Name.Trim().ToUpperInvariant(),
|
NormalizedName = TextNormalizer.Normalize(name),
|
||||||
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim(),
|
||||||
Pictures = resolvedPictures.Pictures,
|
Pictures = picture?.Pictures,
|
||||||
Visibility = request.Visibility
|
Visibility = request.Visibility
|
||||||
};
|
};
|
||||||
await database.PlayLists.AddAsync(playList, cancellationToken);
|
await database.PlayLists.AddAsync(playList, cancellationToken);
|
||||||
|
|
||||||
if (resolvedPictures.Intent != null)
|
if (picture != null)
|
||||||
{
|
|
||||||
var finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, resolvedPictures.Pictures!.OriginalName);
|
|
||||||
var publishResult = await PublishCreatePlayListEventAsync(playList.Id, resolvedPictures.Intent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to save playlist for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: $"Failed to create playlist for user {request.UserId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
|
||||||
return PlayListApplicationResponse.FromEntity(playList);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async ValueTask<ErrorOr<ResolvedPlayListPictures>> ResolvePicturesAsync(
|
|
||||||
CreatePlayListCommand request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (!request.PictureIntentId.HasValue)
|
|
||||||
{
|
|
||||||
return new ResolvedPlayListPictures(null, null);
|
|
||||||
}
|
|
||||||
|
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
|
||||||
uploadIntentConfiguration, request.PictureIntentId.Value, request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
if (validation.IsError)
|
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
return new ResolvedPlayListPictures(new PlayListPictures
|
|
||||||
{
|
|
||||||
OriginalName = validation.Value.ObjectName,
|
|
||||||
SmallName = validation.Value.ObjectName,
|
|
||||||
MediumName = validation.Value.ObjectName,
|
|
||||||
LargeName = validation.Value.ObjectName
|
|
||||||
}, validation.Value);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishCreatePlayListEventAsync(
|
|
||||||
Guid playListId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
{
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new CreatePlayListResourcesEvent(
|
new CreatePlayListResourcesEvent(
|
||||||
playListId,
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.SmallPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.MediumPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.LargePicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish create playlist event for playlist {PlayListId}", playListId);
|
|
||||||
return Error.Failure(description: $"Failed to publish create playlist event for playlist {playListId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Created playlist {PlayListId} for user {UserId}", playList.Id, request.UserId);
|
||||||
|
|
||||||
|
return PlayListApplicationResponse.FromEntity(playList);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
@@ -19,44 +19,19 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeletePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists.SingleOrDefaultAsync(p => p.Id == request.PlayListId, cancellationToken);
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
if (playList == null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
logger.LogDebug("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Playlist {PlayListId} does not belong to user {UserId}", request.PlayListId, request.UserId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var playList = found.Value;
|
||||||
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
playList.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
try
|
await eventBus.PublishAsync(new DeletePlayListEvent(playList.Id, request.UserId), cancellationToken);
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new DeletePlayListEvent(playList.Id, request.UserId),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish delete playlist event for playlist {PlayListId}", request.PlayListId);
|
|
||||||
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to mark playlist {PlayListId} as removing", request.PlayListId);
|
|
||||||
return Error.Failure(description: $"Failed to delete playlist {request.PlayListId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Success();
|
logger.LogInformation("Marked playlist {PlayListId} as removing", request.PlayListId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -15,30 +15,17 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(GetPlayListByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entry = await database.PlayLists
|
var playList = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.VisibleTo(request.RequestingUserId)
|
||||||
.Where(p => p.Id == request.Id)
|
.Where(p => p.Id == request.Id)
|
||||||
.Select(p => new
|
.SelectResponse()
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
TrackCount = p.PlayListTracks.Count,
|
|
||||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entry == null)
|
return playList == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("PlayList", request.Id)
|
||||||
|
: playList;
|
||||||
if (entry.PlayList.Visibility == PlaylistVisibility.Private
|
|
||||||
&& entry.PlayList.UserId != request.RequestingUserId)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using MimeMapping;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListCoverQuery(Guid PlayListId, string Size)
|
|
||||||
: IQuery<ErrorOr<PlayListCoverLocation>>;
|
|
||||||
|
|
||||||
public record PlayListCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetPlayListCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: IQueryHandler<GetPlayListCoverQuery, ErrorOr<PlayListCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PlayListCoverLocation>> Handle(GetPlayListCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var pictures = await database.PlayLists.AsNoTracking()
|
|
||||||
.Where(p => p.Id == request.PlayListId)
|
|
||||||
.Select(p => p.Pictures)
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (pictures == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = playListConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (pictures.SmallName, BuildKey(routes.BuildSmallPicturePath, pictures.SmallName)),
|
|
||||||
"large" => (pictures.LargeName, BuildKey(routes.BuildLargePicturePath, pictures.LargeName)),
|
|
||||||
_ => (pictures.MediumName, BuildKey(routes.BuildMediumPicturePath, pictures.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || string.IsNullOrEmpty(key))
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = MimeUtility.GetMimeMapping(name);
|
|
||||||
return new PlayListCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -2,17 +2,18 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
using Musify.Domain.ValueObjects;
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
public record GetPlayListTracksQuery(
|
public record GetPlayListTracksQuery(
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize,
|
||||||
|
long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||||
|
|
||||||
public class GetPlayListTracksQueryHandler(IDatabase database)
|
public class GetPlayListTracksQueryHandler(IDatabase database)
|
||||||
@@ -20,30 +21,22 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetPlayListTracksQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListExists = await database.PlayLists
|
var playListVisible = await database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(p => p.Id == request.PlayListId, cancellationToken);
|
.Active()
|
||||||
if (!playListExists)
|
.VisibleTo(request.ViewerId)
|
||||||
return Error.NotFound();
|
.AnyAsync(playList => playList.Id == request.PlayListId, cancellationToken);
|
||||||
|
if (!playListVisible)
|
||||||
|
return AppErrors.NotFound("PlayList", request.PlayListId);
|
||||||
|
|
||||||
var tracksQuery = database.PlayListHasTracks
|
return await database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(plt => plt.Track.Owner)
|
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.Include(plt => plt.Track.Tags)
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
.Where(plt => plt.PlayListId == request.PlayListId);
|
.ThenBy(playListTrack => playListTrack.Id)
|
||||||
|
.Select(playListTrack => playListTrack.Track)
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
.SelectResponse()
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
var pagedEntities = await tracksQuery
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,45 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Shared;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Mediator;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
|
||||||
{
|
|
||||||
public record GetPlayListsQuery(int PageNumber, int PageSize)
|
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
|
||||||
|
|
||||||
public class GetPlayListsQueryHandler(IDatabase database)
|
|
||||||
: IQueryHandler<GetPlayListsQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var totalCount = await database.PlayLists.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await database.PlayLists
|
|
||||||
.AsNoTracking()
|
|
||||||
.OrderBy(p => p.Id)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
TrackCount = p.PlayListTracks.Count,
|
|
||||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,12 +1,8 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -15,52 +11,31 @@ namespace Musify.Application.PlayLists
|
|||||||
string? Name,
|
string? Name,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize,
|
int PageSize,
|
||||||
bool OnlyPublic = false)
|
long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>;
|
: IQuery<PaginatedResponse<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
public class GetPlayListsByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetPlayListsByUserIdQuery, ErrorOr<PaginatedResponse<PlayListApplicationResponse>>>
|
: IQueryHandler<GetPlayListsByUserIdQuery, PaginatedResponse<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<PlayListApplicationResponse>>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<PaginatedResponse<PlayListApplicationResponse>> Handle(GetPlayListsByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListsQuery = database.PlayLists
|
var playListsQuery = database.PlayLists
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(p => p.UserId == request.UserId);
|
.Active()
|
||||||
|
.VisibleTo(request.ViewerId)
|
||||||
|
.Where(playList => playList.OwnerUserId == request.UserId);
|
||||||
|
|
||||||
if (request.OnlyPublic)
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
playListsQuery = playListsQuery.Where(p => p.Visibility == PlaylistVisibility.Public);
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
|
playListsQuery = playListsQuery.Where(playList => playList.NormalizedName.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
return await playListsQuery
|
||||||
{
|
.OrderBy(playList => playList.CreatedAt)
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
.ThenBy(playList => playList.Id)
|
||||||
playListsQuery = playListsQuery.Where(p => p.NormalizedName.Contains(normalizedName));
|
.SelectResponse()
|
||||||
}
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
|
||||||
var totalCount = await playListsQuery.CountAsync(cancellationToken);
|
|
||||||
|
|
||||||
var pagedEntities = await playListsQuery
|
|
||||||
.OrderBy(p => p.CreatedAt)
|
|
||||||
.Where(p => p.LifeCycleStatus == LifeCycleStatus.Active)
|
|
||||||
.Select(p => new
|
|
||||||
{
|
|
||||||
PlayList = p,
|
|
||||||
TrackCount = p.PlayListTracks.Count,
|
|
||||||
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
|
|
||||||
CoverTrackIds = p.PlayListTracks
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToList()
|
|
||||||
})
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
|
|
||||||
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -16,34 +17,30 @@ namespace Musify.Application.PlayLists
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveTrackFromPlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playList = await database.PlayLists
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
if (playList.IsError)
|
||||||
if (playList == null)
|
return playList.Errors;
|
||||||
{
|
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playList.UserId != request.UserId)
|
var playListTracks = await database.PlayListHasTracks
|
||||||
{
|
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
.OrderBy(plt => plt.Position)
|
||||||
return Error.Unauthorized();
|
.ToListAsync(cancellationToken);
|
||||||
}
|
|
||||||
|
|
||||||
var link = await database.PlayListHasTracks
|
var link = playListTracks.SingleOrDefault(plt => plt.TrackId == request.TrackId);
|
||||||
.SingleOrDefaultAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
|
||||||
if (link == null)
|
if (link == null)
|
||||||
{
|
return Error.NotFound("PlayList.TrackNotFound", "Track is not in the playlist.");
|
||||||
logger.LogInformation("Track {TrackId} not in playlist {PlayListId}", request.TrackId, request.PlayListId);
|
|
||||||
return Error.NotFound(description: "Track is not in the playlist.");
|
|
||||||
}
|
|
||||||
|
|
||||||
database.PlayListHasTracks.Remove(link);
|
database.PlayListHasTracks.Remove(link);
|
||||||
|
|
||||||
|
var position = 0;
|
||||||
|
foreach (var remaining in playListTracks.Where(plt => plt.Id != link.Id))
|
||||||
|
remaining.Position = position++;
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
logger.LogInformation("Removed track {TrackId} from playlist {PlayListId}", request.TrackId, request.PlayListId);
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.Pictures.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -16,90 +12,14 @@ namespace Musify.Application.PlayLists
|
|||||||
string FileType,
|
string FileType,
|
||||||
string ContentType,
|
string ContentType,
|
||||||
long? ExpectedSizeBytes = null)
|
long? ExpectedSizeBytes = null)
|
||||||
: ICommand<ErrorOr<PlayListPictureUploadResponse>>;
|
: ICommand<ErrorOr<PictureUploadResponse>>;
|
||||||
|
|
||||||
public class RequestPlayListPictureUploadCommandHandler(
|
public class RequestPlayListPictureUploadCommandHandler(UploadIntentIssuer issuer, PlayListConfiguration playListConfiguration)
|
||||||
IDatabase database,
|
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PictureUploadResponse>>
|
||||||
UploadIntentValidator uploadIntentValidator,
|
|
||||||
IStorageService storageService,
|
|
||||||
ILogger<RequestPlayListPictureUploadCommandHandler> logger,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
PlayListConfiguration playListConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<RequestPlayListPictureUploadCommand, ErrorOr<PlayListPictureUploadResponse>>
|
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListPictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PictureUploadResponse>> Handle(RequestPlayListPictureUploadCommand request, CancellationToken cancellationToken) =>
|
||||||
{
|
await issuer.IssuePictureAsync(
|
||||||
var userExists = await database.Users
|
request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration,
|
||||||
.AsNoTracking()
|
request.FileType, request.ContentType, request.ExpectedSizeBytes, cancellationToken);
|
||||||
.AnyAsync(u => u.Id == request.UserId, cancellationToken);
|
|
||||||
if (!userExists)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
|
||||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
var effectiveSizeBytes = request.ExpectedSizeBytes
|
|
||||||
?? uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
|
||||||
|
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
|
||||||
|
|
||||||
var tempKey = playListConfiguration.Routes.BuildTempPicturePath(
|
|
||||||
uploadIntentConfiguration.TemporalRootPrefix, request.UserId, objectName);
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
var uploadUrl = await storageService.GetUploadUrlAsync(
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
request.ContentType,
|
|
||||||
TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
cancellationToken);
|
|
||||||
|
|
||||||
await using var transaction = await database.BeginTransactionAsync(
|
|
||||||
System.Data.IsolationLevel.Serializable, cancellationToken);
|
|
||||||
|
|
||||||
var quotaCheck = await uploadIntentValidator.CheckQuotaAsync(
|
|
||||||
uploadIntentConfiguration,
|
|
||||||
request.UserId, effectiveSizeBytes, 1, cancellationToken);
|
|
||||||
if (quotaCheck.IsError)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} failed upload intent quota check", request.UserId);
|
|
||||||
return quotaCheck.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
var intent = new UploadIntent
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
Bucket = storageConfiguration.Bucket,
|
|
||||||
Key = tempKey,
|
|
||||||
ObjectName = objectName,
|
|
||||||
ContentType = request.ContentType,
|
|
||||||
ExpectedSizeBytes = effectiveSizeBytes,
|
|
||||||
Purpose = UploadIntentPurpose.PlayListPicture,
|
|
||||||
ExpiresAt = DateTime.UtcNow.AddSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds),
|
|
||||||
};
|
|
||||||
|
|
||||||
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
await transaction.CommitAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued upload intent {IntentId} for user {UserId} (PlayListPicture, temp key: {Key})", intent.Id, request.UserId, tempKey);
|
|
||||||
|
|
||||||
return new PlayListPictureUploadResponse(
|
|
||||||
intent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
tempKey,
|
|
||||||
objectName,
|
|
||||||
request.ContentType,
|
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
|
||||||
uploadUrl);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to generate playlist picture upload URL for user {UserId}", request.UserId);
|
|
||||||
return Error.Failure(description: "Failed to generate upload URL");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+13
-14
@@ -1,8 +1,7 @@
|
|||||||
using System.Text.Json.Serialization;
|
|
||||||
using Musify.Application.Serialization;
|
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists.Responses
|
namespace Musify.Application.PlayLists.Responses
|
||||||
{
|
{
|
||||||
public record PlayListApplicationResponse(
|
public record PlayListApplicationResponse(
|
||||||
@@ -12,21 +11,21 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
string? SmallImageKeyName,
|
string? SmallImageKeyName,
|
||||||
string? MediumImageKeyName,
|
string? MediumImageKeyName,
|
||||||
string? LargeImageKeyName,
|
string? LargeImageKeyName,
|
||||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
PlayListVisibility Visibility,
|
||||||
PlaylistVisibility Visibility,
|
|
||||||
int TrackCount,
|
|
||||||
double DurationSeconds,
|
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds,
|
||||||
|
long OwnerUserId,
|
||||||
|
int TrackCount,
|
||||||
|
double DurationSeconds)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = 4;
|
public const int CoverTrackCount = PlayListProjections.CoverTrackCount;
|
||||||
|
|
||||||
public static PlayListApplicationResponse FromEntity(
|
public static PlayListApplicationResponse FromEntity(
|
||||||
PlayList playList,
|
PlayList playList,
|
||||||
|
IReadOnlyList<Guid>? coverTrackIds = null,
|
||||||
int trackCount = 0,
|
int trackCount = 0,
|
||||||
double durationSeconds = 0,
|
double durationSeconds = 0)
|
||||||
IReadOnlyList<Guid>? coverTrackIds = null)
|
|
||||||
{
|
{
|
||||||
return new PlayListApplicationResponse(
|
return new PlayListApplicationResponse(
|
||||||
playList.Id,
|
playList.Id,
|
||||||
@@ -35,13 +34,13 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
playList.Pictures?.SmallName,
|
playList.Pictures?.SmallName,
|
||||||
playList.Pictures?.MediumName,
|
playList.Pictures?.MediumName,
|
||||||
playList.Pictures?.LargeName,
|
playList.Pictures?.LargeName,
|
||||||
playList.UserId,
|
|
||||||
playList.Visibility,
|
playList.Visibility,
|
||||||
trackCount,
|
|
||||||
durationSeconds,
|
|
||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
playList.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? [],
|
||||||
|
playList.OwnerUserId,
|
||||||
|
trackCount,
|
||||||
|
durationSeconds);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +0,0 @@
|
|||||||
namespace Musify.Application.PlayLists.Responses
|
|
||||||
{
|
|
||||||
public record PlayListPictureUploadResponse(
|
|
||||||
Guid IntentId,
|
|
||||||
string Bucket,
|
|
||||||
string Key,
|
|
||||||
string PictureName,
|
|
||||||
string ContentType,
|
|
||||||
int ExpiresInSeconds,
|
|
||||||
string UploadUrl);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists.Responses
|
||||||
|
{
|
||||||
|
public static class PlayListProjections
|
||||||
|
{
|
||||||
|
public const int CoverTrackCount = 4;
|
||||||
|
|
||||||
|
public static IQueryable<PlayList> Active(this IQueryable<PlayList> query) =>
|
||||||
|
query.Where(playList => playList.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
|
public static IQueryable<PlayList> VisibleTo(this IQueryable<PlayList> query, long? viewerId) =>
|
||||||
|
query.Where(playList => playList.Visibility == PlayListVisibility.Public || playList.OwnerUserId == viewerId);
|
||||||
|
|
||||||
|
public static IQueryable<PlayListApplicationResponse> SelectResponse(this IQueryable<PlayList> query) =>
|
||||||
|
query.Select(playList => new PlayListApplicationResponse(
|
||||||
|
playList.Id,
|
||||||
|
playList.Name,
|
||||||
|
playList.Description,
|
||||||
|
playList.Pictures!.SmallName,
|
||||||
|
playList.Pictures!.MediumName,
|
||||||
|
playList.Pictures!.LargeName,
|
||||||
|
playList.Visibility,
|
||||||
|
playList.CreatedAt,
|
||||||
|
playList.UpdatedAt,
|
||||||
|
playList.PlayListTracks
|
||||||
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
|
.Take(CoverTrackCount)
|
||||||
|
.Select(playListTrack => playListTrack.TrackId)
|
||||||
|
.ToList(),
|
||||||
|
playList.OwnerUserId,
|
||||||
|
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
||||||
|
playList.PlayListTracks
|
||||||
|
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
@@ -19,7 +19,7 @@ namespace Musify.Application.PlayLists
|
|||||||
string? NewName,
|
string? NewName,
|
||||||
string? NewDescription,
|
string? NewDescription,
|
||||||
Guid? NewPictureIntentId,
|
Guid? NewPictureIntentId,
|
||||||
PlaylistVisibility? NewVisibility = null)
|
PlayListVisibility? NewVisibility = null)
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class UpdatePlayListCommandHandler(
|
public class UpdatePlayListCommandHandler(
|
||||||
@@ -27,144 +27,55 @@ namespace Musify.Application.PlayLists
|
|||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<UpdatePlayListCommandHandler> logger,
|
ILogger<UpdatePlayListCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
PlayListConfiguration playListConfiguration)
|
||||||
PlayListConfiguration playListConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var playListEntity = await database.PlayLists.SingleOrDefaultAsync(pl => pl.Id == request.PlayListId, cancellationToken);
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
if (playListEntity == null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
logger.LogInformation("Playlist {PlayListId} not found", request.PlayListId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playListEntity.UserId != request.UserId)
|
var playList = found.Value;
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} is not the owner of playlist {PlayListId}", request.UserId, request.PlayListId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||||
{
|
{
|
||||||
playListEntity.Name = request.NewName;
|
playList.Name = request.NewName.Trim();
|
||||||
playListEntity.NormalizedName = request.NewName.Trim().ToUpperInvariant();
|
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewDescription))
|
// null means "unchanged"; a blank description clears it.
|
||||||
{
|
if (request.NewDescription != null)
|
||||||
playListEntity.Description = request.NewDescription;
|
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||||
}
|
|
||||||
|
|
||||||
if (request.NewVisibility.HasValue)
|
if (request.NewVisibility is { } visibility)
|
||||||
{
|
playList.Visibility = visibility;
|
||||||
playListEntity.Visibility = request.NewVisibility.Value;
|
|
||||||
}
|
|
||||||
|
|
||||||
UploadIntent? pictureIntent = null;
|
if (request.NewPictureIntentId is { } intentId)
|
||||||
string? finalPictureKey = null;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId.HasValue)
|
|
||||||
{
|
{
|
||||||
var validation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentConfiguration,
|
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
request.NewPictureIntentId.Value, request.UserId, cancellationToken);
|
if (prepared.IsError)
|
||||||
if (validation.IsError)
|
return prepared.Errors;
|
||||||
return validation.Errors;
|
|
||||||
|
|
||||||
pictureIntent = validation.Value;
|
var picture = prepared.Value;
|
||||||
playListEntity.Pictures = new PlayListPictures
|
playList.Pictures = picture.Pictures;
|
||||||
{
|
|
||||||
OriginalName = pictureIntent.ObjectName,
|
|
||||||
SmallName = pictureIntent.ObjectName,
|
|
||||||
MediumName = pictureIntent.ObjectName,
|
|
||||||
LargeName = pictureIntent.ObjectName
|
|
||||||
};
|
|
||||||
finalPictureKey = playListConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (pictureIntent != null && finalPictureKey != null)
|
|
||||||
{
|
|
||||||
var publishResult = await PublishUpdatePlayListPictureSourceEventAsync(
|
|
||||||
playListEntity.Id, pictureIntent, finalPictureKey, cancellationToken);
|
|
||||||
if (publishResult.IsError)
|
|
||||||
return publishResult.Errors;
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to update playlist {PlayListId}", playListEntity.Id);
|
|
||||||
return Error.Failure(description: $"Failed to update playlist {playListEntity.Id}");
|
|
||||||
}
|
|
||||||
|
|
||||||
var coverTrackIds = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(plt => plt.PlayListId == playListEntity.Id)
|
|
||||||
.OrderBy(plt => plt.Position)
|
|
||||||
.Take(PlayListApplicationResponse.CoverTrackCount)
|
|
||||||
.Select(plt => plt.TrackId)
|
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
|
|
||||||
var totals = await database.PlayListHasTracks
|
|
||||||
.AsNoTracking()
|
|
||||||
.Where(plt => plt.PlayListId == playListEntity.Id)
|
|
||||||
.GroupBy(plt => plt.PlayListId)
|
|
||||||
.Select(group => new
|
|
||||||
{
|
|
||||||
TrackCount = group.Count(),
|
|
||||||
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
|
|
||||||
})
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
return PlayListApplicationResponse.FromEntity(
|
|
||||||
playListEntity,
|
|
||||||
totals?.TrackCount ?? 0,
|
|
||||||
totals?.DurationSeconds ?? 0,
|
|
||||||
coverTrackIds);
|
|
||||||
}
|
|
||||||
|
|
||||||
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
|
|
||||||
Guid playListId,
|
|
||||||
UploadIntent pictureIntent,
|
|
||||||
string finalPictureKey,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new UpdatePlayListPictureSourceEvent(
|
new UpdatePlayListPictureSourceEvent(
|
||||||
playListId,
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
pictureIntent.Id,
|
|
||||||
storageConfiguration.Bucket,
|
|
||||||
pictureIntent.Key,
|
|
||||||
finalPictureKey,
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.SmallPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.MediumPicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
playListConfiguration.Routes.LargePicturesPath,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
playListConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
return new Success();
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish update playlist picture source event for playlist {PlayListId}", playListId);
|
|
||||||
return Error.Failure(description: $"Failed to publish update playlist picture source event for playlist {playListId}");
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated playlist {PlayListId}", playList.Id);
|
||||||
|
|
||||||
|
return await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == playList.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,37 +0,0 @@
|
|||||||
using System.Globalization;
|
|
||||||
using System.Text.Json;
|
|
||||||
using System.Text.Json.Serialization;
|
|
||||||
|
|
||||||
namespace Musify.Application.Serialization;
|
|
||||||
|
|
||||||
public sealed class LongAsStringConverter : JsonConverter<long>
|
|
||||||
{
|
|
||||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
|
||||||
reader.TokenType == JsonTokenType.String
|
|
||||||
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
|
||||||
: reader.GetInt64();
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
|
|
||||||
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
|
|
||||||
}
|
|
||||||
|
|
||||||
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
|
|
||||||
{
|
|
||||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (reader.TokenType == JsonTokenType.Null)
|
|
||||||
return null;
|
|
||||||
|
|
||||||
return reader.TokenType == JsonTokenType.String
|
|
||||||
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
|
||||||
: reader.GetInt64();
|
|
||||||
}
|
|
||||||
|
|
||||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
|
||||||
{
|
|
||||||
if (value.HasValue)
|
|
||||||
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
|
|
||||||
else
|
|
||||||
writer.WriteNullValue();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,28 +1,20 @@
|
|||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
|
||||||
|
|
||||||
|
namespace Musify.Application.Services
|
||||||
|
{
|
||||||
|
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
||||||
public sealed class TrackStreamIssuer(
|
public sealed class TrackStreamIssuer(
|
||||||
IDatabase database,
|
|
||||||
IStreamTicketService ticketService,
|
IStreamTicketService ticketService,
|
||||||
TrackConfiguration trackConfiguration,
|
TrackConfiguration trackConfiguration,
|
||||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||||
PlaybackConfiguration playbackConfiguration)
|
PlaybackConfiguration playbackConfiguration)
|
||||||
{
|
{
|
||||||
public async Task<TrackStreamResponse> IssueAsync(
|
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
|
||||||
Guid trackId,
|
|
||||||
string audioFolderName,
|
|
||||||
long? userId,
|
|
||||||
CancellationToken cancellationToken)
|
|
||||||
{
|
{
|
||||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
||||||
var keyPrefix = $"{folderPath}/";
|
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
|
||||||
|
|
||||||
var maxBytes = AnonymousFragmentBytes(userId);
|
|
||||||
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
|
|
||||||
|
|
||||||
var manifestUrl = string.Join('/',
|
var manifestUrl = string.Join('/',
|
||||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||||
@@ -30,19 +22,6 @@ public sealed class TrackStreamIssuer(
|
|||||||
folderPath,
|
folderPath,
|
||||||
streamGatewayConfiguration.AudioFileName);
|
streamGatewayConfiguration.AudioFileName);
|
||||||
|
|
||||||
Guid? listenId = null;
|
|
||||||
if (userId != null)
|
|
||||||
{
|
|
||||||
var newListeningHistory = new ListeningHistory
|
|
||||||
{
|
|
||||||
UserId = userId.Value,
|
|
||||||
TrackId = trackId,
|
|
||||||
};
|
|
||||||
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
|
||||||
listenId = newListeningHistory.Id;
|
|
||||||
}
|
|
||||||
|
|
||||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,3 +33,4 @@ public sealed class TrackStreamIssuer(
|
|||||||
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
return (long)playbackConfiguration.AnonymousFragmentSeconds * playbackConfiguration.EstimatedAudioBytesPerSecond;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Pictures.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Services
|
||||||
|
{
|
||||||
|
public sealed record UploadRequest(
|
||||||
|
UploadIntentPurpose Purpose,
|
||||||
|
PictureRoutes Routes,
|
||||||
|
string FileType,
|
||||||
|
string ContentType,
|
||||||
|
long? ExpectedSizeBytes);
|
||||||
|
|
||||||
|
public sealed record IssuedUpload(
|
||||||
|
Guid IntentId,
|
||||||
|
string Key,
|
||||||
|
string ObjectName,
|
||||||
|
string ContentType,
|
||||||
|
string UploadUrl);
|
||||||
|
|
||||||
|
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
||||||
|
public sealed class UploadIntentIssuer(
|
||||||
|
IDatabase database,
|
||||||
|
IStorageService storageService,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ApplicationStorageConfiguration storageConfiguration,
|
||||||
|
UploadIntentConfiguration uploadIntentConfiguration,
|
||||||
|
ILogger<UploadIntentIssuer> logger)
|
||||||
|
{
|
||||||
|
public async Task<ErrorOr<IReadOnlyList<IssuedUpload>>> IssueAsync(
|
||||||
|
long userId,
|
||||||
|
IReadOnlyList<UploadRequest> requests,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var userExists = await database.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.AnyAsync(user => user.Id == userId, cancellationToken);
|
||||||
|
if (!userExists)
|
||||||
|
return AppErrors.NotFound("User", userId);
|
||||||
|
|
||||||
|
var planned = requests
|
||||||
|
.Select(request =>
|
||||||
|
{
|
||||||
|
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
||||||
|
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
||||||
|
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
||||||
|
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
||||||
|
})
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
await using var transaction = await database.BeginTransactionAsync(
|
||||||
|
System.Data.IsolationLevel.Serializable, cancellationToken);
|
||||||
|
|
||||||
|
var quota = await uploadIntentValidator.CheckQuotaAsync(
|
||||||
|
userId, planned.Sum(item => item.Size), planned.Count, cancellationToken);
|
||||||
|
if (quota.IsError)
|
||||||
|
return quota.Errors;
|
||||||
|
|
||||||
|
var expiresIn = TimeSpan.FromSeconds(uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||||
|
var expiresAt = DateTime.UtcNow + expiresIn;
|
||||||
|
var issued = new List<IssuedUpload>(planned.Count);
|
||||||
|
|
||||||
|
foreach (var item in planned)
|
||||||
|
{
|
||||||
|
var uploadUrl = await storageService.GetUploadUrlAsync(
|
||||||
|
storageConfiguration.Bucket, item.Key, item.Request.ContentType, expiresIn, cancellationToken: cancellationToken);
|
||||||
|
|
||||||
|
var intent = new UploadIntent
|
||||||
|
{
|
||||||
|
UserId = userId,
|
||||||
|
Bucket = storageConfiguration.Bucket,
|
||||||
|
Key = item.Key,
|
||||||
|
ObjectName = item.ObjectName,
|
||||||
|
ContentType = item.Request.ContentType,
|
||||||
|
ExpectedSizeBytes = item.Size,
|
||||||
|
Purpose = item.Request.Purpose,
|
||||||
|
ExpiresAt = expiresAt,
|
||||||
|
};
|
||||||
|
|
||||||
|
await database.UploadIntents.AddAsync(intent, cancellationToken);
|
||||||
|
issued.Add(new IssuedUpload(intent.Id, item.Key, item.ObjectName, item.Request.ContentType, uploadUrl));
|
||||||
|
}
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
await transaction.CommitAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Issued {Count} upload intents for user {UserId} ({Purposes})",
|
||||||
|
issued.Count, userId, string.Join('/', planned.Select(item => item.Request.Purpose)));
|
||||||
|
|
||||||
|
return issued;
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<IssuedUpload>> IssueOneAsync(long userId, UploadRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var issued = await IssueAsync(userId, [request], cancellationToken);
|
||||||
|
return issued.IsError ? issued.Errors : issued.Value[0];
|
||||||
|
}
|
||||||
|
|
||||||
|
public async Task<ErrorOr<PictureUploadResponse>> IssuePictureAsync(
|
||||||
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
|
IPictureOwnerConfiguration owner,
|
||||||
|
string fileType,
|
||||||
|
string contentType,
|
||||||
|
long? expectedSizeBytes,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var issued = await IssueOneAsync(
|
||||||
|
userId, new UploadRequest(purpose, owner.Routes, fileType, contentType, expectedSizeBytes), cancellationToken);
|
||||||
|
if (issued.IsError)
|
||||||
|
return issued.Errors;
|
||||||
|
|
||||||
|
return new PictureUploadResponse(
|
||||||
|
issued.Value.IntentId,
|
||||||
|
storageConfiguration.Bucket,
|
||||||
|
issued.Value.Key,
|
||||||
|
issued.Value.ObjectName,
|
||||||
|
issued.Value.ContentType,
|
||||||
|
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||||
|
issued.Value.UploadUrl);
|
||||||
|
}
|
||||||
|
|
||||||
|
private long DefaultSize(UploadIntentPurpose purpose) =>
|
||||||
|
purpose == UploadIntentPurpose.TrackAudio
|
||||||
|
? uploadIntentConfiguration.DefaultExpectedAudioSizeBytes
|
||||||
|
: uploadIntentConfiguration.DefaultExpectedPictureSizeBytes;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,59 +1,73 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Services;
|
|
||||||
|
|
||||||
|
namespace Musify.Application.Services
|
||||||
|
{
|
||||||
public sealed class UploadIntentValidator(
|
public sealed class UploadIntentValidator(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IStorageService storageService)
|
IStorageService storageService,
|
||||||
|
UploadIntentConfiguration config)
|
||||||
{
|
{
|
||||||
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
public async Task<ErrorOr<Success>> CheckQuotaAsync(
|
||||||
UploadIntentConfiguration config,
|
|
||||||
long userId,
|
long userId,
|
||||||
long requiredBytes,
|
long requiredBytes,
|
||||||
int requiredIntentCount,
|
int requiredIntentCount,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var activeIntents = await database.UploadIntents
|
var now = DateTime.UtcNow;
|
||||||
.AsNoTracking()
|
var pictureDefault = config.DefaultExpectedPictureSizeBytes;
|
||||||
.Where(i => i.UserId == userId && i.Status == UploadIntentStatus.Issued)
|
var audioDefault = config.DefaultExpectedAudioSizeBytes;
|
||||||
.ToListAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (activeIntents.Count + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
var active = await database.UploadIntents
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(intent => intent.UserId == userId && intent.Status == UploadIntentStatus.Issued && intent.ExpiresAt > now)
|
||||||
|
.GroupBy(_ => 1)
|
||||||
|
.Select(group => new
|
||||||
|
{
|
||||||
|
Count = group.Count(),
|
||||||
|
Bytes = group.Sum(intent => intent.ExpectedSizeBytes
|
||||||
|
?? (intent.Purpose == UploadIntentPurpose.TrackAudio ? audioDefault : pictureDefault))
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if ((active?.Count ?? 0) + requiredIntentCount > config.MaxActiveUploadIntentsPerUser)
|
||||||
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
return Error.Validation(description: "Upload intent limit exceeded. Wait for existing uploads to complete or expire.");
|
||||||
|
|
||||||
var activeBytes = activeIntents.Sum(i => i.ExpectedSizeBytes ?? config.DefaultExpectedPictureSizeBytes);
|
if ((active?.Bytes ?? 0) + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
||||||
if (activeBytes + requiredBytes > config.MaxActiveUploadBytesPerUser)
|
|
||||||
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
return Error.Validation(description: "Upload byte quota exceeded. Wait for existing uploads to complete or expire.");
|
||||||
|
|
||||||
return new Success();
|
return Result.Success;
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
public async Task<ErrorOr<UploadIntent>> ValidateAndLoadAsync(
|
||||||
UploadIntentConfiguration config,
|
|
||||||
Guid intentId,
|
Guid intentId,
|
||||||
long userId,
|
long userId,
|
||||||
|
UploadIntentPurpose purpose,
|
||||||
CancellationToken cancellationToken)
|
CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var intent = await database.UploadIntents
|
var intent = await database.UploadIntents
|
||||||
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
.FirstOrDefaultAsync(i => i.Id == intentId, cancellationToken);
|
||||||
|
|
||||||
if (intent == null || intent.UserId != userId)
|
if (intent == null || intent.UserId != userId)
|
||||||
return Error.NotFound(description: "Upload intent not found or not accessible.");
|
return AppErrors.NotFound("UploadIntent", intentId);
|
||||||
|
|
||||||
|
if (intent.Purpose != purpose)
|
||||||
|
return Error.Validation(description: "Upload intent was issued for a different purpose.");
|
||||||
|
|
||||||
if (intent.IsConsumed)
|
if (intent.IsConsumed)
|
||||||
return Error.Conflict(description: "Upload intent has already been consumed.");
|
return AppErrors.Conflict("UploadIntent.Consumed", "Upload intent has already been consumed.");
|
||||||
|
|
||||||
if (intent.IsExpired)
|
if (intent.IsExpiredAt(DateTime.UtcNow))
|
||||||
return Error.Validation(description: "Upload intent has expired.");
|
return Error.Validation(description: "Upload intent has expired.");
|
||||||
|
|
||||||
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
var metadata = await storageService.HeadObjectAsync(intent.Bucket, intent.Key, cancellationToken);
|
||||||
if (metadata == null)
|
if (metadata == null)
|
||||||
return Error.NotFound(description: "Uploaded object not found in storage. Upload the file first.");
|
return Error.NotFound("UploadIntent.ObjectMissing", "Uploaded object not found in storage. Upload the file first.");
|
||||||
|
|
||||||
if (metadata.ContentLength > config.MaxUploadBytes)
|
if (metadata.ContentLength > config.MaxUploadBytes)
|
||||||
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
return Error.Validation(description: $"Uploaded file exceeds the maximum allowed size of {config.MaxUploadBytes} bytes.");
|
||||||
@@ -61,3 +75,4 @@ public sealed class UploadIntentValidator(
|
|||||||
return intent;
|
return intent;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public static class AppErrors
|
||||||
|
{
|
||||||
|
public static Error NotFound(string entity, object id) =>
|
||||||
|
Error.NotFound($"{entity}.NotFound", $"{entity} {id} was not found.");
|
||||||
|
|
||||||
|
public static Error Forbidden(string entity, object id) =>
|
||||||
|
Error.Forbidden($"{entity}.Forbidden", $"You do not have permission to modify {entity} {id}.");
|
||||||
|
|
||||||
|
public static Error Conflict(string code, string description) =>
|
||||||
|
Error.Conflict(code, description);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +1,12 @@
|
|||||||
namespace Musify.Application.Shared
|
namespace Musify.Application.Shared
|
||||||
{
|
{
|
||||||
public record ImageSize(
|
public record ImageSize(
|
||||||
string SavePath,
|
string SavePath,
|
||||||
int Width,
|
int Width,
|
||||||
int Height);
|
int Height);
|
||||||
|
|
||||||
|
public record ImageSizes(
|
||||||
|
ImageSize Small,
|
||||||
|
ImageSize Medium,
|
||||||
|
ImageSize Large);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Domain.Abstractions;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public static class OwnershipExtensions
|
||||||
|
{
|
||||||
|
/// <summary>
|
||||||
|
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
||||||
|
/// or not active, Forbidden when someone else owns it.
|
||||||
|
/// </summary>
|
||||||
|
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||||
|
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||||
|
where T : class, IOwnedEntity, IHasLifeCycle
|
||||||
|
{
|
||||||
|
var entity = await set.SingleOrDefaultAsync(
|
||||||
|
e => e.Id == id && e.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||||
|
|
||||||
|
if (entity == null)
|
||||||
|
return AppErrors.NotFound(typeof(T).Name, id);
|
||||||
|
|
||||||
|
return entity.OwnerUserId == userId
|
||||||
|
? entity
|
||||||
|
: AppErrors.Forbidden(typeof(T).Name, id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,26 +0,0 @@
|
|||||||
using X.PagedList;
|
|
||||||
|
|
||||||
namespace Musify.Application.Shared
|
|
||||||
{
|
|
||||||
public record PaginatedResponse<T>(
|
|
||||||
IReadOnlyCollection<T> Items,
|
|
||||||
int PageNumber,
|
|
||||||
int PageSize,
|
|
||||||
int PageCount,
|
|
||||||
int TotalItemCount,
|
|
||||||
bool HasNextPage,
|
|
||||||
bool HasPreviousPage)
|
|
||||||
{
|
|
||||||
public static PaginatedResponse<T> FromPagedList(IPagedList<T> pagedList)
|
|
||||||
{
|
|
||||||
return new PaginatedResponse<T>(
|
|
||||||
pagedList.ToArray(),
|
|
||||||
pagedList.PageNumber,
|
|
||||||
pagedList.PageSize,
|
|
||||||
pagedList.PageCount,
|
|
||||||
pagedList.TotalItemCount,
|
|
||||||
pagedList.HasNextPage,
|
|
||||||
pagedList.HasPreviousPage);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public record PageRequest(int PageNumber = 1, int PageSize = 20)
|
||||||
|
{
|
||||||
|
public const int MaxPageSize = 100;
|
||||||
|
}
|
||||||
|
|
||||||
|
public record PaginatedResponse<T>(
|
||||||
|
IReadOnlyList<T> Items,
|
||||||
|
int PageNumber,
|
||||||
|
int PageSize,
|
||||||
|
int PageCount,
|
||||||
|
int TotalItemCount,
|
||||||
|
bool HasNextPage,
|
||||||
|
bool HasPreviousPage);
|
||||||
|
|
||||||
|
public static class PaginationExtensions
|
||||||
|
{
|
||||||
|
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
||||||
|
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||||
|
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var pageNumber = Math.Max(page.PageNumber, 1);
|
||||||
|
var pageSize = Math.Clamp(page.PageSize, 1, PageRequest.MaxPageSize);
|
||||||
|
|
||||||
|
var totalCount = await query.CountAsync(cancellationToken);
|
||||||
|
var items = await query
|
||||||
|
.Skip((pageNumber - 1) * pageSize)
|
||||||
|
.Take(pageSize)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var pageCount = (int)Math.Ceiling(totalCount / (double)pageSize);
|
||||||
|
|
||||||
|
return new PaginatedResponse<T>(
|
||||||
|
items,
|
||||||
|
pageNumber,
|
||||||
|
pageSize,
|
||||||
|
pageCount,
|
||||||
|
totalCount,
|
||||||
|
HasNextPage: pageNumber < pageCount,
|
||||||
|
HasPreviousPage: pageNumber > 1 && pageCount > 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Musify.Application.Shared
|
||||||
|
{
|
||||||
|
public static class TextNormalizer
|
||||||
|
{
|
||||||
|
public static string Normalize(string value) => value.Trim().ToUpperInvariant();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -3,13 +3,13 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
using Musify.Application.Configuration;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
using Musify.Application.Events;
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using Musify.Application.Services;
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -27,9 +27,7 @@ namespace Musify.Application.Tracks
|
|||||||
IEventBus eventBus,
|
IEventBus eventBus,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
UploadIntentValidator uploadIntentValidator,
|
||||||
ILogger<CreateTrackCommandHandler> logger,
|
ILogger<CreateTrackCommandHandler> logger,
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
TrackConfiguration trackConfiguration)
|
||||||
TrackConfiguration trackConfiguration,
|
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
|
||||||
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||||
@@ -43,31 +41,17 @@ namespace Musify.Application.Tracks
|
|||||||
}
|
}
|
||||||
|
|
||||||
var distinctTags = request.Tags.Distinct().ToList();
|
var distinctTags = request.Tags.Distinct().ToList();
|
||||||
if (distinctTags.Count == 0)
|
|
||||||
{
|
|
||||||
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
|
|
||||||
return Error.Validation(description: "At least one tag is required.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
|
if (request.PictureIntentId == request.AudioIntentId)
|
||||||
if (tagConflicts.Count > 0)
|
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||||
{
|
|
||||||
var conflict = tagConflicts.First();
|
|
||||||
logger.LogWarning(
|
|
||||||
"Track {Title} was submitted with incompatible tags {First} and {Second}",
|
|
||||||
request.Title, conflict.First, conflict.Second);
|
|
||||||
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||||
request.PictureIntentId, request.UserId, cancellationToken);
|
|
||||||
if (pictureValidation.IsError)
|
if (pictureValidation.IsError)
|
||||||
return pictureValidation.Errors;
|
return pictureValidation.Errors;
|
||||||
|
|
||||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
uploadIntentConfiguration,
|
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
|
||||||
request.AudioIntentId, request.UserId, cancellationToken);
|
|
||||||
if (audioValidation.IsError)
|
if (audioValidation.IsError)
|
||||||
return audioValidation.Errors;
|
return audioValidation.Errors;
|
||||||
|
|
||||||
@@ -80,8 +64,8 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
var trackEntity = new Track
|
var trackEntity = new Track
|
||||||
{
|
{
|
||||||
Title = request.Title,
|
Title = request.Title.Trim(),
|
||||||
NormalizedTitle = request.Title.ToUpperInvariant(),
|
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||||
OwnerUserId = user.Id,
|
OwnerUserId = user.Id,
|
||||||
Owner = user,
|
Owner = user,
|
||||||
IsExplicit = request.IsExplicit,
|
IsExplicit = request.IsExplicit,
|
||||||
@@ -106,54 +90,23 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||||
|
|
||||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
|
||||||
{
|
|
||||||
UserId = request.UserId,
|
|
||||||
TrackId = trackEntity.Id
|
|
||||||
}, cancellationToken);
|
|
||||||
|
|
||||||
try
|
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new CreateTrackResourcesEvent(
|
new CreateTrackResourcesEvent(
|
||||||
trackEntity.Id,
|
trackEntity.Id,
|
||||||
pictureIntent.Id,
|
pictureIntent.Id,
|
||||||
audioIntent.Id,
|
audioIntent.Id,
|
||||||
storageConfiguration.Bucket,
|
pictureIntent.Bucket,
|
||||||
pictureIntent.Key,
|
pictureIntent.Key,
|
||||||
finalPictureKey,
|
finalPictureKey,
|
||||||
audioIntent.Key,
|
audioIntent.Key,
|
||||||
finalAudioKey,
|
finalAudioKey,
|
||||||
audioProcessedFolderKey,
|
audioProcessedFolderKey,
|
||||||
new ImageSize(
|
sizes),
|
||||||
trackConfiguration.Routes.SmallPicturesPath,
|
|
||||||
trackConfiguration.PicturesSizes.SmallPictureWidth,
|
|
||||||
trackConfiguration.PicturesSizes.SmallPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
trackConfiguration.Routes.MediumPicturesPath,
|
|
||||||
trackConfiguration.PicturesSizes.MediumPictureWidth,
|
|
||||||
trackConfiguration.PicturesSizes.MediumPictureHeight),
|
|
||||||
new ImageSize(
|
|
||||||
trackConfiguration.Routes.LargePicturesPath,
|
|
||||||
trackConfiguration.PicturesSizes.LargePictureWidth,
|
|
||||||
trackConfiguration.PicturesSizes.LargePictureHeight)),
|
|
||||||
cancellationToken);
|
cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish create track event for track {TrackId}", trackEntity.Id);
|
|
||||||
return Error.Failure(description: $"Failed to create track for {request.Title}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to save track {Title}", request.Title);
|
|
||||||
return Error.Failure(description: $"Failed to create track for {request.Title}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,10 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Domain.Entities;
|
|
||||||
using Musify.Domain.ValueObjects;
|
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -22,50 +21,22 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.Tracks.SingleOrDefaultAsync(t => t.Id == request.TrackId, cancellationToken);
|
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||||
if (track == null)
|
if (found.IsError)
|
||||||
{
|
return found.Errors;
|
||||||
logger.LogInformation("Track {TrackId} not found", request.TrackId);
|
|
||||||
return Error.NotFound();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (track.OwnerUserId != request.UserId)
|
|
||||||
{
|
|
||||||
logger.LogWarning("User {UserId} unauthorized to delete track {TrackId}", request.UserId, request.TrackId);
|
|
||||||
return Error.Unauthorized();
|
|
||||||
}
|
|
||||||
|
|
||||||
|
var track = found.Value;
|
||||||
if (track.Audio.IsInProgress)
|
if (track.Audio.IsInProgress)
|
||||||
{
|
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||||
logger.LogWarning("Track {TrackId} is currently being processed and cannot be deleted", request.TrackId);
|
|
||||||
return Error.Conflict(description: "Track is currently being processed and cannot be deleted");
|
|
||||||
}
|
|
||||||
|
|
||||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
|
||||||
try
|
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||||
{
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new DeleteTrackEvent(track.Id, request.UserId),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to publish delete track event for track {TrackId}", request.TrackId);
|
|
||||||
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
try
|
|
||||||
{
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
}
|
|
||||||
catch (Exception exception)
|
|
||||||
{
|
|
||||||
logger.LogError(exception, "Failed to mark track {TrackId} as processing before deletion", request.TrackId);
|
|
||||||
return Error.Failure(description: $"Failed to delete track {request.TrackId}");
|
|
||||||
}
|
|
||||||
|
|
||||||
return new Success();
|
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,30 +1,27 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<ErrorOr<TrackApplicationResponse>>;
|
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
||||||
|
|
||||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, ErrorOr<TrackApplicationResponse>>
|
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var track = await database.ListeningHistories
|
return await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Track.Owner)
|
.Include(t => t.Track.Owner)
|
||||||
.Include(t => t.Track.Tags)
|
.Include(t => t.Track.Tags)
|
||||||
.Where(t => t.UserId == query.UserId)
|
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
.OrderByDescending(t => t.ListenedAt)
|
.OrderByDescending(t => t.ListenedAt)
|
||||||
|
.ThenBy(t => t.Id)
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
return track == null
|
|
||||||
? Error.NotFound("Track.NotFound", "No track found for the given user ID.")
|
|
||||||
: track;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1,40 +1,40 @@
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IEnumerable<TrackApplicationResponse>>;
|
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetListeningHistoryQueryHandler(
|
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||||
IDatabase database)
|
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||||
: IQueryHandler<GetListeningHistoryQuery, IEnumerable<TrackApplicationResponse>>
|
|
||||||
{
|
{
|
||||||
public const int ListSize = 15;
|
public const int ListSize = 15;
|
||||||
|
|
||||||
public async ValueTask<IEnumerable<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var recentTrackIds = await database.ListeningHistories
|
var recentTrackIds = await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
||||||
.GroupBy(l => l.TrackId)
|
.GroupBy(l => l.TrackId)
|
||||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||||
|
.ThenBy(g => g.Key)
|
||||||
.Select(g => g.Key)
|
.Select(g => g.Key)
|
||||||
.Take(ListSize)
|
.Take(ListSize)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Owner)
|
.Active()
|
||||||
.Include(t => t.Tags)
|
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
.Where(t => recentTrackIds.Contains(t.Id))
|
||||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
.SelectResponse()
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|
||||||
return recentTrackIds
|
return recentTrackIds
|
||||||
|
.Where(id => tracksById.ContainsKey(id))
|
||||||
.Select(id => tracksById[id])
|
.Select(id => tracksById[id])
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.ListensCount));
|
.ToList();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
@@ -14,17 +15,16 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var entity = await database.Tracks.AsNoTracking()
|
var track = await database.Tracks
|
||||||
.Include(t => t.Owner)
|
.AsNoTracking()
|
||||||
.Include(t => t.Tags)
|
.Active()
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId)
|
||||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
.SelectResponse()
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
if (entity == null)
|
return track == null
|
||||||
return Error.NotFound();
|
? AppErrors.NotFound("Track", request.TrackId)
|
||||||
|
: track;
|
||||||
return TrackApplicationResponse.FromEntity(entity.Track, entity.ListensCount);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,53 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
public record GetTrackCoverQuery(Guid TrackId, string Size)
|
|
||||||
: IQuery<ErrorOr<TrackCoverLocation>>;
|
|
||||||
|
|
||||||
public record TrackCoverLocation(string Bucket, string Key, string ContentType);
|
|
||||||
|
|
||||||
public class GetTrackCoverQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
ApplicationStorageConfiguration storageConfiguration,
|
|
||||||
TrackConfiguration trackConfiguration)
|
|
||||||
: IQueryHandler<GetTrackCoverQuery, ErrorOr<TrackCoverLocation>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackCoverLocation>> Handle(GetTrackCoverQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
|
||||||
.Where(t => t.Id == request.TrackId)
|
|
||||||
.Select(t => new { t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName })
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (track == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var routes = trackConfiguration.Routes;
|
|
||||||
var (name, key) = request.Size.ToLowerInvariant() switch
|
|
||||||
{
|
|
||||||
"small" => (track.SmallName, BuildKey(routes.BuildSmallPicturePath, track.SmallName)),
|
|
||||||
"large" => (track.LargeName, BuildKey(routes.BuildLargePicturePath, track.LargeName)),
|
|
||||||
_ => (track.MediumName, BuildKey(routes.BuildMediumPicturePath, track.MediumName))
|
|
||||||
};
|
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(name) || key == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
var contentType = name.EndsWith(".webp", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "image/webp"
|
|
||||||
: name.EndsWith(".png", StringComparison.OrdinalIgnoreCase)
|
|
||||||
? "image/png"
|
|
||||||
: "image/jpeg";
|
|
||||||
|
|
||||||
return new TrackCoverLocation(storageConfiguration.Bucket, key, contentType);
|
|
||||||
}
|
|
||||||
|
|
||||||
private static string? BuildKey(Func<string, string> builder, string? name)
|
|
||||||
=> string.IsNullOrEmpty(name) ? null : builder(name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,49 +0,0 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
|
||||||
using Microsoft.EntityFrameworkCore;
|
|
||||||
using Microsoft.Extensions.Logging;
|
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
|
||||||
{
|
|
||||||
|
|
||||||
public record GetTrackStreamQuery(Guid TrackId, long? UserId)
|
|
||||||
: IQuery<ErrorOr<TrackStreamResponse>>;
|
|
||||||
|
|
||||||
public class GetTrackStreamQueryHandler(
|
|
||||||
IDatabase database,
|
|
||||||
TrackStreamIssuer streamIssuer,
|
|
||||||
PlaybackConfiguration playbackConfiguration,
|
|
||||||
ILogger<GetTrackStreamQueryHandler> logger)
|
|
||||||
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
|
|
||||||
{
|
|
||||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
|
||||||
{
|
|
||||||
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
|
|
||||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
|
||||||
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
|
||||||
.Where(t => t.Id == request.TrackId)
|
|
||||||
.Select(t => new { t.Id, t.Audio })
|
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
|
||||||
|
|
||||||
if (track == null)
|
|
||||||
return Error.NotFound();
|
|
||||||
|
|
||||||
if (!track.Audio.IsProcessed)
|
|
||||||
{
|
|
||||||
logger.LogInformation("Stream requested for track {TrackId} but audio is not ready", request.TrackId);
|
|
||||||
return Error.Conflict(description: "Track audio is not available for streaming yet.");
|
|
||||||
}
|
|
||||||
|
|
||||||
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
|
||||||
|
|
||||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString() ?? "(anonymous)");
|
|
||||||
|
|
||||||
return response;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,57 +1,38 @@
|
|||||||
using ErrorOr;
|
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Tracks.Responses;
|
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetTracksQueryHandler(IDatabase database)
|
public class GetTracksQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetTracksQuery, ErrorOr<TracksSearchResponse>>
|
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<TracksSearchResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tracksQuery = database.UserHasTracks
|
var tracksQuery = database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Active();
|
||||||
.Include(ut => ut.Track.Tags)
|
|
||||||
.AsQueryable();
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.Genre is { } genre)
|
if (request.Genre is { } genre)
|
||||||
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
|
tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre));
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
return await tracksQuery
|
||||||
|
.OrderByDescending(track => track.CreatedAt)
|
||||||
var pagedEntities = await tracksQuery
|
.ThenBy(track => track.Id)
|
||||||
.OrderByDescending(t => t.Track.CreatedAt)
|
.SelectResponse()
|
||||||
.ThenBy(t => t.Id)
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
|
||||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var items = pagedEntities
|
|
||||||
.Select(x => TrackSearchItemResponse.FromTrack(TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)))
|
|
||||||
.ToList();
|
|
||||||
|
|
||||||
return new TracksSearchResponse(
|
|
||||||
items,
|
|
||||||
pagedEntities.PageNumber,
|
|
||||||
pagedEntities.PageSize,
|
|
||||||
pagedEntities.PageCount,
|
|
||||||
pagedEntities.TotalItemCount,
|
|
||||||
pagedEntities.HasPreviousPage,
|
|
||||||
pagedEntities.HasNextPage);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,9 @@
|
|||||||
using ErrorOr;
|
using ErrorOr;
|
||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Application.Tracks.Responses;
|
using Musify.Application.Tracks.Responses;
|
||||||
using X.PagedList;
|
|
||||||
using X.PagedList.EF;
|
|
||||||
using Musify.Application.Contracts;
|
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -21,33 +19,22 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var pageNumber = request.PageNumber < 1 ? 1 : request.PageNumber;
|
var tracksQuery = database.Tracks
|
||||||
var pageSize = request.PageSize < 1 ? 10 : request.PageSize;
|
|
||||||
|
|
||||||
var tracksQuery = database.UserHasTracks
|
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(ut => ut.Track.Owner)
|
.Active()
|
||||||
.Include(ut => ut.Track.Tags)
|
.Where(track => track.OwnerUserId == request.UserId);
|
||||||
.Where(p => p.UserId == request.UserId);
|
|
||||||
|
|
||||||
if (!string.IsNullOrEmpty(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
var normalizedName = request.Name.Trim().ToUpperInvariant();
|
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
return await tracksQuery
|
||||||
|
.OrderByDescending(track => track.CreatedAt)
|
||||||
var pagedEntities = await tracksQuery
|
.ThenBy(track => track.Id)
|
||||||
.OrderBy(t => t.Track.CreatedAt)
|
.SelectResponse()
|
||||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
|
|
||||||
|
|
||||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
|
||||||
pagedEntities.Select(x => TrackApplicationResponse.FromEntity(x.Track, x.ListensCount)),
|
|
||||||
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
|
|
||||||
|
|
||||||
return PaginatedResponse<TrackApplicationResponse>.FromPagedList(pagedTracks);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -16,15 +17,12 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
|
|
||||||
return Error.Validation(description: "playedSeconds must be a non-negative number.");
|
|
||||||
|
|
||||||
var listen = await database.ListeningHistories
|
var listen = await database.ListeningHistories
|
||||||
.Include(l => l.Track)
|
.Include(l => l.Track)
|
||||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||||
|
|
||||||
if (listen == null)
|
if (listen == null)
|
||||||
return Error.NotFound();
|
return AppErrors.NotFound("Listen", request.ListenId);
|
||||||
|
|
||||||
var now = DateTime.UtcNow;
|
var now = DateTime.UtcNow;
|
||||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user