From 5df5e635f301a60203082cc5391feb72560726dd Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 19:40:40 +0200 Subject: [PATCH 1/3] Count real listening time instead of full track duration "Listening time this week" summed the full duration of every track whose stream ticket was issued, so pressing play and skipping counted the whole song. Playback is now measured on the client and reported to the API. Backend: - ListeningHistory gains PlayedSeconds, LastProgressAt and IsCounted (migration TrackPlayedSeconds marks existing rows as counted). - /tracks/{id}/stream returns a listenId; new PUT /tracks/listens/{listenId}/progress stores the accumulated seconds, capped by track duration and elapsed wall-clock time, monotonic and owner-only. - A listen counts once it reaches min(30s, 50% of the track). Weekly stats, streak, recent history/albums, mixes and listensCount only use counted listens; seconds sum PlayedSeconds. - ListeningHistoryCleanupJob (daily) deletes uncounted listens older than 8 days. web-player: - ListenTracker accumulates played time (seeks are ignored) and flushes on pause, track change, ended, every 30s, and on pagehide. - Proxy route /api/listens/[id]/progress; repeat starts a new listen per loop. --- .../Albums/GetAlbumTracks.cs | 2 +- .../Albums/GetRecentlyListenedAlbums.cs | 2 +- .../Likes/GetLikedTracks.cs | 2 +- .../Mixes/GenerateMixesForUser.cs | 2 +- .../Musify.Application/Mixes/MixItemMapper.cs | 2 +- .../PlayLists/GetPlayListTracks.cs | 2 +- .../Services/TrackStreamIssuer.cs | 4 +- .../Tracks/DeleteStaleUncountedListens.cs | 33 + .../Tracks/GetLastTrackListenedByUserId.cs | 2 +- .../Tracks/GetListeningHistory.cs | 4 +- .../Musify.Application/Tracks/GetTrackById.cs | 2 +- .../Musify.Application/Tracks/GetTracks.cs | 2 +- .../Tracks/GetTracksByUserId.cs | 2 +- .../Tracks/RecordListeningProgress.cs | 56 + .../Tracks/Responses/TrackStreamResponse.cs | 3 +- .../Users/GetListeningStats.cs | 5 +- .../Entities/ListeningHistory.cs | 6 + .../Jobs/DailyMixGenerationJob.cs | 1 + .../Jobs/ListeningHistoryCleanupJob.cs | 13 + ...60923172640_TrackPlayedSeconds.Designer.cs | 1173 +++++++++++++++++ .../20260923172640_TrackPlayedSeconds.cs | 52 + .../Migrations/DatabaseModelSnapshot.cs | 65 +- .../Services/ServicesDependencyInjection.cs | 1 + .../Tracks/RecordListeningProgressRequest.cs | 3 + .../Musify.Api/Endpoints/TrackEndpoints.cs | 21 + backend/Hosts/Musify.Worker/Program.cs | 5 + .../ListeningProgressEndpointsTests.cs | 109 ++ .../TestSupport/TestEntities.cs | 8 +- .../Tracks/GetTrackStreamQueryHandlerTests.cs | 3 + ...ordListeningProgressCommandHandlerTests.cs | 127 ++ .../Tracks/UncountedListensTests.cs | 106 ++ .../GetListeningStatsQueryHandlerTests.cs | 31 + web-player/src/lib/api/schema.d.ts | 56 + web-player/src/lib/player/listenTracker.ts | 52 + web-player/src/lib/player/player.svelte.ts | 41 +- .../api/listens/[id]/progress/+server.ts | 25 + 36 files changed, 1971 insertions(+), 52 deletions(-) create mode 100644 backend/Core/Musify.Application/Tracks/DeleteStaleUncountedListens.cs create mode 100644 backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs create mode 100644 backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs create mode 100644 backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs create mode 100644 backend/Hosts/Musify.Api/DataTransferObjects/Tracks/RecordListeningProgressRequest.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/ListeningProgressEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs create mode 100644 web-player/src/lib/player/listenTracker.ts create mode 100644 web-player/src/routes/api/listens/[id]/progress/+server.ts 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/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..b188fa2 100644 --- a/backend/Core/Musify.Application/Tracks/GetTracks.cs +++ b/backend/Core/Musify.Application/Tracks/GetTracks.cs @@ -33,7 +33,7 @@ namespace Musify.Application.Tracks 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/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs new file mode 100644 index 0000000..c37fdbe --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -0,0 +1,56 @@ +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 static class ListeningRules + { + public const double CountedThresholdSeconds = 30; + public const double CountedThresholdFraction = 0.5; + public const double DurationToleranceSeconds = 2; + public const double ClockToleranceSeconds = 5; + public const int UncountedRetentionDays = 8; + + public static double CountedThreshold(double durationSeconds) => + Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + } + + public 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 91f3231..008ddcc 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 => @@ -450,7 +459,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("OwnerUserId"); - b.ToTable("Tracks", (string)null); + b.ToTable("Tracks"); }); modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => @@ -475,7 +484,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 => @@ -495,7 +504,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 => @@ -524,7 +533,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.ToTable("Upload", (string)null); + b.ToTable("Upload"); }); modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => @@ -575,7 +584,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasIndex("UserId"); - b.ToTable("UploadIntents", (string)null); + b.ToTable("UploadIntents"); }); modelBuilder.Entity("Musify.Domain.Entities.User", b => @@ -613,7 +622,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("Id"); - b.ToTable("User", (string)null); + b.ToTable("User"); }); modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => @@ -638,7 +647,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 => @@ -659,7 +668,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 => @@ -686,7 +695,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 => @@ -713,7 +722,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 => @@ -749,7 +758,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b.HasKey("CorrelationId"); - b.ToTable("TrackProcessingState", (string)null); + b.ToTable("TrackProcessingState"); }); modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => @@ -803,7 +812,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.HasKey("AlbumId"); - b1.ToTable("Albums", (string)null); + b1.ToTable("Albums"); b1.WithOwner() .HasForeignKey("AlbumId"); @@ -896,32 +905,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"); @@ -992,7 +997,7 @@ namespace Musify.Infrastructure.Persistence.Migrations b1.HasKey("TrackId"); - b1.ToTable("Tracks", (string)null); + b1.ToTable("Tracks"); b1.WithOwner() .HasForeignKey("TrackId"); @@ -1029,7 +1034,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/TrackEndpoints.cs b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs index b285245..ff1ea37 100644 --- a/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs +++ b/backend/Hosts/Musify.Api/Endpoints/TrackEndpoints.cs @@ -41,6 +41,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.") @@ -128,6 +136,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.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/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/TestSupport/TestEntities.cs b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs index 3dce496..b908c68 100644 --- a/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs +++ b/backend/Tests/Musify.Application.Tests/TestSupport/TestEntities.cs @@ -177,12 +177,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/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/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/Tracks/UncountedListensTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs new file mode 100644 index 0000000..9671d05 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs @@ -0,0 +1,106 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Albums; +using Musify.Application.Mixes; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class UncountedListensTests : HandlerTestBase + { + [Fact] + public async Task ListeningHistory_IgnoresUncountedListens() + { + 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 new GetListeningHistoryQueryHandler(Database) + .Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted"], result.Select(track => track.Title)); + } + + [Fact] + public async Task RecentlyListenedAlbums_IgnoresUncountedListens() + { + 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 new GetRecentlyListenedAlbumsQueryHandler(Database) + .Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); + } + + [Fact] + public async Task Mixes_TreatTracksWithOnlyUncountedListensAsUnheard() + { + 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)); + + var handler = new GenerateMixesForUserCommandHandler( + Database, TestConfigurations.Mix(), NoOpLogger()); + await handler.Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); + + var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); + Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); + } + + [Fact] + public async Task TrackListensCount_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 new GetTrackByIdQueryHandler(Database) + .Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.ListensCount); + } + + [Fact] + public async Task Cleanup_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 new DeleteStaleUncountedListensCommandHandler(Database, NoOpLogger()) + .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/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 0854e6d..76d3c82 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -450,6 +450,22 @@ export interface paths { patch?: never; trace?: never; }; + "/tracks/listens/{listenId}/progress": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put: operations["RecordListeningProgress"]; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/tracks/users/{userId}": { parameters: { query?: never; @@ -861,6 +877,9 @@ export interface components { expectedSizeBytes?: null | number | string; }; + RecordListeningProgressRequest: { + playedSeconds: number | string; + }; RequestTrackUploadUrlsRequest: { pictureFileType: string; pictureContentType: string; @@ -912,6 +931,8 @@ export interface components { ticket: string; expiresInSeconds: number | string; + /** Format: uuid */ + listenId?: string | null; }; TrackUploadUrlsResponse: { @@ -2196,6 +2217,41 @@ export interface operations { }; }; }; + RecordListeningProgress: { + parameters: { + query?: never; + header?: never; + path: { + listenId: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["RecordListeningProgressRequest"]; + }; + }; + responses: { + 204: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 401: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; RequestTrackUploadUrls: { parameters: { query?: never; 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 e34af4c..088ff6f 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; @@ -131,6 +132,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); @@ -150,19 +152,30 @@ class PlayerState { audio.volume = this.volume / 100; 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; @@ -176,6 +189,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; } @@ -266,14 +283,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); @@ -299,14 +318,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/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 }); +}; From 8870c65c6f06825f46ae132556f84b211b2ec1ec Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 20:02:50 +0200 Subject: [PATCH 2/3] Move ListeningRules to its own file and split listening tests by subject The counted-listen tests lived in one catch-all UncountedListensTests file. Each now sits next to the handler it covers, and the cleanup handler gets its own test file. --- .../Tracks/ListeningRules.cs | 14 +++ .../Tracks/RecordListeningProgress.cs | 12 -- ...RecentlyListenedAlbumsQueryHandlerTests.cs | 20 ++++ ...GenerateMixesForUserCommandHandlerTests.cs | 16 +++ ...taleUncountedListensCommandHandlerTests.cs | 32 ++++++ .../GetListeningHistoryQueryHandlerTests.cs | 16 +++ .../Tracks/GetTrackByIdQueryHandlerTests.cs | 16 +++ .../Tracks/UncountedListensTests.cs | 106 ------------------ 8 files changed, 114 insertions(+), 118 deletions(-) create mode 100644 backend/Core/Musify.Application/Tracks/ListeningRules.cs create mode 100644 backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs delete mode 100644 backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs 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 index c37fdbe..c23a410 100644 --- a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -11,18 +11,6 @@ namespace Musify.Application.Tracks double PlayedSeconds) : ICommand>; - public static class ListeningRules - { - public const double CountedThresholdSeconds = 30; - public const double CountedThresholdFraction = 0.5; - public const double DurationToleranceSeconds = 2; - public const double ClockToleranceSeconds = 5; - public const int UncountedRetentionDays = 8; - - public static double CountedThreshold(double durationSeconds) => - Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); - } - public class RecordListeningProgressCommandHandler(IDatabase database) : ICommandHandler> { 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/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/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/UncountedListensTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs deleted file mode 100644 index 9671d05..0000000 --- a/backend/Tests/Musify.Application.Tests/Tracks/UncountedListensTests.cs +++ /dev/null @@ -1,106 +0,0 @@ -using Microsoft.EntityFrameworkCore; -using Musify.Application.Albums; -using Musify.Application.Mixes; -using Musify.Application.Tests.TestSupport; -using Musify.Application.Tracks; -using Musify.Domain.Entities; -using Xunit; - -namespace Musify.Application.Tests.Tracks -{ - public sealed class UncountedListensTests : HandlerTestBase - { - [Fact] - public async Task ListeningHistory_IgnoresUncountedListens() - { - 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 new GetListeningHistoryQueryHandler(Database) - .Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); - - Assert.Equal(["Counted"], result.Select(track => track.Title)); - } - - [Fact] - public async Task RecentlyListenedAlbums_IgnoresUncountedListens() - { - 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 new GetRecentlyListenedAlbumsQueryHandler(Database) - .Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken); - - Assert.Equal(["Counted album"], result.Value.Select(album => album.Title)); - } - - [Fact] - public async Task Mixes_TreatTracksWithOnlyUncountedListensAsUnheard() - { - 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)); - - var handler = new GenerateMixesForUserCommandHandler( - Database, TestConfigurations.Mix(), NoOpLogger()); - await handler.Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken); - - var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken); - Assert.Contains(mixes, mix => mix.Title == "Descubrimiento"); - } - - [Fact] - public async Task TrackListensCount_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 new GetTrackByIdQueryHandler(Database) - .Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); - - Assert.Equal(2, result.Value.ListensCount); - } - - [Fact] - public async Task Cleanup_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 new DeleteStaleUncountedListensCommandHandler(Database, NoOpLogger()) - .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); - } - } -} From b61ff1ebec827cc1db3bbe88bd17bf8e27e99588 Mon Sep 17 00:00:00 2001 From: XShine555 Date: Wed, 23 Sep 2026 20:05:30 +0200 Subject: [PATCH 3/3] Make Discover and upload genres dynamic Discover used a hardcoded list of genre tiles that searched track titles, and the upload form kept its own copy of the genre list and compatibility rules. Both now come from the backend. Backend: - GET /genres returns genres with at least one track and their counts. - GET /genres/available returns every assignable genre with the genres it cannot be combined with, so the rules live only in GenreCompatibility. - GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values). web-player: - Discover tiles are built from /genres; each opens /explore?genre=X, a song list filtered by that tag. Unknown genres fall back to a label derived from their name. - The upload form loads /genres/available, disables incompatible genres as you pick, and the server validates before reserving upload URLs. - lib/data/trackGenres.ts is removed; its helpers merge into lib/data/genres.ts. - Regenerate the typed API client. --- .../Genres/GetAvailableGenres.cs | 26 + .../Musify.Application/Genres/GetGenres.cs | 31 + .../Musify.Application/Tracks/GetTracks.cs | 6 +- .../Musify.Api/Endpoints/GenreEndpoints.cs | 39 + .../Musify.Api/Endpoints/TrackEndpoints.cs | 23 +- backend/Hosts/Musify.Api/Program.cs | 1 + .../Endpoints/GenreEndpointsTests.cs | 106 +++ .../GetAvailableGenresQueryHandlerTests.cs | 32 + .../Genres/GetGenresQueryHandlerTests.cs | 69 ++ .../Tracks/GetTracksQueryHandlerTests.cs | 19 + web-player/src/lib/api/schema.d.ts | 699 ++++++++++-------- web-player/src/lib/data/genres.ts | 93 ++- web-player/src/lib/data/trackGenres.ts | 56 -- web-player/src/lib/state/navigation.svelte.ts | 4 + .../src/routes/(app)/explore/+page.server.ts | 16 +- .../src/routes/(app)/explore/+page.svelte | 43 +- .../src/routes/(app)/upload/+page.server.ts | 23 +- .../src/routes/(app)/upload/+page.svelte | 31 +- web-player/src/routes/api/tracks/+server.ts | 3 +- 19 files changed, 906 insertions(+), 414 deletions(-) create mode 100644 backend/Core/Musify.Application/Genres/GetAvailableGenres.cs create mode 100644 backend/Core/Musify.Application/Genres/GetGenres.cs create mode 100644 backend/Hosts/Musify.Api/Endpoints/GenreEndpoints.cs create mode 100644 backend/Tests/Musify.Api.Tests/Endpoints/GenreEndpointsTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Genres/GetAvailableGenresQueryHandlerTests.cs create mode 100644 backend/Tests/Musify.Application.Tests/Genres/GetGenresQueryHandlerTests.cs delete mode 100644 web-player/src/lib/data/trackGenres.ts 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/Tracks/GetTracks.cs b/backend/Core/Musify.Application/Tracks/GetTracks.cs index b188fa2..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,6 +29,9 @@ 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 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 ff1ea37..1e8728c 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") @@ -92,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(); } 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/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.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/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/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 76d3c82..b4cb8c8 100644 --- a/web-player/src/lib/api/schema.d.ts +++ b/web-player/src/lib/api/schema.d.ts @@ -1,4 +1,7 @@ - +/** + * This file was auto-generated by openapi-typescript. + * Do not make direct changes to the file. + */ export interface paths { "/albums": { @@ -8,10 +11,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Albums, Optionally Filtered By Title. */ get: operations["GetAlbums"]; put?: never; - + /** Create A New Album. */ post: operations["CreateAlbum"]; delete?: never; options?: never; @@ -26,7 +29,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get An Album By Id. */ get: operations["GetAlbumById"]; put?: never; post?: never; @@ -43,7 +46,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Albums For A User. */ get: operations["GetAlbumsByUserId"]; put?: never; post?: never; @@ -60,7 +63,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Albums The Current User Listened To Most Recently. */ get: operations["GetRecentlyListenedAlbums"]; put?: never; post?: never; @@ -77,7 +80,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Tracks Of An Album Ordered By Track Number. */ get: operations["GetAlbumTracks"]; put?: never; post?: never; @@ -94,7 +97,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get An Album Cover Image. */ get: operations["GetAlbumCover"]; put?: never; post?: never; @@ -113,7 +116,7 @@ export interface paths { }; get?: never; put?: never; - + /** Request A Pre-Signed URL To Upload An Album Picture. */ post: operations["RequestAlbumPictureUpload"]; delete?: never; options?: never; @@ -129,10 +132,10 @@ export interface paths { cookie?: never; }; get?: never; - + /** Update An Album. */ put: operations["UpdateAlbum"]; post?: never; - + /** Delete An Album. */ delete: operations["DeleteAlbum"]; options?: never; head?: never; @@ -148,9 +151,9 @@ export interface paths { }; get?: never; put?: never; - + /** Add One Of Your Uploaded Tracks To An Album. */ post: operations["AddTrackToAlbum"]; - + /** Remove A Track From An Album. */ delete: operations["RemoveTrackFromAlbum"]; options?: never; head?: never; @@ -164,7 +167,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Public Playback Configuration (Whether Anonymous Listening Is Allowed). Works Anonymously. */ get: operations["GetPlaybackPublicConfig"]; put?: never; post?: never; @@ -174,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; @@ -181,7 +218,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Current User'S Liked Tracks. */ get: operations["GetLikedTracks"]; put?: never; post?: never; @@ -200,7 +237,7 @@ export interface paths { }; get?: never; put?: never; - + /** Like Or Unlike A Track By Id. */ post: operations["ToggleTrackLike"]; delete?: never; options?: never; @@ -215,7 +252,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get The Server-Generated Mixes Of The Current User. */ get: operations["GetMixes"]; put?: never; post?: never; @@ -232,7 +269,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Mix With All Of Its Songs. */ get: operations["GetMixById"]; put?: never; post?: never; @@ -249,10 +286,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated PlayLists. */ get: operations["GetPlayLists"]; put?: never; - + /** Create A New PlayList. */ post: operations["CreatePlayList"]; delete?: never; options?: never; @@ -267,7 +304,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A PlayList By Id. */ get: operations["GetPlayListById"]; put?: never; post?: never; @@ -284,7 +321,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated PlayLists For A User. */ get: operations["GetPlayListsByUserId"]; put?: never; post?: never; @@ -301,10 +338,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Tracks Of A PlayList. */ get: operations["GetPlayListTracks"]; put?: never; - + /** Add One Of Your Tracks To A PlayList. */ post: operations["AddPlayListTrack"]; delete?: never; options?: never; @@ -319,7 +356,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A PlayList Cover Image. */ get: operations["GetPlayListCover"]; put?: never; post?: never; @@ -337,10 +374,10 @@ export interface paths { cookie?: never; }; get?: never; - + /** Update An Existing PlayList. */ put: operations["UpdatePlayList"]; post?: never; - + /** Delete A PlayList. */ delete: operations["DeletePlayList"]; options?: never; head?: never; @@ -356,7 +393,7 @@ export interface paths { }; get?: never; put?: never; - + /** Request A Pre-Signed URL To Upload A PlayList Picture. */ post: operations["RequestPlayListPictureUpload"]; delete?: never; options?: never; @@ -374,7 +411,7 @@ export interface paths { get?: never; put?: never; post?: never; - + /** Remove A Track From A PlayList. */ delete: operations["RemoveTrackFromPlayList"]; options?: never; head?: never; @@ -388,10 +425,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Tracks, Optionally Filtered By Name And Genre. */ get: operations["GetTracks"]; put?: never; - + /** Create A New Track. */ post: operations["CreateTrack"]; delete?: never; options?: never; @@ -406,7 +443,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Track By Id. */ get: operations["GetTrackById"]; put?: never; post?: never; @@ -423,7 +460,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Track Cover Image. */ get: operations["GetTrackCover"]; put?: never; post?: never; @@ -440,7 +477,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It. */ get: operations["GetTrackStream"]; put?: never; post?: never; @@ -458,6 +495,7 @@ export interface paths { 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; @@ -473,7 +511,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Tracks For A User. */ get: operations["GetTracksByUserId"]; put?: never; post?: never; @@ -493,7 +531,7 @@ export interface paths { get?: never; put?: never; post?: never; - + /** Delete A Track. */ delete: operations["DeleteTrack"]; options?: never; head?: never; @@ -509,7 +547,7 @@ export interface paths { }; get?: never; put?: never; - + /** Request Pre-Signed URLs To Upload Track Picture And Audio. */ post: operations["RequestTrackUploadUrls"]; delete?: never; options?: never; @@ -524,10 +562,10 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get Paginated Users. */ get: operations["GetUsers"]; put?: never; - + /** Create A New User. */ post: operations["CreateUser"]; delete?: never; options?: never; @@ -542,7 +580,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User By Id. */ get: operations["GetUserById"]; put?: never; post?: never; @@ -559,7 +597,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User'S Listening History. */ get: operations["GetListeningHistory"]; put?: never; post?: never; @@ -569,23 +607,6 @@ export interface paths { patch?: never; trace?: never; }; - "/users/{id}/last-listened-track": { - parameters: { - query?: never; - header?: never; - path?: never; - cookie?: never; - }; - - get: operations["GetLastTrackListenedByUserId"]; - put?: never; - post?: never; - delete?: never; - options?: never; - head?: never; - patch?: never; - trace?: never; - }; "/users/{id}/listening-stats": { parameters: { query?: never; @@ -593,7 +614,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User'S Listening Stats For The Current Week And Streak. */ get: operations["GetListeningStats"]; put?: never; post?: never; @@ -603,6 +624,23 @@ export interface paths { patch?: never; trace?: never; }; + "/users/{id}/last-listened-track": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get The Last Track A User Listened To. */ + get: operations["GetLastTrackListenedByUserId"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/users/{id}/profile": { parameters: { query?: never; @@ -610,7 +648,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Get A User'S Public Profile, Including Follower Counts. */ get: operations["GetUserProfile"]; put?: never; post?: never; @@ -627,7 +665,7 @@ export interface paths { path?: never; cookie?: never; }; - + /** Check Whether The Current User Follows Another User. */ get: operations["IsFollowingUser"]; put?: never; post?: never; @@ -646,9 +684,9 @@ export interface paths { }; get?: never; put?: never; - + /** Follow A User. */ post: operations["FollowUser"]; - + /** Unfollow A User. */ delete: operations["UnfollowUser"]; options?: never; head?: never; @@ -660,36 +698,36 @@ export type webhooks = Record; export interface components { schemas: { AddPlayListTrackRequest: { - + /** Format: uuid */ trackId: string; }; AlbumApplicationResponse: { - + /** Format: uuid */ id: string; title: string; description: null | string; - + /** Format: int32 */ releaseYear: null | number | string; ownerUserId: string; - + /** Format: int32 */ trackCount: number | string; smallImageKeyName: null | string; mediumImageKeyName: null | string; largeImageKeyName: null | string; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; }; AlbumPictureUploadResponse: { - + /** Format: uuid */ intentId: string; bucket: string; key: string; pictureName: string; contentType: string; - + /** Format: int32 */ expiresInSeconds: number | string; uploadUrl: string; }; @@ -698,52 +736,62 @@ export interface components { }; AlbumsSearchResponse: { items: components["schemas"]["AlbumSearchItemResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasPreviousPage: boolean; hasNextPage: boolean; }; + AvailableGenreResponse: { + genre: components["schemas"]["Genre"]; + incompatibleWith: components["schemas"]["Genre"][]; + }; CreateAlbumRequest: { title: string; description: null | string; - + /** Format: int32 */ releaseYear: null | number | string; - + /** Format: uuid */ pictureIntentId: string; }; CreatePlayListRequest: { name: string; description: null | string; - + /** Format: uuid */ pictureIntentId: null | string; visibility?: components["schemas"]["PlaylistVisibility"]; }; CreateTrackRequest: { title: string; - + /** Format: uuid */ pictureIntentId: string; - + /** Format: uuid */ audioIntentId: string; tags: components["schemas"]["Genre"][]; }; - 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"; CreateUserRequest: { - + /** Format: int64 */ id: number | string; name: string; firstName: null | string; secondName: null | string; }; + /** @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; - + /** Format: int32 */ status?: null | number | string; detail?: null | string; instance?: null | string; @@ -752,91 +800,91 @@ export interface components { }; }; ListeningStatsResponse: { - + /** Format: int32 */ tracksThisWeek: number | string; - + /** Format: int32 */ secondsThisWeek: number | string; - + /** Format: int32 */ streakDays: number | string; }; MixApplicationResponse: { - + /** Format: uuid */ id: string; title: string; subtitle: null | string; - + /** Format: int32 */ itemCount: number | string; items: components["schemas"]["MixItemApplicationResponse"][]; }; MixItemApplicationResponse: { - + /** Format: uuid */ trackId: string; title: string; artist: null | string; - + /** Format: double */ durationSeconds: number | string; - + /** Format: int32 */ listensCount: number | string; }; PaginatedResponseOfAlbumApplicationResponse: { items: components["schemas"]["AlbumApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasNextPage: boolean; hasPreviousPage: boolean; }; PaginatedResponseOfPlayListApplicationResponse: { items: components["schemas"]["PlayListApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasNextPage: boolean; hasPreviousPage: boolean; }; PaginatedResponseOfTrackApplicationResponse: { items: components["schemas"]["TrackApplicationResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; 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 */ anonymousFragmentSeconds: number | string; }; PlayListApplicationResponse: { - + /** Format: uuid */ id: string; name: string; description: null | string; @@ -844,84 +892,86 @@ export interface components { mediumImageKeyName: null | string; largeImageKeyName: null | string; visibility: components["schemas"]["PlaylistVisibility"]; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; coverTrackIds: string[]; }; PlayListPictureUploadResponse: { - + /** Format: uuid */ intentId: string; bucket: string; key: string; pictureName: string; contentType: string; - + /** Format: int32 */ expiresInSeconds: number | string; uploadUrl: string; }; - + /** @enum {unknown} */ PlaylistVisibility: "Private" | "Public"; - + /** @enum {unknown} */ ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; + RecordListeningProgressRequest: { + /** Format: double */ + playedSeconds: number | string; + }; RequestAlbumPictureUploadRequest: { fileType: string; contentType: string; - + /** Format: int64 */ expectedSizeBytes?: null | number | string; }; RequestPlayListPictureUploadRequest: { fileType: string; contentType: string; - + /** Format: int64 */ expectedSizeBytes?: null | number | string; }; - RecordListeningProgressRequest: { - playedSeconds: number | string; - }; RequestTrackUploadUrlsRequest: { pictureFileType: string; pictureContentType: string; audioFileType: string; audioContentType: string; - + /** Format: int64 */ expectedPictureSizeBytes?: null | number | string; - + /** Format: int64 */ expectedAudioSizeBytes?: null | number | string; }; ToggleLikeRequest: { - + /** Format: uuid */ trackId: string; }; TrackApplicationResponse: { - + /** Format: uuid */ id: string; title: string; artist: null | string; audioStatus: components["schemas"]["ProcessingStatus"]; - + /** Format: double */ duration: number | string; - + /** Format: int32 */ listensCount: number | string; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; ownerUserId: string; + tags: components["schemas"]["Genre"][]; }; TrackSearchItemResponse: { track: components["schemas"]["TrackApplicationResponse"]; }; TracksSearchResponse: { items: components["schemas"]["TrackSearchItemResponse"][]; - + /** Format: int32 */ pageNumber: number | string; - + /** Format: int32 */ pageSize: number | string; - + /** Format: int32 */ pageCount: number | string; - + /** Format: int32 */ totalItemCount: number | string; hasPreviousPage: boolean; hasNextPage: boolean; @@ -929,15 +979,15 @@ export interface components { TrackStreamResponse: { manifestUrl: string; ticket: string; - + /** Format: int32 */ expiresInSeconds: number | string; /** Format: uuid */ - listenId?: string | null; + listenId?: null | string; }; TrackUploadUrlsResponse: { - + /** Format: uuid */ pictureIntentId: string; - + /** Format: uuid */ audioIntentId: string; bucket: string; pictureKey: string; @@ -948,21 +998,21 @@ export interface components { audioName: string; audioContentType: string; audioUploadUrl: string; - + /** Format: int32 */ expiresInSeconds: number | string; }; UpdateAlbumRequest: { newTitle: string; newDescription: null | string; - + /** Format: int32 */ newReleaseYear: null | number | string; - + /** Format: uuid */ newPictureIntentId: null | string; }; UpdatePlayListRequest: { newName: null | string; newDescription: null | string; - + /** Format: uuid */ newPictureIntentId: null | string; newVisibility?: null | components["schemas"]["PlaylistVisibility"]; }; @@ -972,9 +1022,9 @@ export interface components { firstName: null | string; secondName: null | string; profilePictureUrl: null | string; - + /** Format: date-time */ createdAt: string; - + /** Format: date-time */ updatedAt: string; }; UserProfileResponse: { @@ -983,11 +1033,11 @@ export interface components { firstName: null | string; secondName: null | string; profilePictureUrl: null | string; - + /** Format: date-time */ createdAt: string; - + /** Format: int32 */ followersCount: number | string; - + /** Format: int32 */ followingCount: number | string; }; }; @@ -1012,7 +1062,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1036,7 +1086,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -1045,7 +1095,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1054,14 +1104,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1081,7 +1131,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1090,7 +1140,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1113,7 +1163,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1122,7 +1172,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfAlbumApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1142,7 +1192,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1151,7 +1201,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"][]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -1174,7 +1224,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1183,7 +1233,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1205,14 +1255,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1234,7 +1284,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1243,7 +1293,7 @@ export interface operations { "application/json": components["schemas"]["AlbumPictureUploadResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1252,14 +1302,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1283,7 +1333,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1292,7 +1342,7 @@ export interface operations { "application/json": components["schemas"]["AlbumApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1301,14 +1351,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1328,21 +1378,21 @@ export interface operations { }; requestBody?: never; 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; @@ -1363,28 +1413,28 @@ export interface operations { }; requestBody?: never; 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; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -1405,21 +1455,21 @@ export interface operations { }; requestBody?: never; 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; @@ -1437,7 +1487,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1448,6 +1498,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?: { @@ -1460,7 +1550,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1469,7 +1559,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -1491,7 +1581,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1500,14 +1590,14 @@ export interface operations { "application/json": boolean; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1525,7 +1615,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1534,7 +1624,7 @@ export interface operations { "application/json": components["schemas"]["MixApplicationResponse"][]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -1554,7 +1644,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1563,14 +1653,14 @@ export interface operations { "application/json": components["schemas"]["MixApplicationResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1591,7 +1681,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1615,7 +1705,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -1624,7 +1714,7 @@ export interface operations { "application/json": components["schemas"]["PlayListApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1633,14 +1723,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1660,7 +1750,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1669,7 +1759,7 @@ export interface operations { "application/json": components["schemas"]["PlayListApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1694,7 +1784,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1719,7 +1809,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1728,7 +1818,7 @@ export interface operations { "application/json": components["schemas"]["PaginatedResponseOfTrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1752,7 +1842,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -1761,21 +1851,21 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -1797,14 +1887,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1828,7 +1918,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1837,7 +1927,7 @@ export interface operations { "application/json": components["schemas"]["PlayListApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1846,14 +1936,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1873,21 +1963,21 @@ export interface operations { }; requestBody?: never; 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; @@ -1909,7 +1999,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -1918,7 +2008,7 @@ export interface operations { "application/json": components["schemas"]["PlayListPictureUploadResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -1927,14 +2017,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -1955,21 +2045,21 @@ export interface operations { }; requestBody?: never; 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; @@ -1982,6 +2072,7 @@ export interface operations { parameters: { query?: { name?: string; + genre?: string; pageNumber?: number | string; pageSize?: number | string; }; @@ -1991,7 +2082,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2000,6 +2091,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: { @@ -2015,7 +2115,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -2024,7 +2124,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2033,21 +2133,21 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2067,7 +2167,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2076,7 +2176,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2098,14 +2198,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2125,7 +2225,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2134,14 +2234,52 @@ export interface operations { "application/json": components["schemas"]["TrackStreamResponse"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + }; + }; + 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; @@ -2165,7 +2303,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2187,28 +2325,28 @@ export interface operations { }; requestBody?: never; 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; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2217,41 +2355,6 @@ export interface operations { }; }; }; - RecordListeningProgress: { - parameters: { - query?: never; - header?: never; - path: { - listenId: string; - }; - cookie?: never; - }; - requestBody: { - content: { - "application/json": components["schemas"]["RecordListeningProgressRequest"]; - }; - }; - responses: { - 204: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 401: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; - }; - }; RequestTrackUploadUrls: { parameters: { query?: never; @@ -2265,7 +2368,7 @@ export interface operations { }; }; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2274,7 +2377,7 @@ export interface operations { "application/json": components["schemas"]["TrackUploadUrlsResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2283,21 +2386,21 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2319,7 +2422,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2343,7 +2446,7 @@ export interface operations { }; }; responses: { - + /** @description Created */ 201: { headers: { [name: string]: unknown; @@ -2352,7 +2455,7 @@ export interface operations { "application/json": components["schemas"]["UserApplicationResponse"]; }; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2361,7 +2464,7 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Conflict */ 409: { headers: { [name: string]: unknown; @@ -2381,7 +2484,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2390,7 +2493,7 @@ export interface operations { "application/json": components["schemas"]["UserApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2410,7 +2513,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2419,7 +2522,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"][]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2439,7 +2542,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2461,7 +2564,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2470,7 +2573,7 @@ export interface operations { "application/json": components["schemas"]["TrackApplicationResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2490,7 +2593,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2499,7 +2602,7 @@ export interface operations { "application/json": components["schemas"]["UserProfileResponse"]; }; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2519,7 +2622,7 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description OK */ 200: { headers: { [name: string]: unknown; @@ -2528,7 +2631,7 @@ export interface operations { "application/json": boolean; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; @@ -2548,14 +2651,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Bad Request */ 400: { headers: { [name: string]: unknown; @@ -2564,14 +2667,14 @@ export interface operations { "application/problem+json": components["schemas"]["HttpValidationProblemDetails"]; }; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Not Found */ 404: { headers: { [name: string]: unknown; @@ -2591,14 +2694,14 @@ export interface operations { }; requestBody?: never; responses: { - + /** @description No Content */ 204: { headers: { [name: string]: unknown; }; content?: never; }; - + /** @description Unauthorized */ 401: { headers: { [name: string]: unknown; 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/state/navigation.svelte.ts b/web-player/src/lib/state/navigation.svelte.ts index 0d32598..8b1414d 100644 --- a/web-player/src/lib/state/navigation.svelte.ts +++ b/web-player/src/lib/state/navigation.svelte.ts @@ -68,6 +68,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 51f7f37..7068513 100644 --- a/web-player/src/routes/(app)/explore/+page.server.ts +++ b/web-player/src/routes/(app)/explore/+page.server.ts @@ -9,13 +9,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 @@ -47,6 +59,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 4d90242..2045a9d 100644 --- a/web-player/src/routes/(app)/explore/+page.svelte +++ b/web-player/src/routes/(app)/explore/+page.svelte @@ -16,13 +16,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, @@ -58,7 +58,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; @@ -73,6 +73,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); @@ -169,6 +172,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; @@ -188,7 +192,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')}
@@ -364,7 +375,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 53a7bdd..a8adcec 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 = { @@ -39,20 +45,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 28e907d..c3d3100 100644 --- a/web-player/src/routes/(app)/upload/+page.svelte +++ b/web-player/src/routes/(app)/upload/+page.svelte @@ -14,13 +14,15 @@ import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte'; import Button from '$lib/components/ui/primitives/Button.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); @@ -31,7 +33,7 @@ let acceptedTerms = $state(false); let errorMsg = $state(''); let errorDetail = $state(''); - let tags = $state([]); + let tags = $state([]); let publishedTitle = $state(''); let audioInput = $state(); @@ -41,6 +43,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( @@ -85,14 +96,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 = ''; @@ -254,13 +261,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/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.'));