Author SHA1 Message Date
NotS1mp 920dcc6779 Merge pull request 'Listening time accuracy' (#19) from listening-time-accuracy into master
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m40s
Reviewed-on: #19
2026-09-23 22:15:10 +00:00
Admin 8870c65c6f Move ListeningRules to its own file and split listening tests by subject
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / BackEnd (pull_request) Canceled after 0s
CI/CD / WebPlayer (pull_request) Canceled after 0s
CI/CD / Deploy (pull_request) Canceled after 0s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
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.
2026-09-23 20:02:50 +02:00
Admin 5df5e635f3 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.
2026-09-23 20:00:45 +02:00
NotS1mp d5e3cadc43 Merge pull request 'Web player missing features' (#3) from web-player-missing-features into master
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 1m35s
Reviewed-on: #3
2026-09-23 17:07:09 +00:00
NotS1mp 4d39008eab Merge pull request 'Web Player Refactor' (#2) from web-player-ref into master
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Successful in 1m42s
Reviewed-on: #2
2026-09-23 15:05:11 +00:00
41 changed files with 1967 additions and 52 deletions
@@ -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<TrackApplicationResponse>(
@@ -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()
@@ -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<TrackApplicationResponse>(
@@ -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);
@@ -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
@@ -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<TrackApplicationResponse>(
@@ -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)
@@ -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<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken);
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;
}
}
}
@@ -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
@@ -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
@@ -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)
@@ -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
@@ -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<TrackApplicationResponse>(
@@ -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);
}
}
@@ -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<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
{
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories
.Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
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;
}
}
}
@@ -3,5 +3,6 @@ namespace Musify.Application.Tracks.Responses
public record TrackStreamResponse(
string ManifestUrl,
string Ticket,
int ExpiresInSeconds);
int ExpiresInSeconds,
Guid? ListenId = null);
}
@@ -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);
@@ -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; }
@@ -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);
@@ -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);
}
}
}
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPlayedSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCounted",
table: "ListeningHistory",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastProgressAt",
table: "ListeningHistory",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "PlayedSeconds",
table: "ListeningHistory",
type: "double precision",
nullable: true);
migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCounted",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "LastProgressAt",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "PlayedSeconds",
table: "ListeningHistory");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
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<bool>("IsCounted")
.HasColumnType("boolean");
b.Property<DateTime?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("PlayedSeconds")
.HasColumnType("double precision");
b.Property<Guid>("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<string>("LargeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<string>("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");
@@ -74,6 +74,7 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddHangfireServer();
serviceDescriptors.AddScoped<DailyMixGenerationJob>();
serviceDescriptors.AddScoped<ListeningHistoryCleanupJob>();
return serviceDescriptors;
}
@@ -0,0 +1,3 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RecordListeningProgressRequest(double PlayedSeconds);
@@ -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<IResult> 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<IResult> GetTracksByUserId(
IMediator mediator,
long userId,
+5
View File
@@ -41,6 +41,11 @@ using (var scope = host.Services.CreateScope())
job => job.RunAsync(CancellationToken.None),
Cron.Daily(),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
"listening-history-cleanup",
job => job.RunAsync(CancellationToken.None),
Cron.Daily(3),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
host.Run();
@@ -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<long> 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<Guid> SeedListenAsync(long userId, double durationSeconds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
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<ListeningHistory> LoadListenAsync(Guid listenId)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
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);
}
}
}
@@ -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));
}
}
}
@@ -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");
}
}
}
@@ -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
};
}
}
@@ -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<DeleteStaleUncountedListensCommandHandler>());
[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);
}
}
}
@@ -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));
}
}
}
@@ -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);
}
}
}
@@ -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]
@@ -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<ListeningHistory> 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);
}
}
}
@@ -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()
{
+56
View File
@@ -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;
@@ -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;
});
}
}
+37 -4
View File
@@ -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<string, Accent>();
#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<string> {
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 {
@@ -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 });
};