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/ListeningRules.cs b/backend/Core/Musify.Application/Tracks/ListeningRules.cs new file mode 100644 index 0000000..146ef1e --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/ListeningRules.cs @@ -0,0 +1,14 @@ +namespace Musify.Application.Tracks +{ + public static class ListeningRules + { + public const double CountedThresholdSeconds = 30; + public const double CountedThresholdFraction = 0.5; + public const double DurationToleranceSeconds = 2; + public const double ClockToleranceSeconds = 5; + public const int UncountedRetentionDays = 8; + + public static double CountedThreshold(double durationSeconds) => + Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction); + } +} diff --git a/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs new file mode 100644 index 0000000..c23a410 --- /dev/null +++ b/backend/Core/Musify.Application/Tracks/RecordListeningProgress.cs @@ -0,0 +1,44 @@ +using ErrorOr; +using Mediator; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Contracts; + +namespace Musify.Application.Tracks +{ + public record RecordListeningProgressCommand( + long UserId, + Guid ListenId, + double PlayedSeconds) + : ICommand>; + + public class RecordListeningProgressCommandHandler(IDatabase database) + : ICommandHandler> + { + public async ValueTask> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken) + { + if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0) + return Error.Validation(description: "playedSeconds must be a non-negative number."); + + var listen = await database.ListeningHistories + .Include(l => l.Track) + .SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken); + + if (listen == null) + return Error.NotFound(); + + var now = DateTime.UtcNow; + var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds; + var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds; + var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap)); + + listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted); + listen.LastProgressAt = now; + listen.IsCounted = listen.IsCounted + || listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds); + + await database.SaveChangesAsync(cancellationToken); + + return Result.Success; + } + } +} diff --git a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs index c925a8b..024edf7 100644 --- a/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs +++ b/backend/Core/Musify.Application/Tracks/Responses/TrackStreamResponse.cs @@ -3,5 +3,6 @@ namespace Musify.Application.Tracks.Responses public record TrackStreamResponse( string ManifestUrl, string Ticket, - int ExpiresInSeconds); + int ExpiresInSeconds, + Guid? ListenId = null); } diff --git a/backend/Core/Musify.Application/Users/GetListeningStats.cs b/backend/Core/Musify.Application/Users/GetListeningStats.cs index 6794717..d097b85 100644 --- a/backend/Core/Musify.Application/Users/GetListeningStats.cs +++ b/backend/Core/Musify.Application/Users/GetListeningStats.cs @@ -20,17 +20,18 @@ namespace Musify.Application.Users .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo); var tracksThisWeek = await thisWeekHistory + .Where(l => l.IsCounted) .Select(l => l.TrackId) .Distinct() .CountAsync(cancellationToken); var secondsThisWeek = await thisWeekHistory - .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds) + .Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0)) .SumAsync(cancellationToken); var listenedDates = await database.ListeningHistories .AsNoTracking() - .Where(l => l.UserId == request.UserId) + .Where(l => l.UserId == request.UserId && l.IsCounted) .Select(l => l.ListenedAt.Date) .Distinct() .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs index ed94b6c..0439cb4 100644 --- a/backend/Core/Musify.Domain/Entities/ListeningHistory.cs +++ b/backend/Core/Musify.Domain/Entities/ListeningHistory.cs @@ -19,6 +19,12 @@ namespace Musify.Domain.Entities [Required] public DateTime ListenedAt { get; set; } = DateTime.UtcNow; + public double? PlayedSeconds { get; set; } + + public DateTime? LastProgressAt { get; set; } + + public bool IsCounted { get; set; } + [ForeignKey(nameof(UserId) )] public User User { get; set; } diff --git a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs index 16a701c..4c7f39c 100644 --- a/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs +++ b/backend/Core/Musify.Infrastructure/Jobs/DailyMixGenerationJob.cs @@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Jobs { var userIds = await database.ListeningHistories .AsNoTracking() + .Where(history => history.IsCounted) .Select(history => history.UserId) .Distinct() .ToListAsync(cancellationToken); diff --git a/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs new file mode 100644 index 0000000..fc8e1b3 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Jobs/ListeningHistoryCleanupJob.cs @@ -0,0 +1,13 @@ +using Mediator; +using Musify.Application.Tracks; + +namespace Musify.Infrastructure.Jobs +{ + public class ListeningHistoryCleanupJob(IMediator mediator) + { + public async Task RunAsync(CancellationToken cancellationToken) + { + await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs new file mode 100644 index 0000000..0d81c88 --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.Designer.cs @@ -0,0 +1,1173 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using Musify.Domain.ValueObjects; +using Musify.Infrastructure.Persistence; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + [DbContext(typeof(Database))] + [Migration("20260923172640_TrackPlayedSeconds")] + partial class TrackPlayedSeconds + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.12") + .HasAnnotation("Relational:MaxIdentifierLength", 63); + + NpgsqlModelBuilderExtensions.HasPostgresEnum(modelBuilder, "genre", new[] { "ambient", "blues", "classical", "country", "drum_and_bass", "dubstep", "electronic", "folk", "funk", "hip_hop", "house", "indie", "jazz", "k_pop", "latin", "lofi", "metal", "pop", "punk", "reggae", "reggaeton", "rn_b", "rock", "soul", "techno", "trance" }); + NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.InboxState", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("Id")); + + b.Property("Consumed") + .HasColumnType("timestamp with time zone"); + + b.Property("ConsumerId") + .HasColumnType("uuid"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("ReceiveCount") + .HasColumnType("integer"); + + b.Property("Received") + .HasColumnType("timestamp with time zone"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("Id"); + + b.HasIndex("Delivered"); + + b.ToTable("InboxState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.Property("SequenceNumber") + .ValueGeneratedOnAdd() + .HasColumnType("bigint"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property("SequenceNumber")); + + b.Property("Body") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("ConversationId") + .HasColumnType("uuid"); + + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("DestinationAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("EnqueueTime") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpirationTime") + .HasColumnType("timestamp with time zone"); + + b.Property("FaultAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Headers") + .HasColumnType("text"); + + b.Property("InboxConsumerId") + .HasColumnType("uuid"); + + b.Property("InboxMessageId") + .HasColumnType("uuid"); + + b.Property("InitiatorId") + .HasColumnType("uuid"); + + b.Property("MessageId") + .HasColumnType("uuid"); + + b.Property("MessageType") + .IsRequired() + .HasColumnType("text"); + + b.Property("OutboxId") + .HasColumnType("uuid"); + + b.Property("Properties") + .HasColumnType("text"); + + b.Property("RequestId") + .HasColumnType("uuid"); + + b.Property("ResponseAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("SentTime") + .HasColumnType("timestamp with time zone"); + + b.Property("SourceAddress") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.HasKey("SequenceNumber"); + + b.HasIndex("EnqueueTime"); + + b.HasIndex("ExpirationTime"); + + b.HasIndex("OutboxId", "SequenceNumber") + .IsUnique(); + + b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") + .IsUnique(); + + b.ToTable("OutboxMessage"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => + { + b.Property("OutboxId") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Created") + .HasColumnType("timestamp with time zone"); + + b.Property("Delivered") + .HasColumnType("timestamp with time zone"); + + b.Property("LastSequenceNumber") + .HasColumnType("bigint"); + + b.Property("LockId") + .HasColumnType("uuid"); + + b.Property("RowVersion") + .IsConcurrencyToken() + .ValueGeneratedOnAddOrUpdate() + .HasColumnType("bytea"); + + b.HasKey("OutboxId"); + + b.HasIndex("Created"); + + b.ToTable("OutboxState"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("ReleaseYear") + .HasColumnType("integer"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Albums"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("AlbumId") + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("TrackNumber") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("AlbumId", "TrackId") + .IsUnique(); + + b.ToTable("AlbumHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("IsCounted") + .HasColumnType("boolean"); + + b.Property("LastProgressAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ListenedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayedSeconds") + .HasColumnType("double precision"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("ListeningHistory"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("Subtitle") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("character varying(100)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId", "Position"); + + b.ToTable("Mixes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("MixId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("MixId", "Position"); + + b.ToTable("MixItems"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Description") + .HasMaxLength(256) + .HasColumnType("character varying(256)"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("character varying(50)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.Property("Visibility") + .ValueGeneratedOnAdd() + .HasColumnType("integer") + .HasDefaultValue(0); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("PlayLists"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("AddedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("PlayListId") + .HasColumnType("uuid"); + + b.Property("Position") + .HasColumnType("integer"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("PlayListId"); + + b.HasIndex("TrackId"); + + b.ToTable("PlayListHasTrack"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("DurationSeconds") + .HasColumnType("double precision"); + + b.Property("LifeCycleStatus") + .HasColumnType("integer"); + + b.Property("NormalizedTitle") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("OwnerUserId") + .HasColumnType("bigint"); + + b.Property("Title") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("character varying(200)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.HasIndex("OwnerUserId"); + + b.ToTable("Tracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId", "TrackId") + .IsUnique(); + + b.ToTable("TrackLikes"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Tag") + .HasColumnType("genre"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.HasKey("Id"); + + b.HasIndex("TrackId", "Tag") + .IsUnique(); + + b.ToTable("TrackTags"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Upload", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasColumnType("text"); + + b.Property("ContentType") + .IsRequired() + .HasColumnType("text"); + + b.Property("CreatedTime") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasColumnType("text"); + + b.Property("State") + .HasColumnType("integer"); + + b.HasKey("Id"); + + b.ToTable("Upload"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("Bucket") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("ContentType") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("ExpectedSizeBytes") + .HasColumnType("bigint"); + + b.Property("ExpiresAt") + .HasColumnType("timestamp with time zone"); + + b.Property("Key") + .IsRequired() + .HasMaxLength(512) + .HasColumnType("character varying(512)"); + + b.Property("ObjectName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("character varying(128)"); + + b.Property("Purpose") + .HasColumnType("integer"); + + b.Property("Status") + .HasColumnType("integer"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("UserId"); + + b.ToTable("UploadIntents"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Property("Id") + .HasColumnType("bigint"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FirstName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("NormalizedName") + .IsRequired() + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("ProfilePictureUrl") + .HasMaxLength(2048) + .HasColumnType("character varying(2048)"); + + b.Property("SecondName") + .HasMaxLength(48) + .HasColumnType("character varying(48)"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("Id"); + + b.ToTable("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("FollowedId") + .HasColumnType("bigint"); + + b.Property("FollowerId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("FollowedId"); + + b.HasIndex("FollowerId", "FollowedId") + .IsUnique(); + + b.ToTable("UserFollows"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("uuid"); + + b.Property("TrackId") + .HasColumnType("uuid"); + + b.Property("UserId") + .HasColumnType("bigint"); + + b.HasKey("Id"); + + b.HasIndex("TrackId"); + + b.HasIndex("UserId"); + + b.ToTable("UserHasTrack"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("AlbumProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("PlayListProcessingState"); + }); + + modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => + { + b.Property("CorrelationId") + .HasColumnType("uuid"); + + b.Property("AudioKey") + .HasColumnType("text"); + + b.Property("AudioProcessed") + .HasColumnType("boolean"); + + b.Property("Bucket") + .HasColumnType("text"); + + b.Property("CreatedAt") + .HasColumnType("timestamp with time zone"); + + b.Property("CurrentState") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)"); + + b.Property("PictureKey") + .HasColumnType("text"); + + b.Property("PictureProcessed") + .HasColumnType("boolean"); + + b.Property("UpdatedAt") + .HasColumnType("timestamp with time zone"); + + b.HasKey("CorrelationId"); + + b.ToTable("TrackProcessingState"); + }); + + modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => + { + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.OutboxState", null) + .WithMany() + .HasForeignKey("OutboxId"); + + b.HasOne("MassTransit.EntityFrameworkCoreIntegration.InboxState", null) + .WithMany() + .HasForeignKey("InboxMessageId", "InboxConsumerId") + .HasPrincipalKey("MessageId", "ConsumerId"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.AlbumPictures", "Pictures", b1 => + { + b1.Property("AlbumId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("AlbumId"); + + b1.ToTable("Albums"); + + b1.WithOwner() + .HasForeignKey("AlbumId"); + }); + + b.Navigation("Owner"); + + b.Navigation("Pictures"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Album", "Album") + .WithMany("AlbumTracks") + .HasForeignKey("AlbumId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("AlbumTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Album"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("ListeningHistories") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("ListeningHistories") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => + { + b.HasOne("Musify.Domain.Entities.Mix", "Mix") + .WithMany("Items") + .HasForeignKey("MixId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Mix"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("PlayLists") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.PlayListPictures", "Pictures", b1 => + { + b1.Property("PlayListId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("PlayListId"); + + b1.ToTable("PlayLists"); + + b1.WithOwner() + .HasForeignKey("PlayListId"); + }); + + b.Navigation("Pictures"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.PlayList", "PlayList") + .WithMany("PlayListTracks") + .HasForeignKey("PlayListId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("PlayListTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("PlayList"); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.HasOne("Musify.Domain.Entities.User", "Owner") + .WithMany() + .HasForeignKey("OwnerUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackAudio", "Audio", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("DownloadRequested") + .HasColumnType("boolean") + .HasColumnName("DownloadRequested"); + + b1.Property("FolderName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("AudioFolderName"); + + b1.Property("LastRetryAt") + .HasColumnType("timestamp with time zone") + .HasColumnName("LastRetryAt"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalAudioName"); + + b1.Property("RetryCount") + .HasColumnType("integer") + .HasColumnName("RetryCount"); + + b1.Property("TranscodeStatus") + .HasColumnType("integer") + .HasColumnName("AudioTranscodeProcessingStatus"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.OwnsOne("Musify.Domain.ValueObjects.TrackPictures", "Pictures", b1 => + { + b1.Property("TrackId") + .HasColumnType("uuid"); + + b1.Property("LargeName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("LargePictureName"); + + b1.Property("MediumName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("MediumPictureName"); + + b1.Property("OriginalName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("OriginalPictureName"); + + b1.Property("ProcessingStatus") + .HasColumnType("integer") + .HasColumnName("PicturesProcessingStatus"); + + b1.Property("SmallName") + .HasMaxLength(64) + .HasColumnType("character varying(64)") + .HasColumnName("SmallPictureName"); + + b1.HasKey("TrackId"); + + b1.ToTable("Tracks"); + + b1.WithOwner() + .HasForeignKey("TrackId"); + }); + + b.Navigation("Audio") + .IsRequired(); + + b.Navigation("Owner"); + + b.Navigation("Pictures") + .IsRequired(); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany() + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("Tags") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => + { + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany() + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => + { + b.HasOne("Musify.Domain.Entities.User", "Followed") + .WithMany() + .HasForeignKey("FollowedId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "Follower") + .WithMany() + .HasForeignKey("FollowerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Followed"); + + b.Navigation("Follower"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => + { + b.HasOne("Musify.Domain.Entities.Track", "Track") + .WithMany("UserTracks") + .HasForeignKey("TrackId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("Musify.Domain.Entities.User", "User") + .WithMany("UserTracks") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Track"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Album", b => + { + b.Navigation("AlbumTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Mix", b => + { + b.Navigation("Items"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => + { + b.Navigation("PlayListTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.Track", b => + { + b.Navigation("AlbumTracks"); + + b.Navigation("ListeningHistories"); + + b.Navigation("PlayListTracks"); + + b.Navigation("Tags"); + + b.Navigation("UserTracks"); + }); + + modelBuilder.Entity("Musify.Domain.Entities.User", b => + { + b.Navigation("ListeningHistories"); + + b.Navigation("PlayLists"); + + b.Navigation("UserTracks"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs new file mode 100644 index 0000000..150570c --- /dev/null +++ b/backend/Core/Musify.Infrastructure/Persistence/Migrations/20260923172640_TrackPlayedSeconds.cs @@ -0,0 +1,52 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace Musify.Infrastructure.Persistence.Migrations +{ + /// + public partial class TrackPlayedSeconds : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn( + name: "IsCounted", + table: "ListeningHistory", + type: "boolean", + nullable: false, + defaultValue: false); + + migrationBuilder.AddColumn( + name: "LastProgressAt", + table: "ListeningHistory", + type: "timestamp with time zone", + nullable: true); + + migrationBuilder.AddColumn( + name: "PlayedSeconds", + table: "ListeningHistory", + type: "double precision", + nullable: true); + + migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "IsCounted", + table: "ListeningHistory"); + + migrationBuilder.DropColumn( + name: "LastProgressAt", + table: "ListeningHistory"); + + migrationBuilder.DropColumn( + name: "PlayedSeconds", + table: "ListeningHistory"); + } + } +} diff --git a/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs b/backend/Core/Musify.Infrastructure/Persistence/Migrations/DatabaseModelSnapshot.cs index 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/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/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/DeleteStaleUncountedListensCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs new file mode 100644 index 0000000..184f3d5 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/DeleteStaleUncountedListensCommandHandlerTests.cs @@ -0,0 +1,32 @@ +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase + { + private DeleteStaleUncountedListensCommandHandler CreateHandler() => + new(Database, NoOpLogger()); + + [Fact] + public async Task Handle_DeletesOnlyOldUncountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false); + var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100); + var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false); + await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted); + + var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken); + + Assert.Equal(1, deleted); + var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken); + Assert.DoesNotContain(oldUncounted.Id, remaining); + Assert.Contains(oldCounted.Id, remaining); + Assert.Contains(recentUncounted.Id, remaining); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs index c52a703..1886f27 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetListeningHistoryQueryHandlerTests.cs @@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks Assert.Empty(result); } + + [Fact] + public async Task Handle_UncountedListens_AreIgnored() + { + var owner = TestEntities.User(); + var counted = TestEntities.Track(owner, "Counted"); + var skipped = TestEntities.Track(owner, "Skipped"); + await SeedAsync( + owner, counted, skipped, + TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90), + TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false)); + + var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken); + + Assert.Equal(["Counted"], result.Select(track => track.Title)); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs index 9805b3d..4a5798a 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackByIdQueryHandlerTests.cs @@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks Assert.Equal(ErrorType.NotFound, result.FirstError.Type); } + + [Fact] + public async Task Handle_ListensCount_OnlyCountsCountedListens() + { + var owner = TestEntities.User(); + var track = TestEntities.Track(owner); + await SeedAsync( + owner, track, + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100), + TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false)); + + var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken); + + Assert.Equal(2, result.Value.ListensCount); + } } } diff --git a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs index d69cc17..de52989 100644 --- a/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Tracks/GetTrackStreamQueryHandlerTests.cs @@ -45,6 +45,9 @@ namespace Musify.Application.Tests.Tracks var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken)); Assert.Equal(owner.Id, history.UserId); Assert.Equal(track.Id, history.TrackId); + Assert.Equal(history.Id, result.Value.ListenId); + Assert.False(history.IsCounted); + Assert.Null(history.PlayedSeconds); } [Fact] diff --git a/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs new file mode 100644 index 0000000..d8f4050 --- /dev/null +++ b/backend/Tests/Musify.Application.Tests/Tracks/RecordListeningProgressCommandHandlerTests.cs @@ -0,0 +1,127 @@ +using ErrorOr; +using Microsoft.EntityFrameworkCore; +using Musify.Application.Tests.TestSupport; +using Musify.Application.Tracks; +using Musify.Domain.Entities; +using Xunit; + +namespace Musify.Application.Tests.Tracks +{ + public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase + { + private RecordListeningProgressCommandHandler CreateHandler() => new(Database); + + private async Task ReloadAsync(Guid id) => + await Database.ListeningHistories.AsNoTracking() + .SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken); + + private async Task<(User User, ListeningHistory Listen)> SeedListenAsync( + double durationSeconds, DateTime listenedAt) + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: durationSeconds); + var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false); + await SeedAsync(user, track, listen); + return (user, listen); + } + + [Fact] + public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.False(result.IsError); + var stored = await ReloadAsync(listen.Id); + Assert.Equal(10, stored.PlayedSeconds); + Assert.False(stored.IsCounted); + } + + [Fact] + public async Task Handle_ReachesThirtySeconds_MarksAsCounted() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_ShortTrack_CountsAtHalfDuration() + { + var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True((await ReloadAsync(listen.Id)).IsCounted); + } + + [Fact] + public async Task Handle_LowerValueThanStored_KeepsHighest() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + var handler = CreateHandler(); + + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken); + await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.Equal(40, stored.PlayedSeconds); + Assert.True(stored.IsCounted); + } + + [Fact] + public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime() + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken); + + var stored = await ReloadAsync(listen.Id); + Assert.InRange(stored.PlayedSeconds!.Value, 24, 27); + } + + [Fact] + public async Task Handle_MoreThanTrackDuration_IsCappedToDuration() + { + var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30)); + + await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken); + + Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds); + } + + [Fact] + public async Task Handle_OtherUsersListen_ReturnsNotFound() + { + var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken); + + Assert.True(result.IsError); + Assert.Equal(ErrorType.NotFound, result.FirstError.Type); + } + + [Theory] + [InlineData(-1)] + [InlineData(double.NaN)] + [InlineData(double.PositiveInfinity)] + public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds) + { + var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60)); + + var result = await CreateHandler().Handle( + new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken); + + Assert.Equal(ErrorType.Validation, result.FirstError.Type); + } + } +} diff --git a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs index 1061cae..c27d93f 100644 --- a/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs +++ b/backend/Tests/Musify.Application.Tests/Users/GetListeningStatsQueryHandlerTests.cs @@ -40,6 +40,37 @@ namespace Musify.Application.Tests.Users Assert.Equal(500, result.SecondsThisWeek); } + [Fact] + public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user, durationSeconds: 200); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200), + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false)); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(212, result.SecondsThisWeek); + } + + [Fact] + public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak() + { + var user = TestEntities.User(); + var track = TestEntities.Track(user); + await SeedAsync( + user, track, + TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false)); + + var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken); + + Assert.Equal(0, result.TracksThisWeek); + Assert.Equal(0, result.StreakDays); + Assert.Equal(3, result.SecondsThisWeek); + } + [Fact] public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() { diff --git a/web-player/src/lib/api/schema.d.ts b/web-player/src/lib/api/schema.d.ts index 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 }); +};