Compare commits
74
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a081b85efc | ||
|
|
76b62ec384 | ||
|
|
2a7e805480 | ||
|
|
dddb57c98b | ||
|
|
058a902a81 | ||
|
|
153c33bf51 | ||
|
|
2b2f4093fb | ||
|
|
8a5b1bd353 | ||
|
|
03f5936944 | ||
|
|
a888a44b41 | ||
|
|
d874e06d64 | ||
|
|
f3ed68856c | ||
|
|
3eccf6dc53 | ||
|
|
e19aba777d | ||
|
|
493cc74cfb | ||
|
|
9f592cfdb8 | ||
|
|
93d7a23c53 | ||
|
|
202164aa50 | ||
|
|
ebfd41b160 | ||
|
|
1c20e106fa | ||
|
|
43928ba9b9 | ||
|
|
ebe9769d4e | ||
|
|
074ea7bf26 | ||
|
|
7cac6ff446 | ||
|
|
d839a72700 | ||
|
|
797bed8e7d | ||
|
|
9928840577 | ||
|
|
4ac73d8fa3 | ||
|
|
88487064e1 | ||
|
|
e4e4617f6e | ||
|
|
c2100c189f | ||
|
|
1a302491d1 | ||
|
|
e84c16cf92 | ||
|
|
ece6ce2a43 | ||
|
|
ecf9faf640 | ||
|
|
35d6314ce8 | ||
|
|
f99f74f4a1 | ||
|
|
f2765e2e7b | ||
|
|
7b8f3f1222 | ||
|
|
c33b0e8bfd | ||
|
|
8e85af2b3b | ||
|
|
2d23eb87c6 | ||
|
|
e5ce63a192 | ||
|
|
b5c9658d65 | ||
|
|
ba0a2373b5 | ||
|
|
b0163f6a4d | ||
|
|
0a192645d6 | ||
|
|
351b333529 | ||
|
|
155f0e2b6a | ||
|
|
a54d1ef077 | ||
|
|
2361b88e0a | ||
|
|
e7c2fd5571 | ||
|
|
3126cb6dd6 | ||
|
|
03afcc8a90 | ||
|
|
f210fa4702 | ||
|
|
4b708aa987 | ||
|
|
bd07b1134e | ||
|
|
c041daf649 | ||
|
|
975fa7e4fd | ||
|
|
1a2ae37f49 | ||
|
|
1cad36b929 | ||
|
|
8cb9386447 | ||
|
|
5cda7cb4c1 | ||
|
|
e89463fb83 | ||
|
|
b011ccdd5b | ||
|
|
1df7f6ccf6 | ||
|
|
2915737f03 | ||
|
|
5b2a0e4d46 | ||
|
|
37fc229bf6 | ||
|
|
bc43b4b6eb | ||
|
|
dda8c30d45 | ||
|
|
906f38140a | ||
|
|
3e546d4a1e | ||
|
|
b2f450dcf7 |
@@ -14,6 +14,12 @@
|
|||||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Api"],
|
||||||
"port": 5111
|
"port": 5111
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "musify-worker",
|
||||||
|
"runtimeExecutable": "dotnet",
|
||||||
|
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Worker"],
|
||||||
|
"port": 5112
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "musify-streaming-gateway",
|
"name": "musify-streaming-gateway",
|
||||||
"runtimeExecutable": "dotnet",
|
"runtimeExecutable": "dotnet",
|
||||||
|
|||||||
@@ -9,7 +9,6 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Application.Albums
|
namespace Musify.Application.Albums
|
||||||
{
|
{
|
||||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
|
||||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||||
: ICommand<ErrorOr<Success>>;
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
@@ -50,7 +49,6 @@ namespace Musify.Application.Albums
|
|||||||
if (toAdd.Count == 0)
|
if (toAdd.Count == 0)
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
|
|
||||||
// Album track numbers are user-visible, so they start at 1.
|
|
||||||
var nextTrackNumber = (await database.AlbumHasTracks
|
var nextTrackNumber = (await database.AlbumHasTracks
|
||||||
.Where(link => link.AlbumId == request.AlbumId)
|
.Where(link => link.AlbumId == request.AlbumId)
|
||||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||||
|
|||||||
@@ -19,7 +19,9 @@ namespace Musify.Application.Albums
|
|||||||
string Title,
|
string Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
int? ReleaseYear,
|
int? ReleaseYear,
|
||||||
Guid PictureIntentId)
|
Guid PictureIntentId,
|
||||||
|
AlbumType Type = AlbumType.Album,
|
||||||
|
IReadOnlyList<Guid>? TrackIds = null)
|
||||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class CreateAlbumCommandHandler(
|
public class CreateAlbumCommandHandler(
|
||||||
@@ -38,6 +40,21 @@ namespace Musify.Application.Albums
|
|||||||
if (!userExists)
|
if (!userExists)
|
||||||
return AppErrors.NotFound("User", request.UserId);
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
|
var trackIds = (request.TrackIds ?? []).Distinct().ToList();
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => new { track.Id, track.OwnerUserId, track.IsPublished })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = trackIds.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 prepared = await PictureSourceChange.PrepareAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
if (prepared.IsError)
|
if (prepared.IsError)
|
||||||
@@ -53,11 +70,19 @@ namespace Musify.Application.Albums
|
|||||||
NormalizedTitle = TextNormalizer.Normalize(title),
|
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||||
Description = request.Description,
|
Description = request.Description,
|
||||||
ReleaseYear = request.ReleaseYear,
|
ReleaseYear = request.ReleaseYear,
|
||||||
|
Type = request.Type,
|
||||||
Pictures = picture.Pictures
|
Pictures = picture.Pictures
|
||||||
};
|
};
|
||||||
|
|
||||||
await database.Albums.AddAsync(album, cancellationToken);
|
await database.Albums.AddAsync(album, cancellationToken);
|
||||||
|
|
||||||
|
for (var index = 0; index < trackIds.Count; index++)
|
||||||
|
{
|
||||||
|
await database.AlbumHasTracks.AddAsync(
|
||||||
|
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
|
||||||
|
cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
await eventBus.PublishAsync(
|
||||||
new CreateAlbumResourcesEvent(
|
new CreateAlbumResourcesEvent(
|
||||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
@@ -65,9 +90,15 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
logger.LogInformation("Created album {AlbumId} for user {UserId}", album.Id, request.UserId);
|
logger.LogInformation("Created album {AlbumId} for user {UserId} with {Count} tracks", album.Id, request.UserId, trackIds.Count);
|
||||||
|
|
||||||
return AlbumApplicationResponse.FromEntity(album, trackCount: 0);
|
var published = tracks.Where(track => track.IsPublished).Select(track => track.Id).ToHashSet();
|
||||||
|
var visibleTrackIds = trackIds.Where(published.Contains).ToList();
|
||||||
|
|
||||||
|
return AlbumApplicationResponse.FromEntity(
|
||||||
|
album,
|
||||||
|
visibleTrackIds.Count,
|
||||||
|
[.. visibleTrackIds.Take(AlbumProjections.CoverTrackCount)]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
public record GetAlbumTrackIdsQuery(long UserId, Guid AlbumId)
|
||||||
|
: IQuery<ErrorOr<AlbumTrackIdsResponse>>;
|
||||||
|
|
||||||
|
public class GetAlbumTrackIdsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetAlbumTrackIdsQuery, ErrorOr<AlbumTrackIdsResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AlbumTrackIdsResponse>> Handle(GetAlbumTrackIdsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var trackIds = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId
|
||||||
|
&& albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return new AlbumTrackIdsResponse(trackIds);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -28,7 +28,7 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
return await database.AlbumHasTracks
|
return await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.ThenBy(albumTrack => albumTrack.Id)
|
.ThenBy(albumTrack => albumTrack.Id)
|
||||||
.Select(albumTrack => albumTrack.Track)
|
.Select(albumTrack => albumTrack.Track)
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ namespace Musify.Application.Albums
|
|||||||
var ranked = await database.AlbumHasTracks
|
var ranked = await database.AlbumHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||||
|
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
.Join(
|
.Join(
|
||||||
listens,
|
listens,
|
||||||
albumTrack => albumTrack.TrackId,
|
albumTrack => albumTrack.TrackId,
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
using Musify.Domain.Entities;
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Albums.Responses
|
namespace Musify.Application.Albums.Responses
|
||||||
{
|
{
|
||||||
@@ -14,7 +15,8 @@ namespace Musify.Application.Albums.Responses
|
|||||||
string? LargeImageKeyName,
|
string? LargeImageKeyName,
|
||||||
DateTime CreatedAt,
|
DateTime CreatedAt,
|
||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
IReadOnlyList<Guid> CoverTrackIds)
|
IReadOnlyList<Guid> CoverTrackIds,
|
||||||
|
AlbumType Type)
|
||||||
{
|
{
|
||||||
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||||
|
|
||||||
@@ -35,7 +37,8 @@ namespace Musify.Application.Albums.Responses
|
|||||||
album.Pictures?.LargeName,
|
album.Pictures?.LargeName,
|
||||||
album.CreatedAt,
|
album.CreatedAt,
|
||||||
album.UpdatedAt,
|
album.UpdatedAt,
|
||||||
coverTrackIds ?? []);
|
coverTrackIds ?? [],
|
||||||
|
album.Type);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -17,16 +17,18 @@ namespace Musify.Application.Albums.Responses
|
|||||||
album.Description,
|
album.Description,
|
||||||
album.ReleaseYear,
|
album.ReleaseYear,
|
||||||
album.OwnerUserId,
|
album.OwnerUserId,
|
||||||
album.AlbumTracks.Count,
|
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
|
||||||
album.Pictures!.SmallName,
|
album.Pictures!.SmallName,
|
||||||
album.Pictures!.MediumName,
|
album.Pictures!.MediumName,
|
||||||
album.Pictures!.LargeName,
|
album.Pictures!.LargeName,
|
||||||
album.CreatedAt,
|
album.CreatedAt,
|
||||||
album.UpdatedAt,
|
album.UpdatedAt,
|
||||||
album.AlbumTracks
|
album.AlbumTracks
|
||||||
|
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
.Take(CoverTrackCount)
|
.Take(CoverTrackCount)
|
||||||
.Select(albumTrack => albumTrack.TrackId)
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
.ToList()));
|
.ToList(),
|
||||||
|
album.Type));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.Albums.Responses
|
||||||
|
{
|
||||||
|
public record AlbumTrackIdsResponse(IReadOnlyList<Guid> TrackIds);
|
||||||
|
}
|
||||||
@@ -3,11 +3,7 @@ using Mediator;
|
|||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Albums.Responses;
|
using Musify.Application.Albums.Responses;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -19,15 +15,12 @@ namespace Musify.Application.Albums
|
|||||||
string Title,
|
string Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
int? ReleaseYear,
|
int? ReleaseYear,
|
||||||
Guid? NewPictureIntentId)
|
AlbumType? Type = null)
|
||||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
public class UpdateAlbumCommandHandler(
|
public class UpdateAlbumCommandHandler(
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
ILogger<UpdateAlbumCommandHandler> logger)
|
||||||
ILogger<UpdateAlbumCommandHandler> logger,
|
|
||||||
AlbumConfiguration albumConfiguration)
|
|
||||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||||
@@ -41,24 +34,9 @@ namespace Musify.Application.Albums
|
|||||||
|
|
||||||
album.Title = title;
|
album.Title = title;
|
||||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||||
album.Description = request.Description;
|
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
|
||||||
album.ReleaseYear = request.ReleaseYear;
|
album.ReleaseYear = request.ReleaseYear;
|
||||||
|
album.Type = request.Type ?? album.Type;
|
||||||
if (request.NewPictureIntentId is { } intentId)
|
|
||||||
{
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
|
||||||
album.Pictures = picture.Pictures;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new UpdateAlbumPictureSourceEvent(
|
|
||||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Albums
|
||||||
|
{
|
||||||
|
public record UpdateAlbumPictureCommand(long UserId, Guid AlbumId, Guid PictureIntentId)
|
||||||
|
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||||
|
|
||||||
|
public class UpdateAlbumPictureCommandHandler(
|
||||||
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ILogger<UpdateAlbumPictureCommandHandler> logger,
|
||||||
|
AlbumConfiguration albumConfiguration)
|
||||||
|
: ICommandHandler<UpdateAlbumPictureCommand, ErrorOr<AlbumApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumPictureCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var album = found.Value;
|
||||||
|
|
||||||
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
var picture = prepared.Value;
|
||||||
|
album.Pictures = picture.Pictures;
|
||||||
|
|
||||||
|
await eventBus.PublishAsync(
|
||||||
|
new UpdateAlbumPictureSourceEvent(
|
||||||
|
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated picture of album {AlbumId}", album.Id);
|
||||||
|
|
||||||
|
return await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(a => a.Id == album.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Coauthors.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record GetCoauthorInvitationsQuery(long UserId, int PageNumber, int PageSize)
|
||||||
|
: IQuery<PaginatedResponse<CoauthorInvitationResponse>>;
|
||||||
|
|
||||||
|
public class GetCoauthorInvitationsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetCoauthorInvitationsQuery, PaginatedResponse<CoauthorInvitationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<PaginatedResponse<CoauthorInvitationResponse>> Handle(GetCoauthorInvitationsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
return await database.TrackCoauthors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(coauthor => coauthor.UserId == request.UserId
|
||||||
|
&& coauthor.Status == CoauthorStatus.Pending
|
||||||
|
&& coauthor.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.OrderByDescending(coauthor => coauthor.InvitedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.Id)
|
||||||
|
.Select(coauthor => new CoauthorInvitationResponse(
|
||||||
|
coauthor.Id,
|
||||||
|
coauthor.TrackId,
|
||||||
|
coauthor.Track.Title,
|
||||||
|
coauthor.Track.Owner.Name,
|
||||||
|
coauthor.Track.OwnerUserId,
|
||||||
|
coauthor.InvitedAt))
|
||||||
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record InviteCoauthorsCommand(long OwnerId, Guid TrackId, IReadOnlyList<long> UserIds)
|
||||||
|
: ICommand<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>;
|
||||||
|
|
||||||
|
public class InviteCoauthorsCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<InviteCoauthorsCommandHandler> logger)
|
||||||
|
: ICommandHandler<InviteCoauthorsCommand, ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<IReadOnlyList<TrackCoauthorInviteReference>>> Handle(InviteCoauthorsCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.OwnerId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var userIds = request.UserIds.Distinct().ToList();
|
||||||
|
if (userIds.Contains(request.OwnerId))
|
||||||
|
return Error.Validation("Coauthor.Self", "You cannot invite yourself as a coauthor.");
|
||||||
|
|
||||||
|
var existingUserIds = await database.Users
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(user => userIds.Contains(user.Id))
|
||||||
|
.Select(user => user.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var missing = userIds.Where(id => !existingUserIds.Contains(id)).Select(id => (long?)id).FirstOrDefault();
|
||||||
|
if (missing != null)
|
||||||
|
return AppErrors.NotFound("User", missing.Value);
|
||||||
|
|
||||||
|
var existing = await database.TrackCoauthors
|
||||||
|
.Where(coauthor => coauthor.TrackId == request.TrackId && userIds.Contains(coauthor.UserId))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var alreadyInvited = existing.FirstOrDefault(coauthor => coauthor.Status != CoauthorStatus.Rejected);
|
||||||
|
if (alreadyInvited != null)
|
||||||
|
return AppErrors.Conflict("Coauthor.AlreadyInvited", $"User {alreadyInvited.UserId} was already invited to this track.");
|
||||||
|
|
||||||
|
var now = DateTime.UtcNow;
|
||||||
|
foreach (var rejected in existing)
|
||||||
|
{
|
||||||
|
rejected.Status = CoauthorStatus.Pending;
|
||||||
|
rejected.InvitedAt = now;
|
||||||
|
rejected.RespondedAt = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reinvitedIds = existing.Select(coauthor => coauthor.UserId).ToHashSet();
|
||||||
|
foreach (var userId in userIds.Where(id => !reinvitedIds.Contains(id)))
|
||||||
|
{
|
||||||
|
await database.TrackCoauthors.AddAsync(new TrackCoauthor
|
||||||
|
{
|
||||||
|
TrackId = request.TrackId,
|
||||||
|
UserId = userId,
|
||||||
|
InvitedAt = now
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("Coauthor.AlreadyInvited", "A user was already invited to this track."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved.Errors;
|
||||||
|
|
||||||
|
logger.LogInformation("Owner {OwnerId} invited {Count} coauthors to track {TrackId}", request.OwnerId, userIds.Count, request.TrackId);
|
||||||
|
|
||||||
|
return await database.TrackCoauthors
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(coauthor => coauthor.TrackId == request.TrackId)
|
||||||
|
.OrderBy(coauthor => coauthor.InvitedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.UserId)
|
||||||
|
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record RemoveCoauthorCommand(long UserId, Guid TrackId, long CoauthorUserId)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class RemoveCoauthorCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<RemoveCoauthorCommandHandler> logger)
|
||||||
|
: ICommandHandler<RemoveCoauthorCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(RemoveCoauthorCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var ownerUserId = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => track.Id == request.TrackId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(track => (long?)track.OwnerUserId)
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (ownerUserId == null)
|
||||||
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
|
|
||||||
|
if (ownerUserId != request.UserId && request.CoauthorUserId != request.UserId)
|
||||||
|
return AppErrors.Forbidden("Track", request.TrackId);
|
||||||
|
|
||||||
|
var coauthor = await database.TrackCoauthors
|
||||||
|
.SingleOrDefaultAsync(c => c.TrackId == request.TrackId && c.UserId == request.CoauthorUserId, cancellationToken);
|
||||||
|
if (coauthor == null)
|
||||||
|
return AppErrors.NotFound("Coauthor", request.CoauthorUserId);
|
||||||
|
|
||||||
|
database.TrackCoauthors.Remove(coauthor);
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("User {UserId} removed coauthor {CoauthorUserId} from track {TrackId}",
|
||||||
|
request.UserId, request.CoauthorUserId, request.TrackId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Coauthors
|
||||||
|
{
|
||||||
|
public record RespondToCoauthorInvitationCommand(long UserId, Guid InvitationId, bool Accept)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class RespondToCoauthorInvitationCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<RespondToCoauthorInvitationCommandHandler> logger)
|
||||||
|
: ICommandHandler<RespondToCoauthorInvitationCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(RespondToCoauthorInvitationCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var invitation = await database.TrackCoauthors
|
||||||
|
.Include(coauthor => coauthor.Track)
|
||||||
|
.SingleOrDefaultAsync(coauthor => coauthor.Id == request.InvitationId, cancellationToken);
|
||||||
|
|
||||||
|
if (invitation == null || invitation.Track.LifeCycleStatus != LifeCycleStatus.Active)
|
||||||
|
return AppErrors.NotFound("CoauthorInvitation", request.InvitationId);
|
||||||
|
|
||||||
|
if (invitation.UserId != request.UserId)
|
||||||
|
return AppErrors.Forbidden("CoauthorInvitation", request.InvitationId);
|
||||||
|
|
||||||
|
if (invitation.Status != CoauthorStatus.Pending)
|
||||||
|
return AppErrors.Conflict("CoauthorInvitation.NotPending", "The invitation was already answered.");
|
||||||
|
|
||||||
|
invitation.Status = request.Accept ? CoauthorStatus.Accepted : CoauthorStatus.Rejected;
|
||||||
|
invitation.RespondedAt = DateTime.UtcNow;
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("User {UserId} {Action} coauthor invitation {InvitationId}",
|
||||||
|
request.UserId, request.Accept ? "accepted" : "rejected", request.InvitationId);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
namespace Musify.Application.Coauthors.Responses
|
||||||
|
{
|
||||||
|
public record CoauthorInvitationResponse(
|
||||||
|
Guid Id,
|
||||||
|
Guid TrackId,
|
||||||
|
string TrackTitle,
|
||||||
|
string? Artist,
|
||||||
|
long OwnerUserId,
|
||||||
|
DateTime InvitedAt);
|
||||||
|
}
|
||||||
@@ -83,7 +83,6 @@ namespace Musify.Application.Configuration
|
|||||||
|
|
||||||
public static class PictureSizeParser
|
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) =>
|
public static PictureSize Parse(string? value) =>
|
||||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||||
? size
|
? size
|
||||||
|
|||||||
@@ -10,10 +10,10 @@ namespace Musify.Application.Configuration
|
|||||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||||
|
|
||||||
[Range(1, 100)]
|
[Range(1, 100)]
|
||||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
|
||||||
|
|
||||||
[Range(1, long.MaxValue)]
|
[Range(1, long.MaxValue)]
|
||||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
|
||||||
|
|
||||||
[Range(1, long.MaxValue)]
|
[Range(1, long.MaxValue)]
|
||||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||||
|
|||||||
@@ -32,6 +32,8 @@ namespace Musify.Application.Contracts
|
|||||||
|
|
||||||
public DbSet<UserFollow> UserFollows { get; }
|
public DbSet<UserFollow> UserFollows { get; }
|
||||||
|
|
||||||
|
public DbSet<TrackCoauthor> TrackCoauthors { get; }
|
||||||
|
|
||||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||||
|
|||||||
@@ -2,7 +2,6 @@ namespace Musify.Application.Contracts
|
|||||||
{
|
{
|
||||||
public interface IStorageService
|
public interface IStorageService
|
||||||
{
|
{
|
||||||
/// <summary>Opens an object for reading, or returns null when it does not exist.</summary>
|
|
||||||
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<Stream?> GetFileAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|
||||||
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
public Task<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||||
|
|||||||
@@ -4,11 +4,11 @@ namespace Musify.Application.Events
|
|||||||
{
|
{
|
||||||
public record CreateTrackResourcesEvent(
|
public record CreateTrackResourcesEvent(
|
||||||
Guid TrackId,
|
Guid TrackId,
|
||||||
Guid PictureIntentId,
|
Guid? PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureSourceKey,
|
string? PictureSourceKey,
|
||||||
string PictureDestinationKey,
|
string? PictureDestinationKey,
|
||||||
string AudioSourceKey,
|
string AudioSourceKey,
|
||||||
string AudioDestinationKey,
|
string AudioDestinationKey,
|
||||||
string AudioProcessedFolderKey,
|
string AudioProcessedFolderKey,
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ namespace Musify.Application.Genres
|
|||||||
{
|
{
|
||||||
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var all = Enum.GetValues<Genre>();
|
var all = Enum.GetValues<Genre>().Where(genre => genre != Genre.None).ToArray();
|
||||||
|
|
||||||
IReadOnlyList<AvailableGenreResponse> result = all
|
IReadOnlyList<AvailableGenreResponse> result = all
|
||||||
.Select(genre => new AvailableGenreResponse(
|
.Select(genre => new AvailableGenreResponse(
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ namespace Musify.Application.Genres
|
|||||||
{
|
{
|
||||||
var rows = await database.TrackTags
|
var rows = await database.TrackTags
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
|
||||||
.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);
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ namespace Musify.Application.Likes
|
|||||||
{
|
{
|
||||||
return await database.TrackLikes
|
return await database.TrackLikes
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(like => like.UserId == request.UserId && like.Track.LifeCycleStatus == LifeCycleStatus.Active && like.Track.IsPublished)
|
||||||
.OrderByDescending(like => like.CreatedAt)
|
.OrderByDescending(like => like.CreatedAt)
|
||||||
.ThenBy(like => like.Id)
|
.ThenBy(like => like.Id)
|
||||||
.Select(like => like.Track)
|
.Select(like => like.Track)
|
||||||
|
|||||||
@@ -21,7 +21,7 @@ namespace Musify.Application.Likes
|
|||||||
{
|
{
|
||||||
var trackExists = await database.Tracks
|
var trackExists = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active && t.IsPublished, cancellationToken);
|
||||||
if (!trackExists)
|
if (!trackExists)
|
||||||
return AppErrors.NotFound("Track", request.TrackId);
|
return AppErrors.NotFound("Track", request.TrackId);
|
||||||
|
|
||||||
@@ -44,7 +44,6 @@ namespace Musify.Application.Likes
|
|||||||
nowLiked = true;
|
nowLiked = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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(
|
var saved = await database.TrySaveChangesAsync(
|
||||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||||
if (saved.IsError)
|
if (saved.IsError)
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ namespace Musify.Application.Mixes
|
|||||||
|
|
||||||
var libraryTrackIds = await database.Tracks
|
var libraryTrackIds = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
.Select(track => track.Id)
|
.Select(track => track.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -25,7 +25,13 @@ namespace Musify.Application.Mixes
|
|||||||
mix => mix.Id,
|
mix => mix.Id,
|
||||||
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
mix => mix.Items.OrderBy(item => item.Position).ToList());
|
||||||
|
|
||||||
var coverItems = orderedItemsByMix.Values
|
var visibleTrackIds = await MixItemMapper.VisibleTrackIdsAsync(
|
||||||
|
database, orderedItemsByMix.Values.SelectMany(items => items), cancellationToken);
|
||||||
|
var visibleItemsByMix = orderedItemsByMix.ToDictionary(
|
||||||
|
entry => entry.Key,
|
||||||
|
entry => entry.Value.Where(item => visibleTrackIds.Contains(item.TrackId)).ToList());
|
||||||
|
|
||||||
|
var coverItems = visibleItemsByMix.Values
|
||||||
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
.SelectMany(items => items.Take(MixApplicationResponse.CoverItemCount))
|
||||||
.ToList();
|
.ToList();
|
||||||
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||||
@@ -35,7 +41,7 @@ namespace Musify.Application.Mixes
|
|||||||
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 = orderedItemsByMix[mix.Id];
|
var orderedItems = visibleItemsByMix[mix.Id];
|
||||||
var covers = orderedItems
|
var covers = orderedItems
|
||||||
.Take(MixApplicationResponse.CoverItemCount)
|
.Take(MixApplicationResponse.CoverItemCount)
|
||||||
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||||
|
|||||||
@@ -9,6 +9,22 @@ namespace Musify.Application.Mixes
|
|||||||
|
|
||||||
internal static class MixItemMapper
|
internal static class MixItemMapper
|
||||||
{
|
{
|
||||||
|
public static async Task<HashSet<Guid>> VisibleTrackIdsAsync(
|
||||||
|
IDatabase database,
|
||||||
|
IEnumerable<MixItem> items,
|
||||||
|
CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var trackIds = items.Select(item => item.TrackId).Distinct().ToList();
|
||||||
|
|
||||||
|
var visible = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
|
.Select(track => track.Id)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
return [.. visible];
|
||||||
|
}
|
||||||
|
|
||||||
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
public static async Task<List<MixItemApplicationResponse>> ToResponsesAsync(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IReadOnlyList<MixItem> orderedItems,
|
IReadOnlyList<MixItem> orderedItems,
|
||||||
@@ -19,7 +35,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) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(track => trackIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
.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);
|
||||||
|
|
||||||
|
|||||||
@@ -35,10 +35,7 @@ namespace Musify.Application.Pictures
|
|||||||
{
|
{
|
||||||
var (source, routes) = request.Owner switch
|
var (source, routes) = request.Owner switch
|
||||||
{
|
{
|
||||||
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
|
||||||
.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()
|
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||||
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||||
@@ -60,7 +57,6 @@ namespace Musify.Application.Pictures
|
|||||||
_ => source.Medium
|
_ => source.Medium
|
||||||
};
|
};
|
||||||
|
|
||||||
// The resized files only exist once processing has finished; until then serve the original.
|
|
||||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||||
: !string.IsNullOrEmpty(source.Original)
|
: !string.IsNullOrEmpty(source.Original)
|
||||||
@@ -72,5 +68,34 @@ namespace Musify.Application.Pictures
|
|||||||
|
|
||||||
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
return new CoverLocation(storageConfiguration.Bucket, key, MimeUtility.GetMimeMapping(name));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async Task<(CoverSource? Source, PictureRoutes Routes)> FindTrackSourceAsync(Guid trackId, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var track = await database.Tracks.AsNoTracking()
|
||||||
|
.Where(t => t.Id == trackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.Select(t => new
|
||||||
|
{
|
||||||
|
Own = new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName),
|
||||||
|
Album = t.AlbumTracks
|
||||||
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Album.Pictures != null)
|
||||||
|
.OrderBy(albumTrack => albumTrack.AddedAt)
|
||||||
|
.ThenBy(albumTrack => albumTrack.AlbumId)
|
||||||
|
.Select(albumTrack => new CoverSource(
|
||||||
|
albumTrack.Album.OwnerUserId,
|
||||||
|
albumTrack.Album.Pictures!.OriginalName,
|
||||||
|
albumTrack.Album.Pictures.SmallName,
|
||||||
|
albumTrack.Album.Pictures.MediumName,
|
||||||
|
albumTrack.Album.Pictures.LargeName))
|
||||||
|
.FirstOrDefault()
|
||||||
|
})
|
||||||
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
if (track == null)
|
||||||
|
return (null, trackConfiguration.Routes);
|
||||||
|
|
||||||
|
return string.IsNullOrEmpty(track.Own.Original) && track.Album != null
|
||||||
|
? (track.Album, albumConfiguration.Routes)
|
||||||
|
: (track.Own, trackConfiguration.Routes);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Application.Pictures
|
namespace Musify.Application.Pictures
|
||||||
{
|
{
|
||||||
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
|
||||||
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||||
|
|
||||||
public static class PictureSourceChange
|
public static class PictureSourceChange
|
||||||
|
|||||||
@@ -0,0 +1,81 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Albums.Responses;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.Entities;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListCommand(long UserId, Guid PlayListId, Guid AlbumId)
|
||||||
|
: ICommand<ErrorOr<AddAlbumToPlayListResponse>>;
|
||||||
|
|
||||||
|
public class AddAlbumToPlayListCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<AddAlbumToPlayListCommandHandler> logger)
|
||||||
|
: ICommandHandler<AddAlbumToPlayListCommand, ErrorOr<AddAlbumToPlayListResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<AddAlbumToPlayListResponse>> Handle(AddAlbumToPlayListCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (playList.IsError)
|
||||||
|
return playList.Errors;
|
||||||
|
|
||||||
|
var albumExists = await database.Albums
|
||||||
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.AnyAsync(album => album.Id == request.AlbumId, cancellationToken);
|
||||||
|
if (!albumExists)
|
||||||
|
return AppErrors.NotFound("Album", request.AlbumId);
|
||||||
|
|
||||||
|
var albumTrackIds = await database.AlbumHasTracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||||
|
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||||
|
.Select(albumTrack => albumTrack.TrackId)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
|
.Where(link => link.PlayListId == request.PlayListId);
|
||||||
|
|
||||||
|
var existingTrackIds = (await playListTracksQuery
|
||||||
|
.Select(link => link.TrackId)
|
||||||
|
.ToListAsync(cancellationToken)).ToHashSet();
|
||||||
|
|
||||||
|
var newTrackIds = albumTrackIds
|
||||||
|
.Where(trackId => !existingTrackIds.Contains(trackId))
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
if (newTrackIds.Count == 0)
|
||||||
|
return new AddAlbumToPlayListResponse(0);
|
||||||
|
|
||||||
|
var nextPosition = existingTrackIds.Count > 0
|
||||||
|
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
|
foreach (var trackId in newTrackIds)
|
||||||
|
{
|
||||||
|
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||||
|
{
|
||||||
|
PlayListId = request.PlayListId,
|
||||||
|
TrackId = trackId,
|
||||||
|
Position = nextPosition++
|
||||||
|
}, cancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
var saved = await database.TrySaveChangesAsync(
|
||||||
|
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||||
|
if (saved.IsError)
|
||||||
|
return saved.Errors;
|
||||||
|
|
||||||
|
logger.LogInformation("Added {Count} tracks from album {AlbumId} to playlist {PlayListId}",
|
||||||
|
newTrackIds.Count, request.AlbumId, request.PlayListId);
|
||||||
|
|
||||||
|
return new AddAlbumToPlayListResponse(newTrackIds.Count);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -9,7 +9,6 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Application.PlayLists
|
namespace Musify.Application.PlayLists
|
||||||
{
|
{
|
||||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
|
||||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||||
: ICommand<ErrorOr<Success>>;
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
@@ -28,7 +27,7 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
var existingIds = await database.Tracks
|
var existingIds = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active && track.IsPublished)
|
||||||
.Select(track => track.Id)
|
.Select(track => track.Id)
|
||||||
.ToListAsync(cancellationToken);
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
@@ -46,10 +45,12 @@ namespace Musify.Application.PlayLists
|
|||||||
if (toAdd.Count == 0)
|
if (toAdd.Count == 0)
|
||||||
return Result.Success;
|
return Result.Success;
|
||||||
|
|
||||||
// Playlist positions are internal indexes, so they start at 0.
|
var playListTracksQuery = database.PlayListHasTracks
|
||||||
var nextPosition = (await database.PlayListHasTracks
|
.Where(link => link.PlayListId == request.PlayListId);
|
||||||
.Where(link => link.PlayListId == request.PlayListId)
|
|
||||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
|
||||||
|
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
|
||||||
|
: 0;
|
||||||
|
|
||||||
foreach (var trackId in toAdd)
|
foreach (var trackId in toAdd)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -39,10 +39,10 @@ namespace Musify.Application.PlayLists
|
|||||||
return AppErrors.NotFound("User", request.UserId);
|
return AppErrors.NotFound("User", request.UserId);
|
||||||
|
|
||||||
PreparedPicture? picture = null;
|
PreparedPicture? picture = null;
|
||||||
if (request.PictureIntentId is { } intentId)
|
if (request.PictureIntentId.HasValue)
|
||||||
{
|
{
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
uploadIntentValidator, request.PictureIntentId.Value, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
if (prepared.IsError)
|
if (prepared.IsError)
|
||||||
return prepared.Errors;
|
return prepared.Errors;
|
||||||
|
|
||||||
|
|||||||
@@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
return await database.PlayListHasTracks
|
return await database.PlayListHasTracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(playListTrack => playListTrack.PlayListId == request.PlayListId && playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||||
.OrderBy(playListTrack => playListTrack.Position)
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
.ThenBy(playListTrack => playListTrack.Id)
|
.ThenBy(playListTrack => playListTrack.Id)
|
||||||
.Select(playListTrack => playListTrack.Track)
|
.Select(playListTrack => playListTrack.Track)
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Application.PlayLists.Responses
|
||||||
|
{
|
||||||
|
public record AddAlbumToPlayListResponse(int AddedCount);
|
||||||
|
}
|
||||||
@@ -25,14 +25,15 @@ namespace Musify.Application.PlayLists.Responses
|
|||||||
playList.CreatedAt,
|
playList.CreatedAt,
|
||||||
playList.UpdatedAt,
|
playList.UpdatedAt,
|
||||||
playList.PlayListTracks
|
playList.PlayListTracks
|
||||||
|
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||||
.OrderBy(playListTrack => playListTrack.Position)
|
.OrderBy(playListTrack => playListTrack.Position)
|
||||||
.Take(CoverTrackCount)
|
.Take(CoverTrackCount)
|
||||||
.Select(playListTrack => playListTrack.TrackId)
|
.Select(playListTrack => playListTrack.TrackId)
|
||||||
.ToList(),
|
.ToList(),
|
||||||
playList.OwnerUserId,
|
playList.OwnerUserId,
|
||||||
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active),
|
playList.PlayListTracks.Count(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished),
|
||||||
playList.PlayListTracks
|
playList.PlayListTracks
|
||||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||||
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,12 +2,8 @@ using ErrorOr;
|
|||||||
using Mediator;
|
using Mediator;
|
||||||
using Microsoft.EntityFrameworkCore;
|
using Microsoft.EntityFrameworkCore;
|
||||||
using Microsoft.Extensions.Logging;
|
using Microsoft.Extensions.Logging;
|
||||||
using Musify.Application.Configuration;
|
|
||||||
using Musify.Application.Contracts;
|
using Musify.Application.Contracts;
|
||||||
using Musify.Application.Events;
|
|
||||||
using Musify.Application.Pictures;
|
|
||||||
using Musify.Application.PlayLists.Responses;
|
using Musify.Application.PlayLists.Responses;
|
||||||
using Musify.Application.Services;
|
|
||||||
using Musify.Application.Shared;
|
using Musify.Application.Shared;
|
||||||
using Musify.Domain.ValueObjects;
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
@@ -16,18 +12,14 @@ namespace Musify.Application.PlayLists
|
|||||||
public record UpdatePlayListCommand(
|
public record UpdatePlayListCommand(
|
||||||
long UserId,
|
long UserId,
|
||||||
Guid PlayListId,
|
Guid PlayListId,
|
||||||
string? NewName,
|
string NewName,
|
||||||
string? NewDescription,
|
string? NewDescription,
|
||||||
Guid? NewPictureIntentId,
|
PlayListVisibility NewVisibility)
|
||||||
PlayListVisibility? NewVisibility = null)
|
|
||||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
public class UpdatePlayListCommandHandler(
|
public class UpdatePlayListCommandHandler(
|
||||||
IEventBus eventBus,
|
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
UploadIntentValidator uploadIntentValidator,
|
ILogger<UpdatePlayListCommandHandler> logger)
|
||||||
ILogger<UpdatePlayListCommandHandler> logger,
|
|
||||||
PlayListConfiguration playListConfiguration)
|
|
||||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||||
@@ -38,34 +30,10 @@ namespace Musify.Application.PlayLists
|
|||||||
|
|
||||||
var playList = found.Value;
|
var playList = found.Value;
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
|
||||||
{
|
|
||||||
playList.Name = request.NewName.Trim();
|
playList.Name = request.NewName.Trim();
|
||||||
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
playList.NormalizedName = TextNormalizer.Normalize(request.NewName);
|
||||||
}
|
|
||||||
|
|
||||||
// null means "unchanged"; a blank description clears it.
|
|
||||||
if (request.NewDescription != null)
|
|
||||||
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
playList.Description = string.IsNullOrWhiteSpace(request.NewDescription) ? null : request.NewDescription.Trim();
|
||||||
|
playList.Visibility = request.NewVisibility;
|
||||||
if (request.NewVisibility is { } visibility)
|
|
||||||
playList.Visibility = visibility;
|
|
||||||
|
|
||||||
if (request.NewPictureIntentId is { } intentId)
|
|
||||||
{
|
|
||||||
var prepared = await PictureSourceChange.PrepareAsync(
|
|
||||||
uploadIntentValidator, intentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
|
||||||
if (prepared.IsError)
|
|
||||||
return prepared.Errors;
|
|
||||||
|
|
||||||
var picture = prepared.Value;
|
|
||||||
playList.Pictures = picture.Pictures;
|
|
||||||
|
|
||||||
await eventBus.PublishAsync(
|
|
||||||
new UpdatePlayListPictureSourceEvent(
|
|
||||||
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
|
||||||
cancellationToken);
|
|
||||||
}
|
|
||||||
|
|
||||||
await database.SaveChangesAsync(cancellationToken);
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,59 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Configuration;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Pictures;
|
||||||
|
using Musify.Application.PlayLists.Responses;
|
||||||
|
using Musify.Application.Services;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.PlayLists
|
||||||
|
{
|
||||||
|
public record UpdatePlayListPictureCommand(long UserId, Guid PlayListId, Guid PictureIntentId)
|
||||||
|
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||||
|
|
||||||
|
public class UpdatePlayListPictureCommandHandler(
|
||||||
|
IEventBus eventBus,
|
||||||
|
IDatabase database,
|
||||||
|
UploadIntentValidator uploadIntentValidator,
|
||||||
|
ILogger<UpdatePlayListPictureCommandHandler> logger,
|
||||||
|
PlayListConfiguration playListConfiguration)
|
||||||
|
: ICommandHandler<UpdatePlayListPictureCommand, ErrorOr<PlayListApplicationResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListPictureCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var playList = found.Value;
|
||||||
|
|
||||||
|
var prepared = await PictureSourceChange.PrepareAsync(
|
||||||
|
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.PlayListPicture, playListConfiguration, cancellationToken);
|
||||||
|
if (prepared.IsError)
|
||||||
|
return prepared.Errors;
|
||||||
|
|
||||||
|
var picture = prepared.Value;
|
||||||
|
playList.Pictures = picture.Pictures;
|
||||||
|
|
||||||
|
await eventBus.PublishAsync(
|
||||||
|
new UpdatePlayListPictureSourceEvent(
|
||||||
|
playList.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||||
|
cancellationToken);
|
||||||
|
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("Updated picture of playlist {PlayListId}", playList.Id);
|
||||||
|
|
||||||
|
return await database.PlayLists
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(p => p.Id == playList.Id)
|
||||||
|
.SelectResponse()
|
||||||
|
.SingleAsync(cancellationToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -4,7 +4,6 @@ using Musify.Application.Tracks.Responses;
|
|||||||
|
|
||||||
namespace Musify.Application.Services
|
namespace Musify.Application.Services
|
||||||
{
|
{
|
||||||
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
|
||||||
public sealed class TrackStreamIssuer(
|
public sealed class TrackStreamIssuer(
|
||||||
IStreamTicketService ticketService,
|
IStreamTicketService ticketService,
|
||||||
TrackConfiguration trackConfiguration,
|
TrackConfiguration trackConfiguration,
|
||||||
|
|||||||
@@ -15,7 +15,8 @@ namespace Musify.Application.Services
|
|||||||
PictureRoutes Routes,
|
PictureRoutes Routes,
|
||||||
string FileType,
|
string FileType,
|
||||||
string ContentType,
|
string ContentType,
|
||||||
long? ExpectedSizeBytes);
|
long? ExpectedSizeBytes,
|
||||||
|
int? SharedKeyGroup = null);
|
||||||
|
|
||||||
public sealed record IssuedUpload(
|
public sealed record IssuedUpload(
|
||||||
Guid IntentId,
|
Guid IntentId,
|
||||||
@@ -24,7 +25,6 @@ namespace Musify.Application.Services
|
|||||||
string ContentType,
|
string ContentType,
|
||||||
string UploadUrl);
|
string UploadUrl);
|
||||||
|
|
||||||
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
|
||||||
public sealed class UploadIntentIssuer(
|
public sealed class UploadIntentIssuer(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IStorageService storageService,
|
IStorageService storageService,
|
||||||
@@ -44,11 +44,15 @@ namespace Musify.Application.Services
|
|||||||
if (!userExists)
|
if (!userExists)
|
||||||
return AppErrors.NotFound("User", userId);
|
return AppErrors.NotFound("User", userId);
|
||||||
|
|
||||||
|
var sharedKeys = new Dictionary<int, string>();
|
||||||
var planned = requests
|
var planned = requests
|
||||||
.Select(request =>
|
.Select(request =>
|
||||||
{
|
{
|
||||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
||||||
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
var key = request.Routes.BuildTempPath(uploadIntentConfiguration.TempRootPrefix, userId, objectName);
|
||||||
|
if (request.SharedKeyGroup is { } group)
|
||||||
|
key = sharedKeys.TryAdd(group, key) ? key : sharedKeys[group];
|
||||||
|
|
||||||
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
var size = request.ExpectedSizeBytes ?? DefaultSize(request.Purpose);
|
||||||
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -7,10 +7,6 @@ namespace Musify.Application.Shared
|
|||||||
{
|
{
|
||||||
public static class OwnershipExtensions
|
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>(
|
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||||
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||||
where T : class, IOwnedEntity, IHasLifeCycle
|
where T : class, IOwnedEntity, IHasLifeCycle
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ namespace Musify.Application.Shared
|
|||||||
|
|
||||||
public static class PaginationExtensions
|
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>(
|
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||||
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -16,7 +16,7 @@ namespace Musify.Application.Tracks
|
|||||||
public record CreateTrackCommand(
|
public record CreateTrackCommand(
|
||||||
long UserId,
|
long UserId,
|
||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid? PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId,
|
||||||
IReadOnlyCollection<Genre> Tags,
|
IReadOnlyCollection<Genre> Tags,
|
||||||
bool IsExplicit = false)
|
bool IsExplicit = false)
|
||||||
@@ -45,20 +45,27 @@ namespace Musify.Application.Tracks
|
|||||||
if (request.PictureIntentId == request.AudioIntentId)
|
if (request.PictureIntentId == request.AudioIntentId)
|
||||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||||
|
|
||||||
|
UploadIntent? pictureIntent = null;
|
||||||
|
if (request.PictureIntentId is { } pictureIntentId)
|
||||||
|
{
|
||||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
pictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||||
if (pictureValidation.IsError)
|
if (pictureValidation.IsError)
|
||||||
return pictureValidation.Errors;
|
return pictureValidation.Errors;
|
||||||
|
|
||||||
|
pictureIntent = pictureValidation.Value;
|
||||||
|
}
|
||||||
|
|
||||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||||
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
|
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
|
||||||
if (audioValidation.IsError)
|
if (audioValidation.IsError)
|
||||||
return audioValidation.Errors;
|
return audioValidation.Errors;
|
||||||
|
|
||||||
var pictureIntent = pictureValidation.Value;
|
|
||||||
var audioIntent = audioValidation.Value;
|
var audioIntent = audioValidation.Value;
|
||||||
|
|
||||||
var finalPictureKey = trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
var finalPictureKey = pictureIntent == null
|
||||||
|
? null
|
||||||
|
: trackConfiguration.Routes.BuildOriginalPicturePath(request.UserId, pictureIntent.ObjectName);
|
||||||
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
var finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||||
|
|
||||||
@@ -71,11 +78,11 @@ namespace Musify.Application.Tracks
|
|||||||
IsExplicit = request.IsExplicit,
|
IsExplicit = request.IsExplicit,
|
||||||
Pictures = new TrackPictures
|
Pictures = new TrackPictures
|
||||||
{
|
{
|
||||||
OriginalName = pictureIntent.ObjectName,
|
OriginalName = pictureIntent?.ObjectName,
|
||||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||||
ProcessingStatus = ProcessingStatus.Pending
|
ProcessingStatus = pictureIntent == null ? ProcessingStatus.Completed : ProcessingStatus.Pending
|
||||||
},
|
},
|
||||||
Audio = new TrackAudio
|
Audio = new TrackAudio
|
||||||
{
|
{
|
||||||
@@ -90,15 +97,14 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||||
|
|
||||||
|
|
||||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
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,
|
||||||
pictureIntent.Bucket,
|
audioIntent.Bucket,
|
||||||
pictureIntent.Key,
|
pictureIntent?.Key,
|
||||||
finalPictureKey,
|
finalPictureKey,
|
||||||
audioIntent.Key,
|
audioIntent.Key,
|
||||||
finalAudioKey,
|
finalAudioKey,
|
||||||
|
|||||||
@@ -0,0 +1,60 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Events;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks
|
||||||
|
{
|
||||||
|
public record DeleteTracksCommand(long UserId, IReadOnlyList<Guid> TrackIds)
|
||||||
|
: ICommand<ErrorOr<DeleteTracksResponse>>;
|
||||||
|
|
||||||
|
public class DeleteTracksCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
IEventBus eventBus,
|
||||||
|
ILogger<DeleteTracksCommandHandler> logger)
|
||||||
|
: ICommandHandler<DeleteTracksCommand, ErrorOr<DeleteTracksResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<DeleteTracksResponse>> Handle(DeleteTracksCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||||
|
if (foreign != null)
|
||||||
|
return AppErrors.Forbidden("Track", foreign.Id);
|
||||||
|
|
||||||
|
var deleted = new List<Guid>();
|
||||||
|
var failed = new List<Guid>();
|
||||||
|
|
||||||
|
foreach (var trackId in requestedIds)
|
||||||
|
{
|
||||||
|
var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId);
|
||||||
|
if (track == null || track.Audio.IsInProgress)
|
||||||
|
{
|
||||||
|
failed.Add(trackId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||||
|
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||||
|
deleted.Add(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (deleted.Count > 0)
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("Marked {Count} tracks of user {UserId} as removing", deleted.Count, request.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new DeleteTracksResponse(deleted, failed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,12 +15,11 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
return await database.ListeningHistories
|
return await database.ListeningHistories
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Include(t => t.Track.Owner)
|
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished)
|
||||||
.Include(t => t.Track.Tags)
|
|
||||||
.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)
|
.ThenBy(t => t.Id)
|
||||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
.Select(t => t.Track)
|
||||||
|
.SelectResponse()
|
||||||
.FirstOrDefaultAsync(cancellationToken);
|
.FirstOrDefaultAsync(cancellationToken);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
|
|||||||
var tracksById = await database.Tracks
|
var tracksById = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Active()
|
.Active()
|
||||||
|
.WherePublished()
|
||||||
.Where(t => recentTrackIds.Contains(t.Id))
|
.Where(t => recentTrackIds.Contains(t.Id))
|
||||||
.SelectResponse()
|
.SelectResponse()
|
||||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ using Musify.Application.Tracks.Responses;
|
|||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
public record GetTrackByIdQuery(Guid TrackId)
|
public record GetTrackByIdQuery(Guid TrackId, long? ViewerId = null)
|
||||||
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
||||||
|
|
||||||
public class GetTrackByIdQueryHandler(IDatabase database)
|
public class GetTrackByIdQueryHandler(IDatabase database)
|
||||||
@@ -18,6 +18,7 @@ namespace Musify.Application.Tracks
|
|||||||
var track = await database.Tracks
|
var track = await database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Active()
|
.Active()
|
||||||
|
.VisibleTo(request.ViewerId)
|
||||||
.Where(t => t.Id == request.TrackId)
|
.Where(t => t.Id == request.TrackId)
|
||||||
.SelectResponse()
|
.SelectResponse()
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks
|
||||||
|
{
|
||||||
|
public record GetTrackStatusCountsQuery(long UserId)
|
||||||
|
: IQuery<TrackStatusCountsResponse>;
|
||||||
|
|
||||||
|
public class GetTrackStatusCountsQueryHandler(IDatabase database)
|
||||||
|
: IQueryHandler<GetTrackStatusCountsQuery, TrackStatusCountsResponse>
|
||||||
|
{
|
||||||
|
public async ValueTask<TrackStatusCountsResponse> Handle(GetTrackStatusCountsQuery request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var counts = await database.Tracks
|
||||||
|
.AsNoTracking()
|
||||||
|
.Active()
|
||||||
|
.Where(track => track.OwnerUserId == request.UserId)
|
||||||
|
.GroupBy(track => new { track.Audio.TranscodeStatus, track.IsPublished })
|
||||||
|
.Select(group => new { group.Key.TranscodeStatus, group.Key.IsPublished, Count = group.Count() })
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
int CountOf(ProcessingStatus status) => counts.Where(c => c.TranscodeStatus == status).Sum(c => c.Count);
|
||||||
|
|
||||||
|
var published = counts.Where(c => c.IsPublished).Sum(c => c.Count);
|
||||||
|
var total = counts.Sum(c => c.Count);
|
||||||
|
|
||||||
|
return new TrackStatusCountsResponse(
|
||||||
|
total,
|
||||||
|
CountOf(ProcessingStatus.Pending),
|
||||||
|
CountOf(ProcessingStatus.Processing),
|
||||||
|
CountOf(ProcessingStatus.Completed),
|
||||||
|
CountOf(ProcessingStatus.Failed),
|
||||||
|
published,
|
||||||
|
total - published);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -17,7 +17,8 @@ namespace Musify.Application.Tracks
|
|||||||
{
|
{
|
||||||
var tracksQuery = database.Tracks
|
var tracksQuery = database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Active();
|
.Active()
|
||||||
|
.WherePublished();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ 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 Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
@@ -11,18 +12,33 @@ namespace Musify.Application.Tracks
|
|||||||
long UserId,
|
long UserId,
|
||||||
string? Name,
|
string? Name,
|
||||||
int PageNumber,
|
int PageNumber,
|
||||||
int PageSize)
|
int PageSize,
|
||||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
IReadOnlyList<ProcessingStatus>? Statuses = null,
|
||||||
|
long? ViewerId = null,
|
||||||
|
bool? IsPublished = null,
|
||||||
|
bool? IncludeCoauthored = null)
|
||||||
|
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
|
||||||
|
|
||||||
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
||||||
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>
|
||||||
{
|
{
|
||||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var tracksQuery = database.Tracks
|
var tracksQuery = database.Tracks
|
||||||
.AsNoTracking()
|
.AsNoTracking()
|
||||||
.Active()
|
.Active();
|
||||||
.Where(track => track.OwnerUserId == request.UserId);
|
|
||||||
|
var isOwnerView = request.ViewerId == request.UserId;
|
||||||
|
var includeCoauthored = request.IncludeCoauthored ?? !isOwnerView;
|
||||||
|
|
||||||
|
tracksQuery = includeCoauthored
|
||||||
|
? tracksQuery.Where(track => track.OwnerUserId == request.UserId
|
||||||
|
|| track.Coauthors.Any(coauthor => coauthor.UserId == request.UserId && coauthor.Status == CoauthorStatus.Accepted))
|
||||||
|
: tracksQuery.Where(track => track.OwnerUserId == request.UserId);
|
||||||
|
|
||||||
|
tracksQuery = isOwnerView
|
||||||
|
? tracksQuery.Where(track => track.OwnerUserId == request.UserId || track.IsPublished)
|
||||||
|
: tracksQuery.WherePublished();
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||||
{
|
{
|
||||||
@@ -30,11 +46,27 @@ namespace Musify.Application.Tracks
|
|||||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||||
}
|
}
|
||||||
|
|
||||||
return await tracksQuery
|
if (request.Statuses is { Count: > 0 } statuses)
|
||||||
|
{
|
||||||
|
var statusList = statuses.Distinct().ToList();
|
||||||
|
tracksQuery = tracksQuery.Where(track => statusList.Contains(track.Audio.TranscodeStatus));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.IsPublished is { } isPublished)
|
||||||
|
tracksQuery = tracksQuery.Where(track => track.IsPublished == isPublished);
|
||||||
|
|
||||||
|
var page = await tracksQuery
|
||||||
.OrderByDescending(track => track.CreatedAt)
|
.OrderByDescending(track => track.CreatedAt)
|
||||||
.ThenBy(track => track.Id)
|
.ThenBy(track => track.Id)
|
||||||
.SelectResponse()
|
.SelectUserResponse(includeInvites: request.ViewerId != null)
|
||||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||||
|
|
||||||
|
// Invitations are private to the track owner.
|
||||||
|
var items = page.Items
|
||||||
|
.Select(track => track.OwnerUserId == request.ViewerId ? track : track with { CoauthorInvites = [] })
|
||||||
|
.ToList();
|
||||||
|
|
||||||
|
return page with { Items = items };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,14 +7,17 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
|
public record AudioUploadSpec(string FileType, string ContentType, long? ExpectedSizeBytes = null);
|
||||||
|
|
||||||
public record RequestTrackUploadUrlsCommand(
|
public record RequestTrackUploadUrlsCommand(
|
||||||
long UserId,
|
long UserId,
|
||||||
string PictureFileType,
|
string? PictureFileType,
|
||||||
string PictureContentType,
|
string? PictureContentType,
|
||||||
string AudioFileType,
|
string? AudioFileType = null,
|
||||||
string AudioContentType,
|
string? AudioContentType = null,
|
||||||
long? ExpectedPictureSizeBytes = null,
|
long? ExpectedPictureSizeBytes = null,
|
||||||
long? ExpectedAudioSizeBytes = null)
|
long? ExpectedAudioSizeBytes = null,
|
||||||
|
IReadOnlyList<AudioUploadSpec>? Audios = null)
|
||||||
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
|
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
|
||||||
|
|
||||||
public class RequestTrackUploadUrlsCommandHandler(
|
public class RequestTrackUploadUrlsCommandHandler(
|
||||||
@@ -24,35 +27,75 @@ namespace Musify.Application.Tracks
|
|||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
|
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
|
||||||
{
|
{
|
||||||
|
private const int PictureKeyGroup = 1;
|
||||||
|
|
||||||
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
|
var audios = ResolveAudios(request);
|
||||||
|
if (audios.Count == 0)
|
||||||
|
return Error.Validation(description: "At least one audio file is required.");
|
||||||
|
|
||||||
|
var hasPicture = request.PictureFileType != null && request.PictureContentType != null;
|
||||||
var routes = trackConfiguration.Routes;
|
var routes = trackConfiguration.Routes;
|
||||||
var issued = await issuer.IssueAsync(
|
|
||||||
request.UserId,
|
var uploads = new List<UploadRequest>();
|
||||||
[
|
if (hasPicture)
|
||||||
new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes),
|
{
|
||||||
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)
|
for (var index = 0; index < audios.Count; index++)
|
||||||
],
|
{
|
||||||
cancellationToken);
|
uploads.Add(new UploadRequest(
|
||||||
|
UploadIntentPurpose.TrackPicture, routes, request.PictureFileType!, request.PictureContentType!,
|
||||||
|
request.ExpectedPictureSizeBytes, PictureKeyGroup));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
uploads.AddRange(audios.Select(audio =>
|
||||||
|
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, audio.FileType, audio.ContentType, audio.ExpectedSizeBytes)));
|
||||||
|
|
||||||
|
var issued = await issuer.IssueAsync(request.UserId, uploads, cancellationToken);
|
||||||
if (issued.IsError)
|
if (issued.IsError)
|
||||||
return issued.Errors;
|
return issued.Errors;
|
||||||
|
|
||||||
var picture = issued.Value[0];
|
var pictures = hasPicture ? issued.Value.Take(audios.Count).ToList() : [];
|
||||||
var audio = issued.Value[1];
|
var audioUploads = issued.Value.Skip(pictures.Count).ToList();
|
||||||
|
|
||||||
return new TrackUploadUrlsResponse(
|
var items = audioUploads
|
||||||
picture.IntentId,
|
.Select((audio, index) => new TrackAudioUpload(
|
||||||
audio.IntentId,
|
audio.IntentId,
|
||||||
storageConfiguration.Bucket,
|
hasPicture ? pictures[index].IntentId : null,
|
||||||
picture.Key,
|
|
||||||
picture.ObjectName,
|
|
||||||
picture.ContentType,
|
|
||||||
picture.UploadUrl,
|
|
||||||
audio.Key,
|
audio.Key,
|
||||||
audio.ObjectName,
|
audio.ObjectName,
|
||||||
audio.ContentType,
|
audio.ContentType,
|
||||||
audio.UploadUrl,
|
audio.UploadUrl))
|
||||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
.ToList();
|
||||||
|
|
||||||
|
var firstPicture = pictures.Count > 0 ? pictures[0] : null;
|
||||||
|
var firstAudio = audioUploads[0];
|
||||||
|
|
||||||
|
return new TrackUploadUrlsResponse(
|
||||||
|
firstPicture?.IntentId,
|
||||||
|
firstAudio.IntentId,
|
||||||
|
storageConfiguration.Bucket,
|
||||||
|
firstPicture?.Key,
|
||||||
|
firstPicture?.ObjectName,
|
||||||
|
firstPicture?.ContentType,
|
||||||
|
firstPicture?.UploadUrl,
|
||||||
|
firstAudio.Key,
|
||||||
|
firstAudio.ObjectName,
|
||||||
|
firstAudio.ContentType,
|
||||||
|
firstAudio.UploadUrl,
|
||||||
|
uploadIntentConfiguration.UploadUrlExpiresInSeconds,
|
||||||
|
items);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static IReadOnlyList<AudioUploadSpec> ResolveAudios(RequestTrackUploadUrlsCommand request)
|
||||||
|
{
|
||||||
|
if (request.Audios is { Count: > 0 } audios)
|
||||||
|
return audios;
|
||||||
|
|
||||||
|
return request.AudioFileType != null && request.AudioContentType != null
|
||||||
|
? [new AudioUploadSpec(request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)]
|
||||||
|
: [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record DeleteTracksResponse(
|
||||||
|
IReadOnlyList<Guid> DeletedTrackIds,
|
||||||
|
IReadOnlyList<Guid> FailedTrackIds);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record SetTracksPublishedResponse(
|
||||||
|
IReadOnlyList<Guid> UpdatedTrackIds,
|
||||||
|
IReadOnlyList<Guid> SkippedTrackIds);
|
||||||
|
}
|
||||||
@@ -14,7 +14,9 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
DateTime UpdatedAt,
|
DateTime UpdatedAt,
|
||||||
long OwnerUserId,
|
long OwnerUserId,
|
||||||
IReadOnlyCollection<Genre> Tags,
|
IReadOnlyCollection<Genre> Tags,
|
||||||
bool IsExplicit)
|
bool IsExplicit,
|
||||||
|
bool IsPublished,
|
||||||
|
IReadOnlyList<TrackCoauthorReference> Coauthors)
|
||||||
{
|
{
|
||||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||||
new(
|
new(
|
||||||
@@ -28,6 +30,8 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
track.UpdatedAt,
|
track.UpdatedAt,
|
||||||
track.OwnerUserId,
|
track.OwnerUserId,
|
||||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||||
track.IsExplicit);
|
track.IsExplicit,
|
||||||
|
track.IsPublished,
|
||||||
|
[]);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,8 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record TrackCoauthorReference(long Id, string Name);
|
||||||
|
|
||||||
|
public record TrackCoauthorInviteReference(long UserId, string Name, CoauthorStatus Status);
|
||||||
|
}
|
||||||
@@ -8,6 +8,12 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
|
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
|
||||||
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
|
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||||
|
|
||||||
|
public static IQueryable<Track> WherePublished(this IQueryable<Track> query) =>
|
||||||
|
query.Where(track => track.IsPublished);
|
||||||
|
|
||||||
|
public static IQueryable<Track> VisibleTo(this IQueryable<Track> query, long? viewerId) =>
|
||||||
|
query.Where(track => track.IsPublished || track.OwnerUserId == viewerId);
|
||||||
|
|
||||||
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
|
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
|
||||||
query.Select(track => new TrackApplicationResponse(
|
query.Select(track => new TrackApplicationResponse(
|
||||||
track.Id,
|
track.Id,
|
||||||
@@ -20,6 +26,47 @@ namespace Musify.Application.Tracks.Responses
|
|||||||
track.UpdatedAt,
|
track.UpdatedAt,
|
||||||
track.OwnerUserId,
|
track.OwnerUserId,
|
||||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||||
track.IsExplicit));
|
track.IsExplicit,
|
||||||
|
track.IsPublished,
|
||||||
|
track.Coauthors
|
||||||
|
.Where(coauthor => coauthor.Status == CoauthorStatus.Accepted)
|
||||||
|
.OrderBy(coauthor => coauthor.RespondedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.UserId)
|
||||||
|
.Select(coauthor => new TrackCoauthorReference(coauthor.UserId, coauthor.User.Name))
|
||||||
|
.ToList()));
|
||||||
|
|
||||||
|
public static IQueryable<UserTrackApplicationResponse> SelectUserResponse(this IQueryable<Track> query, bool includeInvites = false) =>
|
||||||
|
query.Select(track => new UserTrackApplicationResponse(
|
||||||
|
track.Id,
|
||||||
|
track.Title,
|
||||||
|
track.Owner.Name,
|
||||||
|
track.Audio.TranscodeStatus,
|
||||||
|
track.DurationSeconds,
|
||||||
|
track.ListeningHistories.Count(listen => listen.IsCounted),
|
||||||
|
track.CreatedAt,
|
||||||
|
track.UpdatedAt,
|
||||||
|
track.OwnerUserId,
|
||||||
|
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||||
|
track.IsExplicit,
|
||||||
|
track.IsPublished,
|
||||||
|
track.AlbumTracks
|
||||||
|
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.OrderBy(albumTrack => albumTrack.AddedAt)
|
||||||
|
.ThenBy(albumTrack => albumTrack.AlbumId)
|
||||||
|
.Select(albumTrack => new TrackAlbumReference(albumTrack.AlbumId, albumTrack.Album.Title))
|
||||||
|
.ToList(),
|
||||||
|
track.Coauthors
|
||||||
|
.Where(coauthor => coauthor.Status == CoauthorStatus.Accepted)
|
||||||
|
.OrderBy(coauthor => coauthor.RespondedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.UserId)
|
||||||
|
.Select(coauthor => new TrackCoauthorReference(coauthor.UserId, coauthor.User.Name))
|
||||||
|
.ToList(),
|
||||||
|
includeInvites
|
||||||
|
? track.Coauthors
|
||||||
|
.OrderBy(coauthor => coauthor.InvitedAt)
|
||||||
|
.ThenBy(coauthor => coauthor.UserId)
|
||||||
|
.Select(coauthor => new TrackCoauthorInviteReference(coauthor.UserId, coauthor.User.Name, coauthor.Status))
|
||||||
|
.ToList()
|
||||||
|
: new List<TrackCoauthorInviteReference>()));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,11 @@
|
|||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record TrackStatusCountsResponse(
|
||||||
|
int Total,
|
||||||
|
int Pending,
|
||||||
|
int Processing,
|
||||||
|
int Completed,
|
||||||
|
int Failed,
|
||||||
|
int Published,
|
||||||
|
int Unpublished);
|
||||||
|
}
|
||||||
@@ -1,16 +1,25 @@
|
|||||||
namespace Musify.Application.Tracks.Responses
|
namespace Musify.Application.Tracks.Responses
|
||||||
{
|
{
|
||||||
|
public record TrackAudioUpload(
|
||||||
|
Guid IntentId,
|
||||||
|
Guid? PictureIntentId,
|
||||||
|
string Key,
|
||||||
|
string Name,
|
||||||
|
string ContentType,
|
||||||
|
string UploadUrl);
|
||||||
|
|
||||||
public record TrackUploadUrlsResponse(
|
public record TrackUploadUrlsResponse(
|
||||||
Guid PictureIntentId,
|
Guid? PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string PictureKey,
|
string? PictureKey,
|
||||||
string PictureName,
|
string? PictureName,
|
||||||
string PictureContentType,
|
string? PictureContentType,
|
||||||
string PictureUploadUrl,
|
string? PictureUploadUrl,
|
||||||
string AudioKey,
|
string AudioKey,
|
||||||
string AudioName,
|
string AudioName,
|
||||||
string AudioContentType,
|
string AudioContentType,
|
||||||
string AudioUploadUrl,
|
string AudioUploadUrl,
|
||||||
int ExpiresInSeconds);
|
int ExpiresInSeconds,
|
||||||
|
IReadOnlyList<TrackAudioUpload> Audios);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,23 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks.Responses
|
||||||
|
{
|
||||||
|
public record TrackAlbumReference(Guid Id, string Title);
|
||||||
|
|
||||||
|
public record UserTrackApplicationResponse(
|
||||||
|
Guid Id,
|
||||||
|
string Title,
|
||||||
|
string? Artist,
|
||||||
|
ProcessingStatus AudioStatus,
|
||||||
|
double Duration,
|
||||||
|
int ListensCount,
|
||||||
|
DateTime CreatedAt,
|
||||||
|
DateTime UpdatedAt,
|
||||||
|
long OwnerUserId,
|
||||||
|
IReadOnlyCollection<Genre> Tags,
|
||||||
|
bool IsExplicit,
|
||||||
|
bool IsPublished,
|
||||||
|
IReadOnlyList<TrackAlbumReference> Albums,
|
||||||
|
IReadOnlyList<TrackCoauthorReference> Coauthors,
|
||||||
|
IReadOnlyList<TrackCoauthorInviteReference> CoauthorInvites);
|
||||||
|
}
|
||||||
@@ -0,0 +1,38 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks
|
||||||
|
{
|
||||||
|
public record SetTrackPublishedCommand(long UserId, Guid TrackId, bool IsPublished)
|
||||||
|
: ICommand<ErrorOr<Success>>;
|
||||||
|
|
||||||
|
public class SetTrackPublishedCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<SetTrackPublishedCommandHandler> logger)
|
||||||
|
: ICommandHandler<SetTrackPublishedCommand, ErrorOr<Success>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<Success>> Handle(SetTrackPublishedCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||||
|
if (found.IsError)
|
||||||
|
return found.Errors;
|
||||||
|
|
||||||
|
var track = found.Value;
|
||||||
|
if (request.IsPublished && !track.Audio.IsProcessed)
|
||||||
|
return Error.Validation("Track.NotProcessed", "Only tracks whose audio finished processing can be published.");
|
||||||
|
|
||||||
|
if (track.IsPublished == request.IsPublished)
|
||||||
|
return Result.Success;
|
||||||
|
|
||||||
|
track.IsPublished = request.IsPublished;
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
|
||||||
|
logger.LogInformation("{Action} track {TrackId}", request.IsPublished ? "Published" : "Unpublished", track.Id);
|
||||||
|
|
||||||
|
return Result.Success;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,58 @@
|
|||||||
|
using ErrorOr;
|
||||||
|
using Mediator;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
using Microsoft.Extensions.Logging;
|
||||||
|
using Musify.Application.Contracts;
|
||||||
|
using Musify.Application.Shared;
|
||||||
|
using Musify.Application.Tracks.Responses;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Application.Tracks
|
||||||
|
{
|
||||||
|
public record SetTracksPublishedCommand(long UserId, IReadOnlyList<Guid> TrackIds, bool IsPublished)
|
||||||
|
: ICommand<ErrorOr<SetTracksPublishedResponse>>;
|
||||||
|
|
||||||
|
public class SetTracksPublishedCommandHandler(
|
||||||
|
IDatabase database,
|
||||||
|
ILogger<SetTracksPublishedCommandHandler> logger)
|
||||||
|
: ICommandHandler<SetTracksPublishedCommand, ErrorOr<SetTracksPublishedResponse>>
|
||||||
|
{
|
||||||
|
public async ValueTask<ErrorOr<SetTracksPublishedResponse>> Handle(SetTracksPublishedCommand request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||||
|
|
||||||
|
var tracks = await database.Tracks
|
||||||
|
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.ToListAsync(cancellationToken);
|
||||||
|
|
||||||
|
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||||
|
if (foreign != null)
|
||||||
|
return AppErrors.Forbidden("Track", foreign.Id);
|
||||||
|
|
||||||
|
var updated = new List<Guid>();
|
||||||
|
var skipped = new List<Guid>();
|
||||||
|
|
||||||
|
foreach (var trackId in requestedIds)
|
||||||
|
{
|
||||||
|
var track = tracks.SingleOrDefault(candidate => candidate.Id == trackId);
|
||||||
|
if (track == null || (request.IsPublished && !track.Audio.IsProcessed))
|
||||||
|
{
|
||||||
|
skipped.Add(trackId);
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
track.IsPublished = request.IsPublished;
|
||||||
|
updated.Add(trackId);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (updated.Count > 0)
|
||||||
|
{
|
||||||
|
await database.SaveChangesAsync(cancellationToken);
|
||||||
|
logger.LogInformation("{Action} {Count} tracks of user {UserId}",
|
||||||
|
request.IsPublished ? "Published" : "Unpublished", updated.Count, request.UserId);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new SetTracksPublishedResponse(updated, skipped);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -13,7 +13,6 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Application.Tracks
|
namespace Musify.Application.Tracks
|
||||||
{
|
{
|
||||||
/// <summary>Starts a listen: records it for signed-in users and issues the stream ticket.</summary>
|
|
||||||
public record StartListeningCommand(Guid TrackId, long? UserId)
|
public record StartListeningCommand(Guid TrackId, long? UserId)
|
||||||
: ICommand<ErrorOr<TrackStreamResponse>>;
|
: ICommand<ErrorOr<TrackStreamResponse>>;
|
||||||
|
|
||||||
@@ -31,6 +30,7 @@ namespace Musify.Application.Tracks
|
|||||||
|
|
||||||
var track = await database.Tracks.AsNoTracking()
|
var track = await database.Tracks.AsNoTracking()
|
||||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||||
|
.VisibleTo(request.UserId)
|
||||||
.Select(t => new { t.Id, t.Audio })
|
.Select(t => new { t.Id, t.Audio })
|
||||||
.SingleOrDefaultAsync(cancellationToken);
|
.SingleOrDefaultAsync(cancellationToken);
|
||||||
|
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using Musify.Application.Contracts;
|
|||||||
|
|
||||||
namespace Musify.Application.UploadIntents
|
namespace Musify.Application.UploadIntents
|
||||||
{
|
{
|
||||||
/// <summary>Deletes temporary upload objects that nobody claimed within the retention window.</summary>
|
|
||||||
public record DeleteStaleTempUploadsCommand : ICommand<int>;
|
public record DeleteStaleTempUploadsCommand : ICommand<int>;
|
||||||
|
|
||||||
public class DeleteStaleTempUploadsCommandHandler(
|
public class DeleteStaleTempUploadsCommandHandler(
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Application.UploadIntents
|
namespace Musify.Application.UploadIntents
|
||||||
{
|
{
|
||||||
/// <summary>Marks overdue upload intents as expired and deletes the ones expired for longer than the retention window.</summary>
|
|
||||||
public record ExpireUploadIntentsCommand : ICommand<int>;
|
public record ExpireUploadIntentsCommand : ICommand<int>;
|
||||||
|
|
||||||
public class ExpireUploadIntentsCommandHandler(
|
public class ExpireUploadIntentsCommandHandler(
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ namespace Musify.Application.Users
|
|||||||
FollowedId = request.FollowedId
|
FollowedId = request.FollowedId
|
||||||
}, cancellationToken);
|
}, cancellationToken);
|
||||||
|
|
||||||
// A concurrent double click can hit the unique index; already following is a successful no-op.
|
|
||||||
var saved = await database.TrySaveChangesAsync(
|
var saved = await database.TrySaveChangesAsync(
|
||||||
AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken);
|
AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken);
|
||||||
if (saved.IsError)
|
if (saved.IsError)
|
||||||
|
|||||||
@@ -22,6 +22,8 @@ namespace Musify.Domain.Entities
|
|||||||
|
|
||||||
public int? ReleaseYear { get; set; }
|
public int? ReleaseYear { get; set; }
|
||||||
|
|
||||||
|
public AlbumType Type { get; set; }
|
||||||
|
|
||||||
public EntityPictures? Pictures { get; set; }
|
public EntityPictures? Pictures { get; set; }
|
||||||
|
|
||||||
public required long OwnerUserId { get; set; }
|
public required long OwnerUserId { get; set; }
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ namespace Musify.Domain.Entities
|
|||||||
|
|
||||||
public bool IsExplicit { get; set; }
|
public bool IsExplicit { get; set; }
|
||||||
|
|
||||||
|
public bool IsPublished { get; set; }
|
||||||
|
|
||||||
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
public DateTime UpdatedAt { get; set; } = DateTime.UtcNow;
|
||||||
@@ -43,5 +45,7 @@ namespace Musify.Domain.Entities
|
|||||||
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
public ICollection<ListeningHistory> ListeningHistories { get; set; } = new List<ListeningHistory>();
|
||||||
|
|
||||||
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
|
public ICollection<TrackTag> Tags { get; set; } = new List<TrackTag>();
|
||||||
|
|
||||||
|
public ICollection<TrackCoauthor> Coauthors { get; set; } = new List<TrackCoauthor>();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using System.ComponentModel.DataAnnotations.Schema;
|
||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
|
namespace Musify.Domain.Entities
|
||||||
|
{
|
||||||
|
[Table("TrackCoauthors")]
|
||||||
|
public class TrackCoauthor
|
||||||
|
{
|
||||||
|
[Key]
|
||||||
|
public Guid Id { get; set; } = Guid.NewGuid();
|
||||||
|
|
||||||
|
public required Guid TrackId { get; set; }
|
||||||
|
|
||||||
|
public required long UserId { get; set; }
|
||||||
|
|
||||||
|
public CoauthorStatus Status { get; set; } = CoauthorStatus.Pending;
|
||||||
|
|
||||||
|
public DateTime InvitedAt { get; set; } = DateTime.UtcNow;
|
||||||
|
|
||||||
|
public DateTime? RespondedAt { get; set; }
|
||||||
|
|
||||||
|
[ForeignKey(nameof(TrackId))]
|
||||||
|
public Track Track { get; set; } = null!;
|
||||||
|
|
||||||
|
[ForeignKey(nameof(UserId))]
|
||||||
|
public User User { get; set; } = null!;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
using System.Diagnostics.CodeAnalysis;
|
||||||
|
|
||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum AlbumType
|
||||||
|
{
|
||||||
|
Album = 0,
|
||||||
|
EP = 1,
|
||||||
|
[SuppressMessage("Naming", "CA1720:Identifier contains type name", Justification = "Part of the public API contract.")]
|
||||||
|
Single = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Musify.Domain.ValueObjects
|
||||||
|
{
|
||||||
|
public enum CoauthorStatus
|
||||||
|
{
|
||||||
|
Pending = 0,
|
||||||
|
Accepted = 1,
|
||||||
|
Rejected = 2
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,7 +2,6 @@ using System.ComponentModel.DataAnnotations;
|
|||||||
|
|
||||||
namespace Musify.Domain.ValueObjects
|
namespace Musify.Domain.ValueObjects
|
||||||
{
|
{
|
||||||
/// <summary>The picture file names of an album or playlist. Resized names stay null until processing finishes.</summary>
|
|
||||||
public class EntityPictures
|
public class EntityPictures
|
||||||
{
|
{
|
||||||
[Required]
|
[Required]
|
||||||
|
|||||||
@@ -2,31 +2,32 @@ namespace Musify.Domain.ValueObjects
|
|||||||
{
|
{
|
||||||
public enum Genre
|
public enum Genre
|
||||||
{
|
{
|
||||||
Pop = 0,
|
None = 0,
|
||||||
Rock = 1,
|
Pop = 1,
|
||||||
HipHop = 2,
|
Rock = 2,
|
||||||
RnB = 3,
|
HipHop = 3,
|
||||||
Jazz = 4,
|
RnB = 4,
|
||||||
Blues = 5,
|
Jazz = 5,
|
||||||
Classical = 6,
|
Blues = 6,
|
||||||
Electronic = 7,
|
Classical = 7,
|
||||||
House = 8,
|
Electronic = 8,
|
||||||
Techno = 9,
|
House = 9,
|
||||||
Trance = 10,
|
Techno = 10,
|
||||||
Dubstep = 11,
|
Trance = 11,
|
||||||
DrumAndBass = 12,
|
Dubstep = 12,
|
||||||
Metal = 13,
|
DrumAndBass = 13,
|
||||||
Punk = 14,
|
Metal = 14,
|
||||||
Reggae = 15,
|
Punk = 15,
|
||||||
Reggaeton = 16,
|
Reggae = 16,
|
||||||
Country = 17,
|
Reggaeton = 17,
|
||||||
Folk = 18,
|
Country = 18,
|
||||||
Indie = 19,
|
Folk = 19,
|
||||||
KPop = 20,
|
Indie = 20,
|
||||||
Latin = 21,
|
KPop = 21,
|
||||||
Soul = 22,
|
Latin = 22,
|
||||||
Funk = 23,
|
Soul = 23,
|
||||||
Ambient = 24,
|
Funk = 24,
|
||||||
Lofi = 25
|
Ambient = 25,
|
||||||
|
Lofi = 26
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ using Musify.Application.Configuration;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.Jobs
|
namespace Musify.Infrastructure.Jobs
|
||||||
{
|
{
|
||||||
/// <summary>Registers every recurring Hangfire job when the host starts.</summary>
|
|
||||||
public sealed class RecurringJobsRegistrar(
|
public sealed class RecurringJobsRegistrar(
|
||||||
IRecurringJobManager recurringJobManager,
|
IRecurringJobManager recurringJobManager,
|
||||||
UploadIntentConfiguration uploadIntentConfiguration)
|
UploadIntentConfiguration uploadIntentConfiguration)
|
||||||
@@ -30,7 +29,6 @@ namespace Musify.Infrastructure.Jobs
|
|||||||
|
|
||||||
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
public Task StopAsync(CancellationToken cancellationToken) => Task.CompletedTask;
|
||||||
|
|
||||||
/// <summary>Hangfire cron has a one-minute resolution, so the interval is rounded up to whole minutes.</summary>
|
|
||||||
internal static string EveryCron(int intervalSeconds)
|
internal static string EveryCron(int intervalSeconds)
|
||||||
{
|
{
|
||||||
var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0));
|
var minutes = Math.Max(1, (int)Math.Ceiling(intervalSeconds / 60.0));
|
||||||
|
|||||||
@@ -5,7 +5,6 @@ using Musify.Domain.ValueObjects;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Activities.Audio
|
namespace Musify.Infrastructure.MassTransit.Activities.Audio
|
||||||
{
|
{
|
||||||
/// <summary>Shared bookkeeping of the audio processing activities.</summary>
|
|
||||||
internal static class TrackAudioStatus
|
internal static class TrackAudioStatus
|
||||||
{
|
{
|
||||||
public static async Task<Track> LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) =>
|
public static async Task<Track> LoadAsync(IDatabase database, Guid trackId, CancellationToken cancellationToken) =>
|
||||||
|
|||||||
-4
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
||||||
{
|
{
|
||||||
/// <summary>Sets the life-cycle status of an entity (removing, failed, ...). A missing entity is skipped.</summary>
|
|
||||||
internal abstract class MarkLifeCycleActivity<TEntity>(IDatabase database, ILogger logger)
|
internal abstract class MarkLifeCycleActivity<TEntity>(IDatabase database, ILogger logger)
|
||||||
: IExecuteActivity<MarkLifeCycleArguments>
|
: IExecuteActivity<MarkLifeCycleArguments>
|
||||||
where TEntity : class, IHasLifeCycle
|
where TEntity : class, IHasLifeCycle
|
||||||
@@ -33,7 +32,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Deletes an entity row. A missing entity is skipped.</summary>
|
|
||||||
internal abstract class DeleteEntityActivity<TEntity>(IDatabase database, ILogger logger)
|
internal abstract class DeleteEntityActivity<TEntity>(IDatabase database, ILogger logger)
|
||||||
: IExecuteActivity<DeleteEntityArguments>
|
: IExecuteActivity<DeleteEntityArguments>
|
||||||
where TEntity : class, IHasLifeCycle
|
where TEntity : class, IHasLifeCycle
|
||||||
@@ -58,8 +56,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.LifeCycle
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// One concrete activity per entity keeps a separate queue for each of them:
|
|
||||||
// the endpoint name is the kebab-cased class name.
|
|
||||||
internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger<MarkTrackLifeCycleActivity> logger)
|
internal sealed class MarkTrackLifeCycleActivity(IDatabase database, ILogger<MarkTrackLifeCycleActivity> logger)
|
||||||
: MarkLifeCycleActivity<Track>(database, logger)
|
: MarkLifeCycleActivity<Track>(database, logger)
|
||||||
{
|
{
|
||||||
|
|||||||
-2
@@ -12,7 +12,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
|||||||
{
|
{
|
||||||
internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large);
|
internal sealed record PictureNames(string? Original, string? Small, string? Medium, string? Large);
|
||||||
|
|
||||||
/// <summary>Stores the processed picture names on the owner and reports it; compensation restores the previous names.</summary>
|
|
||||||
internal abstract class UpdatePicturesActivity<TEntity>(
|
internal abstract class UpdatePicturesActivity<TEntity>(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
IPublishEndpoint publishEndpoint,
|
IPublishEndpoint publishEndpoint,
|
||||||
@@ -24,7 +23,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
|||||||
|
|
||||||
protected abstract Task<TEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
|
protected abstract Task<TEntity?> FindAsync(Guid id, CancellationToken cancellationToken);
|
||||||
|
|
||||||
/// <summary>Reads the current names; throws when the entity has no pending picture.</summary>
|
|
||||||
protected abstract PictureNames Read(TEntity entity);
|
protected abstract PictureNames Read(TEntity entity);
|
||||||
|
|
||||||
protected abstract void Write(TEntity entity, PictureNames names, bool succeeded);
|
protected abstract void Write(TEntity entity, PictureNames names, bool succeeded);
|
||||||
|
|||||||
+3
@@ -16,6 +16,8 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
|||||||
{
|
{
|
||||||
var arguments = executeContext.Arguments;
|
var arguments = executeContext.Arguments;
|
||||||
|
|
||||||
|
if (arguments.FinalPictureKey != null)
|
||||||
|
{
|
||||||
await publishEndpoint.Publish(
|
await publishEndpoint.Publish(
|
||||||
new UpdateTrackPictureEvent(
|
new UpdateTrackPictureEvent(
|
||||||
arguments.TrackId,
|
arguments.TrackId,
|
||||||
@@ -23,6 +25,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
|||||||
arguments.FinalPictureKey,
|
arguments.FinalPictureKey,
|
||||||
arguments.Sizes),
|
arguments.Sizes),
|
||||||
executeContext.CancellationToken);
|
executeContext.CancellationToken);
|
||||||
|
}
|
||||||
|
|
||||||
await publishEndpoint.Publish(
|
await publishEndpoint.Publish(
|
||||||
new UpdateTrackAudioEvent(
|
new UpdateTrackAudioEvent(
|
||||||
|
|||||||
+1
-1
@@ -5,7 +5,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments
|
|||||||
public record PublishTrackProcessingEventsArguments(
|
public record PublishTrackProcessingEventsArguments(
|
||||||
Guid TrackId,
|
Guid TrackId,
|
||||||
string Bucket,
|
string Bucket,
|
||||||
string FinalPictureKey,
|
string? FinalPictureKey,
|
||||||
string FinalAudioKey,
|
string FinalAudioKey,
|
||||||
string AudioDestinationFolderKey,
|
string AudioDestinationFolderKey,
|
||||||
ImageSizes Sizes);
|
ImageSizes Sizes);
|
||||||
|
|||||||
@@ -7,7 +7,6 @@ namespace Musify.Infrastructure.MassTransit.Consumers
|
|||||||
{
|
{
|
||||||
internal static class ProcessingFailedCleanup
|
internal static class ProcessingFailedCleanup
|
||||||
{
|
{
|
||||||
/// <summary>Removes whatever files were left behind and marks the entity as failed.</summary>
|
|
||||||
public static Task ExecuteAsync(
|
public static Task ExecuteAsync(
|
||||||
IBus bus,
|
IBus bus,
|
||||||
Guid subjectId,
|
Guid subjectId,
|
||||||
|
|||||||
+18
-6
@@ -11,11 +11,21 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
|||||||
{
|
{
|
||||||
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
|
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
|
||||||
{
|
{
|
||||||
var builder = RoutingSlips.Create(correlationId)
|
var builder = RoutingSlips.Create(correlationId);
|
||||||
.AddStep(
|
|
||||||
|
var hasPicture = message.PictureIntentId != null
|
||||||
|
&& message.PictureSourceKey != null
|
||||||
|
&& message.PictureDestinationKey != null;
|
||||||
|
|
||||||
|
if (hasPicture)
|
||||||
|
{
|
||||||
|
builder.AddStep(
|
||||||
ActivityNames.CopyPictureToFinal,
|
ActivityNames.CopyPictureToFinal,
|
||||||
CopyFileInBucketActivity.ExecuteEndpointName,
|
CopyFileInBucketActivity.ExecuteEndpointName,
|
||||||
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey))
|
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey!, message.Bucket, message.PictureDestinationKey!));
|
||||||
|
}
|
||||||
|
|
||||||
|
builder
|
||||||
.AddStep(
|
.AddStep(
|
||||||
ActivityNames.CopyAudioToFinal,
|
ActivityNames.CopyAudioToFinal,
|
||||||
CopyFileInBucketActivity.ExecuteEndpointName,
|
CopyFileInBucketActivity.ExecuteEndpointName,
|
||||||
@@ -23,21 +33,23 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
|||||||
.AddStep(
|
.AddStep(
|
||||||
ActivityNames.ConsumeUploadIntents,
|
ActivityNames.ConsumeUploadIntents,
|
||||||
ConsumeUploadIntentsActivity.ExecuteEndpointName,
|
ConsumeUploadIntentsActivity.ExecuteEndpointName,
|
||||||
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId]))
|
new ConsumeUploadIntentsArguments(
|
||||||
|
hasPicture ? [message.PictureIntentId!.Value, message.AudioIntentId] : [message.AudioIntentId]))
|
||||||
.AddStep(
|
.AddStep(
|
||||||
ActivityNames.PublishTrackProcessingEvents,
|
ActivityNames.PublishTrackProcessingEvents,
|
||||||
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
|
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
|
||||||
new PublishTrackProcessingEventsArguments(
|
new PublishTrackProcessingEventsArguments(
|
||||||
message.TrackId,
|
message.TrackId,
|
||||||
message.Bucket,
|
message.Bucket,
|
||||||
message.PictureDestinationKey,
|
hasPicture ? message.PictureDestinationKey : null,
|
||||||
message.AudioDestinationKey,
|
message.AudioDestinationKey,
|
||||||
message.AudioProcessedFolderKey,
|
message.AudioProcessedFolderKey,
|
||||||
message.Sizes))
|
message.Sizes))
|
||||||
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
|
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
|
||||||
|
|
||||||
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket);
|
builder.AddVariable(RoutingSlipVariableNames.Workflow.Bucket, message.Bucket);
|
||||||
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey);
|
if (hasPicture)
|
||||||
|
builder.AddVariable(RoutingSlipVariableNames.Workflow.PictureKey, message.PictureDestinationKey!);
|
||||||
builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey);
|
builder.AddVariable(RoutingSlipVariableNames.Workflow.AudioKey, message.AudioDestinationKey);
|
||||||
|
|
||||||
return builder;
|
return builder;
|
||||||
|
|||||||
+1
-3
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||||
{
|
{
|
||||||
/// <summary>Marks an entity as removing, deletes its files from the bucket and finally deletes its row.</summary>
|
|
||||||
public class DeleteRoutingSlipBuilder(
|
public class DeleteRoutingSlipBuilder(
|
||||||
IDatabase database,
|
IDatabase database,
|
||||||
TrackConfiguration trackConfiguration,
|
TrackConfiguration trackConfiguration,
|
||||||
@@ -48,8 +47,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
|||||||
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
|
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Unprocessed tracks still point at the shared preset pictures, which must never be deleted.
|
if (track.Pictures.IsProcessed && !string.IsNullOrEmpty(track.Pictures.OriginalName))
|
||||||
if (track.Pictures.IsProcessed)
|
|
||||||
{
|
{
|
||||||
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
|
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
|
||||||
builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName));
|
builder.AddRemoveFile(ActivityNames.RemoveTrackMediumPicture, bucket, routes.BuildPicturePath(PictureSize.Medium, track.Pictures.MediumName));
|
||||||
|
|||||||
-4
@@ -8,10 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Moves an uploaded album/playlist picture to its final key, consumes the upload intent and hands over to the picture workflow.
|
|
||||||
/// Creation faults mark the whole entity as failed; update faults only report the picture as failed.
|
|
||||||
/// </summary>
|
|
||||||
public class PictureSourceRoutingSlipBuilder
|
public class PictureSourceRoutingSlipBuilder
|
||||||
{
|
{
|
||||||
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) =>
|
public RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) =>
|
||||||
|
|||||||
-1
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||||
{
|
{
|
||||||
/// <summary>Downloads a picture, resizes it to the three sizes, uploads them and records the result on the owner.</summary>
|
|
||||||
public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
|
public class PictureWorkflowRoutingSlipBuilder(WorkerConfiguration workerConfiguration)
|
||||||
{
|
{
|
||||||
public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) =>
|
public RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) =>
|
||||||
|
|||||||
@@ -6,14 +6,12 @@ using Musify.Infrastructure.MassTransit.Consumers;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
||||||
{
|
{
|
||||||
/// <summary>Shared building blocks for every routing slip in the worker.</summary>
|
|
||||||
internal static class RoutingSlips
|
internal static class RoutingSlips
|
||||||
{
|
{
|
||||||
public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute");
|
public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute");
|
||||||
|
|
||||||
public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}");
|
public static Uri ConsumerUri(string queueName) => new($"queue:{queueName}");
|
||||||
|
|
||||||
/// <summary>A new slip that carries the correlation id and always cleans its temporary directory up.</summary>
|
|
||||||
public static RoutingSlipBuilder Create(Guid? correlationId)
|
public static RoutingSlipBuilder Create(Guid? correlationId)
|
||||||
{
|
{
|
||||||
var builder = new RoutingSlipBuilder(NewId.NextGuid());
|
var builder = new RoutingSlipBuilder(NewId.NextGuid());
|
||||||
@@ -32,7 +30,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
|||||||
return builder;
|
return builder;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>Publishes a failure event for the subject when the slip faults.</summary>
|
|
||||||
public static RoutingSlipBuilder TrackFaults(
|
public static RoutingSlipBuilder TrackFaults(
|
||||||
this RoutingSlipBuilder builder, Guid subjectId, string processKind)
|
this RoutingSlipBuilder builder, Guid subjectId, string processKind)
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -48,6 +48,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
|
|||||||
context.Saga.UpdatedAt = DateTime.UtcNow;
|
context.Saga.UpdatedAt = DateTime.UtcNow;
|
||||||
context.Saga.Bucket = context.Message.Bucket;
|
context.Saga.Bucket = context.Message.Bucket;
|
||||||
context.Saga.PictureKey = context.Message.PictureDestinationKey;
|
context.Saga.PictureKey = context.Message.PictureDestinationKey;
|
||||||
|
context.Saga.PictureProcessed = context.Message.PictureDestinationKey == null;
|
||||||
context.Saga.AudioKey = context.Message.AudioDestinationKey;
|
context.Saga.AudioKey = context.Message.AudioDestinationKey;
|
||||||
})
|
})
|
||||||
.TransitionTo(Processing));
|
.TransitionTo(Processing));
|
||||||
|
|||||||
@@ -59,6 +59,8 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
|
|
||||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
||||||
|
|
||||||
|
public DbSet<TrackCoauthor> TrackCoauthors => Set<TrackCoauthor>();
|
||||||
|
|
||||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken)
|
||||||
{
|
{
|
||||||
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
|
var transaction = await Database.BeginTransactionAsync(isolationLevel, cancellationToken);
|
||||||
|
|||||||
Generated
+1084
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class GenreNone : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,none,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance")
|
||||||
|
.OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AlterDatabase()
|
||||||
|
.Annotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance")
|
||||||
|
.OldAnnotation("Npgsql:Enum:genre", "ambient,blues,classical,country,drum_and_bass,dubstep,electronic,folk,funk,hip_hop,house,indie,jazz,k_pop,latin,lofi,metal,none,pop,punk,reggae,reggaeton,rn_b,rock,soul,techno,trance");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1090
File diff suppressed because it is too large
Load Diff
+42
@@ -0,0 +1,42 @@
|
|||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class TrackPublicationAndAlbumType : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.AddColumn<bool>(
|
||||||
|
name: "IsPublished",
|
||||||
|
table: "Tracks",
|
||||||
|
type: "boolean",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: false);
|
||||||
|
|
||||||
|
migrationBuilder.Sql("UPDATE \"Tracks\" SET \"IsPublished\" = TRUE;");
|
||||||
|
|
||||||
|
migrationBuilder.AddColumn<int>(
|
||||||
|
name: "Type",
|
||||||
|
table: "Albums",
|
||||||
|
type: "integer",
|
||||||
|
nullable: false,
|
||||||
|
defaultValue: 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "IsPublished",
|
||||||
|
table: "Tracks");
|
||||||
|
|
||||||
|
migrationBuilder.DropColumn(
|
||||||
|
name: "Type",
|
||||||
|
table: "Albums");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
+1142
File diff suppressed because it is too large
Load Diff
+61
@@ -0,0 +1,61 @@
|
|||||||
|
using System;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace Musify.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
public partial class AddTrackCoauthors : Migration
|
||||||
|
{
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Up(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.CreateTable(
|
||||||
|
name: "TrackCoauthors",
|
||||||
|
columns: table => new
|
||||||
|
{
|
||||||
|
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
TrackId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||||
|
UserId = table.Column<long>(type: "bigint", nullable: false),
|
||||||
|
Status = table.Column<int>(type: "integer", nullable: false),
|
||||||
|
InvitedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||||
|
RespondedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true)
|
||||||
|
},
|
||||||
|
constraints: table =>
|
||||||
|
{
|
||||||
|
table.PrimaryKey("PK_TrackCoauthors", x => x.Id);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TrackCoauthors_Tracks_TrackId",
|
||||||
|
column: x => x.TrackId,
|
||||||
|
principalTable: "Tracks",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
table.ForeignKey(
|
||||||
|
name: "FK_TrackCoauthors_User_UserId",
|
||||||
|
column: x => x.UserId,
|
||||||
|
principalTable: "User",
|
||||||
|
principalColumn: "Id",
|
||||||
|
onDelete: ReferentialAction.Cascade);
|
||||||
|
});
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TrackCoauthors_TrackId_UserId",
|
||||||
|
table: "TrackCoauthors",
|
||||||
|
columns: new[] { "TrackId", "UserId" },
|
||||||
|
unique: true);
|
||||||
|
|
||||||
|
migrationBuilder.CreateIndex(
|
||||||
|
name: "IX_TrackCoauthors_UserId_Status",
|
||||||
|
table: "TrackCoauthors",
|
||||||
|
columns: new[] { "UserId", "Status" });
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <inheritdoc />
|
||||||
|
protected override void Down(MigrationBuilder migrationBuilder)
|
||||||
|
{
|
||||||
|
migrationBuilder.DropTable(
|
||||||
|
name: "TrackCoauthors");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,7 +21,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.HasAnnotation("ProductVersion", "10.0.12")
|
.HasAnnotation("ProductVersion", "10.0.12")
|
||||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||||
|
|
||||||
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
|
NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "none", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" });
|
||||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||||
|
|
||||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||||
@@ -224,6 +224,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
.HasColumnType("character varying(200)");
|
.HasColumnType("character varying(200)");
|
||||||
|
|
||||||
|
b.Property<int>("Type")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<DateTime>("UpdatedAt")
|
b.Property<DateTime>("UpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
@@ -435,6 +438,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.Property<bool>("IsExplicit")
|
b.Property<bool>("IsExplicit")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsPublished")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<int>("LifeCycleStatus")
|
b.Property<int>("LifeCycleStatus")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
@@ -461,6 +467,37 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
b.ToTable("Tracks");
|
b.ToTable("Tracks");
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b =>
|
||||||
|
{
|
||||||
|
b.Property<Guid>("Id")
|
||||||
|
.ValueGeneratedOnAdd()
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<DateTime>("InvitedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("RespondedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int>("Status")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
b.Property<Guid>("TrackId")
|
||||||
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<long>("UserId")
|
||||||
|
.HasColumnType("bigint");
|
||||||
|
|
||||||
|
b.HasKey("Id");
|
||||||
|
|
||||||
|
b.HasIndex("TrackId", "UserId")
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
b.HasIndex("UserId", "Status");
|
||||||
|
|
||||||
|
b.ToTable("TrackCoauthors");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
|
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
|
||||||
{
|
{
|
||||||
b.Property<Guid>("Id")
|
b.Property<Guid>("Id")
|
||||||
@@ -983,6 +1020,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
.IsRequired();
|
.IsRequired();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
modelBuilder.Entity("Musify.Domain.Entities.TrackCoauthor", b =>
|
||||||
|
{
|
||||||
|
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||||
|
.WithMany("Coauthors")
|
||||||
|
.HasForeignKey("TrackId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.HasOne("Musify.Domain.Entities.User", "User")
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey("UserId")
|
||||||
|
.OnDelete(DeleteBehavior.Cascade)
|
||||||
|
.IsRequired();
|
||||||
|
|
||||||
|
b.Navigation("Track");
|
||||||
|
|
||||||
|
b.Navigation("User");
|
||||||
|
});
|
||||||
|
|
||||||
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
|
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
|
||||||
{
|
{
|
||||||
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||||
@@ -1062,6 +1118,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
|||||||
{
|
{
|
||||||
b.Navigation("AlbumTracks");
|
b.Navigation("AlbumTracks");
|
||||||
|
|
||||||
|
b.Navigation("Coauthors");
|
||||||
|
|
||||||
b.Navigation("ListeningHistories");
|
b.Navigation("ListeningHistories");
|
||||||
|
|
||||||
b.Navigation("PlayListTracks");
|
b.Navigation("PlayListTracks");
|
||||||
|
|||||||
@@ -5,9 +5,6 @@ using Musify.Infrastructure.MassTransit.Sagas;
|
|||||||
|
|
||||||
namespace Musify.Infrastructure.Persistence
|
namespace Musify.Infrastructure.Persistence
|
||||||
{
|
{
|
||||||
/// <summary>
|
|
||||||
/// Entity model shared by <see cref="Database"/> and the in-memory test database, so both always agree.
|
|
||||||
/// </summary>
|
|
||||||
public static class ModelConfiguration
|
public static class ModelConfiguration
|
||||||
{
|
{
|
||||||
public static void Apply(ModelBuilder modelBuilder)
|
public static void Apply(ModelBuilder modelBuilder)
|
||||||
@@ -125,6 +122,25 @@ namespace Musify.Infrastructure.Persistence
|
|||||||
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||||
.IsUnique();
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackCoauthor>()
|
||||||
|
.HasOne(coauthor => coauthor.Track)
|
||||||
|
.WithMany(track => track.Coauthors)
|
||||||
|
.HasForeignKey(coauthor => coauthor.TrackId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackCoauthor>()
|
||||||
|
.HasOne(coauthor => coauthor.User)
|
||||||
|
.WithMany()
|
||||||
|
.HasForeignKey(coauthor => coauthor.UserId)
|
||||||
|
.OnDelete(DeleteBehavior.Cascade);
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackCoauthor>()
|
||||||
|
.HasIndex(coauthor => new { coauthor.TrackId, coauthor.UserId })
|
||||||
|
.IsUnique();
|
||||||
|
|
||||||
|
modelBuilder.Entity<TrackCoauthor>()
|
||||||
|
.HasIndex(coauthor => new { coauthor.UserId, coauthor.Status });
|
||||||
|
|
||||||
modelBuilder.Entity<UserFollow>()
|
modelBuilder.Entity<UserFollow>()
|
||||||
.HasOne(follow => follow.Follower)
|
.HasOne(follow => follow.Follower)
|
||||||
.WithMany()
|
.WithMany()
|
||||||
|
|||||||
@@ -1,8 +1,12 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Api.DataTransferObjects.Albums
|
namespace Musify.Api.DataTransferObjects.Albums
|
||||||
{
|
{
|
||||||
public record CreateAlbumRequest(
|
public record CreateAlbumRequest(
|
||||||
string Title,
|
string Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
int? ReleaseYear,
|
int? ReleaseYear,
|
||||||
Guid PictureIntentId);
|
Guid PictureIntentId,
|
||||||
|
AlbumType Type = AlbumType.Album,
|
||||||
|
IReadOnlyList<Guid>? TrackIds = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Api.DataTransferObjects.Albums
|
||||||
|
{
|
||||||
|
public record UpdateAlbumPictureRequest(Guid PictureIntentId);
|
||||||
|
}
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
|
using Musify.Domain.ValueObjects;
|
||||||
|
|
||||||
namespace Musify.Api.DataTransferObjects.Albums
|
namespace Musify.Api.DataTransferObjects.Albums
|
||||||
{
|
{
|
||||||
public record UpdateAlbumRequest(
|
public record UpdateAlbumRequest(
|
||||||
string Title,
|
string Title,
|
||||||
string? Description,
|
string? Description,
|
||||||
int? ReleaseYear,
|
int? ReleaseYear,
|
||||||
Guid? PictureIntentId);
|
AlbumType? Type = null);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,4 @@
|
|||||||
|
namespace Musify.Api.DataTransferObjects.PlayLists
|
||||||
|
{
|
||||||
|
public record UpdatePlayListPictureRequest(Guid PictureIntentId);
|
||||||
|
}
|
||||||
@@ -3,8 +3,7 @@ using Musify.Domain.ValueObjects;
|
|||||||
namespace Musify.Api.DataTransferObjects.PlayLists
|
namespace Musify.Api.DataTransferObjects.PlayLists
|
||||||
{
|
{
|
||||||
public record UpdatePlayListRequest(
|
public record UpdatePlayListRequest(
|
||||||
string? Name,
|
string Name,
|
||||||
string? Description,
|
string? Description,
|
||||||
Guid? PictureIntentId,
|
PlayListVisibility? Visibility);
|
||||||
PlayListVisibility? Visibility = null);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ namespace Musify.Api.DataTransferObjects.Tracks
|
|||||||
{
|
{
|
||||||
public record CreateTrackRequest(
|
public record CreateTrackRequest(
|
||||||
string Title,
|
string Title,
|
||||||
Guid PictureIntentId,
|
Guid? PictureIntentId,
|
||||||
Guid AudioIntentId,
|
Guid AudioIntentId,
|
||||||
IReadOnlyCollection<Genre> Tags,
|
IReadOnlyCollection<Genre> Tags,
|
||||||
bool IsExplicit = false);
|
bool IsExplicit = false);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user