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"],
|
||||
"port": 5111
|
||||
},
|
||||
{
|
||||
"name": "musify-worker",
|
||||
"runtimeExecutable": "dotnet",
|
||||
"runtimeArgs": ["run", "--project", "backend/Hosts/Musify.Worker"],
|
||||
"port": 5112
|
||||
},
|
||||
{
|
||||
"name": "musify-streaming-gateway",
|
||||
"runtimeExecutable": "dotnet",
|
||||
|
||||
@@ -9,7 +9,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums
|
||||
{
|
||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
@@ -50,7 +49,6 @@ namespace Musify.Application.Albums
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Album track numbers are user-visible, so they start at 1.
|
||||
var nextTrackNumber = (await database.AlbumHasTracks
|
||||
.Where(link => link.AlbumId == request.AlbumId)
|
||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||
|
||||
@@ -19,7 +19,9 @@ namespace Musify.Application.Albums
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
Guid PictureIntentId)
|
||||
Guid PictureIntentId,
|
||||
AlbumType Type = AlbumType.Album,
|
||||
IReadOnlyList<Guid>? TrackIds = null)
|
||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class CreateAlbumCommandHandler(
|
||||
@@ -38,6 +40,21 @@ namespace Musify.Application.Albums
|
||||
if (!userExists)
|
||||
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(
|
||||
uploadIntentValidator, request.PictureIntentId, request.UserId, UploadIntentPurpose.AlbumPicture, albumConfiguration, cancellationToken);
|
||||
if (prepared.IsError)
|
||||
@@ -53,11 +70,19 @@ namespace Musify.Application.Albums
|
||||
NormalizedTitle = TextNormalizer.Normalize(title),
|
||||
Description = request.Description,
|
||||
ReleaseYear = request.ReleaseYear,
|
||||
Type = request.Type,
|
||||
Pictures = picture.Pictures
|
||||
};
|
||||
|
||||
await database.Albums.AddAsync(album, cancellationToken);
|
||||
|
||||
for (var index = 0; index < trackIds.Count; index++)
|
||||
{
|
||||
await database.AlbumHasTracks.AddAsync(
|
||||
new AlbumHasTrack { AlbumId = album.Id, TrackId = trackIds[index], TrackNumber = index + 1 },
|
||||
cancellationToken);
|
||||
}
|
||||
|
||||
await eventBus.PublishAsync(
|
||||
new CreateAlbumResourcesEvent(
|
||||
album.Id, picture.Intent.Id, picture.Intent.Bucket, picture.Intent.Key, picture.FinalKey, picture.Sizes),
|
||||
@@ -65,9 +90,15 @@ namespace Musify.Application.Albums
|
||||
|
||||
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
|
||||
.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)
|
||||
.ThenBy(albumTrack => albumTrack.Id)
|
||||
.Select(albumTrack => albumTrack.Track)
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace Musify.Application.Albums
|
||||
var ranked = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(albumTrack => albumTrack.Album.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active)
|
||||
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == Domain.ValueObjects.LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||
.Join(
|
||||
listens,
|
||||
albumTrack => albumTrack.TrackId,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums.Responses
|
||||
{
|
||||
@@ -14,7 +15,8 @@ namespace Musify.Application.Albums.Responses
|
||||
string? LargeImageKeyName,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds)
|
||||
IReadOnlyList<Guid> CoverTrackIds,
|
||||
AlbumType Type)
|
||||
{
|
||||
public const int CoverTrackCount = AlbumProjections.CoverTrackCount;
|
||||
|
||||
@@ -35,7 +37,8 @@ namespace Musify.Application.Albums.Responses
|
||||
album.Pictures?.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
coverTrackIds ?? []);
|
||||
coverTrackIds ?? [],
|
||||
album.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -17,16 +17,18 @@ namespace Musify.Application.Albums.Responses
|
||||
album.Description,
|
||||
album.ReleaseYear,
|
||||
album.OwnerUserId,
|
||||
album.AlbumTracks.Count,
|
||||
album.AlbumTracks.Count(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished),
|
||||
album.Pictures!.SmallName,
|
||||
album.Pictures!.MediumName,
|
||||
album.Pictures!.LargeName,
|
||||
album.CreatedAt,
|
||||
album.UpdatedAt,
|
||||
album.AlbumTracks
|
||||
.Where(albumTrack => albumTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && albumTrack.Track.IsPublished)
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Take(CoverTrackCount)
|
||||
.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.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;
|
||||
|
||||
@@ -19,15 +15,12 @@ namespace Musify.Application.Albums
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
Guid? NewPictureIntentId)
|
||||
AlbumType? Type = null)
|
||||
: ICommand<ErrorOr<AlbumApplicationResponse>>;
|
||||
|
||||
public class UpdateAlbumCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<UpdateAlbumCommandHandler> logger,
|
||||
AlbumConfiguration albumConfiguration)
|
||||
ILogger<UpdateAlbumCommandHandler> logger)
|
||||
: ICommandHandler<UpdateAlbumCommand, ErrorOr<AlbumApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<AlbumApplicationResponse>> Handle(UpdateAlbumCommand request, CancellationToken cancellationToken)
|
||||
@@ -41,24 +34,9 @@ namespace Musify.Application.Albums
|
||||
|
||||
album.Title = title;
|
||||
album.NormalizedTitle = TextNormalizer.Normalize(title);
|
||||
album.Description = request.Description;
|
||||
album.Description = string.IsNullOrWhiteSpace(request.Description) ? null : request.Description.Trim();
|
||||
album.ReleaseYear = request.ReleaseYear;
|
||||
|
||||
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);
|
||||
}
|
||||
album.Type = request.Type ?? album.Type;
|
||||
|
||||
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
|
||||
{
|
||||
/// <summary>Parses the <c>size</c> query value; anything unknown means <see cref="PictureSize.Medium"/>.</summary>
|
||||
public static PictureSize Parse(string? value) =>
|
||||
Enum.TryParse<PictureSize>(value, ignoreCase: true, out var size) && Enum.IsDefined(size)
|
||||
? size
|
||||
|
||||
@@ -10,10 +10,10 @@ namespace Musify.Application.Configuration
|
||||
public int UploadUrlExpiresInSeconds { get; set; } = 120;
|
||||
|
||||
[Range(1, 100)]
|
||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 5;
|
||||
public int MaxActiveUploadIntentsPerUser { get; set; } = 50;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxActiveUploadBytesPerUser { get; set; } = 200L * 1024 * 1024;
|
||||
public long MaxActiveUploadBytesPerUser { get; set; } = 1024L * 1024 * 1024;
|
||||
|
||||
[Range(1, long.MaxValue)]
|
||||
public long MaxUploadBytes { get; set; } = 100L * 1024 * 1024;
|
||||
|
||||
@@ -32,6 +32,8 @@ namespace Musify.Application.Contracts
|
||||
|
||||
public DbSet<UserFollow> UserFollows { get; }
|
||||
|
||||
public DbSet<TrackCoauthor> TrackCoauthors { get; }
|
||||
|
||||
public Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken cancellationToken);
|
||||
|
||||
public DbSet<TEntity> Set<TEntity>() where TEntity : class;
|
||||
|
||||
@@ -2,7 +2,6 @@ namespace Musify.Application.Contracts
|
||||
{
|
||||
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<ObjectMetaData?> HeadObjectAsync(string bucket, string key, CancellationToken cancellationToken);
|
||||
|
||||
@@ -4,11 +4,11 @@ namespace Musify.Application.Events
|
||||
{
|
||||
public record CreateTrackResourcesEvent(
|
||||
Guid TrackId,
|
||||
Guid PictureIntentId,
|
||||
Guid? PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureSourceKey,
|
||||
string PictureDestinationKey,
|
||||
string? PictureSourceKey,
|
||||
string? PictureDestinationKey,
|
||||
string AudioSourceKey,
|
||||
string AudioDestinationKey,
|
||||
string AudioProcessedFolderKey,
|
||||
|
||||
@@ -11,7 +11,7 @@ namespace Musify.Application.Genres
|
||||
{
|
||||
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
|
||||
.Select(genre => new AvailableGenreResponse(
|
||||
|
||||
@@ -15,7 +15,7 @@ namespace Musify.Application.Genres
|
||||
{
|
||||
var rows = await database.TrackTags
|
||||
.AsNoTracking()
|
||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active && tag.Track.IsPublished)
|
||||
.GroupBy(tag => tag.Tag)
|
||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -20,7 +20,7 @@ namespace Musify.Application.Likes
|
||||
{
|
||||
return await database.TrackLikes
|
||||
.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)
|
||||
.ThenBy(like => like.Id)
|
||||
.Select(like => like.Track)
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace Musify.Application.Likes
|
||||
{
|
||||
var trackExists = await database.Tracks
|
||||
.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)
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
@@ -44,7 +44,6 @@ namespace Musify.Application.Likes
|
||||
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(
|
||||
AppErrors.Conflict("TrackLike.Duplicate", "Track is already liked."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
|
||||
@@ -25,7 +25,7 @@ namespace Musify.Application.Mixes
|
||||
|
||||
var libraryTrackIds = await database.Tracks
|
||||
.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)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -25,7 +25,13 @@ namespace Musify.Application.Mixes
|
||||
mix => mix.Id,
|
||||
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))
|
||||
.ToList();
|
||||
var coverResponses = (await MixItemMapper.ToResponsesAsync(database, coverItems, cancellationToken))
|
||||
@@ -35,7 +41,7 @@ namespace Musify.Application.Mixes
|
||||
var responses = new List<MixApplicationResponse>(mixes.Count);
|
||||
foreach (var mix in mixes)
|
||||
{
|
||||
var orderedItems = orderedItemsByMix[mix.Id];
|
||||
var orderedItems = visibleItemsByMix[mix.Id];
|
||||
var covers = orderedItems
|
||||
.Take(MixApplicationResponse.CoverItemCount)
|
||||
.Where(item => coverResponses.ContainsKey(item.TrackId))
|
||||
|
||||
@@ -9,6 +9,22 @@ namespace Musify.Application.Mixes
|
||||
|
||||
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(
|
||||
IDatabase database,
|
||||
IReadOnlyList<MixItem> orderedItems,
|
||||
@@ -19,7 +35,7 @@ namespace Musify.Application.Mixes
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.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) })
|
||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||
|
||||
|
||||
@@ -35,10 +35,7 @@ namespace Musify.Application.Pictures
|
||||
{
|
||||
var (source, routes) = request.Owner switch
|
||||
{
|
||||
CoverOwner.Track => (await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.Id && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new CoverSource(t.OwnerUserId, t.Pictures.OriginalName, t.Pictures.SmallName, t.Pictures.MediumName, t.Pictures.LargeName))
|
||||
.SingleOrDefaultAsync(cancellationToken), trackConfiguration.Routes),
|
||||
CoverOwner.Track => await FindTrackSourceAsync(request.Id, cancellationToken),
|
||||
CoverOwner.Album => (await database.Albums.AsNoTracking()
|
||||
.Where(a => a.Id == request.Id && a.LifeCycleStatus == LifeCycleStatus.Active && a.Pictures != null)
|
||||
.Select(a => new CoverSource(a.OwnerUserId, a.Pictures!.OriginalName, a.Pictures.SmallName, a.Pictures.MediumName, a.Pictures.LargeName))
|
||||
@@ -60,7 +57,6 @@ namespace Musify.Application.Pictures
|
||||
_ => source.Medium
|
||||
};
|
||||
|
||||
// The resized files only exist once processing has finished; until then serve the original.
|
||||
var (name, key) = !string.IsNullOrEmpty(resizedName)
|
||||
? (resizedName, routes.BuildPicturePath(request.Size, resizedName))
|
||||
: !string.IsNullOrEmpty(source.Original)
|
||||
@@ -72,5 +68,34 @@ namespace Musify.Application.Pictures
|
||||
|
||||
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
|
||||
{
|
||||
/// <summary>What an album or playlist needs to switch to a freshly uploaded picture.</summary>
|
||||
public sealed record PreparedPicture(UploadIntent Intent, EntityPictures Pictures, string FinalKey, ImageSizes Sizes);
|
||||
|
||||
public static class PictureSourceChange
|
||||
|
||||
@@ -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
|
||||
{
|
||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
@@ -28,7 +27,7 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var existingIds = await database.Tracks
|
||||
.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)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -46,10 +45,12 @@ namespace Musify.Application.PlayLists
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var nextPosition = (await database.PlayListHasTracks
|
||||
.Where(link => link.PlayListId == request.PlayListId)
|
||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||
var playListTracksQuery = database.PlayListHasTracks
|
||||
.Where(link => link.PlayListId == request.PlayListId);
|
||||
|
||||
var nextPosition = await playListTracksQuery.AnyAsync(cancellationToken)
|
||||
? await playListTracksQuery.MaxAsync(link => link.Position, cancellationToken) + 1
|
||||
: 0;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
{
|
||||
|
||||
@@ -39,10 +39,10 @@ namespace Musify.Application.PlayLists
|
||||
return AppErrors.NotFound("User", request.UserId);
|
||||
|
||||
PreparedPicture? picture = null;
|
||||
if (request.PictureIntentId is { } intentId)
|
||||
if (request.PictureIntentId.HasValue)
|
||||
{
|
||||
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)
|
||||
return prepared.Errors;
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
return await database.PlayListHasTracks
|
||||
.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)
|
||||
.ThenBy(playListTrack => playListTrack.Id)
|
||||
.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.UpdatedAt,
|
||||
playList.PlayListTracks
|
||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||
.OrderBy(playListTrack => playListTrack.Position)
|
||||
.Take(CoverTrackCount)
|
||||
.Select(playListTrack => playListTrack.TrackId)
|
||||
.ToList(),
|
||||
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
|
||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Where(playListTrack => playListTrack.Track.LifeCycleStatus == LifeCycleStatus.Active && playListTrack.Track.IsPublished)
|
||||
.Sum(playListTrack => playListTrack.Track.DurationSeconds)));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,12 +2,8 @@ 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;
|
||||
|
||||
@@ -16,18 +12,14 @@ namespace Musify.Application.PlayLists
|
||||
public record UpdatePlayListCommand(
|
||||
long UserId,
|
||||
Guid PlayListId,
|
||||
string? NewName,
|
||||
string NewName,
|
||||
string? NewDescription,
|
||||
Guid? NewPictureIntentId,
|
||||
PlayListVisibility? NewVisibility = null)
|
||||
PlayListVisibility NewVisibility)
|
||||
: ICommand<ErrorOr<PlayListApplicationResponse>>;
|
||||
|
||||
public class UpdatePlayListCommandHandler(
|
||||
IEventBus eventBus,
|
||||
IDatabase database,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<UpdatePlayListCommandHandler> logger,
|
||||
PlayListConfiguration playListConfiguration)
|
||||
ILogger<UpdatePlayListCommandHandler> logger)
|
||||
: ICommandHandler<UpdatePlayListCommand, ErrorOr<PlayListApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<PlayListApplicationResponse>> Handle(UpdatePlayListCommand request, CancellationToken cancellationToken)
|
||||
@@ -38,34 +30,10 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var playList = found.Value;
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.NewName))
|
||||
{
|
||||
playList.Name = request.NewName.Trim();
|
||||
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();
|
||||
|
||||
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);
|
||||
}
|
||||
playList.Visibility = request.NewVisibility;
|
||||
|
||||
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
|
||||
{
|
||||
/// <summary>Builds the stream URL and signed ticket for a processed track. It never touches the database.</summary>
|
||||
public sealed class TrackStreamIssuer(
|
||||
IStreamTicketService ticketService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
|
||||
@@ -15,7 +15,8 @@ namespace Musify.Application.Services
|
||||
PictureRoutes Routes,
|
||||
string FileType,
|
||||
string ContentType,
|
||||
long? ExpectedSizeBytes);
|
||||
long? ExpectedSizeBytes,
|
||||
int? SharedKeyGroup = null);
|
||||
|
||||
public sealed record IssuedUpload(
|
||||
Guid IntentId,
|
||||
@@ -24,7 +25,6 @@ namespace Musify.Application.Services
|
||||
string ContentType,
|
||||
string UploadUrl);
|
||||
|
||||
/// <summary>Issues pre-signed upload URLs and the upload intents that track them, checking the user quota first.</summary>
|
||||
public sealed class UploadIntentIssuer(
|
||||
IDatabase database,
|
||||
IStorageService storageService,
|
||||
@@ -44,11 +44,15 @@ namespace Musify.Application.Services
|
||||
if (!userExists)
|
||||
return AppErrors.NotFound("User", userId);
|
||||
|
||||
var sharedKeys = new Dictionary<int, string>();
|
||||
var planned = requests
|
||||
.Select(request =>
|
||||
{
|
||||
var objectName = $"{Guid.NewGuid()}.{request.FileType.TrimStart('.').ToLowerInvariant()}";
|
||||
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);
|
||||
return (Request: request, ObjectName: objectName, Key: key, Size: size);
|
||||
})
|
||||
|
||||
@@ -7,10 +7,6 @@ namespace Musify.Application.Shared
|
||||
{
|
||||
public static class OwnershipExtensions
|
||||
{
|
||||
/// <summary>
|
||||
/// Loads an active, tracked entity owned by <paramref name="userId"/>: NotFound when it is missing
|
||||
/// or not active, Forbidden when someone else owns it.
|
||||
/// </summary>
|
||||
public static async Task<ErrorOr<T>> FindOwnedAsync<T>(
|
||||
this DbSet<T> set, Guid id, long userId, CancellationToken cancellationToken)
|
||||
where T : class, IOwnedEntity, IHasLifeCycle
|
||||
|
||||
@@ -18,7 +18,6 @@ namespace Musify.Application.Shared
|
||||
|
||||
public static class PaginationExtensions
|
||||
{
|
||||
/// <summary>Counts and pages the same (already filtered, ordered and projected) query.</summary>
|
||||
public static async Task<PaginatedResponse<T>> ToPaginatedAsync<T>(
|
||||
this IQueryable<T> query, PageRequest page, CancellationToken cancellationToken)
|
||||
{
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Musify.Application.Tracks
|
||||
public record CreateTrackCommand(
|
||||
long UserId,
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid? PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false)
|
||||
@@ -45,20 +45,27 @@ namespace Musify.Application.Tracks
|
||||
if (request.PictureIntentId == request.AudioIntentId)
|
||||
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(
|
||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
pictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
if (pictureValidation.IsError)
|
||||
return pictureValidation.Errors;
|
||||
|
||||
pictureIntent = pictureValidation.Value;
|
||||
}
|
||||
|
||||
var audioValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.AudioIntentId, request.UserId, UploadIntentPurpose.TrackAudio, cancellationToken);
|
||||
if (audioValidation.IsError)
|
||||
return audioValidation.Errors;
|
||||
|
||||
var pictureIntent = pictureValidation.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 audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
@@ -71,11 +78,11 @@ namespace Musify.Application.Tracks
|
||||
IsExplicit = request.IsExplicit,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
OriginalName = pictureIntent.ObjectName,
|
||||
OriginalName = pictureIntent?.ObjectName,
|
||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
ProcessingStatus = pictureIntent == null ? ProcessingStatus.Completed : ProcessingStatus.Pending
|
||||
},
|
||||
Audio = new TrackAudio
|
||||
{
|
||||
@@ -90,15 +97,14 @@ namespace Musify.Application.Tracks
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
|
||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||
await eventBus.PublishAsync(
|
||||
new CreateTrackResourcesEvent(
|
||||
trackEntity.Id,
|
||||
pictureIntent.Id,
|
||||
pictureIntent?.Id,
|
||||
audioIntent.Id,
|
||||
pictureIntent.Bucket,
|
||||
pictureIntent.Key,
|
||||
audioIntent.Bucket,
|
||||
pictureIntent?.Key,
|
||||
finalPictureKey,
|
||||
audioIntent.Key,
|
||||
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
|
||||
.AsNoTracking()
|
||||
.Include(t => t.Track.Owner)
|
||||
.Include(t => t.Track.Tags)
|
||||
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Where(t => t.UserId == query.UserId && t.IsCounted && t.Track.LifeCycleStatus == LifeCycleStatus.Active && t.Track.IsPublished)
|
||||
.OrderByDescending(t => t.ListenedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||
.Select(t => t.Track)
|
||||
.SelectResponse()
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace Musify.Application.Tracks
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.WherePublished()
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
@@ -7,7 +7,7 @@ using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record GetTrackByIdQuery(Guid TrackId)
|
||||
public record GetTrackByIdQuery(Guid TrackId, long? ViewerId = null)
|
||||
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class GetTrackByIdQueryHandler(IDatabase database)
|
||||
@@ -18,6 +18,7 @@ namespace Musify.Application.Tracks
|
||||
var track = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.VisibleTo(request.ViewerId)
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.SelectResponse()
|
||||
.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
|
||||
.AsNoTracking()
|
||||
.Active();
|
||||
.Active()
|
||||
.WherePublished();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
|
||||
@@ -4,6 +4,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
@@ -11,18 +12,33 @@ namespace Musify.Application.Tracks
|
||||
long UserId,
|
||||
string? Name,
|
||||
int PageNumber,
|
||||
int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
int PageSize,
|
||||
IReadOnlyList<ProcessingStatus>? Statuses = null,
|
||||
long? ViewerId = null,
|
||||
bool? IsPublished = null,
|
||||
bool? IncludeCoauthored = null)
|
||||
: IQuery<ErrorOr<PaginatedResponse<UserTrackApplicationResponse>>>;
|
||||
|
||||
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
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(track => track.OwnerUserId == request.UserId);
|
||||
.Active();
|
||||
|
||||
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))
|
||||
{
|
||||
@@ -30,11 +46,27 @@ namespace Musify.Application.Tracks
|
||||
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)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.SelectUserResponse(includeInvites: request.ViewerId != null)
|
||||
.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
|
||||
{
|
||||
public record AudioUploadSpec(string FileType, string ContentType, long? ExpectedSizeBytes = null);
|
||||
|
||||
public record RequestTrackUploadUrlsCommand(
|
||||
long UserId,
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType,
|
||||
string? PictureFileType,
|
||||
string? PictureContentType,
|
||||
string? AudioFileType = null,
|
||||
string? AudioContentType = null,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null)
|
||||
long? ExpectedAudioSizeBytes = null,
|
||||
IReadOnlyList<AudioUploadSpec>? Audios = null)
|
||||
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
|
||||
|
||||
public class RequestTrackUploadUrlsCommandHandler(
|
||||
@@ -24,35 +27,75 @@ namespace Musify.Application.Tracks
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
|
||||
{
|
||||
private const int PictureKeyGroup = 1;
|
||||
|
||||
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 issued = await issuer.IssueAsync(
|
||||
request.UserId,
|
||||
[
|
||||
new UploadRequest(UploadIntentPurpose.TrackPicture, routes, request.PictureFileType, request.PictureContentType, request.ExpectedPictureSizeBytes),
|
||||
new UploadRequest(UploadIntentPurpose.TrackAudio, routes, request.AudioFileType, request.AudioContentType, request.ExpectedAudioSizeBytes)
|
||||
],
|
||||
cancellationToken);
|
||||
|
||||
var uploads = new List<UploadRequest>();
|
||||
if (hasPicture)
|
||||
{
|
||||
for (var index = 0; index < audios.Count; index++)
|
||||
{
|
||||
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)
|
||||
return issued.Errors;
|
||||
|
||||
var picture = issued.Value[0];
|
||||
var audio = issued.Value[1];
|
||||
var pictures = hasPicture ? issued.Value.Take(audios.Count).ToList() : [];
|
||||
var audioUploads = issued.Value.Skip(pictures.Count).ToList();
|
||||
|
||||
return new TrackUploadUrlsResponse(
|
||||
picture.IntentId,
|
||||
var items = audioUploads
|
||||
.Select((audio, index) => new TrackAudioUpload(
|
||||
audio.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
picture.Key,
|
||||
picture.ObjectName,
|
||||
picture.ContentType,
|
||||
picture.UploadUrl,
|
||||
hasPicture ? pictures[index].IntentId : null,
|
||||
audio.Key,
|
||||
audio.ObjectName,
|
||||
audio.ContentType,
|
||||
audio.UploadUrl,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
audio.UploadUrl))
|
||||
.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,
|
||||
long OwnerUserId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit)
|
||||
bool IsExplicit,
|
||||
bool IsPublished,
|
||||
IReadOnlyList<TrackCoauthorReference> Coauthors)
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||
new(
|
||||
@@ -28,6 +30,8 @@ namespace Musify.Application.Tracks.Responses
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId,
|
||||
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) =>
|
||||
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) =>
|
||||
query.Select(track => new TrackApplicationResponse(
|
||||
track.Id,
|
||||
@@ -20,6 +26,47 @@ namespace Musify.Application.Tracks.Responses
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId,
|
||||
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
|
||||
{
|
||||
public record TrackAudioUpload(
|
||||
Guid IntentId,
|
||||
Guid? PictureIntentId,
|
||||
string Key,
|
||||
string Name,
|
||||
string ContentType,
|
||||
string UploadUrl);
|
||||
|
||||
public record TrackUploadUrlsResponse(
|
||||
Guid PictureIntentId,
|
||||
Guid? PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureKey,
|
||||
string PictureName,
|
||||
string PictureContentType,
|
||||
string PictureUploadUrl,
|
||||
string? PictureKey,
|
||||
string? PictureName,
|
||||
string? PictureContentType,
|
||||
string? PictureUploadUrl,
|
||||
string AudioKey,
|
||||
string AudioName,
|
||||
string AudioContentType,
|
||||
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
|
||||
{
|
||||
/// <summary>Starts a listen: records it for signed-in users and issues the stream ticket.</summary>
|
||||
public record StartListeningCommand(Guid TrackId, long? UserId)
|
||||
: ICommand<ErrorOr<TrackStreamResponse>>;
|
||||
|
||||
@@ -31,6 +30,7 @@ namespace Musify.Application.Tracks
|
||||
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.VisibleTo(request.UserId)
|
||||
.Select(t => new { t.Id, t.Audio })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
|
||||
@@ -5,7 +5,6 @@ using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.UploadIntents
|
||||
{
|
||||
/// <summary>Deletes temporary upload objects that nobody claimed within the retention window.</summary>
|
||||
public record DeleteStaleTempUploadsCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleTempUploadsCommandHandler(
|
||||
|
||||
@@ -7,7 +7,6 @@ using Musify.Domain.ValueObjects;
|
||||
|
||||
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 class ExpireUploadIntentsCommandHandler(
|
||||
|
||||
@@ -38,7 +38,6 @@ namespace Musify.Application.Users
|
||||
FollowedId = request.FollowedId
|
||||
}, cancellationToken);
|
||||
|
||||
// A concurrent double click can hit the unique index; already following is a successful no-op.
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("UserFollow.Duplicate", "Already following this user."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
|
||||
@@ -22,6 +22,8 @@ namespace Musify.Domain.Entities
|
||||
|
||||
public int? ReleaseYear { get; set; }
|
||||
|
||||
public AlbumType Type { get; set; }
|
||||
|
||||
public EntityPictures? Pictures { get; set; }
|
||||
|
||||
public required long OwnerUserId { get; set; }
|
||||
|
||||
@@ -28,6 +28,8 @@ namespace Musify.Domain.Entities
|
||||
|
||||
public bool IsExplicit { get; set; }
|
||||
|
||||
public bool IsPublished { get; set; }
|
||||
|
||||
public DateTime CreatedAt { 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<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
|
||||
{
|
||||
/// <summary>The picture file names of an album or playlist. Resized names stay null until processing finishes.</summary>
|
||||
public class EntityPictures
|
||||
{
|
||||
[Required]
|
||||
|
||||
@@ -2,31 +2,32 @@ namespace Musify.Domain.ValueObjects
|
||||
{
|
||||
public enum Genre
|
||||
{
|
||||
Pop = 0,
|
||||
Rock = 1,
|
||||
HipHop = 2,
|
||||
RnB = 3,
|
||||
Jazz = 4,
|
||||
Blues = 5,
|
||||
Classical = 6,
|
||||
Electronic = 7,
|
||||
House = 8,
|
||||
Techno = 9,
|
||||
Trance = 10,
|
||||
Dubstep = 11,
|
||||
DrumAndBass = 12,
|
||||
Metal = 13,
|
||||
Punk = 14,
|
||||
Reggae = 15,
|
||||
Reggaeton = 16,
|
||||
Country = 17,
|
||||
Folk = 18,
|
||||
Indie = 19,
|
||||
KPop = 20,
|
||||
Latin = 21,
|
||||
Soul = 22,
|
||||
Funk = 23,
|
||||
Ambient = 24,
|
||||
Lofi = 25
|
||||
None = 0,
|
||||
Pop = 1,
|
||||
Rock = 2,
|
||||
HipHop = 3,
|
||||
RnB = 4,
|
||||
Jazz = 5,
|
||||
Blues = 6,
|
||||
Classical = 7,
|
||||
Electronic = 8,
|
||||
House = 9,
|
||||
Techno = 10,
|
||||
Trance = 11,
|
||||
Dubstep = 12,
|
||||
DrumAndBass = 13,
|
||||
Metal = 14,
|
||||
Punk = 15,
|
||||
Reggae = 16,
|
||||
Reggaeton = 17,
|
||||
Country = 18,
|
||||
Folk = 19,
|
||||
Indie = 20,
|
||||
KPop = 21,
|
||||
Latin = 22,
|
||||
Soul = 23,
|
||||
Funk = 24,
|
||||
Ambient = 25,
|
||||
Lofi = 26
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,7 +4,6 @@ using Musify.Application.Configuration;
|
||||
|
||||
namespace Musify.Infrastructure.Jobs
|
||||
{
|
||||
/// <summary>Registers every recurring Hangfire job when the host starts.</summary>
|
||||
public sealed class RecurringJobsRegistrar(
|
||||
IRecurringJobManager recurringJobManager,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
@@ -30,7 +29,6 @@ namespace Musify.Infrastructure.Jobs
|
||||
|
||||
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)
|
||||
{
|
||||
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
|
||||
{
|
||||
/// <summary>Shared bookkeeping of the audio processing activities.</summary>
|
||||
internal static class TrackAudioStatus
|
||||
{
|
||||
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
|
||||
{
|
||||
/// <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)
|
||||
: IExecuteActivity<MarkLifeCycleArguments>
|
||||
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)
|
||||
: IExecuteActivity<DeleteEntityArguments>
|
||||
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)
|
||||
: 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);
|
||||
|
||||
/// <summary>Stores the processed picture names on the owner and reports it; compensation restores the previous names.</summary>
|
||||
internal abstract class UpdatePicturesActivity<TEntity>(
|
||||
IDatabase database,
|
||||
IPublishEndpoint publishEndpoint,
|
||||
@@ -24,7 +23,6 @@ namespace Musify.Infrastructure.MassTransit.Activities.Pictures
|
||||
|
||||
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 void Write(TEntity entity, PictureNames names, bool succeeded);
|
||||
|
||||
+3
@@ -16,6 +16,8 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
||||
{
|
||||
var arguments = executeContext.Arguments;
|
||||
|
||||
if (arguments.FinalPictureKey != null)
|
||||
{
|
||||
await publishEndpoint.Publish(
|
||||
new UpdateTrackPictureEvent(
|
||||
arguments.TrackId,
|
||||
@@ -23,6 +25,7 @@ namespace Musify.Infrastructure.MassTransit.Activities.Tracks
|
||||
arguments.FinalPictureKey,
|
||||
arguments.Sizes),
|
||||
executeContext.CancellationToken);
|
||||
}
|
||||
|
||||
await publishEndpoint.Publish(
|
||||
new UpdateTrackAudioEvent(
|
||||
|
||||
+1
-1
@@ -5,7 +5,7 @@ namespace Musify.Infrastructure.MassTransit.Arguments
|
||||
public record PublishTrackProcessingEventsArguments(
|
||||
Guid TrackId,
|
||||
string Bucket,
|
||||
string FinalPictureKey,
|
||||
string? FinalPictureKey,
|
||||
string FinalAudioKey,
|
||||
string AudioDestinationFolderKey,
|
||||
ImageSizes Sizes);
|
||||
|
||||
@@ -7,7 +7,6 @@ namespace Musify.Infrastructure.MassTransit.Consumers
|
||||
{
|
||||
internal static class ProcessingFailedCleanup
|
||||
{
|
||||
/// <summary>Removes whatever files were left behind and marks the entity as failed.</summary>
|
||||
public static Task ExecuteAsync(
|
||||
IBus bus,
|
||||
Guid subjectId,
|
||||
|
||||
+18
-6
@@ -11,11 +11,21 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
{
|
||||
public RoutingSlipBuilder Build(CreateTrackResourcesEvent message, Guid? correlationId)
|
||||
{
|
||||
var builder = RoutingSlips.Create(correlationId)
|
||||
.AddStep(
|
||||
var builder = RoutingSlips.Create(correlationId);
|
||||
|
||||
var hasPicture = message.PictureIntentId != null
|
||||
&& message.PictureSourceKey != null
|
||||
&& message.PictureDestinationKey != null;
|
||||
|
||||
if (hasPicture)
|
||||
{
|
||||
builder.AddStep(
|
||||
ActivityNames.CopyPictureToFinal,
|
||||
CopyFileInBucketActivity.ExecuteEndpointName,
|
||||
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey, message.Bucket, message.PictureDestinationKey))
|
||||
new CopyFileInBucketArguments(message.Bucket, message.PictureSourceKey!, message.Bucket, message.PictureDestinationKey!));
|
||||
}
|
||||
|
||||
builder
|
||||
.AddStep(
|
||||
ActivityNames.CopyAudioToFinal,
|
||||
CopyFileInBucketActivity.ExecuteEndpointName,
|
||||
@@ -23,21 +33,23 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
.AddStep(
|
||||
ActivityNames.ConsumeUploadIntents,
|
||||
ConsumeUploadIntentsActivity.ExecuteEndpointName,
|
||||
new ConsumeUploadIntentsArguments([message.PictureIntentId, message.AudioIntentId]))
|
||||
new ConsumeUploadIntentsArguments(
|
||||
hasPicture ? [message.PictureIntentId!.Value, message.AudioIntentId] : [message.AudioIntentId]))
|
||||
.AddStep(
|
||||
ActivityNames.PublishTrackProcessingEvents,
|
||||
PublishTrackProcessingEventsActivity.ExecuteEndpointName,
|
||||
new PublishTrackProcessingEventsArguments(
|
||||
message.TrackId,
|
||||
message.Bucket,
|
||||
message.PictureDestinationKey,
|
||||
hasPicture ? message.PictureDestinationKey : null,
|
||||
message.AudioDestinationKey,
|
||||
message.AudioProcessedFolderKey,
|
||||
message.Sizes))
|
||||
.TrackFaults(message.TrackId, RoutingSlipVariableNames.ProcessKinds.TrackCreation);
|
||||
|
||||
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);
|
||||
|
||||
return builder;
|
||||
|
||||
+1
-3
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
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(
|
||||
IDatabase database,
|
||||
TrackConfiguration trackConfiguration,
|
||||
@@ -48,8 +47,7 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip.Builders
|
||||
routes.BuildOriginalPicturePath(userId, track.Pictures.OriginalName));
|
||||
}
|
||||
|
||||
// Unprocessed tracks still point at the shared preset pictures, which must never be deleted.
|
||||
if (track.Pictures.IsProcessed)
|
||||
if (track.Pictures.IsProcessed && !string.IsNullOrEmpty(track.Pictures.OriginalName))
|
||||
{
|
||||
builder.AddRemoveFile(ActivityNames.RemoveTrackSmallPicture, bucket, routes.BuildPicturePath(PictureSize.Small, track.Pictures.SmallName));
|
||||
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
|
||||
{
|
||||
/// <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 RoutingSlipBuilder Build(CreateAlbumResourcesEvent message, Guid? correlationId) =>
|
||||
|
||||
-1
@@ -8,7 +8,6 @@ using Musify.Infrastructure.MassTransit.Arguments;
|
||||
|
||||
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 RoutingSlipBuilder Build(UpdateTrackPictureEvent message, Guid? correlationId) =>
|
||||
|
||||
@@ -6,14 +6,12 @@ using Musify.Infrastructure.MassTransit.Consumers;
|
||||
|
||||
namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
||||
{
|
||||
/// <summary>Shared building blocks for every routing slip in the worker.</summary>
|
||||
internal static class RoutingSlips
|
||||
{
|
||||
public static Uri ExecuteUri(string endpointName) => new($"queue:{endpointName}_execute");
|
||||
|
||||
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)
|
||||
{
|
||||
var builder = new RoutingSlipBuilder(NewId.NextGuid());
|
||||
@@ -32,7 +30,6 @@ namespace Musify.Infrastructure.MassTransit.RoutingSlip
|
||||
return builder;
|
||||
}
|
||||
|
||||
/// <summary>Publishes a failure event for the subject when the slip faults.</summary>
|
||||
public static RoutingSlipBuilder TrackFaults(
|
||||
this RoutingSlipBuilder builder, Guid subjectId, string processKind)
|
||||
{
|
||||
|
||||
@@ -48,6 +48,7 @@ namespace Musify.Infrastructure.MassTransit.Sagas
|
||||
context.Saga.UpdatedAt = DateTime.UtcNow;
|
||||
context.Saga.Bucket = context.Message.Bucket;
|
||||
context.Saga.PictureKey = context.Message.PictureDestinationKey;
|
||||
context.Saga.PictureProcessed = context.Message.PictureDestinationKey == null;
|
||||
context.Saga.AudioKey = context.Message.AudioDestinationKey;
|
||||
})
|
||||
.TransitionTo(Processing));
|
||||
|
||||
@@ -59,6 +59,8 @@ namespace Musify.Infrastructure.Persistence
|
||||
|
||||
public DbSet<UserFollow> UserFollows => Set<UserFollow>();
|
||||
|
||||
public DbSet<TrackCoauthor> TrackCoauthors => Set<TrackCoauthor>();
|
||||
|
||||
public async Task<IDatabaseTransaction> BeginTransactionAsync(System.Data.IsolationLevel isolationLevel, CancellationToken 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("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);
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b =>
|
||||
@@ -224,6 +224,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasMaxLength(200)
|
||||
.HasColumnType("character varying(200)");
|
||||
|
||||
b.Property<int>("Type")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
@@ -435,6 +438,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.Property<bool>("IsExplicit")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsPublished")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<int>("LifeCycleStatus")
|
||||
.HasColumnType("integer");
|
||||
|
||||
@@ -461,6 +467,37 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
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 =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
@@ -983,6 +1020,25 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.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 =>
|
||||
{
|
||||
b.HasOne("Musify.Domain.Entities.Track", "Track")
|
||||
@@ -1062,6 +1118,8 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
b.Navigation("AlbumTracks");
|
||||
|
||||
b.Navigation("Coauthors");
|
||||
|
||||
b.Navigation("ListeningHistories");
|
||||
|
||||
b.Navigation("PlayListTracks");
|
||||
|
||||
@@ -5,9 +5,6 @@ using Musify.Infrastructure.MassTransit.Sagas;
|
||||
|
||||
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 void Apply(ModelBuilder modelBuilder)
|
||||
@@ -125,6 +122,25 @@ namespace Musify.Infrastructure.Persistence
|
||||
.HasIndex(trackTag => new { trackTag.TrackId, trackTag.Tag })
|
||||
.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>()
|
||||
.HasOne(follow => follow.Follower)
|
||||
.WithMany()
|
||||
|
||||
@@ -1,8 +1,12 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Api.DataTransferObjects.Albums
|
||||
{
|
||||
public record CreateAlbumRequest(
|
||||
string Title,
|
||||
string? Description,
|
||||
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
|
||||
{
|
||||
public record UpdateAlbumRequest(
|
||||
string Title,
|
||||
string? Description,
|
||||
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
|
||||
{
|
||||
public record UpdatePlayListRequest(
|
||||
string? Name,
|
||||
string Name,
|
||||
string? Description,
|
||||
Guid? PictureIntentId,
|
||||
PlayListVisibility? Visibility = null);
|
||||
PlayListVisibility? Visibility);
|
||||
}
|
||||
|
||||
@@ -4,7 +4,7 @@ namespace Musify.Api.DataTransferObjects.Tracks
|
||||
{
|
||||
public record CreateTrackRequest(
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid? PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
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