Change editor rule
This commit is contained in:
@@ -11,103 +11,104 @@ using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record CreateTrackCommand(
|
||||
long UserId,
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false)
|
||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class CreateTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<CreateTrackCommandHandler> logger,
|
||||
TrackConfiguration trackConfiguration)
|
||||
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||
public record CreateTrackCommand(
|
||||
long UserId,
|
||||
string Title,
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit = false)
|
||||
: ICommand<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class CreateTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
UploadIntentValidator uploadIntentValidator,
|
||||
ILogger<CreateTrackCommandHandler> logger,
|
||||
TrackConfiguration trackConfiguration)
|
||||
: ICommandHandler<CreateTrackCommand, ErrorOr<TrackApplicationResponse>>
|
||||
{
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(CreateTrackCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
|
||||
var distinctTags = request.Tags.Distinct().ToList();
|
||||
|
||||
if (request.PictureIntentId == request.AudioIntentId)
|
||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
if (pictureValidation.IsError)
|
||||
return pictureValidation.Errors;
|
||||
|
||||
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 finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
var trackEntity = new Track
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||
OwnerUserId = user.Id,
|
||||
Owner = user,
|
||||
IsExplicit = request.IsExplicit,
|
||||
Pictures = new TrackPictures
|
||||
var user = await database.Users
|
||||
.SingleOrDefaultAsync(u => u.Id == request.UserId, cancellationToken);
|
||||
if (user == null)
|
||||
{
|
||||
OriginalName = pictureIntent.ObjectName,
|
||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
},
|
||||
Audio = new TrackAudio
|
||||
{
|
||||
OriginalName = audioIntent.ObjectName,
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
logger.LogWarning("User {UserId} not found", request.UserId);
|
||||
return Error.NotFound(description: $"User {request.UserId} not found");
|
||||
}
|
||||
};
|
||||
|
||||
trackEntity.Tags = distinctTags
|
||||
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
||||
.ToList();
|
||||
var distinctTags = request.Tags.Distinct().ToList();
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
if (request.PictureIntentId == request.AudioIntentId)
|
||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||
|
||||
var pictureValidation = await uploadIntentValidator.ValidateAndLoadAsync(
|
||||
request.PictureIntentId, request.UserId, UploadIntentPurpose.TrackPicture, cancellationToken);
|
||||
if (pictureValidation.IsError)
|
||||
return pictureValidation.Errors;
|
||||
|
||||
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 finalAudioKey = trackConfiguration.Routes.BuildOriginalAudioPath(request.UserId, audioIntent.ObjectName);
|
||||
var audioProcessedFolderKey = trackConfiguration.Routes.BuildProcessedAudioPath(Guid.NewGuid().ToString());
|
||||
|
||||
var trackEntity = new Track
|
||||
{
|
||||
Title = request.Title.Trim(),
|
||||
NormalizedTitle = TextNormalizer.Normalize(request.Title),
|
||||
OwnerUserId = user.Id,
|
||||
Owner = user,
|
||||
IsExplicit = request.IsExplicit,
|
||||
Pictures = new TrackPictures
|
||||
{
|
||||
OriginalName = pictureIntent.ObjectName,
|
||||
SmallName = trackConfiguration.Routes.PresetSmallPicture,
|
||||
MediumName = trackConfiguration.Routes.PresetMediumPicture,
|
||||
LargeName = trackConfiguration.Routes.PresetLargePicture,
|
||||
ProcessingStatus = ProcessingStatus.Pending
|
||||
},
|
||||
Audio = new TrackAudio
|
||||
{
|
||||
OriginalName = audioIntent.ObjectName,
|
||||
TranscodeStatus = ProcessingStatus.Pending
|
||||
}
|
||||
};
|
||||
|
||||
trackEntity.Tags = distinctTags
|
||||
.Select(tag => new TrackTag { TrackId = trackEntity.Id, Tag = tag })
|
||||
.ToList();
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
|
||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||
await eventBus.PublishAsync(
|
||||
new CreateTrackResourcesEvent(
|
||||
trackEntity.Id,
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
pictureIntent.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
audioIntent.Key,
|
||||
finalAudioKey,
|
||||
audioProcessedFolderKey,
|
||||
sizes),
|
||||
cancellationToken);
|
||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||
await eventBus.PublishAsync(
|
||||
new CreateTrackResourcesEvent(
|
||||
trackEntity.Id,
|
||||
pictureIntent.Id,
|
||||
audioIntent.Id,
|
||||
pictureIntent.Bucket,
|
||||
pictureIntent.Key,
|
||||
finalPictureKey,
|
||||
audioIntent.Key,
|
||||
finalAudioKey,
|
||||
audioProcessedFolderKey,
|
||||
sizes),
|
||||
cancellationToken);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
||||
return TrackApplicationResponse.FromEntity(trackEntity, listensCount: 0);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,30 +3,31 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record DeleteStaleUncountedListensCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleUncountedListensCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
|
||||
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
|
||||
public record DeleteStaleUncountedListensCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleUncountedListensCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
|
||||
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
|
||||
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
|
||||
|
||||
var stale = await database.ListeningHistories
|
||||
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
var stale = await database.ListeningHistories
|
||||
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (stale.Count == 0)
|
||||
return 0;
|
||||
if (stale.Count == 0)
|
||||
return 0;
|
||||
|
||||
database.ListeningHistories.RemoveRange(stale);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
database.ListeningHistories.RemoveRange(stale);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
|
||||
return stale.Count;
|
||||
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
|
||||
return stale.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,36 +6,37 @@ using Musify.Application.Events;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record DeleteTrackCommand(
|
||||
long UserId,
|
||||
Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class DeleteTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeleteTrackCommandHandler> logger)
|
||||
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand request, CancellationToken cancellationToken)
|
||||
public record DeleteTrackCommand(
|
||||
long UserId,
|
||||
Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class DeleteTrackCommandHandler(
|
||||
IDatabase database,
|
||||
IEventBus eventBus,
|
||||
ILogger<DeleteTrackCommandHandler> logger)
|
||||
: ICommandHandler<DeleteTrackCommand, ErrorOr<Success>>
|
||||
{
|
||||
var found = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||
if (found.IsError)
|
||||
return found.Errors;
|
||||
public async ValueTask<ErrorOr<Success>> Handle(DeleteTrackCommand 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 (track.Audio.IsInProgress)
|
||||
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||
var track = found.Value;
|
||||
if (track.Audio.IsInProgress)
|
||||
return AppErrors.Conflict("Track.Processing", "Track is currently being processed and cannot be deleted.");
|
||||
|
||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
track.LifeCycleStatus = LifeCycleStatus.Removing;
|
||||
|
||||
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await eventBus.PublishAsync(new DeleteTrackEvent(track.Id, request.UserId), cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
||||
logger.LogInformation("Marked track {TrackId} as removing", request.TrackId);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,23 +4,24 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
||||
|
||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||
public record GetLastTrackListenedByUserIdQuery(long UserId) : IQuery<TrackApplicationResponse?>;
|
||||
|
||||
public class GetLastTrackListenedByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetLastTrackListenedByUserIdQuery, TrackApplicationResponse?>
|
||||
{
|
||||
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)
|
||||
.OrderByDescending(t => t.ListenedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
public async ValueTask<TrackApplicationResponse?> Handle(GetLastTrackListenedByUserIdQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
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)
|
||||
.OrderByDescending(t => t.ListenedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,37 +3,38 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||
|
||||
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public const int ListSize = 15;
|
||||
public record GetListeningHistoryQuery(long UserId) : IQuery<IReadOnlyList<TrackApplicationResponse>>;
|
||||
|
||||
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||
public class GetListeningHistoryQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetListeningHistoryQuery, IReadOnlyList<TrackApplicationResponse>>
|
||||
{
|
||||
var recentTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
||||
.GroupBy(l => l.TrackId)
|
||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||
.ThenBy(g => g.Key)
|
||||
.Select(g => g.Key)
|
||||
.Take(ListSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
public const int ListSize = 15;
|
||||
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
public async ValueTask<IReadOnlyList<TrackApplicationResponse>> Handle(GetListeningHistoryQuery query, CancellationToken cancellationToken)
|
||||
{
|
||||
var recentTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
||||
.GroupBy(l => l.TrackId)
|
||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||
.ThenBy(g => g.Key)
|
||||
.Select(g => g.Key)
|
||||
.Take(ListSize)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return recentTrackIds
|
||||
.Where(id => tracksById.ContainsKey(id))
|
||||
.Select(id => tracksById[id])
|
||||
.ToList();
|
||||
var tracksById = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.SelectResponse()
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
return recentTrackIds
|
||||
.Where(id => tracksById.ContainsKey(id))
|
||||
.Select(id => tracksById[id])
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,25 +5,26 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetTrackByIdQuery(Guid TrackId)
|
||||
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
public class GetTrackByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
public record GetTrackByIdQuery(Guid TrackId)
|
||||
: IQuery<ErrorOr<TrackApplicationResponse>>;
|
||||
|
||||
return track == null
|
||||
? AppErrors.NotFound("Track", request.TrackId)
|
||||
: track;
|
||||
public class GetTrackByIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTrackByIdQuery, ErrorOr<TrackApplicationResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackApplicationResponse>> Handle(GetTrackByIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var track = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.SelectResponse()
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
return track == null
|
||||
? AppErrors.NotFound("Track", request.TrackId)
|
||||
: track;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,33 +5,34 @@ using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||
: IQuery<PaginatedResponse<TrackApplicationResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksQuery, PaginatedResponse<TrackApplicationResponse>>
|
||||
{
|
||||
var tracksQuery = database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
public async ValueTask<PaginatedResponse<TrackApplicationResponse>> Handle(GetTracksQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||
var tracksQuery = database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active();
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
if (request.Genre is { } genre)
|
||||
tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre));
|
||||
|
||||
return await tracksQuery
|
||||
.OrderByDescending(track => track.CreatedAt)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
|
||||
if (request.Genre is { } genre)
|
||||
tracksQuery = tracksQuery.Where(track => track.Tags.Any(tag => tag.Tag == genre));
|
||||
|
||||
return await tracksQuery
|
||||
.OrderByDescending(track => track.CreatedAt)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,35 +5,36 @@ using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record GetTracksByUserIdQuery(
|
||||
long UserId,
|
||||
string? Name,
|
||||
int PageNumber,
|
||||
int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
|
||||
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||
public record GetTracksByUserIdQuery(
|
||||
long UserId,
|
||||
string? Name,
|
||||
int PageNumber,
|
||||
int PageSize)
|
||||
: IQuery<ErrorOr<PaginatedResponse<TrackApplicationResponse>>>;
|
||||
|
||||
public class GetTracksByUserIdQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetTracksByUserIdQuery, ErrorOr<PaginatedResponse<TrackApplicationResponse>>>
|
||||
{
|
||||
var tracksQuery = database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(track => track.OwnerUserId == request.UserId);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
public async ValueTask<ErrorOr<PaginatedResponse<TrackApplicationResponse>>> Handle(GetTracksByUserIdQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
var tracksQuery = database.Tracks
|
||||
.AsNoTracking()
|
||||
.Active()
|
||||
.Where(track => track.OwnerUserId == request.UserId);
|
||||
|
||||
return await tracksQuery
|
||||
.OrderByDescending(track => track.CreatedAt)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
if (!string.IsNullOrWhiteSpace(request.Name))
|
||||
{
|
||||
var normalizedName = TextNormalizer.Normalize(request.Name);
|
||||
tracksQuery = tracksQuery.Where(track => track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
return await tracksQuery
|
||||
.OrderByDescending(track => track.CreatedAt)
|
||||
.ThenBy(track => track.Id)
|
||||
.SelectResponse()
|
||||
.ToPaginatedAsync(new PageRequest(request.PageNumber, request.PageSize), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public static class ListeningRules
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public const double CountedThresholdSeconds = 30;
|
||||
public const double CountedThresholdFraction = 0.5;
|
||||
public const double DurationToleranceSeconds = 2;
|
||||
public const double ClockToleranceSeconds = 5;
|
||||
public const int UncountedRetentionDays = 8;
|
||||
public static class ListeningRules
|
||||
{
|
||||
public const double CountedThresholdSeconds = 30;
|
||||
public const double CountedThresholdFraction = 0.5;
|
||||
public const double DurationToleranceSeconds = 2;
|
||||
public const double ClockToleranceSeconds = 5;
|
||||
public const int UncountedRetentionDays = 8;
|
||||
|
||||
public static double CountedThreshold(double durationSeconds) =>
|
||||
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
|
||||
public static double CountedThreshold(double durationSeconds) =>
|
||||
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,38 +4,39 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record RecordListeningProgressCommand(
|
||||
long UserId,
|
||||
Guid ListenId,
|
||||
double PlayedSeconds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class RecordListeningProgressCommandHandler(IDatabase database)
|
||||
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
||||
public record RecordListeningProgressCommand(
|
||||
long UserId,
|
||||
Guid ListenId,
|
||||
double PlayedSeconds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class RecordListeningProgressCommandHandler(IDatabase database)
|
||||
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
|
||||
{
|
||||
var listen = await database.ListeningHistories
|
||||
.Include(l => l.Track)
|
||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var listen = await database.ListeningHistories
|
||||
.Include(l => l.Track)
|
||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||
|
||||
if (listen == null)
|
||||
return AppErrors.NotFound("Listen", request.ListenId);
|
||||
if (listen == null)
|
||||
return AppErrors.NotFound("Listen", request.ListenId);
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
||||
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
|
||||
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
|
||||
var now = DateTime.UtcNow;
|
||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
||||
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
|
||||
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
|
||||
|
||||
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
|
||||
listen.LastProgressAt = now;
|
||||
listen.IsCounted = listen.IsCounted
|
||||
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
|
||||
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
|
||||
listen.LastProgressAt = now;
|
||||
listen.IsCounted = listen.IsCounted
|
||||
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success;
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,53 +5,54 @@ using Musify.Application.Services;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks;
|
||||
|
||||
public record RequestTrackUploadUrlsCommand(
|
||||
long UserId,
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null)
|
||||
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
|
||||
|
||||
public class RequestTrackUploadUrlsCommandHandler(
|
||||
UploadIntentIssuer issuer,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
||||
public record RequestTrackUploadUrlsCommand(
|
||||
long UserId,
|
||||
string PictureFileType,
|
||||
string PictureContentType,
|
||||
string AudioFileType,
|
||||
string AudioContentType,
|
||||
long? ExpectedPictureSizeBytes = null,
|
||||
long? ExpectedAudioSizeBytes = null)
|
||||
: ICommand<ErrorOr<TrackUploadUrlsResponse>>;
|
||||
|
||||
public class RequestTrackUploadUrlsCommandHandler(
|
||||
UploadIntentIssuer issuer,
|
||||
ApplicationStorageConfiguration storageConfiguration,
|
||||
TrackConfiguration trackConfiguration,
|
||||
UploadIntentConfiguration uploadIntentConfiguration)
|
||||
: ICommandHandler<RequestTrackUploadUrlsCommand, ErrorOr<TrackUploadUrlsResponse>>
|
||||
{
|
||||
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);
|
||||
if (issued.IsError)
|
||||
return issued.Errors;
|
||||
public async ValueTask<ErrorOr<TrackUploadUrlsResponse>> Handle(RequestTrackUploadUrlsCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
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);
|
||||
if (issued.IsError)
|
||||
return issued.Errors;
|
||||
|
||||
var picture = issued.Value[0];
|
||||
var audio = issued.Value[1];
|
||||
var picture = issued.Value[0];
|
||||
var audio = issued.Value[1];
|
||||
|
||||
return new TrackUploadUrlsResponse(
|
||||
picture.IntentId,
|
||||
audio.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
picture.Key,
|
||||
picture.ObjectName,
|
||||
picture.ContentType,
|
||||
picture.UploadUrl,
|
||||
audio.Key,
|
||||
audio.ObjectName,
|
||||
audio.ContentType,
|
||||
audio.UploadUrl,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
return new TrackUploadUrlsResponse(
|
||||
picture.IntentId,
|
||||
audio.IntentId,
|
||||
storageConfiguration.Bucket,
|
||||
picture.Key,
|
||||
picture.ObjectName,
|
||||
picture.ContentType,
|
||||
picture.UploadUrl,
|
||||
audio.Key,
|
||||
audio.ObjectName,
|
||||
audio.ContentType,
|
||||
audio.UploadUrl,
|
||||
uploadIntentConfiguration.UploadUrlExpiresInSeconds);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,32 +1,33 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
public record TrackApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Artist,
|
||||
ProcessingStatus AudioStatus,
|
||||
double Duration,
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
long OwnerUserId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit)
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||
new(
|
||||
track.Id,
|
||||
track.Title,
|
||||
track.Owner.Name,
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
listensCount,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId,
|
||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||
track.IsExplicit);
|
||||
public record TrackApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Artist,
|
||||
ProcessingStatus AudioStatus,
|
||||
double Duration,
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
long OwnerUserId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit)
|
||||
{
|
||||
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
|
||||
new(
|
||||
track.Id,
|
||||
track.Title,
|
||||
track.Owner.Name,
|
||||
track.Audio.TranscodeStatus,
|
||||
track.DurationSeconds,
|
||||
listensCount,
|
||||
track.CreatedAt,
|
||||
track.UpdatedAt,
|
||||
track.OwnerUserId,
|
||||
track.Tags.Select(tag => tag.Tag).ToList(),
|
||||
track.IsExplicit);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,24 +1,25 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
public static class TrackProjections
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
|
||||
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
public static class TrackProjections
|
||||
{
|
||||
public static IQueryable<Track> Active(this IQueryable<Track> query) =>
|
||||
query.Where(track => track.LifeCycleStatus == LifeCycleStatus.Active);
|
||||
|
||||
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
|
||||
query.Select(track => new TrackApplicationResponse(
|
||||
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));
|
||||
public static IQueryable<TrackApplicationResponse> SelectResponse(this IQueryable<Track> query) =>
|
||||
query.Select(track => new TrackApplicationResponse(
|
||||
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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
public record TrackStreamResponse(
|
||||
string ManifestUrl,
|
||||
string Ticket,
|
||||
int ExpiresInSeconds,
|
||||
Guid? ListenId = null);
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackStreamResponse(
|
||||
string ManifestUrl,
|
||||
string Ticket,
|
||||
int ExpiresInSeconds,
|
||||
Guid? ListenId = null);
|
||||
}
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
namespace Musify.Application.Tracks.Responses;
|
||||
|
||||
public record TrackUploadUrlsResponse(
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureKey,
|
||||
string PictureName,
|
||||
string PictureContentType,
|
||||
string PictureUploadUrl,
|
||||
string AudioKey,
|
||||
string AudioName,
|
||||
string AudioContentType,
|
||||
string AudioUploadUrl,
|
||||
int ExpiresInSeconds);
|
||||
namespace Musify.Application.Tracks.Responses
|
||||
{
|
||||
public record TrackUploadUrlsResponse(
|
||||
Guid PictureIntentId,
|
||||
Guid AudioIntentId,
|
||||
string Bucket,
|
||||
string PictureKey,
|
||||
string PictureName,
|
||||
string PictureContentType,
|
||||
string PictureUploadUrl,
|
||||
string AudioKey,
|
||||
string AudioName,
|
||||
string AudioContentType,
|
||||
string AudioUploadUrl,
|
||||
int ExpiresInSeconds);
|
||||
}
|
||||
|
||||
@@ -11,46 +11,47 @@ using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
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>>;
|
||||
|
||||
public class StartListeningCommandHandler(
|
||||
IDatabase database,
|
||||
TrackStreamIssuer streamIssuer,
|
||||
PlaybackConfiguration playbackConfiguration,
|
||||
ILogger<StartListeningCommandHandler> logger)
|
||||
: ICommandHandler<StartListeningCommand, ErrorOr<TrackStreamResponse>>
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(StartListeningCommand request, CancellationToken cancellationToken)
|
||||
/// <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>>;
|
||||
|
||||
public class StartListeningCommandHandler(
|
||||
IDatabase database,
|
||||
TrackStreamIssuer streamIssuer,
|
||||
PlaybackConfiguration playbackConfiguration,
|
||||
ILogger<StartListeningCommandHandler> logger)
|
||||
: ICommandHandler<StartListeningCommand, ErrorOr<TrackStreamResponse>>
|
||||
{
|
||||
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
|
||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new { t.Id, t.Audio })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (track == null)
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
if (!track.Audio.IsProcessed)
|
||||
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
|
||||
|
||||
Guid? listenId = null;
|
||||
if (request.UserId is { } userId)
|
||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(StartListeningCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var listen = new ListeningHistory { UserId = userId, TrackId = track.Id };
|
||||
await database.ListeningHistories.AddAsync(listen, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
listenId = listen.Id;
|
||||
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
|
||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||
|
||||
var track = await database.Tracks.AsNoTracking()
|
||||
.Where(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(t => new { t.Id, t.Audio })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (track == null)
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
if (!track.Audio.IsProcessed)
|
||||
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
|
||||
|
||||
Guid? listenId = null;
|
||||
if (request.UserId is { } userId)
|
||||
{
|
||||
var listen = new ListeningHistory { UserId = userId, TrackId = track.Id };
|
||||
await database.ListeningHistories.AddAsync(listen, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
listenId = listen.Id;
|
||||
}
|
||||
|
||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)");
|
||||
|
||||
return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId);
|
||||
}
|
||||
|
||||
logger.LogInformation("Issued stream ticket for track {TrackId} to user {UserId}", request.TrackId, request.UserId?.ToString(CultureInfo.InvariantCulture) ?? "(anonymous)");
|
||||
|
||||
return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user