Separate responsibilities: start-listening command, API-side JSON converters, bulk track endpoints, mix kinds
This commit is contained in:
@@ -1,58 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
public record AddTrackToAlbumCommand(long UserId, Guid AlbumId, Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTrackToAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTrackToAlbumCommandHandler> logger)
|
||||
: ICommandHandler<AddTrackToAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (album.IsError)
|
||||
return album.Errors;
|
||||
|
||||
var track = await database.Tracks.FindOwnedAsync(request.TrackId, request.UserId, cancellationToken);
|
||||
if (track.IsError)
|
||||
return track.Errors;
|
||||
|
||||
var alreadyAdded = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(albumTrack => albumTrack.AlbumId == request.AlbumId && albumTrack.TrackId == request.TrackId, cancellationToken);
|
||||
if (alreadyAdded)
|
||||
return AlreadyInAlbum;
|
||||
|
||||
// Album track numbers are user-visible, so they start at 1.
|
||||
var nextTrackNumber = (await database.AlbumHasTracks
|
||||
.Where(albumTrack => albumTrack.AlbumId == request.AlbumId)
|
||||
.MaxAsync(albumTrack => (int?)albumTrack.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||
|
||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||
{
|
||||
AlbumId = request.AlbumId,
|
||||
TrackId = request.TrackId,
|
||||
TrackNumber = nextTrackNumber
|
||||
}, cancellationToken);
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(AlreadyInAlbum, cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added track {TrackId} to album {AlbumId} as number {TrackNumber}",
|
||||
request.TrackId, request.AlbumId, nextTrackNumber);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
private static Error AlreadyInAlbum => AppErrors.Conflict("Album.TrackAlreadyAdded", "Track is already in the album.");
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Albums;
|
||||
|
||||
/// <summary>Appends your own tracks to an album in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToAlbumCommand(long UserId, Guid AlbumId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTracksToAlbumCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTracksToAlbumCommandHandler> logger)
|
||||
: ICommandHandler<AddTracksToAlbumCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToAlbumCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var album = await database.Albums.FindOwnedAsync(request.AlbumId, request.UserId, cancellationToken);
|
||||
if (album.IsError)
|
||||
return album.Errors;
|
||||
|
||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||
|
||||
var tracks = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => new { track.Id, track.OwnerUserId })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missing = requestedIds.FirstOrDefault(id => tracks.All(track => track.Id != id));
|
||||
if (missing != Guid.Empty)
|
||||
return AppErrors.NotFound("Track", missing);
|
||||
|
||||
var foreign = tracks.FirstOrDefault(track => track.OwnerUserId != request.UserId);
|
||||
if (foreign != null)
|
||||
return AppErrors.Forbidden("Track", foreign.Id);
|
||||
|
||||
var alreadyInAlbum = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(link => link.AlbumId == request.AlbumId && requestedIds.Contains(link.TrackId))
|
||||
.Select(link => link.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var toAdd = requestedIds.Where(id => !alreadyInAlbum.Contains(id)).ToList();
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Album track numbers are user-visible, so they start at 1.
|
||||
var nextTrackNumber = (await database.AlbumHasTracks
|
||||
.Where(link => link.AlbumId == request.AlbumId)
|
||||
.MaxAsync(link => (int?)link.TrackNumber, cancellationToken) ?? 0) + 1;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
{
|
||||
await database.AlbumHasTracks.AddAsync(new AlbumHasTrack
|
||||
{
|
||||
AlbumId = request.AlbumId,
|
||||
TrackId = trackId,
|
||||
TrackNumber = nextTrackNumber++
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("Album.ConcurrentChange", "The album changed while adding tracks. Try again."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added {Count} tracks to album {AlbumId}", toAdd.Count, request.AlbumId);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Albums.Responses;
|
||||
@@ -9,7 +7,7 @@ public record AlbumApplicationResponse(
|
||||
string Title,
|
||||
string? Description,
|
||||
int? ReleaseYear,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
string? SmallImageKeyName,
|
||||
string? MediumImageKeyName,
|
||||
|
||||
@@ -16,8 +16,6 @@ public interface IDatabase
|
||||
|
||||
public DbSet<Track> Tracks { get; }
|
||||
|
||||
public DbSet<UserHasTrack> UserHasTracks { get; }
|
||||
|
||||
public DbSet<PlayListHasTrack> PlayListHasTracks { get; }
|
||||
|
||||
public DbSet<Mix> Mixes { get; }
|
||||
|
||||
@@ -15,7 +15,7 @@ public class GetGenresQueryHandler(IDatabase database)
|
||||
{
|
||||
var rows = await database.TrackTags
|
||||
.AsNoTracking()
|
||||
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active))
|
||||
.Where(tag => tag.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.GroupBy(tag => tag.Tag)
|
||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -18,16 +18,16 @@ public class GenerateMixesForUserCommandHandler(
|
||||
ILogger<GenerateMixesForUserCommandHandler> logger)
|
||||
: ICommandHandler<GenerateMixesForUserCommand, ErrorOr<Success>>
|
||||
{
|
||||
private sealed record MixDraft(string Title, string? Subtitle, IReadOnlyList<Guid> TrackIds);
|
||||
private sealed record MixDraft(MixKind Kind, IReadOnlyList<Guid> TrackIds);
|
||||
|
||||
public async ValueTask<ErrorOr<Success>> Handle(GenerateMixesForUserCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var userId = request.UserId;
|
||||
|
||||
var libraryTrackIds = await database.UserHasTracks
|
||||
var libraryTrackIds = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(userTrack => userTrack.UserId == userId && userTrack.Track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(userTrack => userTrack.TrackId)
|
||||
.Where(track => track.OwnerUserId == userId && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => track.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (libraryTrackIds.Count == 0)
|
||||
@@ -51,19 +51,13 @@ public class GenerateMixesForUserCommandHandler(
|
||||
var discovery = Take(Shuffle(unheardTrackIds));
|
||||
if (discovery.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(
|
||||
"Descubrimiento",
|
||||
"Canciones de tu biblioteca que todavía no has escuchado.",
|
||||
discovery));
|
||||
drafts.Add(new MixDraft(MixKind.Discovery, discovery));
|
||||
}
|
||||
|
||||
var daily = Take(Interleave(Shuffle(historyTrackIds), Shuffle(unheardTrackIds)));
|
||||
if (daily.Count > 0)
|
||||
{
|
||||
drafts.Add(new MixDraft(
|
||||
"Tu mezcla diaria",
|
||||
"Lo que más escuchas, con alguna sorpresa de tu biblioteca.",
|
||||
daily));
|
||||
drafts.Add(new MixDraft(MixKind.Daily, daily));
|
||||
}
|
||||
|
||||
if (drafts.Count == 0)
|
||||
@@ -108,12 +102,6 @@ public class GenerateMixesForUserCommandHandler(
|
||||
|
||||
if (previousMixes.Count > 0)
|
||||
{
|
||||
var previousIds = previousMixes.Select(mix => mix.Id).ToList();
|
||||
var previousItems = await database.MixItems
|
||||
.Where(item => previousIds.Contains(item.MixId))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
database.MixItems.RemoveRange(previousItems);
|
||||
database.Mixes.RemoveRange(previousMixes);
|
||||
}
|
||||
|
||||
@@ -123,8 +111,7 @@ public class GenerateMixesForUserCommandHandler(
|
||||
var mix = new Mix
|
||||
{
|
||||
UserId = userId,
|
||||
Title = draft.Title,
|
||||
Subtitle = draft.Subtitle,
|
||||
Kind = draft.Kind,
|
||||
Position = position
|
||||
};
|
||||
|
||||
|
||||
@@ -26,6 +26,6 @@ public class GetMixByIdQueryHandler(IDatabase database)
|
||||
var orderedItems = mix.Items.OrderBy(item => item.Position).ToList();
|
||||
var items = await MixItemMapper.ToResponsesAsync(database, orderedItems, cancellationToken);
|
||||
|
||||
return new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, items.Count, items);
|
||||
return new MixApplicationResponse(mix.Id, mix.Kind, items.Count, items);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -42,7 +42,7 @@ public class GetMixesByUserIdQueryHandler(IDatabase database)
|
||||
.Select(item => coverResponses[item.TrackId])
|
||||
.ToList();
|
||||
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Title, mix.Subtitle, orderedItems.Count, covers));
|
||||
responses.Add(new MixApplicationResponse(mix.Id, mix.Kind, orderedItems.Count, covers));
|
||||
}
|
||||
|
||||
return responses;
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Mixes.Responses;
|
||||
|
||||
public record MixApplicationResponse(
|
||||
Guid Id,
|
||||
string Title,
|
||||
string? Subtitle,
|
||||
MixKind Kind,
|
||||
int ItemCount,
|
||||
IReadOnlyList<MixItemApplicationResponse> Items)
|
||||
{
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
public record AddTrackToPlayListCommand(long UserId, Guid PlayListId, Guid TrackId)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTrackToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTrackToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddTrackToPlayListCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTrackToPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (playList.IsError)
|
||||
return playList.Errors;
|
||||
|
||||
var trackExists = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(t => t.Id == request.TrackId && t.LifeCycleStatus == LifeCycleStatus.Active, cancellationToken);
|
||||
if (!trackExists)
|
||||
return AppErrors.NotFound("Track", request.TrackId);
|
||||
|
||||
var alreadyAdded = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.AnyAsync(plt => plt.PlayListId == request.PlayListId && plt.TrackId == request.TrackId, cancellationToken);
|
||||
if (alreadyAdded)
|
||||
return AlreadyInPlayList;
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var nextPosition = (await database.PlayListHasTracks
|
||||
.Where(plt => plt.PlayListId == request.PlayListId)
|
||||
.MaxAsync(plt => (int?)plt.Position, cancellationToken) ?? -1) + 1;
|
||||
|
||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = request.TrackId,
|
||||
Position = nextPosition
|
||||
}, cancellationToken);
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(AlreadyInPlayList, cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added track {TrackId} to playlist {PlayListId} at position {Position}",
|
||||
request.TrackId, request.PlayListId, nextPosition);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
|
||||
private static Error AlreadyInPlayList => AppErrors.Conflict("PlayList.TrackAlreadyAdded", "Track is already in the playlist.");
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Shared;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.PlayLists;
|
||||
|
||||
/// <summary>Appends tracks to a playlist in the given order, skipping the ones it already has.</summary>
|
||||
public record AddTracksToPlayListCommand(long UserId, Guid PlayListId, IReadOnlyList<Guid> TrackIds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class AddTracksToPlayListCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<AddTracksToPlayListCommandHandler> logger)
|
||||
: ICommandHandler<AddTracksToPlayListCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(AddTracksToPlayListCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var playList = await database.PlayLists.FindOwnedAsync(request.PlayListId, request.UserId, cancellationToken);
|
||||
if (playList.IsError)
|
||||
return playList.Errors;
|
||||
|
||||
var requestedIds = request.TrackIds.Distinct().ToList();
|
||||
|
||||
var existingIds = await database.Tracks
|
||||
.AsNoTracking()
|
||||
.Where(track => requestedIds.Contains(track.Id) && track.LifeCycleStatus == LifeCycleStatus.Active)
|
||||
.Select(track => track.Id)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var missing = requestedIds.FirstOrDefault(id => !existingIds.Contains(id));
|
||||
if (missing != Guid.Empty)
|
||||
return AppErrors.NotFound("Track", missing);
|
||||
|
||||
var alreadyInPlayList = await database.PlayListHasTracks
|
||||
.AsNoTracking()
|
||||
.Where(link => link.PlayListId == request.PlayListId && requestedIds.Contains(link.TrackId))
|
||||
.Select(link => link.TrackId)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var toAdd = requestedIds.Where(id => !alreadyInPlayList.Contains(id)).ToList();
|
||||
if (toAdd.Count == 0)
|
||||
return Result.Success;
|
||||
|
||||
// Playlist positions are internal indexes, so they start at 0.
|
||||
var nextPosition = (await database.PlayListHasTracks
|
||||
.Where(link => link.PlayListId == request.PlayListId)
|
||||
.MaxAsync(link => (int?)link.Position, cancellationToken) ?? -1) + 1;
|
||||
|
||||
foreach (var trackId in toAdd)
|
||||
{
|
||||
await database.PlayListHasTracks.AddAsync(new PlayListHasTrack
|
||||
{
|
||||
PlayListId = request.PlayListId,
|
||||
TrackId = trackId,
|
||||
Position = nextPosition++
|
||||
}, cancellationToken);
|
||||
}
|
||||
|
||||
var saved = await database.TrySaveChangesAsync(
|
||||
AppErrors.Conflict("PlayList.ConcurrentChange", "The playlist changed while adding tracks. Try again."), cancellationToken);
|
||||
if (saved.IsError)
|
||||
return saved;
|
||||
|
||||
logger.LogInformation("Added {Count} tracks to playlist {PlayListId}", toAdd.Count, request.PlayListId);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
|
||||
namespace Musify.Application.PlayLists.Responses;
|
||||
|
||||
@@ -17,7 +15,7 @@ public record PlayListApplicationResponse(
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
IReadOnlyList<Guid> CoverTrackIds,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
long OwnerUserId,
|
||||
int TrackCount,
|
||||
double DurationSeconds)
|
||||
{
|
||||
|
||||
@@ -1,37 +0,0 @@
|
||||
using System.Globalization;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
|
||||
namespace Musify.Application.Serialization;
|
||||
|
||||
public sealed class LongAsStringConverter : JsonConverter<long>
|
||||
{
|
||||
public override long Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options) =>
|
||||
reader.TokenType == JsonTokenType.String
|
||||
? long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
||||
: reader.GetInt64();
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long value, JsonSerializerOptions options) =>
|
||||
writer.WriteStringValue(value.ToString(CultureInfo.InvariantCulture));
|
||||
}
|
||||
|
||||
public sealed class NullableLongAsStringConverter : JsonConverter<long?>
|
||||
{
|
||||
public override long? Read(ref Utf8JsonReader reader, Type typeToConvert, JsonSerializerOptions options)
|
||||
{
|
||||
if (reader.TokenType == JsonTokenType.Null)
|
||||
return null;
|
||||
|
||||
return reader.TokenType == JsonTokenType.String
|
||||
? string.IsNullOrEmpty(reader.GetString()) ? null : long.Parse(reader.GetString()!, CultureInfo.InvariantCulture)
|
||||
: reader.GetInt64();
|
||||
}
|
||||
|
||||
public override void Write(Utf8JsonWriter writer, long? value, JsonSerializerOptions options)
|
||||
{
|
||||
if (value.HasValue)
|
||||
writer.WriteStringValue(value.Value.ToString(CultureInfo.InvariantCulture));
|
||||
else
|
||||
writer.WriteNullValue();
|
||||
}
|
||||
}
|
||||
@@ -1,28 +1,20 @@
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
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(
|
||||
IDatabase database,
|
||||
IStreamTicketService ticketService,
|
||||
TrackConfiguration trackConfiguration,
|
||||
StreamGatewayConfiguration streamGatewayConfiguration,
|
||||
PlaybackConfiguration playbackConfiguration)
|
||||
{
|
||||
public async Task<TrackStreamResponse> IssueAsync(
|
||||
Guid trackId,
|
||||
string audioFolderName,
|
||||
long? userId,
|
||||
CancellationToken cancellationToken)
|
||||
public TrackStreamResponse Issue(string audioFolderName, long? userId, Guid? listenId)
|
||||
{
|
||||
var folderPath = trackConfiguration.Routes.BuildProcessedAudioPath(audioFolderName);
|
||||
var keyPrefix = $"{folderPath}/";
|
||||
|
||||
var maxBytes = AnonymousFragmentBytes(userId);
|
||||
var ticket = ticketService.IssueTicket(userId, keyPrefix, maxBytes);
|
||||
var ticket = ticketService.IssueTicket(userId, $"{folderPath}/", AnonymousFragmentBytes(userId));
|
||||
|
||||
var manifestUrl = string.Join('/',
|
||||
streamGatewayConfiguration.PublicBaseUrl.TrimEnd('/'),
|
||||
@@ -30,19 +22,6 @@ public sealed class TrackStreamIssuer(
|
||||
folderPath,
|
||||
streamGatewayConfiguration.AudioFileName);
|
||||
|
||||
Guid? listenId = null;
|
||||
if (userId != null)
|
||||
{
|
||||
var newListeningHistory = new ListeningHistory
|
||||
{
|
||||
UserId = userId.Value,
|
||||
TrackId = trackId,
|
||||
};
|
||||
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
listenId = newListeningHistory.Id;
|
||||
}
|
||||
|
||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
||||
}
|
||||
|
||||
|
||||
@@ -41,21 +41,6 @@ public class CreateTrackCommandHandler(
|
||||
}
|
||||
|
||||
var distinctTags = request.Tags.Distinct().ToList();
|
||||
if (distinctTags.Count == 0)
|
||||
{
|
||||
logger.LogWarning("Track {Title} was submitted without any tags", request.Title);
|
||||
return Error.Validation(description: "At least one tag is required.");
|
||||
}
|
||||
|
||||
var tagConflicts = GenreCompatibility.FindConflicts(distinctTags);
|
||||
if (tagConflicts.Count > 0)
|
||||
{
|
||||
var conflict = tagConflicts.First();
|
||||
logger.LogWarning(
|
||||
"Track {Title} was submitted with incompatible tags {First} and {Second}",
|
||||
request.Title, conflict.First, conflict.Second);
|
||||
return Error.Validation(description: $"Tags '{conflict.First}' and '{conflict.Second}' are not compatible.");
|
||||
}
|
||||
|
||||
if (request.PictureIntentId == request.AudioIntentId)
|
||||
return Error.Validation(description: "Picture and audio must use different upload intents.");
|
||||
@@ -105,11 +90,6 @@ public class CreateTrackCommandHandler(
|
||||
|
||||
await database.Tracks.AddAsync(trackEntity, cancellationToken);
|
||||
|
||||
await database.UserHasTracks.AddAsync(new UserHasTrack
|
||||
{
|
||||
UserId = request.UserId,
|
||||
TrackId = trackEntity.Id
|
||||
}, cancellationToken);
|
||||
|
||||
var sizes = trackConfiguration.PicturesSizes.ToImageSizes(trackConfiguration.Routes);
|
||||
await eventBus.PublishAsync(
|
||||
|
||||
@@ -17,9 +17,6 @@ public class RecordListeningProgressCommandHandler(IDatabase database)
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
|
||||
return Error.Validation(description: "playedSeconds must be a non-negative number.");
|
||||
|
||||
var listen = await database.ListeningHistories
|
||||
.Include(l => l.Track)
|
||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||
|
||||
@@ -1,5 +1,3 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
@@ -14,7 +12,7 @@ public record TrackApplicationResponse(
|
||||
int ListensCount,
|
||||
DateTime CreatedAt,
|
||||
DateTime UpdatedAt,
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
|
||||
long OwnerUserId,
|
||||
IReadOnlyCollection<Genre> Tags,
|
||||
bool IsExplicit)
|
||||
{
|
||||
|
||||
+19
-11
@@ -5,24 +5,25 @@ using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Configuration;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Application.Services;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Shared;
|
||||
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 record GetTrackStreamQuery(Guid TrackId, long? UserId)
|
||||
: IQuery<ErrorOr<TrackStreamResponse>>;
|
||||
|
||||
public class GetTrackStreamQueryHandler(
|
||||
public class StartListeningCommandHandler(
|
||||
IDatabase database,
|
||||
TrackStreamIssuer streamIssuer,
|
||||
PlaybackConfiguration playbackConfiguration,
|
||||
ILogger<GetTrackStreamQueryHandler> logger)
|
||||
: IQueryHandler<GetTrackStreamQuery, ErrorOr<TrackStreamResponse>>
|
||||
ILogger<StartListeningCommandHandler> logger)
|
||||
: ICommandHandler<StartListeningCommand, ErrorOr<TrackStreamResponse>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(GetTrackStreamQuery request, CancellationToken cancellationToken)
|
||||
public async ValueTask<ErrorOr<TrackStreamResponse>> Handle(StartListeningCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (request.UserId == null && !playbackConfiguration.AllowAnonymousListening)
|
||||
return Error.Unauthorized(description: "Sign in to stream music, or ask an administrator to enable anonymous listening.");
|
||||
@@ -38,10 +39,17 @@ public class GetTrackStreamQueryHandler(
|
||||
if (!track.Audio.IsProcessed)
|
||||
return AppErrors.Conflict("Track.AudioNotReady", "Track audio is not available for streaming yet.");
|
||||
|
||||
var response = await streamIssuer.IssueAsync(track.Id, track.Audio.FolderName, request.UserId, cancellationToken);
|
||||
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() ?? "(anonymous)");
|
||||
|
||||
return response;
|
||||
return streamIssuer.Issue(track.Audio.FolderName, request.UserId, listenId);
|
||||
}
|
||||
}
|
||||
@@ -1,11 +1,9 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
using Musify.Domain.Entities;
|
||||
|
||||
namespace Musify.Application.Users.Responses;
|
||||
|
||||
public record UserApplicationResponse(
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||
long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
|
||||
namespace Musify.Application.Users.Responses;
|
||||
|
||||
public record UserProfileResponse(
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||
long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName,
|
||||
|
||||
@@ -1,10 +1,8 @@
|
||||
using System.Text.Json.Serialization;
|
||||
using Musify.Application.Serialization;
|
||||
|
||||
namespace Musify.Application.Users.Responses;
|
||||
|
||||
public record UserSummaryResponse(
|
||||
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
|
||||
long Id,
|
||||
string Name,
|
||||
string? FirstName,
|
||||
string? SecondName,
|
||||
|
||||
Reference in New Issue
Block a user