8 Commits
Author SHA1 Message Date
NotS1mp 87c3f97529 Merge pull request 'Web player general changes' (#22) from web-player-general-changes into master
CI/CD / BackEnd (push) Successful in 1m17s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Successful in 2m4s
Reviewed-on: #22
2026-09-23 22:37:30 +00:00
Admin 302dd1e0f8 Merge master into web-player-general-changes
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m23s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
CI/CD / Deploy (push) Canceled after 0s
2026-09-24 00:32:03 +02:00
NotS1mp 421fc929e8 Merge pull request 'Dynamic genres' (#20) from dynamic-genres into master
CI/CD / BackEnd (push) Successful in 1m23s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m38s
Reviewed-on: #20
2026-09-23 22:24:22 +00:00
NotS1mp c4ab5dc388 Merge branch 'master' into dynamic-genres
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m20s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 22:15:22 +00:00
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 b61ff1ebec Make Discover and upload genres dynamic
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles,
and the upload form kept its own copy of the genre list and compatibility
rules. Both now come from the backend.

Backend:
- GET /genres returns genres with at least one track and their counts.
- GET /genres/available returns every assignable genre with the genres
  it cannot be combined with, so the rules live only in
  GenreCompatibility.
- GET /tracks accepts ?genre= (case-insensitive, 400 on unknown values).

web-player:
- Discover tiles are built from /genres; each opens /explore?genre=X, a
  song list filtered by that tag. Unknown genres fall back to a label
  derived from their name.
- The upload form loads /genres/available, disables incompatible genres
  as you pick, and the server validates before reserving upload URLs.
- lib/data/trackGenres.ts is removed; its helpers merge into
  lib/data/genres.ts.
- Regenerate the typed API client.
2026-09-23 20:05:30 +02: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
57 changed files with 2584 additions and 169 deletions
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber) .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); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
{ {
var listenedTrackIds = database.ListeningHistories var listenedTrackIds = database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == request.UserId); .Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks var ranked = await database.AlbumHasTracks
.AsNoTracking() .AsNoTracking()
@@ -0,0 +1,26 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -0,0 +1,31 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -30,7 +30,7 @@ namespace Musify.Application.Likes
var pagedEntities = await likesQuery var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt) .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); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -38,7 +38,7 @@ namespace Musify.Application.Mixes
var historyTrackIds = await database.ListeningHistories var historyTrackIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.UserId == userId) .Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId) .Select(history => history.TrackId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -19,7 +19,7 @@ namespace Musify.Application.Mixes
.AsNoTracking() .AsNoTracking()
.Include(track => track.Owner) .Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id)) .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); .ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems return orderedItems
@@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position) .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); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -30,6 +30,7 @@ public sealed class TrackStreamIssuer(
folderPath, folderPath,
streamGatewayConfiguration.AudioFileName); streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
if (userId != null) if (userId != null)
{ {
var newListeningHistory = new ListeningHistory var newListeningHistory = new ListeningHistory
@@ -39,9 +40,10 @@ public sealed class TrackStreamIssuer(
}; };
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken); await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(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) 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) .Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId) .Where(t => t.UserId == query.UserId)
.OrderByDescending(t => t.ListenedAt) .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); .FirstOrDefaultAsync(cancellationToken);
return track == null return track == null
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
{ {
var recentTrackIds = await database.ListeningHistories var recentTrackIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(l => l.UserId == query.UserId) .Where(l => l.UserId == query.UserId && l.IsCounted)
.GroupBy(l => l.TrackId) .GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt)) .OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key) .Select(g => g.Key)
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner) .Include(t => t.Owner)
.Include(t => t.Tags) .Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id)) .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); .ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds return recentTrackIds
@@ -18,7 +18,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner) .Include(t => t.Owner)
.Include(t => t.Tags) .Include(t => t.Tags)
.Where(t => t.Id == request.TrackId) .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); .SingleOrDefaultAsync(cancellationToken);
if (entity == null) if (entity == null)
@@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record GetTracksQuery(string? Name, int PageNumber, int PageSize) public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>; : IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database) public class GetTracksQueryHandler(IDatabase database)
@@ -28,12 +29,15 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
} }
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken); var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt) .OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id) .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); .ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities var items = pagedEntities
@@ -40,7 +40,7 @@ namespace Musify.Application.Tracks
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt) .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); .ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>( 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( public record TrackStreamResponse(
string ManifestUrl, string ManifestUrl,
string Ticket, 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); .Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory var tracksThisWeek = await thisWeekHistory
.Where(l => l.IsCounted)
.Select(l => l.TrackId) .Select(l => l.TrackId)
.Distinct() .Distinct()
.CountAsync(cancellationToken); .CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory 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); .SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories var listenedDates = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(l => l.UserId == request.UserId) .Where(l => l.UserId == request.UserId && l.IsCounted)
.Select(l => l.ListenedAt.Date) .Select(l => l.ListenedAt.Date)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .ToListAsync(cancellationToken);
@@ -19,6 +19,12 @@ namespace Musify.Domain.Entities
[Required] [Required]
public DateTime ListenedAt { get; set; } = DateTime.UtcNow; 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) )] [ForeignKey(nameof(UserId) )]
public User User { get; set; } public User User { get; set; }
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Jobs
{ {
var userIds = await database.ListeningHistories var userIds = await database.ListeningHistories
.AsNoTracking() .AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId) .Select(history => history.UserId)
.Distinct() .Distinct()
.ToListAsync(cancellationToken); .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 System;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure; using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Delivered"); b.HasIndex("Delivered");
b.ToTable("InboxState", (string)null); b.ToTable("InboxState");
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber") b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique(); .IsUnique();
b.ToTable("OutboxMessage", (string)null); b.ToTable("OutboxMessage");
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
@@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Created"); b.HasIndex("Created");
b.ToTable("OutboxState", (string)null); b.ToTable("OutboxState");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Album", b => modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
@@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Albums", (string)null); b.ToTable("Albums");
}); });
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
@@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("AlbumId", "TrackId") b.HasIndex("AlbumId", "TrackId")
.IsUnique(); .IsUnique();
b.ToTable("AlbumHasTrack", (string)null); b.ToTable("AlbumHasTrack");
}); });
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b => modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
@@ -268,9 +268,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd() .ValueGeneratedOnAdd()
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<bool>("IsCounted")
.HasColumnType("boolean");
b.Property<DateTime?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ListenedAt") b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<double?>("PlayedSeconds")
.HasColumnType("double precision");
b.Property<Guid>("TrackId") b.Property<Guid>("TrackId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@@ -283,7 +292,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("ListeningHistory", (string)null); b.ToTable("ListeningHistory");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Mix", b => modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
@@ -317,7 +326,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Position"); b.HasIndex("UserId", "Position");
b.ToTable("Mixes", (string)null); b.ToTable("Mixes");
}); });
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b => modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
@@ -341,7 +350,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("MixId", "Position"); b.HasIndex("MixId", "Position");
b.ToTable("MixItems", (string)null); b.ToTable("MixItems");
}); });
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b => modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
@@ -385,7 +394,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("PlayLists", (string)null); b.ToTable("PlayLists");
}); });
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
@@ -412,7 +421,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId"); b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack", (string)null); b.ToTable("PlayListHasTrack");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Track", b => modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
@@ -453,7 +462,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId"); b.HasIndex("OwnerUserId");
b.ToTable("Tracks", (string)null); b.ToTable("Tracks");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b => modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
@@ -478,7 +487,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "TrackId") b.HasIndex("UserId", "TrackId")
.IsUnique(); .IsUnique();
b.ToTable("TrackLikes", (string)null); b.ToTable("TrackLikes");
}); });
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b => modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
@@ -498,7 +507,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId", "Tag") b.HasIndex("TrackId", "Tag")
.IsUnique(); .IsUnique();
b.ToTable("TrackTags", (string)null); b.ToTable("TrackTags");
}); });
modelBuilder.Entity("Musify.Domain.Entities.Upload", b => modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
@@ -527,7 +536,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("Upload", (string)null); b.ToTable("Upload");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b => modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
@@ -578,7 +587,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UploadIntents", (string)null); b.ToTable("UploadIntents");
}); });
modelBuilder.Entity("Musify.Domain.Entities.User", b => modelBuilder.Entity("Musify.Domain.Entities.User", b =>
@@ -616,7 +625,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id"); b.HasKey("Id");
b.ToTable("User", (string)null); b.ToTable("User");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b => modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
@@ -641,7 +650,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("FollowerId", "FollowedId") b.HasIndex("FollowerId", "FollowedId")
.IsUnique(); .IsUnique();
b.ToTable("UserFollows", (string)null); b.ToTable("UserFollows");
}); });
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b => modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
@@ -662,7 +671,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId"); b.HasIndex("UserId");
b.ToTable("UserHasTrack", (string)null); b.ToTable("UserHasTrack");
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
@@ -689,7 +698,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("AlbumProcessingState", (string)null); b.ToTable("AlbumProcessingState");
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
@@ -716,7 +725,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState", (string)null); b.ToTable("PlayListProcessingState");
}); });
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b => modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
@@ -752,7 +761,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId"); b.HasKey("CorrelationId");
b.ToTable("TrackProcessingState", (string)null); b.ToTable("TrackProcessingState");
}); });
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b => modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -806,7 +815,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("AlbumId"); b1.HasKey("AlbumId");
b1.ToTable("Albums", (string)null); b1.ToTable("Albums");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("AlbumId"); .HasForeignKey("AlbumId");
@@ -899,32 +908,28 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid"); .HasColumnType("uuid");
b1.Property<string>("LargeName") b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("LargePictureName"); .HasColumnName("LargePictureName");
b1.Property<string>("MediumName") b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName"); .HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName") b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName"); .HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName") b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64) .HasMaxLength(64)
.HasColumnType("character varying(64)") .HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName"); .HasColumnName("SmallPictureName");
b1.HasKey("PlayListId"); b1.HasKey("PlayListId");
b1.ToTable("PlayLists", (string)null); b1.ToTable("PlayLists");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("PlayListId"); .HasForeignKey("PlayListId");
@@ -995,7 +1000,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId"); b1.HasKey("TrackId");
b1.ToTable("Tracks", (string)null); b1.ToTable("Tracks");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("TrackId"); .HasForeignKey("TrackId");
@@ -1032,7 +1037,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId"); b1.HasKey("TrackId");
b1.ToTable("Tracks", (string)null); b1.ToTable("Tracks");
b1.WithOwner() b1.WithOwner()
.HasForeignKey("TrackId"); .HasForeignKey("TrackId");
@@ -74,6 +74,7 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddHangfireServer(); serviceDescriptors.AddHangfireServer();
serviceDescriptors.AddScoped<DailyMixGenerationJob>(); serviceDescriptors.AddScoped<DailyMixGenerationJob>();
serviceDescriptors.AddScoped<ListeningHistoryCleanupJob>();
return serviceDescriptors; return serviceDescriptors;
} }
@@ -0,0 +1,3 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RecordListeningProgressRequest(double PlayedSeconds);
@@ -0,0 +1,39 @@
using Mediator;
using Musify.Application.Genres;
namespace Musify.Api.Endpoints;
public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/genres")
.WithTags("Genres");
group.MapGet("/", GetGenres)
.WithName("GetGenres")
.WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.")
.Produces<IReadOnlyList<GenreResponse>>();
group.MapGet("/available", GetAvailableGenres)
.WithName("GetAvailableGenres")
.WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.")
.Produces<IReadOnlyList<AvailableGenreResponse>>();
return app;
}
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
}
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetGenresQuery(), cancellationToken);
}
}
@@ -1,4 +1,5 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
@@ -19,8 +20,9 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>(); .Produces<TracksSearchResponse>()
.ProducesValidationProblem();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
@@ -41,6 +43,14 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status401Unauthorized) .Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound); .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) group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId") .WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.") .WithSummary("Get Paginated Tracks For A User.")
@@ -84,10 +94,25 @@ public static class TrackEndpoints
IMediator mediator, IMediator mediator,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? name, string? name,
string? genre,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) int pageSize = 10)
{ {
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken); Genre? parsedGenre = null;
if (!string.IsNullOrWhiteSpace(genre))
{
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
});
}
parsedGenre = value;
}
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -128,6 +153,19 @@ public static class TrackEndpoints
return result.ToHttpResult(); 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( private static async Task<IResult> GetTracksByUserId(
IMediator mediator, IMediator mediator,
long userId, long userId,
+1
View File
@@ -40,6 +40,7 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapConfigEndpoints() .MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints() .MapLikeEndpoints()
.MapMixEndpoints() .MapMixEndpoints()
.MapPlayListEndpoints() .MapPlayListEndpoints()
+5
View File
@@ -41,6 +41,11 @@ using (var scope = host.Services.CreateScope())
job => job.RunAsync(CancellationToken.None), job => job.RunAsync(CancellationToken.None),
Cron.Daily(), Cron.Daily(),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc }); 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(); host.Run();
@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Genres;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private async Task<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
var title = $"Genre track {Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
}
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
[Theory]
[InlineData("Nope")]
[InlineData("999")]
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
{
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
}
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
}
}
}
@@ -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.False(result.IsError);
Assert.Equal(2, result.Value.Count); 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));
}
} }
} }
@@ -0,0 +1,32 @@
using Musify.Application.Genres;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetAvailableGenresQueryHandlerTests
{
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
}
[Fact]
public async Task Handle_ListsIncompatibleGenresSymmetrically()
{
var result = await RunAsync();
var metal = result.Single(r => r.Genre == Genre.Metal);
var lofi = result.Single(r => r.Genre == Genre.Lofi);
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
}
}
}
@@ -0,0 +1,69 @@
using Musify.Application.Genres;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
{
private GetGenresQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTracks_ReturnsEmpty()
{
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
[Fact]
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
{
var owner = TestEntities.User();
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
await SeedAsync(
owner, rockA, rockB, jazz,
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
result.Select(r => (r.Genre, r.TrackCount)));
}
[Fact]
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
{
var owner = TestEntities.User();
var other = TestEntities.User(id: 2, name: "other-user");
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
await SeedAsync(
owner, other, track,
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(1, Assert.Single(result).TrackCount);
}
[Fact]
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
}
}
@@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes
.ToListAsync(TestContext.Current.CancellationToken); .ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); 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");
}
} }
} }
@@ -179,12 +179,16 @@ namespace Musify.Application.Tests.TestSupport
public static ListeningHistory ListeningHistory( public static ListeningHistory ListeningHistory(
long userId, long userId,
Guid trackId, Guid trackId,
DateTime? listenedAt = null) => DateTime? listenedAt = null,
double? playedSeconds = null,
bool isCounted = true) =>
new() new()
{ {
UserId = userId, UserId = userId,
TrackId = trackId, 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); 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); 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)); var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(owner.Id, history.UserId); Assert.Equal(owner.Id, history.UserId);
Assert.Equal(track.Id, history.TrackId); Assert.Equal(track.Id, history.TrackId);
Assert.Equal(history.Id, result.Value.ListenId);
Assert.False(history.IsCounted);
Assert.Null(history.PlayedSeconds);
} }
[Fact] [Fact]
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks namespace Musify.Application.Tests.Tracks
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
var item = Assert.Single(result.Value.Items); var item = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", item.Track.Title); Assert.Equal("Bohemian Rhapsody", item.Track.Title);
} }
[Fact]
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
{
var owner = TestEntities.User();
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
await SeedAsync(
owner, rock, jazz,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal("Loud", item.Track.Title);
}
} }
} }
@@ -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); 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] [Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo() public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{ {
+168 -1
View File
@@ -177,6 +177,40 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/genres": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously. */
get: operations["GetGenres"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/genres/available": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously. */
get: operations["GetAvailableGenres"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/likes": { "/likes": {
parameters: { parameters: {
query?: never; query?: never;
@@ -391,7 +425,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
/** Get Paginated Tracks, Optionally Filtered By Name. */ /** Get Paginated Tracks, Optionally Filtered By Name And Genre. */
get: operations["GetTracks"]; get: operations["GetTracks"];
put?: never; put?: never;
/** Create A New Track. */ /** Create A New Track. */
@@ -453,6 +487,23 @@ export interface paths {
patch?: never; patch?: never;
trace?: never; trace?: never;
}; };
"/tracks/listens/{listenId}/progress": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint. */
put: operations["RecordListeningProgress"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks/users/{userId}": { "/tracks/users/{userId}": {
parameters: { parameters: {
query?: never; query?: never;
@@ -730,6 +781,10 @@ export interface components {
hasPreviousPage: boolean; hasPreviousPage: boolean;
hasNextPage: boolean; hasNextPage: boolean;
}; };
AvailableGenreResponse: {
genre: components["schemas"]["Genre"];
incompatibleWith: components["schemas"]["Genre"][];
};
CreateAlbumRequest: { CreateAlbumRequest: {
title: string; title: string;
description: null | string; description: null | string;
@@ -764,6 +819,11 @@ export interface components {
}; };
/** @enum {unknown} */ /** @enum {unknown} */
Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi"; Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi";
GenreResponse: {
genre: components["schemas"]["Genre"];
/** Format: int32 */
trackCount: number | string;
};
HttpValidationProblemDetails: { HttpValidationProblemDetails: {
type?: null | string; type?: null | string;
title?: null | string; title?: null | string;
@@ -854,6 +914,19 @@ export interface components {
hasNextPage: boolean; hasNextPage: boolean;
hasPreviousPage: boolean; hasPreviousPage: boolean;
}; };
PaginatedResponseOfUserApplicationResponse: {
items: components["schemas"]["UserApplicationResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
pageSize: number | string;
/** Format: int32 */
pageCount: number | string;
/** Format: int32 */
totalItemCount: number | string;
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PlaybackPublicConfigResponse: { PlaybackPublicConfigResponse: {
allowAnonymousListening: boolean; allowAnonymousListening: boolean;
/** Format: int32 */ /** Format: int32 */
@@ -889,6 +962,10 @@ export interface components {
PlaylistVisibility: "Private" | "Public"; PlaylistVisibility: "Private" | "Public";
/** @enum {unknown} */ /** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed"; ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RecordListeningProgressRequest: {
/** Format: double */
playedSeconds: number | string;
};
RequestAlbumPictureUploadRequest: { RequestAlbumPictureUploadRequest: {
fileType: string; fileType: string;
contentType: string; contentType: string;
@@ -954,6 +1031,8 @@ export interface components {
ticket: string; ticket: string;
/** Format: int32 */ /** Format: int32 */
expiresInSeconds: number | string; expiresInSeconds: number | string;
/** Format: uuid */
listenId?: null | string;
}; };
TrackUploadUrlsResponse: { TrackUploadUrlsResponse: {
/** Format: uuid */ /** Format: uuid */
@@ -1479,6 +1558,46 @@ export interface operations {
}; };
}; };
}; };
GetGenres: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GenreResponse"][];
};
};
};
};
GetAvailableGenres: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AvailableGenreResponse"][];
};
};
};
};
GetLikedTracks: { GetLikedTracks: {
parameters: { parameters: {
query?: { query?: {
@@ -2013,6 +2132,7 @@ export interface operations {
parameters: { parameters: {
query?: { query?: {
name?: string; name?: string;
genre?: string;
pageNumber?: number | string; pageNumber?: number | string;
pageSize?: number | string; pageSize?: number | string;
}; };
@@ -2031,6 +2151,15 @@ export interface operations {
"application/json": components["schemas"]["TracksSearchResponse"]; "application/json": components["schemas"]["TracksSearchResponse"];
}; };
}; };
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
}; };
}; };
CreateTrack: { CreateTrack: {
@@ -2181,6 +2310,44 @@ export interface operations {
}; };
}; };
}; };
RecordListeningProgress: {
parameters: {
query?: never;
header?: never;
path: {
listenId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RecordListeningProgressRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetTracksByUserId: { GetTracksByUserId: {
parameters: { parameters: {
query?: { query?: {
+73 -20
View File
@@ -1,22 +1,75 @@
export const GENRE_INFO: Record<string, { label: string; tagline: string }> = { const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' }, Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' }, Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' }, HipHop: { label: 'Hip hop', tagline: 'Rimas con actitud' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }, RnB: { label: 'R&B', tagline: 'Ritmo suave y voces con alma' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' }, Jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' }, Blues: { label: 'Blues', tagline: 'Sentimiento en cada nota' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' }, Classical: { label: 'Clásica', tagline: 'Siglos de composición' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' }, Electronic: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' }, House: { label: 'House', tagline: 'Pista de baile sin fin' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' }, Techno: { label: 'Techno', tagline: 'Pulso hipnótico' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' }, Trance: { label: 'Trance', tagline: 'Melodías que te elevan' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' }, Dubstep: { label: 'Dubstep', tagline: 'Bajos que hacen temblar' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' }, DrumAndBass: { label: 'Drum and bass', tagline: 'Velocidad y graves' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' } Metal: { label: 'Metal', tagline: 'Riffs pesados' },
Punk: { label: 'Punk', tagline: 'Rápido, crudo y directo' },
Reggae: { label: 'Reggae', tagline: 'Buenas vibraciones' },
Reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
Country: { label: 'Country', tagline: 'Historias con guitarra' },
Folk: { label: 'Folk', tagline: 'Raíces y acústico' },
Indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
KPop: { label: 'K-pop', tagline: 'Coreografías pegajosas' },
Latin: { label: 'Latina', tagline: 'Sabor y ritmo' },
Soul: { label: 'Soul', tagline: 'Emoción en estado puro' },
Funk: { label: 'Funk', tagline: 'Groove imposible de quedarse quieto' },
Ambient: { label: 'Ambient', tagline: 'Paisajes sonoros' },
Lofi: { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }
}; };
export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({ const KNOWN_GENRES = Object.keys(GENRE_INFO);
query,
hue: Math.round((i * 360) / all.length), function humanize(name: string): string {
...info return name.replace(/([a-z])([A-Z])/g, '$1 $2');
})); }
export function genreInfo(genre: string) {
return GENRE_INFO[genre] ?? { label: humanize(genre), tagline: '' };
}
export function genreHue(genre: string): number {
const known = KNOWN_GENRES.indexOf(genre);
if (known >= 0) return Math.round((known * 360) / KNOWN_GENRES.length);
let hash = 0;
for (const char of genre) hash = (hash * 31 + char.charCodeAt(0)) % 360;
return hash;
}
export function genreTiles(genres: { genre: string }[]) {
return genres.map(({ genre }) => ({
genre,
hue: genreHue(genre),
...genreInfo(genre)
}));
}
export function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) {
return genres.map(({ genre, incompatibleWith }) => ({
genre,
incompatibleWith,
label: genreInfo(genre).label
}));
}
export function findConflict(
tags: string[],
options: { genre: string; incompatibleWith: string[] }[]
): [string, string] | undefined {
const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith]));
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
if (byGenre.get(tags[i])?.includes(tags[j])) return [tags[i], tags[j]];
}
}
return undefined;
}
-56
View File
@@ -1,56 +0,0 @@
export const TRACK_GENRES = [
{ value: 'Pop', label: 'Pop' },
{ value: 'Rock', label: 'Rock' },
{ value: 'HipHop', label: 'Hip-hop' },
{ value: 'RnB', label: 'R&B' },
{ value: 'Jazz', label: 'Jazz' },
{ value: 'Blues', label: 'Blues' },
{ value: 'Classical', label: 'Clásica' },
{ value: 'Electronic', label: 'Electrónica' },
{ value: 'House', label: 'House' },
{ value: 'Techno', label: 'Techno' },
{ value: 'Trance', label: 'Trance' },
{ value: 'Dubstep', label: 'Dubstep' },
{ value: 'DrumAndBass', label: 'Drum and bass' },
{ value: 'Metal', label: 'Metal' },
{ value: 'Punk', label: 'Punk' },
{ value: 'Reggae', label: 'Reggae' },
{ value: 'Reggaeton', label: 'Reggaetón' },
{ value: 'Country', label: 'Country' },
{ value: 'Folk', label: 'Folk' },
{ value: 'Indie', label: 'Indie' },
{ value: 'KPop', label: 'K-pop' },
{ value: 'Latin', label: 'Latina' },
{ value: 'Soul', label: 'Soul' },
{ value: 'Funk', label: 'Funk' },
{ value: 'Ambient', label: 'Ambient' },
{ value: 'Lofi', label: 'Lo-fi' }
] as const;
export type TrackGenre = (typeof TRACK_GENRES)[number]['value'];
const GENRE_VALUES: ReadonlySet<string> = new Set(TRACK_GENRES.map((genre) => genre.value));
const INTENSE: ReadonlySet<TrackGenre> = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']);
const MELLOW: ReadonlySet<TrackGenre> = new Set(['Classical', 'Ambient', 'Lofi']);
export function isTrackGenre(value: string): value is TrackGenre {
return GENRE_VALUES.has(value);
}
export function areCompatible(a: TrackGenre, b: TrackGenre): boolean {
return !((INTENSE.has(a) && MELLOW.has(b)) || (INTENSE.has(b) && MELLOW.has(a)));
}
export function genreLabel(value: TrackGenre): string {
return TRACK_GENRES.find((genre) => genre.value === value)?.label ?? value;
}
export function findConflict(tags: readonly TrackGenre[]): [TrackGenre, TrackGenre] | undefined {
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
if (!areCompatible(tags[i], tags[j])) return [tags[i], tags[j]];
}
}
return undefined;
}
@@ -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 { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections'; import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte'; import { dialog } from '$lib/state/dialog.svelte';
import { ListenTracker } from './listenTracker';
export interface PlayerTrack { export interface PlayerTrack {
id: string | number; id: string | number;
@@ -132,6 +133,7 @@ class PlayerState {
#loadToken = 0; #loadToken = 0;
#accentCache = new Map<string, Accent>(); #accentCache = new Map<string, Accent>();
#rafId: number | null = null; #rafId: number | null = null;
#listen = new ListenTracker();
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY); current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
accent = $derived(this.accentColor ?? DEFAULT_ACCENT); accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
@@ -152,19 +154,30 @@ class PlayerState {
audio.muted = this.muted; audio.muted = this.muted;
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration)); audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => 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', () => { audio.addEventListener('play', () => {
this.#listen.resync(audio.currentTime);
this.playing = true; this.playing = true;
this.#startProgressLoop(); this.#startProgressLoop();
this.#syncMediaSessionPlaybackState(); this.#syncMediaSessionPlaybackState();
}); });
audio.addEventListener('pause', () => { audio.addEventListener('pause', () => {
this.#listen.interrupt();
this.#listen.flush();
this.playing = false; this.playing = false;
this.#stopProgressLoop(); this.#stopProgressLoop();
this.#syncMediaSessionPlaybackState(); this.#syncMediaSessionPlaybackState();
}); });
audio.addEventListener('ended', () => { audio.addEventListener('ended', () => {
this.#stopProgressLoop(); this.#stopProgressLoop();
this.#listen.tick(audio.currentTime);
this.#listen.begin(null);
if (this.repeat) { if (this.repeat) {
this.#beginRepeatListen();
audio.currentTime = 0; audio.currentTime = 0;
audio.play().catch(() => {}); audio.play().catch(() => {});
return; return;
@@ -178,6 +191,10 @@ class PlayerState {
this.#fail(); this.#fail();
}); });
this.#audio = audio; this.#audio = audio;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.#listen.flush();
});
window.addEventListener('pagehide', () => this.#listen.flush());
this.#setupMediaSession(audio); this.#setupMediaSession(audio);
return audio; return audio;
} }
@@ -277,14 +294,16 @@ class PlayerState {
const track = this.tracks.find((t) => t.id === id); const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id); this.#applyAccent(id);
if (track) this.#syncMediaSessionMetadata(track); if (track) this.#syncMediaSessionMetadata(track);
this.#listen.begin(null);
const token = ++this.#loadToken; const token = ++this.#loadToken;
this.loading = true; this.loading = true;
this.progress = 0; this.progress = 0;
try { try {
const src = await this.#resolveLocalSrc(String(id)); const { src, listenId } = await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return; if (token !== this.#loadToken) return;
audio.src = src; audio.src = src;
audio.volume = this.volume / 100; audio.volume = this.volume / 100;
this.#listen.begin(listenId);
await audio.play(); await audio.play();
this.loading = false; this.loading = false;
this.#pushRecent(this.current); this.#pushRecent(this.current);
@@ -310,14 +329,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`); const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res)); 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; manifestUrl: string;
ticket: 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 { #withTicket(url: string, ticket: string): string {
@@ -88,6 +88,10 @@ export function searchHref(query: string): string {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore'; return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
} }
export function genreHref(genre: string): string {
return `/explore?genre=${encodeURIComponent(genre)}`;
}
export interface AppNavLink { export interface AppNavLink {
href: string; href: string;
label: string; label: string;
@@ -10,13 +10,25 @@ import {
export const load: PageServerLoad = async ({ url, locals, fetch }) => { export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? ''; const query = url.searchParams.get('q')?.trim() ?? '';
const genreParam = url.searchParams.get('genre')?.trim() || null;
const page = Math.max(1, Number(url.searchParams.get('page')) || 1); const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const user = locals.user; const user = locals.user;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const genresRes = await api.GET('/genres');
const genres = genresRes.data ?? [];
const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null;
const tracksPromise = api.GET('/tracks', { const tracksPromise = api.GET('/tracks', {
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } } params: {
query: {
name: query || undefined,
genre: genre ?? undefined,
pageNumber: page,
pageSize: PAGE_SIZE
}
}
}); });
const playlistsPromise = user const playlistsPromise = user
@@ -48,6 +60,8 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
return { return {
query, query,
genre,
genres,
tracks, tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album), albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [], users: usersRes?.data?.items ?? [],
@@ -17,13 +17,13 @@
import Chip from '$lib/components/ui/primitives/Chip.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte'; import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config'; import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { searchHref } from '$lib/state/navigation.svelte'; import { genreHref } from '$lib/state/navigation.svelte';
import { createTrackMenu } from '$lib/state/menus.svelte'; import { createTrackMenu } from '$lib/state/menus.svelte';
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte'; import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus'; import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end'; import ListEnd from '@lucide/svelte/icons/list-end';
import { appendUnique } from '$lib/data/collections'; import { appendUnique } from '$lib/data/collections';
import { genreTiles } from '$lib/data/genres'; import { genreInfo, genreTiles } from '$lib/data/genres';
import { import {
type SearchFilter, type SearchFilter,
showGroup, showGroup,
@@ -59,7 +59,7 @@
$effect(() => { $effect(() => {
trackMenu.close(); trackMenu.close();
albumMenu = null; albumMenu = null;
sfilter = 'Todo'; sfilter = data.genre ? 'Canciones' : 'Todo';
localItems = data.tracks.items.map((item) => item.track); localItems = data.tracks.items.map((item) => item.track);
localPage = Number(data.tracks.pageNumber); localPage = Number(data.tracks.pageNumber);
hasMore = data.tracks.hasNextPage; hasMore = data.tracks.hasNextPage;
@@ -74,6 +74,9 @@
const items = $derived(songRows.map((row) => row.track)); const items = $derived(songRows.map((row) => row.track));
const tiles = $derived(genreTiles(data.genres));
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
const albums = $derived(data.albums); const albums = $derived(data.albums);
const users = $derived(data.users); const users = $derived(data.users);
@@ -170,6 +173,7 @@
pageSize: String(EXPLORE_PAGE_SIZE) pageSize: String(EXPLORE_PAGE_SIZE)
}); });
if (data.query) params.set('name', data.query); if (data.query) params.set('name', data.query);
if (data.genre) params.set('genre', data.genre);
const res = await fetch(`/api/tracks?${params}`); const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status)); if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks; const next = (await res.json()) as typeof data.tracks;
@@ -189,7 +193,9 @@
</script> </script>
<svelte:head> <svelte:head>
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title> <title
>{activeGenre ? activeGenre.label : data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title
>
<meta <meta
name="description" name="description"
content="Busca canciones, álbumes y personas, o descubre por género en Musify." content="Busca canciones, álbumes y personas, o descubre por género en Musify."
@@ -197,7 +203,9 @@
</svelte:head> </svelte:head>
<Page> <Page>
{#if !data.query} {#if activeGenre}
<PageHeader eyebrow="Género" title={activeGenre.label} description={activeGenre.tagline} />
{:else if !data.query}
<PageHeader <PageHeader
title="Descubrir" title="Descubrir"
description="Encuentra tu próxima canción favorita explorando por género." description="Encuentra tu próxima canción favorita explorando por género."
@@ -301,31 +309,34 @@
por playlist. por playlist.
</p> </p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2"> <div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each genreTiles.slice(0, 4) as genre (genre.query)} {#each tiles.slice(0, 4) as tile (tile.genre)}
<Chip href={searchHref(genre.query)}>{genre.label}</Chip> <Chip href={genreHref(tile.genre)}>{tile.label}</Chip>
{/each} {/each}
</div> </div>
</div> </div>
{/if} {/if}
{#if !data.query} {#if !data.query && !data.genre}
<div class="grid-wide"> <div class="grid-wide">
{#each genreTiles as genre, i (genre.query)} {#each tiles as tile, i (tile.genre)}
<a <a
href={searchHref(genre.query)} href={genreHref(tile.genre)}
class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110" class="animate-enter group relative flex h-28 flex-col gap-1 overflow-hidden rounded-panel p-4 transition-[filter] genre-tile hover:brightness-110"
style="--i:{i}; --tile-hue:{genre.hue}" style="--i:{i}; --tile-hue:{tile.hue}"
> >
<div class="text-on-art"> <div class="text-on-art">
{genre.label} {tile.label}
</div> </div>
<div class="text-xs text-on-art-2">{genre.tagline}</div> <div class="text-xs text-on-art-2">{tile.tagline}</div>
</a> </a>
{/each} {/each}
</div> </div>
{#if tiles.length === 0}
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
{/if}
{/if} {/if}
{#if data.query && !nothingFound} {#if (data.query || data.genre) && !nothingFound}
<div class="mt-9 flex flex-col gap-9"> <div class="mt-9 flex flex-col gap-9">
{#if showGroup(sfilter, 'Canciones')} {#if showGroup(sfilter, 'Canciones')}
<div> <div>
@@ -366,7 +377,9 @@
{:else} {:else}
<EmptyState <EmptyState
icon={Music} icon={Music}
description="No hay canciones que coincidan con «{data.query}»." description={activeGenre
? 'Todavía no hay canciones de este género.'
: `No hay canciones que coincidan con «${data.query}».`}
/> />
{/if} {/if}
</div> </div>
@@ -7,12 +7,18 @@ import {
unwrapOrFail unwrapOrFail
} from '$lib/server/api'; } from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload'; import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreLabel, isTrackGenre, type TrackGenre } from '$lib/data/trackGenres'; import { findConflict, genreInfo } from '$lib/data/genres';
import type { components } from '$lib/api/schema';
type Genre = components['schemas']['Genre'];
const MAX_TITLE = 100; const MAX_TITLE = 100;
export const load: PageServerLoad = ({ locals, url }) => { export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url); requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data } = await api.GET('/genres/available');
return { genres: data ?? [] };
}; };
export const actions: Actions = { export const actions: Actions = {
@@ -40,20 +46,23 @@ export const actions: Actions = {
if (rawTags.length === 0) { if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' }); return fail(400, { message: 'Elige al menos un género.' });
} }
if (!rawTags.every(isTrackGenre)) { const api = createApiClient({ fetch, accessToken });
const { data: available } = await api.GET('/genres/available');
const options = available ?? [];
const known = new Set<string>(options.map((option) => option.genre));
if (!rawTags.every((tag) => known.has(tag))) {
return fail(400, { message: 'Alguno de los géneros no es válido.' }); return fail(400, { message: 'Alguno de los géneros no es válido.' });
} }
const tags: TrackGenre[] = [...new Set(rawTags)]; const tags = [...new Set(rawTags)] as Genre[];
const conflict = findConflict(tags); const conflict = findConflict(tags, options);
if (conflict) { if (conflict) {
return fail(400, { return fail(400, {
message: `Los géneros ${genreLabel(conflict[0])} y ${genreLabel(conflict[1])} no se pueden combinar.` message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`
}); });
} }
const audioContentType = contentTypeOf(audio, AUDIO_TYPES); const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES); const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken });
const urlsResult = await api.POST('/tracks/upload-urls', { const urlsResult = await api.POST('/tracks/upload-urls', {
body: { body: {
+19 -12
View File
@@ -15,13 +15,15 @@
import Button from '$lib/components/ui/primitives/Button.svelte'; import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte'; import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres'; import { genreOptions } from '$lib/data/genres';
type Status = 'idle' | 'uploading' | 'done' | 'error'; type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done'; type StepState = 'idle' | 'active' | 'done';
const MAX_TITLE = 100; const MAX_TITLE = 100;
let { data } = $props();
let title = $state(''); let title = $state('');
let audioName = $state(''); let audioName = $state('');
let audioSize = $state(0); let audioSize = $state(0);
@@ -33,7 +35,7 @@
let isExplicit = $state(false); let isExplicit = $state(false);
let errorMsg = $state(''); let errorMsg = $state('');
let errorDetail = $state(''); let errorDetail = $state('');
let tags = $state<TrackGenre[]>([]); let tags = $state<string[]>([]);
let publishedTitle = $state(''); let publishedTitle = $state('');
let audioInput = $state<HTMLInputElement>(); let audioInput = $state<HTMLInputElement>();
@@ -43,6 +45,15 @@
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' } { key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
]; ];
const options = $derived(genreOptions(data.genres));
const blocked = $derived(
new Set(
options
.filter((option) => tags.includes(option.genre))
.flatMap((option) => option.incompatibleWith)
)
);
const busy = $derived(status === 'uploading'); const busy = $derived(status === 'uploading');
const canPublish = $derived( const canPublish = $derived(
@@ -87,14 +98,10 @@
pickAudio(file); pickAudio(file);
} }
function toggleTag(tag: TrackGenre) { function toggleTag(tag: string) {
tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag]; tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag];
} }
function tagBlocked(tag: TrackGenre) {
return !tags.includes(tag) && tags.some((current) => !areCompatible(current, tag));
}
function reset() { function reset() {
title = ''; title = '';
audioName = ''; audioName = '';
@@ -260,13 +267,13 @@
<Field label="Géneros"> <Field label="Géneros">
<div class="mb-2 flex flex-wrap gap-2"> <div class="mb-2 flex flex-wrap gap-2">
{#each TRACK_GENRES as genre (genre.value)} {#each options as option (option.genre)}
<Chip <Chip
selected={tags.includes(genre.value)} selected={tags.includes(option.genre)}
disabled={tagBlocked(genre.value)} disabled={blocked.has(option.genre)}
onclick={() => toggleTag(genre.value)} onclick={() => toggleTag(option.genre)}
> >
{genre.label} {option.label}
</Chip> </Chip>
{/each} {/each}
</div> </div>
@@ -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 });
};
+2 -1
View File
@@ -5,12 +5,13 @@ import { EXPLORE_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => { export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined; const name = url.searchParams.get('name')?.trim() || undefined;
const genre = url.searchParams.get('genre')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1); const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE); const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined }); const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks', { const result = await api.GET('/tracks', {
params: { query: { name, pageNumber, pageSize } } params: { query: { name, genre, pageNumber, pageSize } }
}); });
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.')); return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));