diff --git a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs index 9d80336..fbee9b8 100644 --- a/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs +++ b/backend/Core/Musify.Application/Albums/GetAlbumTracks.cs @@ -36,7 +36,7 @@ namespace Musify.Application.Albums var pagedEntities = await tracksQuery .OrderBy(albumTrack => albumTrack.TrackNumber) - .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count }) + .Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedTracks = new StaticPagedList( diff --git a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs index 59e8631..b46190d 100644 --- a/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs +++ b/backend/Core/Musify.Application/Albums/GetRecentlyListenedAlbums.cs @@ -16,7 +16,7 @@ namespace Musify.Application.Albums { var listenedTrackIds = database.ListeningHistories .AsNoTracking() - .Where(history => history.UserId == request.UserId); + .Where(history => history.UserId == request.UserId && history.IsCounted); var ranked = await database.AlbumHasTracks .AsNoTracking() diff --git a/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs new file mode 100644 index 0000000..32c2563 --- /dev/null +++ b/backend/Core/Musify.Application/Genres/GetAvailableGenres.cs @@ -0,0 +1,26 @@ +using Mediator; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres +{ + public record GetAvailableGenresQuery : IQuery>; + + public record AvailableGenreResponse(Genre Genre, IReadOnlyList IncompatibleWith); + + public class GetAvailableGenresQueryHandler + : IQueryHandler> + { + public ValueTask> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken) + { + var all = Enum.GetValues(); + + IReadOnlyList result = all + .Select(genre => new AvailableGenreResponse( + genre, + all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList())) + .ToList(); + + return ValueTask.FromResult(result); + } + } +} diff --git a/backend/Core/Musify.Application/Genres/GetGenres.cs b/backend/Core/Musify.Application/Genres/GetGenres.cs new file mode 100644 index 0000000..1dece8f --- /dev/null +++ b/backend/Core/Musify.Application/Genres/GetGenres.cs @@ -0,0 +1,31 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; + +namespace Musify.Application.Genres +{ + public record GetGenresQuery : IQuery>; + + public record GenreResponse(Genre Genre, int TrackCount); + + public class GetGenresQueryHandler(IDatabase database) + : IQueryHandler> + { + public async ValueTask> Handle(GetGenresQuery request, CancellationToken cancellationToken) + { + var rows = await database.TrackTags + .AsNoTracking() + .Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId)) + .GroupBy(tag => tag.Tag) + .Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() }) + .ToListAsync(cancellationToken); + + return rows + .OrderByDescending(row => row.TrackCount) + .ThenBy(row => row.Genre) + .Select(row => new GenreResponse(row.Genre, row.TrackCount)) + .ToList(); + } + } +} diff --git a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs index 3cd61e0..e50d7c1 100644 --- a/backend/Core/Musify.Application/Likes/GetLikedTracks.cs +++ b/backend/Core/Musify.Application/Likes/GetLikedTracks.cs @@ -30,7 +30,7 @@ namespace Musify.Application.Likes var pagedEntities = await likesQuery .OrderByDescending(like => like.CreatedAt) - .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count }) + .Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedTracks = new StaticPagedList( diff --git a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs index 0aad625..84df25f 100644 --- a/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs +++ b/backend/Core/Musify.Application/Mixes/GenerateMixesForUser.cs @@ -38,7 +38,7 @@ namespace Musify.Application.Mixes var historyTrackIds = await database.ListeningHistories .AsNoTracking() - .Where(history => history.UserId == userId) + .Where(history => history.UserId == userId && history.IsCounted) .Select(history => history.TrackId) .Distinct() .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs index 10b533b..e6493df 100644 --- a/backend/Core/Musify.Application/Mixes/MixItemMapper.cs +++ b/backend/Core/Musify.Application/Mixes/MixItemMapper.cs @@ -19,7 +19,7 @@ namespace Musify.Application.Mixes .AsNoTracking() .Include(track => track.Owner) .Where(track => trackIds.Contains(track.Id)) - .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count }) + .Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(x => x.Track.Id, cancellationToken); return orderedItems diff --git a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs index cb3cbc9..b26a3fc 100644 --- a/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs +++ b/backend/Core/Musify.Application/PlayLists/GetPlayListTracks.cs @@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists var pagedEntities = await tracksQuery .OrderBy(plt => plt.Position) - .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count }) + .Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var pagedTracks = new StaticPagedList( diff --git a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs index 91031b9..490cfb0 100644 --- a/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs +++ b/backend/Core/Musify.Application/Services/TrackStreamIssuer.cs @@ -30,6 +30,7 @@ public sealed class TrackStreamIssuer( folderPath, streamGatewayConfiguration.AudioFileName); + Guid? listenId = null; if (userId != null) { var newListeningHistory = new ListeningHistory @@ -39,9 +40,10 @@ public sealed class TrackStreamIssuer( }; await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); await database.SaveChangesAsync(cancellationToken); + listenId = newListeningHistory.Id; } - return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds); + return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId); } private long? AnonymousFragmentBytes(long? userId) diff --git a/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs new file mode 100644 index 0000000..bdd7c39 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs @@ -0,0 +1,33 @@ +using Mediator; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.Logging; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks +{ + public record DeleteStaleUncountedListensCommand : ICommand; + + public class DeleteStaleUncountedListensCommandHandler( + IDatabase database, + ILogger logger) + : ICommandHandler + { + public async ValueTask 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); + + if (stale.Count == 0) + return 0; + + 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; + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs index a73bbac..8cee026 100644 --- a/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetLastTrackListenedByUserId.cs @@ -19,7 +19,7 @@ namespace Musify.Application.Tracks .Include(t => t.Track.Tags) .Where(t => t.UserId == query.UserId) .OrderByDescending(t => t.ListenedAt) - .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count)) + .Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted))) .FirstOrDefaultAsync(cancellationToken); return track == null diff --git a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs index a828fa8..3da51ea 100644 --- a/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs +++ b/backend/Core/Musify.Application/Tracks/GetListeningHistory.cs @@ -17,7 +17,7 @@ namespace Musify.Application.Tracks { var recentTrackIds = await database.ListeningHistories .AsNoTracking() - .Where(l => l.UserId == query.UserId) + .Where(l => l.UserId == query.UserId && l.IsCounted) .GroupBy(l => l.TrackId) .OrderByDescending(g => g.Max(l => l.ListenedAt)) .Select(g => g.Key) @@ -29,7 +29,7 @@ namespace Musify.Application.Tracks .Include(t => t.Owner) .Include(t => t.Tags) .Where(t => recentTrackIds.Contains(t.Id)) - .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count }) + .Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) .ToDictionaryAsync(t => t.Id, cancellationToken); return recentTrackIds diff --git a/backend/Core/Musify.Application/Tracks/GetTrackById.cs b/backend/Core/Musify.Application/Tracks/GetTrackById.cs index d1fb84b..68eaf5f 100644 --- a/backend/Core/Musify.Application/Tracks/GetTrackById.cs +++ b/backend/Core/Musify.Application/Tracks/GetTrackById.cs @@ -18,7 +18,7 @@ namespace Musify.Application.Tracks .Include(t => t.Owner) .Include(t => t.Tags) .Where(t => t.Id == request.TrackId) - .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count }) + .Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) }) .SingleOrDefaultAsync(cancellationToken); if (entity == null) diff --git a/backend/Core/Musify.Application/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index c2b614b..8174418 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses; using X.PagedList; using X.PagedList.EF; using Musify.Application.Contracts; +using Musify.Domain.ValueObjects; namespace Musify.Application.Tracks { - public record GetTracksQuery(string? Name, int PageNumber, int PageSize) + public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) : IQuery>; public class GetTracksQueryHandler(IDatabase database) @@ -28,12 +29,15 @@ namespace Musify.Application.Tracks tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); } + if (request.Genre is { } genre) + tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre)); + var totalCount = await tracksQuery.CountAsync(cancellationToken); var pagedEntities = await tracksQuery .OrderByDescending(t => t.Track.CreatedAt) .ThenBy(t => t.Id) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); var items = pagedEntities diff --git a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs index ecc7a81..20a7cd6 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracksByUserId.cs @@ -40,7 +40,7 @@ namespace Musify.Application.Tracks var pagedEntities = await tracksQuery .OrderBy(t => t.Track.CreatedAt) - .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count }) + .Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) }) .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken); var pagedTracks = new StaticPagedList( diff --git a/backend/Core/Musify.Application/Tracks/ListeningRules.cs b/backend/Core/Musify.Application/Tracks/ListeningRules.cs new file mode 100644 index 0000000..146ef1e --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/ListeningRules.cs @@ -0,0 +1,14 @@ +namespace Musify.Application.Tracks +{ + 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); + } +} diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs new file mode 100644 index 0000000..c23a410 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -0,0 +1,44 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks +{ + public record RecordListeningProgressCommand( + long UserId, + Guid ListenId, + double PlayedSeconds) + : ICommand>; + + public class RecordListeningProgressCommandHandler(IDatabase database) + : ICommandHandler> + { + public async ValueTask> 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); + + if (listen == null) + return Error.NotFound(); + + 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); + + await database.SaveChangesAsync(cancellationToken); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs index c925a8b..024edf7 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs @@ -3,5 +3,6 @@ namespace Musify.Application.Tracks.Responses public record TrackStreamResponse( string ManifestUrl, string Ticket, - int ExpiresInSeconds); + int ExpiresInSeconds, + Guid? ListenId = null); } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index 6794717..d097b85 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -20,17 +20,18 @@ namespace Musify.Application.Users .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); var tracksThisWeek = await thisWeekHistory + .Where(l => l.IsCounted) .Select(l => l.TrackId) .Distinct() .CountAsync(cancellationToken); var secondsThisWeek = await thisWeekHistory - .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds) + .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0)) .SumAsync(cancellationToken); var listenedDates = await database.ListeningHistories .AsNoTracking() - .Where(l => l.UserId == request.UserId) + .Where(l => l.UserId == request.UserId && l.IsCounted) .Select(l => l.ListenedAt.Date) .Distinct() .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index ed94b6c..0439cb4 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -19,6 +19,12 @@ namespace Musify.Domain.Entities [Required] public DateTime ListenedAt { get; set; } = DateTime.UtcNow; + public double? PlayedSeconds { get; set; } + + public DateTime? LastProgressAt { get; set; } + + public bool IsCounted { get; set; } + [ForeignKey(nameof(UserId) )] public User User { get; set; } diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index 16a701c..4c7f39c 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Jobs { var userIds = await database.ListeningHistories .AsNoTracking() + .Where(history => history.IsCounted) .Select(history => history.UserId) .Distinct() .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs new file mode 100644 index 0000000..fc8e1b3 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs @@ -0,0 +1,13 @@ +using Mediator; +using Musify.Application.Tracks; + +namespace Musify.Infrastructure.Jobs +{ + public class ListeningHistoryCleanupJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) + { + await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs new file mode 100644 index 0000000..0d81c88 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs @@ -0,0 +1,1173 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260923172640_TrackPlayedSeconds")] + partial class TrackPlayedSeconds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .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.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .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.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs new file mode 100644 index 0000000..150570c --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class TrackPlayedSeconds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsCounted", + table: "ListeningHistory", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastProgressAt", + table: "ListeningHistory", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "PlayedSeconds", + table: "ListeningHistory", + type: "double precision", + nullable: true); + + migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsCounted", + table: "ListeningHistory"); + + migrationBuilder.DropColumn( + name: "LastProgressAt", + table: "ListeningHistory"); + + migrationBuilder.DropColumn( + name: "PlayedSeconds", + table: "ListeningHistory"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 7d4af2d..bf0434b 100644 --- a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs @@ -1,4 +1,4 @@ - +// using System; using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore.Infrastructure; @@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("Delivered"); - b.ToTable("InboxState", (string)null); + b.ToTable("InboxState"); }); modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => @@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") .IsUnique(); - b.ToTable("OutboxMessage", (string)null); + b.ToTable("OutboxMessage"); }); modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => @@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("Created"); - b.ToTable("OutboxState", (string)null); + b.ToTable("OutboxState"); }); modelBuilder.Entity("Musify.Domain.Entities.Album", b => @@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("OwnerUserId"); - b.ToTable("Albums", (string)null); + b.ToTable("Albums"); }); modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => @@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("AlbumId", "TrackId") .IsUnique(); - b.ToTable("AlbumHasTrack", (string)null); + b.ToTable("AlbumHasTrack"); }); modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => @@ -268,9 +268,18 @@ namespace Musify.Infrastructure.Persistence.Migrations .ValueGeneratedOnAdd() .HasColumnType("uuid"); + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + b.Property("ListenedAt") .HasColumnType("timestamp with time zone"); + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + b.Property("TrackId") .HasColumnType("uuid"); @@ -283,7 +292,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId"); - b.ToTable("ListeningHistory", (string)null); + b.ToTable("ListeningHistory"); }); modelBuilder.Entity("Musify.Domain.Entities.Mix", b => @@ -317,7 +326,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId", "Position"); - b.ToTable("Mixes", (string)null); + b.ToTable("Mixes"); }); modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => @@ -341,7 +350,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("MixId", "Position"); - b.ToTable("MixItems", (string)null); + b.ToTable("MixItems"); }); modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => @@ -385,7 +394,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId"); - b.ToTable("PlayLists", (string)null); + b.ToTable("PlayLists"); }); modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => @@ -412,7 +421,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("TrackId"); - b.ToTable("PlayListHasTrack", (string)null); + b.ToTable("PlayListHasTrack"); }); modelBuilder.Entity("Musify.Domain.Entities.Track", b => @@ -453,7 +462,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("OwnerUserId"); - b.ToTable("Tracks", (string)null); + b.ToTable("Tracks"); }); modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => @@ -478,7 +487,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId", "TrackId") .IsUnique(); - b.ToTable("TrackLikes", (string)null); + b.ToTable("TrackLikes"); }); modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => @@ -498,7 +507,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("TrackId", "Tag") .IsUnique(); - b.ToTable("TrackTags", (string)null); + b.ToTable("TrackTags"); }); modelBuilder.Entity("Musify.Domain.Entities.Upload", b => @@ -527,7 +536,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.ToTable("Upload", (string)null); + b.ToTable("Upload"); }); modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => @@ -578,7 +587,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId"); - b.ToTable("UploadIntents", (string)null); + b.ToTable("UploadIntents"); }); modelBuilder.Entity("Musify.Domain.Entities.User", b => @@ -616,7 +625,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.ToTable("User", (string)null); + b.ToTable("User"); }); modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => @@ -641,7 +650,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("FollowerId", "FollowedId") .IsUnique(); - b.ToTable("UserFollows", (string)null); + b.ToTable("UserFollows"); }); modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => @@ -662,7 +671,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId"); - b.ToTable("UserHasTrack", (string)null); + b.ToTable("UserHasTrack"); }); modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => @@ -689,7 +698,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("CorrelationId"); - b.ToTable("AlbumProcessingState", (string)null); + b.ToTable("AlbumProcessingState"); }); modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => @@ -716,7 +725,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("CorrelationId"); - b.ToTable("PlayListProcessingState", (string)null); + b.ToTable("PlayListProcessingState"); }); modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => @@ -752,7 +761,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("CorrelationId"); - b.ToTable("TrackProcessingState", (string)null); + b.ToTable("TrackProcessingState"); }); modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => @@ -806,7 +815,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.HasKey("AlbumId"); - b1.ToTable("Albums", (string)null); + b1.ToTable("Albums"); b1.WithOwner() .HasForeignKey("AlbumId"); @@ -899,32 +908,28 @@ namespace Musify.Infrastructure.Persistence.Migrations .HasColumnType("uuid"); b1.Property("LargeName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("LargePictureName"); b1.Property("MediumName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("MediumPictureName"); b1.Property("OriginalName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("OriginalPictureName"); b1.Property("SmallName") - .IsRequired() .HasMaxLength(64) .HasColumnType("character varying(64)") .HasColumnName("SmallPictureName"); b1.HasKey("PlayListId"); - b1.ToTable("PlayLists", (string)null); + b1.ToTable("PlayLists"); b1.WithOwner() .HasForeignKey("PlayListId"); @@ -995,7 +1000,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.HasKey("TrackId"); - b1.ToTable("Tracks", (string)null); + b1.ToTable("Tracks"); b1.WithOwner() .HasForeignKey("TrackId"); @@ -1032,7 +1037,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.HasKey("TrackId"); - b1.ToTable("Tracks", (string)null); + b1.ToTable("Tracks"); b1.WithOwner() .HasForeignKey("TrackId"); diff --git a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs index 109d631..734692e 100644 --- a/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs +++ b/backend/Core/Musify.Infrastructure/Services/ServicesDependencyInjection.cs @@ -74,6 +74,7 @@ namespace Musify.Infrastructure.Services serviceDescriptors.AddHangfireServer(); serviceDescriptors.AddScoped(); + serviceDescriptors.AddScoped(); return serviceDescriptors; } diff --git a/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs new file mode 100644 index 0000000..32eff3c --- /dev/null +++ b/backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs @@ -0,0 +1,3 @@ +namespace Musify.Api.DataTransferObjects.Tracks; + +public record RecordListeningProgressRequest(double PlayedSeconds); diff --git a/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs new file mode 100644 index 0000000..da31c5b --- /dev/null +++ b/backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs @@ -0,0 +1,39 @@ +using Mediator; +using Musify.Application.Genres; + +namespace Musify.Api.Endpoints; + +public static class GenreEndpoints +{ + public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app) + { + var group = app.MapGroup("/genres") + .WithTags("Genres"); + + group.MapGet("/", GetGenres) + .WithName("GetGenres") + .WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.") + .Produces>(); + + group.MapGet("/available", GetAvailableGenres) + .WithName("GetAvailableGenres") + .WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.") + .Produces>(); + + return app; + } + + private static async Task> GetAvailableGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken); + } + + private static async Task> GetGenres( + IMediator mediator, + CancellationToken cancellationToken) + { + return await mediator.Send(new GetGenresQuery(), cancellationToken); + } +} diff --git a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index e032de9..eaa8325 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -1,4 +1,5 @@ using Mediator; +using Musify.Domain.ValueObjects; using Musify.Application.Tracks; using Musify.Application.Tracks.Responses; using Musify.Application.Shared; @@ -19,8 +20,9 @@ public static class TrackEndpoints group.MapGet("/", GetTracks) .WithName("GetTracks") - .WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") - .Produces(); + .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") + .Produces() + .ProducesValidationProblem(); group.MapGet("/{id}", GetTrackById) .WithName("GetTrackById") @@ -41,6 +43,14 @@ public static class TrackEndpoints .Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status404NotFound); + group.MapPut("/listens/{listenId}/progress", RecordListeningProgress) + .WithName("RecordListeningProgress") + .WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.") + .RequireAuthorization() + .Produces(StatusCodes.Status204NoContent) + .Produces(StatusCodes.Status401Unauthorized) + .Produces(StatusCodes.Status404NotFound); + group.MapGet("/users/{userId}", GetTracksByUserId) .WithName("GetTracksByUserId") .WithSummary("Get Paginated Tracks For A User.") @@ -84,10 +94,25 @@ public static class TrackEndpoints IMediator mediator, CancellationToken cancellationToken, string? name, + string? genre, int pageNumber = 1, int pageSize = 10) { - var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); + Genre? parsedGenre = null; + if (!string.IsNullOrWhiteSpace(genre)) + { + if (!Enum.TryParse(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value)) + { + return Results.ValidationProblem(new Dictionary + { + [nameof(genre)] = [$"'{genre}' is not a valid genre."] + }); + } + + parsedGenre = value; + } + + var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken); return result.ToHttpResult(); } @@ -128,6 +153,19 @@ public static class TrackEndpoints return result.ToHttpResult(); } + private static async Task RecordListeningProgress( + IMediator mediator, + CurrentUser currentUser, + Guid listenId, + RecordListeningProgressRequest request, + CancellationToken cancellationToken) + { + var result = await mediator.Send( + new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds), + cancellationToken); + return result.ToHttpResult(); + } + private static async Task GetTracksByUserId( IMediator mediator, long userId, diff --git a/backend/Hosts/Musify.Api/Program.cs b/backend/Hosts/Musify.Api/Program.cs index e31b5e0..7e8772b 100644 --- a/backend/Hosts/Musify.Api/Program.cs +++ b/backend/Hosts/Musify.Api/Program.cs @@ -40,6 +40,7 @@ app.UseAuthorization(); app .MapAlbumEndpoints() .MapConfigEndpoints() + .MapGenreEndpoints() .MapLikeEndpoints() .MapMixEndpoints() .MapPlayListEndpoints() diff --git a/backend/Hosts/Musify.Worker/Program.cs b/backend/Hosts/Musify.Worker/Program.cs index d86b01f..bda423d 100644 --- a/backend/Hosts/Musify.Worker/Program.cs +++ b/backend/Hosts/Musify.Worker/Program.cs @@ -41,6 +41,11 @@ using (var scope = host.Services.CreateScope()) job => job.RunAsync(CancellationToken.None), Cron.Daily(), new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); + recurringJobManager.AddOrUpdate( + "listening-history-cleanup", + job => job.RunAsync(CancellationToken.None), + Cron.Daily(3), + new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); } host.Run(); diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs new file mode 100644 index 0000000..539cd97 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs @@ -0,0 +1,106 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json; +using System.Text.Json.Serialization; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.Tests.TestSupport; +using Musify.Application.Genres; +using Musify.Application.Tracks.Responses; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Xunit; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class GenreEndpointsTests(ApiTestFixture fixture) + { + private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web) + { + Converters = { new JsonStringEnumConverter() } + }; + + private async Task SeedTrackAsync(Genre genre) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" }; + var title = $"Genre track {Guid.NewGuid():N}"; + var track = new Track + { + Title = title, + NormalizedTitle = title.ToUpperInvariant(), + OwnerUserId = owner.Id, + Owner = owner, + Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }] + }; + foreach (var tag in track.Tags) + tag.TrackId = track.Id; + database.Users.Add(owner); + database.Tracks.Add(track); + database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id }); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return title; + } + + [Fact] + public async Task GetGenres_Anonymous_ListsGenresInUse() + { + await SeedTrackAsync(Genre.Funk); + + var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1); + } + + [Fact] + public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities() + { + var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync>(JsonOptions, TestContext.Current.CancellationToken); + Assert.Equal(Enum.GetValues().Length, body!.Count); + Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith); + } + + [Theory] + [InlineData("Nope")] + [InlineData("999")] + public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre) + { + var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + + [Fact] + public async Task GetTracks_GenreQuery_IsCaseInsensitive() + { + var title = await SeedTrackAsync(Genre.Blues); + + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Track.Title == title); + } + + [Fact] + public async Task GetTracks_GenreQuery_FiltersByTag() + { + var title = await SeedTrackAsync(Genre.Ambient); + await SeedTrackAsync(Genre.Techno); + + var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + var body = await response.Content.ReadFromJsonAsync(JsonOptions, TestContext.Current.CancellationToken); + Assert.Contains(body!.Items, item => item.Track.Title == title); + Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags)); + } + } +} diff --git a/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs new file mode 100644 index 0000000..01cdda4 --- /dev/null +++ b/backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs @@ -0,0 +1,109 @@ +using System.Net; +using System.Net.Http.Json; +using Microsoft.EntityFrameworkCore; +using Microsoft.Extensions.DependencyInjection; +using Musify.Api.DataTransferObjects.Tracks; +using Musify.Api.DataTransferObjects.Users; +using Musify.Domain.Entities; +using Musify.Infrastructure.Persistence; +using Xunit; +using Musify.Api.Tests.TestSupport; + +namespace Musify.Api.Tests.Endpoints +{ + [Collection(ApiCollection.Name)] + public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture) + { + private async Task CreateUserAsync() + { + var userId = Random.Shared.NextInt64(1, long.MaxValue); + var response = await fixture.CreateAnonymousClient().PostAsJsonAsync( + "/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken); + response.EnsureSuccessStatusCode(); + return userId; + } + + private async Task SeedListenAsync(long userId, double durationSeconds) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken); + var track = new Track + { + Title = "Progress track", + NormalizedTitle = "PROGRESS TRACK", + OwnerUserId = owner.Id, + Owner = owner, + DurationSeconds = durationSeconds + }; + var listen = new ListeningHistory + { + UserId = userId, + TrackId = track.Id, + ListenedAt = DateTime.UtcNow.AddMinutes(-10) + }; + database.Tracks.Add(track); + database.ListeningHistories.Add(listen); + await database.SaveChangesAsync(TestContext.Current.CancellationToken); + return listen.Id; + } + + private async Task LoadListenAsync(Guid listenId) + { + using var scope = fixture.Services.CreateScope(); + var database = scope.ServiceProvider.GetRequiredService(); + return await database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken); + } + + [Fact] + public async Task PutProgress_Anonymous_ReturnsUnauthorized() + { + var response = await fixture.CreateAnonymousClient().PutAsJsonAsync( + $"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode); + } + + [Fact] + public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted() + { + var userId = await CreateUserAsync(); + var listenId = await SeedListenAsync(userId, durationSeconds: 200); + + var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NoContent, response.StatusCode); + var stored = await LoadListenAsync(listenId); + Assert.Equal(45, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } + + [Fact] + public async Task PutProgress_OtherUsersListen_ReturnsNotFound() + { + var ownerId = await CreateUserAsync(); + var otherId = await CreateUserAsync(); + var listenId = await SeedListenAsync(ownerId, durationSeconds: 200); + + var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds); + } + + [Fact] + public async Task PutProgress_NegativeSeconds_ReturnsBadRequest() + { + var userId = await CreateUserAsync(); + var listenId = await SeedListenAsync(userId, durationSeconds: 200); + + var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync( + $"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs index 8898711..cff1661 100644 --- a/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Albums/GetRecentlyListenedAlbumsQueryHandlerTests.cs @@ -57,5 +57,25 @@ namespace Musify.Application.Tests.Albums Assert.False(result.IsError); Assert.Equal(2, result.Value.Count); } + + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var countedAlbum = TestEntities.Album(owner.Id, "Counted album"); + var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album"); + var countedTrack = TestEntities.Track(owner, "In counted"); + var skippedTrack = TestEntities.Track(owner, "In skipped"); + await SeedAsync( + owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack, + new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 }, + new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 }, + TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120), + TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false)); + + var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs new file mode 100644 index 0000000..0f8d698 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs @@ -0,0 +1,32 @@ +using Musify.Application.Genres; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Genres +{ + public sealed class GetAvailableGenresQueryHandlerTests + { + private static async Task> RunAsync() => + await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken); + + [Fact] + public async Task Handle_ReturnsEveryGenre() + { + var result = await RunAsync(); + + Assert.Equal(Enum.GetValues(), result.Select(r => r.Genre)); + } + + [Fact] + public async Task Handle_ListsIncompatibleGenresSymmetrically() + { + var result = await RunAsync(); + + var metal = result.Single(r => r.Genre == Genre.Metal); + var lofi = result.Single(r => r.Genre == Genre.Lofi); + Assert.Contains(Genre.Lofi, metal.IncompatibleWith); + Assert.Contains(Genre.Metal, lofi.IncompatibleWith); + Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs new file mode 100644 index 0000000..cb4d6e9 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs @@ -0,0 +1,69 @@ +using Musify.Application.Genres; +using Musify.Application.Tests.TestSupport; +using Musify.Domain.Entities; +using Musify.Domain.ValueObjects; +using Xunit; + +namespace Musify.Application.Tests.Genres +{ + public sealed class GetGenresQueryHandlerTests : HandlerTestBase + { + private GetGenresQueryHandler CreateHandler() => new(Database); + + [Fact] + public async Task Handle_NoTracks_ReturnsEmpty() + { + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + + [Fact] + public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst() + { + var owner = TestEntities.User(); + var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]); + var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]); + await SeedAsync( + owner, rockA, rockB, jazz, + new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id }, + new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id }, + new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Equal( + [(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)], + result.Select(r => (r.Genre, r.TrackCount))); + } + + [Fact] + public async Task Handle_TrackInSeveralLibraries_IsCountedOnce() + { + var owner = TestEntities.User(); + var other = TestEntities.User(id: 2, name: "other-user"); + var track = TestEntities.Track(owner, tags: [Genre.Pop]); + await SeedAsync( + owner, other, track, + new UserHasTrack { UserId = owner.Id, TrackId = track.Id }, + new UserHasTrack { UserId = other.Id, TrackId = track.Id }); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Equal(1, Assert.Single(result).TrackCount); + } + + [Fact] + public async Task Handle_TrackNotInAnyLibrary_IsIgnored() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner, tags: [Genre.Metal]); + await SeedAsync(owner, track); + + var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken); + + Assert.Empty(result); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs index 7a3a8a4..70bd2d1 100644 --- a/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Mixes/GenerateMixesForUserCommandHandlerTests.cs @@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes .ToListAsync(TestContext.Current.CancellationToken); Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); } + + [Fact] + public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard() + { + var user = TestEntities.User(); + var skipped = TestEntities.Track(user, "Only skipped"); + await SeedAsync( + user, skipped, + new UserHasTrack { UserId = user.Id, TrackId = skipped.Id }, + TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false)); + + await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + } } } diff --git a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index e0850b8..c10efba 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -179,12 +179,16 @@ namespace Musify.Application.Tests.TestSupport public static ListeningHistory ListeningHistory( long userId, Guid trackId, - DateTime? listenedAt = null) => + DateTime? listenedAt = null, + double? playedSeconds = null, + bool isCounted = true) => new() { UserId = userId, TrackId = trackId, - ListenedAt = listenedAt ?? DateTime.UtcNow + ListenedAt = listenedAt ?? DateTime.UtcNow, + PlayedSeconds = playedSeconds, + IsCounted = isCounted }; } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs new file mode 100644 index 0000000..184f3d5 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase + { + private DeleteStaleUncountedListensCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_DeletesOnlyOldUncountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false); + var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100); + var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false); + await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted); + + var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(oldUncounted.Id, remaining); + Assert.Contains(oldCounted.Id, remaining); + Assert.Contains(recentUncounted.Id, remaining); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index c52a703..1886f27 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks Assert.Empty(result); } + + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var counted = TestEntities.Track(owner, "Counted"); + var skipped = TestEntities.Track(owner, "Skipped"); + await SeedAsync( + owner, counted, skipped, + TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90), + TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false)); + + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted"], result.Select(track => track.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 9805b3d..4a5798a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_ListensCount_OnlyCountsCountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync( + owner, track, + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false)); + + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.ListensCount); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index d69cc17..de52989 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -45,6 +45,9 @@ namespace Musify.Application.Tests.Tracks var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); Assert.Equal(owner.Id, history.UserId); Assert.Equal(track.Id, history.TrackId); + Assert.Equal(history.Id, result.Value.ListenId); + Assert.False(history.IsCounted); + Assert.Null(history.PlayedSeconds); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs index a22dd53..90f828e 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTracksQueryHandlerTests.cs @@ -1,5 +1,6 @@ using Musify.Application.Tests.TestSupport; using Musify.Application.Tracks; +using Musify.Domain.ValueObjects; using Xunit; namespace Musify.Application.Tests.Tracks @@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks var item = Assert.Single(result.Value.Items); Assert.Equal("Bohemian Rhapsody", item.Track.Title); } + + [Fact] + public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag() + { + var owner = TestEntities.User(); + var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]); + var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]); + await SeedAsync( + owner, rock, jazz, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id }, + new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id }); + + var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var item = Assert.Single(result.Value.Items); + Assert.Equal("Loud", item.Track.Title); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs new file mode 100644 index 0000000..d8f4050 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -0,0 +1,127 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase + { + private RecordListeningProgressCommandHandler CreateHandler() => new(Database); + + private async Task ReloadAsync(Guid id) => + await Database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); + + private async Task<(User User, ListeningHistory Listen)> SeedListenAsync( + double durationSeconds, DateTime listenedAt) + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: durationSeconds); + var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false); + await SeedAsync(user, track, listen); + return (user, listen); + } + + [Fact] + public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await ReloadAsync(listen.Id); + Assert.Equal(10, stored.PlayedSeconds); + Assert.False(stored.IsCounted); + } + + [Fact] + public async Task Handle_ReachesThirtySeconds_MarksAsCounted() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_ShortTrack_CountsAtHalfDuration() + { + var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_LowerValueThanStored_KeepsHighest() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + var handler = CreateHandler(); + + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken); + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.Equal(40, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } + + [Fact] + public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); + } + + [Fact] + public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() + { + var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); + + Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); + } + + [Fact] + public async Task Handle_OtherUsersListen_ReturnsNotFound() + { + var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds) + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index 1061cae..c27d93f 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -40,6 +40,37 @@ namespace Musify.Application.Tests.Users Assert.Equal(500, result.SecondsThisWeek); } + [Fact] + public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: 200); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200), + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false)); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(212, result.SecondsThisWeek); + } + + [Fact] + public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false)); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.StreakDays); + Assert.Equal(3, result.SecondsThisWeek); + } + [Fact] public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() { diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 3c0035e..064a333 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -177,6 +177,40 @@ export interface paths { patch?: never; trace?: never; }; + "/genres": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously. */ + get: operations["GetGenres"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/genres/available": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously. */ + get: operations["GetAvailableGenres"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/likes": { parameters: { query?: never; @@ -391,7 +425,7 @@ export interface paths { path?: never; cookie?: never; }; - /** Get Paginated Tracks, Optionally Filtered By Name. */ + /** Get Paginated Tracks, Optionally Filtered By Name And Genre. */ get: operations["GetTracks"]; put?: never; /** Create A New Track. */ @@ -453,6 +487,23 @@ export interface paths { patch?: never; trace?: never; }; + "/tracks/listens/{listenId}/progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + /** Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint. */ + put: operations["RecordListeningProgress"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tracks/users/{userId}": { parameters: { query?: never; @@ -730,6 +781,10 @@ export interface components { hasPreviousPage: boolean; hasNextPage: boolean; }; + AvailableGenreResponse: { + genre: components["schemas"]["Genre"]; + incompatibleWith: components["schemas"]["Genre"][]; + }; CreateAlbumRequest: { title: string; description: null | string; @@ -764,6 +819,11 @@ export interface components { }; /** @enum {unknown} */ Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; + GenreResponse: { + genre: components["schemas"]["Genre"]; + /** Format: int32 */ + trackCount: number | string; + }; HttpValidationProblemDetails: { type?: null | string; title?: null | string; @@ -854,6 +914,19 @@ export interface components { hasNextPage: boolean; hasPreviousPage: boolean; }; + PaginatedResponseOfUserApplicationResponse: { + items: components["schemas"]["UserApplicationResponse"][]; + /** Format: int32 */ + pageNumber: number | string; + /** Format: int32 */ + pageSize: number | string; + /** Format: int32 */ + pageCount: number | string; + /** Format: int32 */ + totalItemCount: number | string; + hasNextPage: boolean; + hasPreviousPage: boolean; + }; PlaybackPublicConfigResponse: { allowAnonymousListening: boolean; /** Format: int32 */ @@ -889,6 +962,10 @@ export interface components { PlaylistVisibility: "Private" | "Public"; /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; + RecordListeningProgressRequest: { + /** Format: double */ + playedSeconds: number | string; + }; RequestAlbumPictureUploadRequest: { fileType: string; contentType: string; @@ -954,6 +1031,8 @@ export interface components { ticket: string; /** Format: int32 */ expiresInSeconds: number | string; + /** Format: uuid */ + listenId?: null | string; }; TrackUploadUrlsResponse: { /** Format: uuid */ @@ -1479,6 +1558,46 @@ export interface operations { }; }; }; + GetGenres: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["GenreResponse"][]; + }; + }; + }; + }; + GetAvailableGenres: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description OK */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AvailableGenreResponse"][]; + }; + }; + }; + }; GetLikedTracks: { parameters: { query?: { @@ -2013,6 +2132,7 @@ export interface operations { parameters: { query?: { name?: string; + genre?: string; pageNumber?: number | string; pageSize?: number | string; }; @@ -2031,6 +2151,15 @@ export interface operations { "application/json": components["schemas"]["TracksSearchResponse"]; }; }; + /** @description Bad Request */ + 400: { + headers: { + [name: string]: unknown; + }; + content: { + "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; + }; + }; }; }; CreateTrack: { @@ -2181,6 +2310,44 @@ export interface operations { }; }; }; + RecordListeningProgress: { + parameters: { + query?: never; + header?: never; + path: { + listenId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordListeningProgressRequest"]; + }; + }; + responses: { + /** @description No Content */ + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Unauthorized */ + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; GetTracksByUserId: { parameters: { query?: { diff --git a/web-player/src/lib/data/genres.ts b/web-player/src/lib/data/genres.ts index 8dbc9f2..a7b1c64 100644 --- a/web-player/src/lib/data/genres.ts +++ b/web-player/src/lib/data/genres.ts @@ -1,22 +1,75 @@ -export const GENRE_INFO: Record = { - pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, - rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, - reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, - 'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }, - indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, - salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' }, - 'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' }, - electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' }, - jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, - baladas: { label: 'Baladas', tagline: 'Para sentir con calma' }, - trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' }, - cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' }, - 'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' }, - 'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' } +const GENRE_INFO: Record = { + Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, + Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, + HipHop: { label: 'Hip hop', tagline: 'Rimas con actitud' }, + RnB: { label: 'R&B', tagline: 'Ritmo suave y voces con alma' }, + Jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, + Blues: { label: 'Blues', tagline: 'Sentimiento en cada nota' }, + Classical: { label: 'Clásica', tagline: 'Siglos de composición' }, + Electronic: { label: 'Electrónica', tagline: 'Beats para perderte' }, + House: { label: 'House', tagline: 'Pista de baile sin fin' }, + Techno: { label: 'Techno', tagline: 'Pulso hipnótico' }, + Trance: { label: 'Trance', tagline: 'Melodías que te elevan' }, + Dubstep: { label: 'Dubstep', tagline: 'Bajos que hacen temblar' }, + DrumAndBass: { label: 'Drum and bass', tagline: 'Velocidad y graves' }, + Metal: { label: 'Metal', tagline: 'Riffs pesados' }, + Punk: { label: 'Punk', tagline: 'Rápido, crudo y directo' }, + Reggae: { label: 'Reggae', tagline: 'Buenas vibraciones' }, + Reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, + Country: { label: 'Country', tagline: 'Historias con guitarra' }, + Folk: { label: 'Folk', tagline: 'Raíces y acústico' }, + Indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, + KPop: { label: 'K-pop', tagline: 'Coreografías pegajosas' }, + Latin: { label: 'Latina', tagline: 'Sabor y ritmo' }, + Soul: { label: 'Soul', tagline: 'Emoción en estado puro' }, + Funk: { label: 'Funk', tagline: 'Groove imposible de quedarse quieto' }, + Ambient: { label: 'Ambient', tagline: 'Paisajes sonoros' }, + Lofi: { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' } }; -export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({ - query, - hue: Math.round((i * 360) / all.length), - ...info -})); +const KNOWN_GENRES = Object.keys(GENRE_INFO); + +function humanize(name: string): string { + return name.replace(/([a-z])([A-Z])/g, '$1 $2'); +} + +export function genreInfo(genre: string) { + return GENRE_INFO[genre] ?? { label: humanize(genre), tagline: '' }; +} + +export function genreHue(genre: string): number { + const known = KNOWN_GENRES.indexOf(genre); + if (known >= 0) return Math.round((known * 360) / KNOWN_GENRES.length); + let hash = 0; + for (const char of genre) hash = (hash * 31 + char.charCodeAt(0)) % 360; + return hash; +} + +export function genreTiles(genres: { genre: string }[]) { + return genres.map(({ genre }) => ({ + genre, + hue: genreHue(genre), + ...genreInfo(genre) + })); +} + +export function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) { + return genres.map(({ genre, incompatibleWith }) => ({ + genre, + incompatibleWith, + label: genreInfo(genre).label + })); +} + +export function findConflict( + tags: string[], + options: { genre: string; incompatibleWith: string[] }[] +): [string, string] | undefined { + const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith])); + for (let i = 0; i < tags.length; i++) { + for (let j = i + 1; j < tags.length; j++) { + if (byGenre.get(tags[i])?.includes(tags[j])) return [tags[i], tags[j]]; + } + } + return undefined; +} diff --git a/web-player/src/lib/data/trackGenres.ts b/web-player/src/lib/data/trackGenres.ts deleted file mode 100644 index 9e64458..0000000 --- a/web-player/src/lib/data/trackGenres.ts +++ /dev/null @@ -1,56 +0,0 @@ -export const TRACK_GENRES = [ - { value: 'Pop', label: 'Pop' }, - { value: 'Rock', label: 'Rock' }, - { value: 'HipHop', label: 'Hip-hop' }, - { value: 'RnB', label: 'R&B' }, - { value: 'Jazz', label: 'Jazz' }, - { value: 'Blues', label: 'Blues' }, - { value: 'Classical', label: 'Clásica' }, - { value: 'Electronic', label: 'Electrónica' }, - { value: 'House', label: 'House' }, - { value: 'Techno', label: 'Techno' }, - { value: 'Trance', label: 'Trance' }, - { value: 'Dubstep', label: 'Dubstep' }, - { value: 'DrumAndBass', label: 'Drum and bass' }, - { value: 'Metal', label: 'Metal' }, - { value: 'Punk', label: 'Punk' }, - { value: 'Reggae', label: 'Reggae' }, - { value: 'Reggaeton', label: 'Reggaetón' }, - { value: 'Country', label: 'Country' }, - { value: 'Folk', label: 'Folk' }, - { value: 'Indie', label: 'Indie' }, - { value: 'KPop', label: 'K-pop' }, - { value: 'Latin', label: 'Latina' }, - { value: 'Soul', label: 'Soul' }, - { value: 'Funk', label: 'Funk' }, - { value: 'Ambient', label: 'Ambient' }, - { value: 'Lofi', label: 'Lo-fi' } -] as const; - -export type TrackGenre = (typeof TRACK_GENRES)[number]['value']; - -const GENRE_VALUES: ReadonlySet = new Set(TRACK_GENRES.map((genre) => genre.value)); - -const INTENSE: ReadonlySet = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']); -const MELLOW: ReadonlySet = new Set(['Classical', 'Ambient', 'Lofi']); - -export function isTrackGenre(value: string): value is TrackGenre { - return GENRE_VALUES.has(value); -} - -export function areCompatible(a: TrackGenre, b: TrackGenre): boolean { - return !((INTENSE.has(a) && MELLOW.has(b)) || (INTENSE.has(b) && MELLOW.has(a))); -} - -export function genreLabel(value: TrackGenre): string { - return TRACK_GENRES.find((genre) => genre.value === value)?.label ?? value; -} - -export function findConflict(tags: readonly TrackGenre[]): [TrackGenre, TrackGenre] | undefined { - for (let i = 0; i < tags.length; i++) { - for (let j = i + 1; j < tags.length; j++) { - if (!areCompatible(tags[i], tags[j])) return [tags[i], tags[j]]; - } - } - return undefined; -} diff --git a/web-player/src/lib/player/listenTracker.ts b/web-player/src/lib/player/listenTracker.ts new file mode 100644 index 0000000..c2f5268 --- /dev/null +++ b/web-player/src/lib/player/listenTracker.ts @@ -0,0 +1,52 @@ +const MAX_TICK_GAP_SECONDS = 2.5; +const FLUSH_EVERY_SECONDS = 30; + +export class ListenTracker { + #listenId: string | null = null; + #played = 0; + #sent = 0; + #lastTime: number | null = null; + #lastFlushAt = 0; + + begin(listenId: string | null | undefined) { + this.flush(); + this.#listenId = listenId ?? null; + this.#played = 0; + this.#sent = 0; + this.#lastTime = null; + this.#lastFlushAt = Date.now(); + } + + resync(currentTime: number) { + this.#lastTime = currentTime; + } + + interrupt() { + this.#lastTime = null; + } + + tick(currentTime: number) { + if (this.#listenId === null) return; + const last = this.#lastTime; + this.#lastTime = currentTime; + if (last === null) return; + const delta = currentTime - last; + if (delta > 0 && delta <= MAX_TICK_GAP_SECONDS) this.#played += delta; + if (Date.now() - this.#lastFlushAt >= FLUSH_EVERY_SECONDS * 1000) this.flush(); + } + + flush() { + const id = this.#listenId; + if (id === null || this.#played <= this.#sent) return; + this.#sent = this.#played; + this.#lastFlushAt = Date.now(); + fetch(`/api/listens/${id}/progress`, { + method: 'PUT', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ playedSeconds: this.#played }), + keepalive: true + }).catch(() => { + this.#sent = 0; + }); + } +} diff --git a/web-player/src/lib/player/player.svelte.ts b/web-player/src/lib/player/player.svelte.ts index c43c7e4..c56dc7b 100644 --- a/web-player/src/lib/player/player.svelte.ts +++ b/web-player/src/lib/player/player.svelte.ts @@ -3,6 +3,7 @@ import { DEFAULT_ACCENT } from '$lib/theme/color'; import { extractAccent, type Accent } from '$lib/theme/palette'; import { shuffle } from '$lib/data/collections'; import { dialog } from '$lib/state/dialog.svelte'; +import { ListenTracker } from './listenTracker'; export interface PlayerTrack { id: string | number; @@ -132,6 +133,7 @@ class PlayerState { #loadToken = 0; #accentCache = new Map(); #rafId: number | null = null; + #listen = new ListenTracker(); current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY); accent = $derived(this.accentColor ?? DEFAULT_ACCENT); @@ -152,19 +154,30 @@ class PlayerState { audio.muted = this.muted; audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration)); audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration)); + audio.addEventListener('timeupdate', () => { + if (!audio.paused) this.#listen.tick(audio.currentTime); + }); + audio.addEventListener('seeking', () => this.#listen.interrupt()); + audio.addEventListener('seeked', () => this.#listen.resync(audio.currentTime)); audio.addEventListener('play', () => { + this.#listen.resync(audio.currentTime); this.playing = true; this.#startProgressLoop(); this.#syncMediaSessionPlaybackState(); }); audio.addEventListener('pause', () => { + this.#listen.interrupt(); + this.#listen.flush(); this.playing = false; this.#stopProgressLoop(); this.#syncMediaSessionPlaybackState(); }); audio.addEventListener('ended', () => { this.#stopProgressLoop(); + this.#listen.tick(audio.currentTime); + this.#listen.begin(null); if (this.repeat) { + this.#beginRepeatListen(); audio.currentTime = 0; audio.play().catch(() => {}); return; @@ -178,6 +191,10 @@ class PlayerState { this.#fail(); }); this.#audio = audio; + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') this.#listen.flush(); + }); + window.addEventListener('pagehide', () => this.#listen.flush()); this.#setupMediaSession(audio); return audio; } @@ -277,14 +294,16 @@ class PlayerState { const track = this.tracks.find((t) => t.id === id); this.#applyAccent(id); if (track) this.#syncMediaSessionMetadata(track); + this.#listen.begin(null); const token = ++this.#loadToken; this.loading = true; this.progress = 0; try { - const src = await this.#resolveLocalSrc(String(id)); + const { src, listenId } = await this.#resolveLocalSrc(String(id)); if (token !== this.#loadToken) return; audio.src = src; audio.volume = this.volume / 100; + this.#listen.begin(listenId); await audio.play(); this.loading = false; this.#pushRecent(this.current); @@ -310,14 +329,28 @@ class PlayerState { ); } - async #resolveLocalSrc(id: string): Promise { + async #resolveLocalSrc(id: string): Promise<{ src: string; listenId: string | null }> { const res = await fetch(`/api/tracks/${id}/stream`); if (!res.ok) throw new Error(await readErrorMessage(res)); - const { manifestUrl, ticket } = (await res.json()) as { + const { manifestUrl, ticket, listenId } = (await res.json()) as { manifestUrl: string; ticket: string; + listenId?: string | null; }; - return this.#withTicket(manifestUrl, ticket); + return { src: this.#withTicket(manifestUrl, ticket), listenId: listenId ?? null }; + } + + async #beginRepeatListen() { + const id = this.currentId; + if (id === null) return; + try { + const res = await fetch(`/api/tracks/${id}/stream`); + if (!res.ok) return; + const { listenId } = (await res.json()) as { listenId?: string | null }; + if (this.currentId === id) this.#listen.begin(listenId ?? null); + } catch { + return; + } } #withTicket(url: string, ticket: string): string { diff --git a/web-player/src/lib/state/navigation.svelte.ts b/web-player/src/lib/state/navigation.svelte.ts index afb3f44..3eae4fa 100644 --- a/web-player/src/lib/state/navigation.svelte.ts +++ b/web-player/src/lib/state/navigation.svelte.ts @@ -88,6 +88,10 @@ export function searchHref(query: string): string { return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; } +export function genreHref(genre: string): string { + return `/explore?genre=${encodeURIComponent(genre)}`; +} + export interface AppNavLink { href: string; label: string; diff --git a/web-player/src/routes/(app)/explore/+page.server.ts b/web-player/src/routes/(app)/explore/+page.server.ts index 176bde1..9cb30d2 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -10,13 +10,25 @@ import { export const load: PageServerLoad = async ({ url, locals, fetch }) => { const query = url.searchParams.get('q')?.trim() ?? ''; + const genreParam = url.searchParams.get('genre')?.trim() || null; const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const user = locals.user; const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const genresRes = await api.GET('/genres'); + const genres = genresRes.data ?? []; + const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null; + const tracksPromise = api.GET('/tracks', { - params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } } + params: { + query: { + name: query || undefined, + genre: genre ?? undefined, + pageNumber: page, + pageSize: PAGE_SIZE + } + } }); const playlistsPromise = user @@ -48,6 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => { return { query, + genre, + genres, tracks, albums: (albumsRes?.data?.items ?? []).map((item) => item.album), users: usersRes?.data?.items ?? [], diff --git a/web-player/src/routes/(app)/explore/+page.svelte b/web-player/src/routes/(app)/explore/+page.svelte index 8e7ff6d..de92dc7 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -17,13 +17,13 @@ import Chip from '$lib/components/ui/primitives/Chip.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import { EXPLORE_PAGE_SIZE } from '$lib/config'; - import { searchHref } from '$lib/state/navigation.svelte'; + import { genreHref } from '$lib/state/navigation.svelte'; import { createTrackMenu } from '$lib/state/menus.svelte'; import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte'; import ListPlus from '@lucide/svelte/icons/list-plus'; import ListEnd from '@lucide/svelte/icons/list-end'; import { appendUnique } from '$lib/data/collections'; - import { genreTiles } from '$lib/data/genres'; + import { genreInfo, genreTiles } from '$lib/data/genres'; import { type SearchFilter, showGroup, @@ -59,7 +59,7 @@ $effect(() => { trackMenu.close(); albumMenu = null; - sfilter = 'Todo'; + sfilter = data.genre ? 'Canciones' : 'Todo'; localItems = data.tracks.items.map((item) => item.track); localPage = Number(data.tracks.pageNumber); hasMore = data.tracks.hasNextPage; @@ -74,6 +74,9 @@ const items = $derived(songRows.map((row) => row.track)); + const tiles = $derived(genreTiles(data.genres)); + const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null); + const albums = $derived(data.albums); const users = $derived(data.users); @@ -170,6 +173,7 @@ pageSize: String(EXPLORE_PAGE_SIZE) }); if (data.query) params.set('name', data.query); + if (data.genre) params.set('genre', data.genre); const res = await fetch(`/api/tracks?${params}`); if (!res.ok) throw new Error(String(res.status)); const next = (await res.json()) as typeof data.tracks; @@ -189,7 +193,9 @@ - {data.query ? `Buscar «${data.query}»` : 'Descubrir'} + {activeGenre ? activeGenre.label : data.query ? `Buscar «${data.query}»` : 'Descubrir'} - {#if !data.query} + {#if activeGenre} + + {:else if !data.query}
- {#each genreTiles.slice(0, 4) as genre (genre.query)} - {genre.label} + {#each tiles.slice(0, 4) as tile (tile.genre)} + {tile.label} {/each}
{/if} - {#if !data.query} + {#if !data.query && !data.genre}
- {#each genreTiles as genre, i (genre.query)} + {#each tiles as tile, i (tile.genre)}
- {genre.label} + {tile.label}
-
{genre.tagline}
+
{tile.tagline}
{/each}
+ {#if tiles.length === 0} + + {/if} {/if} - {#if data.query && !nothingFound} + {#if (data.query || data.genre) && !nothingFound}
{#if showGroup(sfilter, 'Canciones')}
@@ -366,7 +377,9 @@ {:else} {/if}
diff --git a/web-player/src/routes/(app)/upload/+page.server.ts b/web-player/src/routes/(app)/upload/+page.server.ts index 1809b7f..b7e11c9 100644 --- a/web-player/src/routes/(app)/upload/+page.server.ts +++ b/web-player/src/routes/(app)/upload/+page.server.ts @@ -7,12 +7,18 @@ import { unwrapOrFail } from '$lib/server/api'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; -import { findConflict, genreLabel, isTrackGenre, type TrackGenre } from '$lib/data/trackGenres'; +import { findConflict, genreInfo } from '$lib/data/genres'; +import type { components } from '$lib/api/schema'; + +type Genre = components['schemas']['Genre']; const MAX_TITLE = 100; -export const load: PageServerLoad = ({ locals, url }) => { +export const load: PageServerLoad = async ({ locals, url, fetch }) => { requireUser(locals, url); + const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); + const { data } = await api.GET('/genres/available'); + return { genres: data ?? [] }; }; export const actions: Actions = { @@ -40,20 +46,23 @@ export const actions: Actions = { if (rawTags.length === 0) { return fail(400, { message: 'Elige al menos un género.' }); } - if (!rawTags.every(isTrackGenre)) { + const api = createApiClient({ fetch, accessToken }); + const { data: available } = await api.GET('/genres/available'); + const options = available ?? []; + const known = new Set(options.map((option) => option.genre)); + if (!rawTags.every((tag) => known.has(tag))) { return fail(400, { message: 'Alguno de los géneros no es válido.' }); } - const tags: TrackGenre[] = [...new Set(rawTags)]; - const conflict = findConflict(tags); + const tags = [...new Set(rawTags)] as Genre[]; + const conflict = findConflict(tags, options); if (conflict) { return fail(400, { - message: `Los géneros ${genreLabel(conflict[0])} y ${genreLabel(conflict[1])} no se pueden combinar.` + message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.` }); } const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); - const api = createApiClient({ fetch, accessToken }); const urlsResult = await api.POST('/tracks/upload-urls', { body: { diff --git a/web-player/src/routes/(app)/upload/+page.svelte b/web-player/src/routes/(app)/upload/+page.svelte index ba9b3da..2a93141 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -15,13 +15,15 @@ import Button from '$lib/components/ui/primitives/Button.svelte'; import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte'; - import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres'; + import { genreOptions } from '$lib/data/genres'; type Status = 'idle' | 'uploading' | 'done' | 'error'; type StepState = 'idle' | 'active' | 'done'; const MAX_TITLE = 100; + let { data } = $props(); + let title = $state(''); let audioName = $state(''); let audioSize = $state(0); @@ -33,7 +35,7 @@ let isExplicit = $state(false); let errorMsg = $state(''); let errorDetail = $state(''); - let tags = $state([]); + let tags = $state([]); let publishedTitle = $state(''); let audioInput = $state(); @@ -43,6 +45,15 @@ { key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' } ]; + const options = $derived(genreOptions(data.genres)); + const blocked = $derived( + new Set( + options + .filter((option) => tags.includes(option.genre)) + .flatMap((option) => option.incompatibleWith) + ) + ); + const busy = $derived(status === 'uploading'); const canPublish = $derived( @@ -87,14 +98,10 @@ pickAudio(file); } - function toggleTag(tag: TrackGenre) { + function toggleTag(tag: string) { tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag]; } - function tagBlocked(tag: TrackGenre) { - return !tags.includes(tag) && tags.some((current) => !areCompatible(current, tag)); - } - function reset() { title = ''; audioName = ''; @@ -260,13 +267,13 @@
- {#each TRACK_GENRES as genre (genre.value)} + {#each options as option (option.genre)} toggleTag(genre.value)} + selected={tags.includes(option.genre)} + disabled={blocked.has(option.genre)} + onclick={() => toggleTag(option.genre)} > - {genre.label} + {option.label} {/each}
diff --git a/web-player/src/routes/api/listens/[id]/progress/+server.ts b/web-player/src/routes/api/listens/[id]/progress/+server.ts new file mode 100644 index 0000000..78e4c9b --- /dev/null +++ b/web-player/src/routes/api/listens/[id]/progress/+server.ts @@ -0,0 +1,25 @@ +import type { RequestHandler } from './$types'; +import { json } from '@sveltejs/kit'; +import { createApiClient } from '$lib/server/api'; + +export const PUT: RequestHandler = async ({ params, request, locals, fetch }) => { + if (!locals.accessToken) return json({ message: 'Inicia sesión.' }, { status: 401 }); + + const { playedSeconds } = (await request.json()) as { playedSeconds?: number }; + if (typeof playedSeconds !== 'number' || !Number.isFinite(playedSeconds) || playedSeconds < 0) + return json({ message: 'Tiempo inválido.' }, { status: 400 }); + + const api = createApiClient({ fetch, accessToken: locals.accessToken }); + const result = await api.PUT('/tracks/listens/{listenId}/progress', { + params: { path: { listenId: params.id } }, + body: { playedSeconds } + }); + + if (result.error !== undefined) + return json( + { message: 'No se pudo registrar la escucha.' }, + { status: result.response.status === 404 ? 404 : 502 } + ); + + return new Response(null, { status: 204 }); +}; diff --git a/web-player/src/routes/api/tracks/+server.ts b/web-player/src/routes/api/tracks/+server.ts index 4823c8c..a460898 100644 --- a/web-player/src/routes/api/tracks/+server.ts +++ b/web-player/src/routes/api/tracks/+server.ts @@ -5,12 +5,13 @@ import { EXPLORE_PAGE_SIZE } from '$lib/config'; export const GET: RequestHandler = async ({ url, locals, fetch }) => { const name = url.searchParams.get('name')?.trim() || undefined; + const genre = url.searchParams.get('genre')?.trim() || undefined; const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const result = await api.GET('/tracks', { - params: { query: { name, pageNumber, pageSize } } + params: { query: { name, genre, pageNumber, pageSize } } }); return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));