"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.
207 lines
7.7 KiB
C#
207 lines
7.7 KiB
C#
using Mediator;
|
|
using Musify.Application.Tracks;
|
|
using Musify.Application.Tracks.Responses;
|
|
using Musify.Application.Shared;
|
|
using Musify.Application.Contracts;
|
|
using Musify.Api.Authentication;
|
|
using Musify.Api.DataTransferObjects.Tracks;
|
|
using Musify.Api.Extensions;
|
|
using Musify.Api.Filters;
|
|
|
|
namespace Musify.Api.Endpoints;
|
|
|
|
public static class TrackEndpoints
|
|
{
|
|
public static IEndpointRouteBuilder MapTrackEndpoints(this IEndpointRouteBuilder app)
|
|
{
|
|
var group = app.MapGroup("/tracks")
|
|
.WithTags("Tracks");
|
|
|
|
group.MapGet("/", GetTracks)
|
|
.WithName("GetTracks")
|
|
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
|
|
.Produces<TracksSearchResponse>();
|
|
|
|
group.MapGet("/{id}", GetTrackById)
|
|
.WithName("GetTrackById")
|
|
.WithSummary("Get A Track By Id.")
|
|
.Produces<TrackApplicationResponse>()
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{id}/cover", GetTrackCover)
|
|
.WithName("GetTrackCover")
|
|
.WithSummary("Get A Track Cover Image.")
|
|
.Produces(StatusCodes.Status200OK)
|
|
.Produces(StatusCodes.Status404NotFound);
|
|
|
|
group.MapGet("/{id}/stream", GetTrackStream)
|
|
.WithName("GetTrackStream")
|
|
.WithSummary("Get A Streaming Manifest URL And Ticket For A Track. Works Anonymously When The Playback Configuration Allows It.")
|
|
.Produces<TrackStreamResponse>()
|
|
.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.")
|
|
.Produces<PaginatedResponse<TrackApplicationResponse>>();
|
|
|
|
group.MapPost("/", CreateTrack)
|
|
.WithName("CreateTrack")
|
|
.WithSummary("Create A New Track.")
|
|
.AddEndpointFilter<ValidationFilter<CreateTrackRequest>>()
|
|
.RequireAuthorization()
|
|
.Produces<TrackApplicationResponse>(StatusCodes.Status201Created)
|
|
.ProducesValidationProblem()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
group.MapDelete("/{trackId}", DeleteTrack)
|
|
.WithName("DeleteTrack")
|
|
.WithSummary("Delete A Track.")
|
|
.RequireAuthorization()
|
|
.Produces(StatusCodes.Status204NoContent)
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
group.MapPost("/upload-urls", RequestTrackUploadUrls)
|
|
.WithName("RequestTrackUploadUrls")
|
|
.WithSummary("Request Pre-Signed URLs To Upload Track Picture And Audio.")
|
|
.AddEndpointFilter<ValidationFilter<RequestTrackUploadUrlsRequest>>()
|
|
.RequireAuthorization()
|
|
.Produces<TrackUploadUrlsResponse>()
|
|
.ProducesValidationProblem()
|
|
.Produces(StatusCodes.Status401Unauthorized)
|
|
.Produces(StatusCodes.Status404NotFound)
|
|
.Produces(StatusCodes.Status409Conflict);
|
|
|
|
return app;
|
|
}
|
|
|
|
private static async Task<IResult> GetTracks(
|
|
IMediator mediator,
|
|
CancellationToken cancellationToken,
|
|
string? name,
|
|
int pageNumber = 1,
|
|
int pageSize = 10)
|
|
{
|
|
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetTrackById(
|
|
IMediator mediator,
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new GetTrackByIdQuery(id), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> GetTrackCover(
|
|
IMediator mediator,
|
|
IStorageService storageService,
|
|
HttpResponse response,
|
|
Guid id,
|
|
CancellationToken cancellationToken,
|
|
string size = "medium")
|
|
{
|
|
var result = await mediator.Send(new GetTrackCoverQuery(id, size), cancellationToken);
|
|
if (result.IsError)
|
|
return Results.NotFound();
|
|
|
|
var location = result.Value;
|
|
var stream = await storageService.GetFileAsync(location.Bucket, location.Key, cancellationToken);
|
|
response.Headers.CacheControl = "public, max-age=31536000, immutable";
|
|
return Results.Stream(stream, location.ContentType);
|
|
}
|
|
|
|
private static async Task<IResult> GetTrackStream(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid id,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new GetTrackStreamQuery(id, currentUser.Id), cancellationToken);
|
|
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,
|
|
CancellationToken cancellationToken,
|
|
string? name,
|
|
int pageNumber = 1,
|
|
int pageSize = 10)
|
|
{
|
|
var result = await mediator.Send(new GetTracksByUserIdQuery(userId, name, pageNumber, pageSize), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> CreateTrack(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
CreateTrackRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
|
|
cancellationToken);
|
|
|
|
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
|
|
}
|
|
|
|
private static async Task<IResult> DeleteTrack(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
Guid trackId,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(new DeleteTrackCommand(currentUser.RequiredId, trackId), cancellationToken);
|
|
return result.ToHttpResult();
|
|
}
|
|
|
|
private static async Task<IResult> RequestTrackUploadUrls(
|
|
IMediator mediator,
|
|
CurrentUser currentUser,
|
|
RequestTrackUploadUrlsRequest request,
|
|
CancellationToken cancellationToken)
|
|
{
|
|
var result = await mediator.Send(
|
|
new RequestTrackUploadUrlsCommand(
|
|
currentUser.RequiredId,
|
|
request.PictureFileType,
|
|
request.PictureContentType,
|
|
request.AudioFileType,
|
|
request.AudioContentType,
|
|
request.ExpectedPictureSizeBytes,
|
|
request.ExpectedAudioSizeBytes),
|
|
cancellationToken);
|
|
|
|
return result.ToHttpResult();
|
|
}
|
|
}
|