81 changed files with 603 additions and 2896 deletions
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId && history.IsCounted);
.Where(history => history.UserId == request.UserId);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
@@ -1,26 +0,0 @@
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);
}
}
}
@@ -1,31 +0,0 @@
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
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -38,7 +38,7 @@ namespace Musify.Application.Mixes
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId && history.IsCounted)
.Where(history => history.UserId == userId)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -19,7 +19,7 @@ namespace Musify.Application.Mixes
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
@@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -30,7 +30,6 @@ public sealed class TrackStreamIssuer(
folderPath,
streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
if (userId != null)
{
var newListeningHistory = new ListeningHistory
@@ -40,10 +39,9 @@ public sealed class TrackStreamIssuer(
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
listenId = newListeningHistory.Id;
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
}
private long? AnonymousFragmentBytes(long? userId)
@@ -1,33 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
database.ListeningHistories.RemoveRange(stale);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count;
}
}
}
@@ -19,7 +19,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId)
.OrderByDescending(t => t.ListenedAt)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
.FirstOrDefaultAsync(cancellationToken);
return track == null
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
{
var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId && l.IsCounted)
.Where(l => l.UserId == query.UserId)
.GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key)
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count })
.ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds
@@ -18,7 +18,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.SingleOrDefaultAsync(cancellationToken);
if (entity == null)
@@ -5,11 +5,10 @@ using Musify.Application.Tracks.Responses;
using X.PagedList;
using X.PagedList.EF;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks
{
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database)
@@ -29,15 +28,12 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
}
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery
.OrderByDescending(t => t.Track.CreatedAt)
.ThenBy(t => t.Id)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
@@ -40,7 +40,7 @@ namespace Musify.Application.Tracks
var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -1,14 +0,0 @@
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);
}
}
@@ -1,44 +0,0 @@
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,6 +3,5 @@ namespace Musify.Application.Tracks.Responses
public record TrackStreamResponse(
string ManifestUrl,
string Ticket,
int ExpiresInSeconds,
Guid? ListenId = null);
int ExpiresInSeconds);
}
@@ -20,18 +20,17 @@ namespace Musify.Application.Users
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory
.Where(l => l.IsCounted)
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId && l.IsCounted)
.Where(l => l.UserId == request.UserId)
.Select(l => l.ListenedAt.Date)
.Distinct()
.ToListAsync(cancellationToken);
@@ -19,12 +19,6 @@ namespace Musify.Domain.Entities
[Required]
public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
public double? PlayedSeconds { get; set; }
public DateTime? LastProgressAt { get; set; }
public bool IsCounted { get; set; }
[ForeignKey(nameof(UserId) )]
public User User { get; set; }
@@ -15,7 +15,6 @@ namespace Musify.Infrastructure.Jobs
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -1,13 +0,0 @@
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);
}
}
}
@@ -1,52 +0,0 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPlayedSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCounted",
table: "ListeningHistory",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastProgressAt",
table: "ListeningHistory",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "PlayedSeconds",
table: "ListeningHistory",
type: "double precision",
nullable: true);
migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCounted",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "LastProgressAt",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "PlayedSeconds",
table: "ListeningHistory");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Delivered");
b.ToTable("InboxState");
b.ToTable("InboxState", (string)null);
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage");
b.ToTable("OutboxMessage", (string)null);
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
@@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Created");
b.ToTable("OutboxState");
b.ToTable("OutboxState", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
@@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId");
b.ToTable("Albums");
b.ToTable("Albums", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
@@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("AlbumId", "TrackId")
.IsUnique();
b.ToTable("AlbumHasTrack");
b.ToTable("AlbumHasTrack", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
@@ -268,18 +268,9 @@ namespace Musify.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsCounted")
.HasColumnType("boolean");
b.Property<DateTime?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("PlayedSeconds")
.HasColumnType("double precision");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
@@ -292,7 +283,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("ListeningHistory");
b.ToTable("ListeningHistory", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
@@ -326,7 +317,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Position");
b.ToTable("Mixes");
b.ToTable("Mixes", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
@@ -350,7 +341,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("MixId", "Position");
b.ToTable("MixItems");
b.ToTable("MixItems", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
@@ -394,7 +385,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("PlayLists");
b.ToTable("PlayLists", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
@@ -421,7 +412,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack");
b.ToTable("PlayListHasTrack", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
@@ -459,7 +450,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId");
b.ToTable("Tracks");
b.ToTable("Tracks", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
@@ -484,7 +475,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "TrackId")
.IsUnique();
b.ToTable("TrackLikes");
b.ToTable("TrackLikes", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
@@ -504,7 +495,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId", "Tag")
.IsUnique();
b.ToTable("TrackTags");
b.ToTable("TrackTags", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
@@ -533,7 +524,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.ToTable("Upload");
b.ToTable("Upload", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
@@ -584,7 +575,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("UploadIntents");
b.ToTable("UploadIntents", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
@@ -622,7 +613,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.ToTable("User");
b.ToTable("User", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
@@ -647,7 +638,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("FollowerId", "FollowedId")
.IsUnique();
b.ToTable("UserFollows");
b.ToTable("UserFollows", (string)null);
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
@@ -668,7 +659,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("UserHasTrack");
b.ToTable("UserHasTrack", (string)null);
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
@@ -695,7 +686,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId");
b.ToTable("AlbumProcessingState");
b.ToTable("AlbumProcessingState", (string)null);
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
@@ -722,7 +713,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState");
b.ToTable("PlayListProcessingState", (string)null);
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
@@ -758,7 +749,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId");
b.ToTable("TrackProcessingState");
b.ToTable("TrackProcessingState", (string)null);
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -812,7 +803,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("AlbumId");
b1.ToTable("Albums");
b1.ToTable("Albums", (string)null);
b1.WithOwner()
.HasForeignKey("AlbumId");
@@ -905,28 +896,32 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName");
b1.HasKey("PlayListId");
b1.ToTable("PlayLists");
b1.ToTable("PlayLists", (string)null);
b1.WithOwner()
.HasForeignKey("PlayListId");
@@ -997,7 +992,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId");
b1.ToTable("Tracks");
b1.ToTable("Tracks", (string)null);
b1.WithOwner()
.HasForeignKey("TrackId");
@@ -1034,7 +1029,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId");
b1.ToTable("Tracks");
b1.ToTable("Tracks", (string)null);
b1.WithOwner()
.HasForeignKey("TrackId");
@@ -74,7 +74,6 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddHangfireServer();
serviceDescriptors.AddScoped<DailyMixGenerationJob>();
serviceDescriptors.AddScoped<ListeningHistoryCleanupJob>();
return serviceDescriptors;
}
@@ -1,3 +0,0 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RecordListeningProgressRequest(double PlayedSeconds);
@@ -1,39 +0,0 @@
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,5 +1,4 @@
using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
@@ -20,9 +19,8 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks)
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>()
.ProducesValidationProblem();
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
.Produces<TracksSearchResponse>();
group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById")
@@ -43,14 +41,6 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/listens/{listenId}/progress", RecordListeningProgress)
.WithName("RecordListeningProgress")
.WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.")
@@ -94,25 +84,10 @@ public static class TrackEndpoints
IMediator mediator,
CancellationToken cancellationToken,
string? name,
string? genre,
int pageNumber = 1,
int pageSize = 10)
{
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);
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
@@ -153,19 +128,6 @@ public static class TrackEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> RecordListeningProgress(
IMediator mediator,
CurrentUser currentUser,
Guid listenId,
RecordListeningProgressRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTracksByUserId(
IMediator mediator,
long userId,
-1
View File
@@ -40,7 +40,6 @@ app.UseAuthorization();
app
.MapAlbumEndpoints()
.MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints()
.MapMixEndpoints()
.MapPlayListEndpoints()
-5
View File
@@ -41,11 +41,6 @@ using (var scope = host.Services.CreateScope())
job => job.RunAsync(CancellationToken.None),
Cron.Daily(),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
"listening-history-cleanup",
job => job.RunAsync(CancellationToken.None),
Cron.Daily(3),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
host.Run();
@@ -1,106 +0,0 @@
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));
}
}
}
@@ -1,109 +0,0 @@
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,25 +57,5 @@ namespace Musify.Application.Tests.Albums
Assert.False(result.IsError);
Assert.Equal(2, result.Value.Count);
}
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
var countedTrack = TestEntities.Track(owner, "In counted");
var skippedTrack = TestEntities.Track(owner, "In skipped");
await SeedAsync(
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
}
}
}
@@ -1,32 +0,0 @@
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);
}
}
}
@@ -1,69 +0,0 @@
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,21 +72,5 @@ namespace Musify.Application.Tests.Mixes
.ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
}
[Fact]
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
{
var user = TestEntities.User();
var skipped = TestEntities.Track(user, "Only skipped");
await SeedAsync(
user, skipped,
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
}
}
}
@@ -177,16 +177,12 @@ namespace Musify.Application.Tests.TestSupport
public static ListeningHistory ListeningHistory(
long userId,
Guid trackId,
DateTime? listenedAt = null,
double? playedSeconds = null,
bool isCounted = true) =>
DateTime? listenedAt = null) =>
new()
{
UserId = userId,
TrackId = trackId,
ListenedAt = listenedAt ?? DateTime.UtcNow,
PlayedSeconds = playedSeconds,
IsCounted = isCounted
ListenedAt = listenedAt ?? DateTime.UtcNow
};
}
}
@@ -1,32 +0,0 @@
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,21 +35,5 @@ namespace Musify.Application.Tests.Tracks
Assert.Empty(result);
}
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var counted = TestEntities.Track(owner, "Counted");
var skipped = TestEntities.Track(owner, "Skipped");
await SeedAsync(
owner, counted, skipped,
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["Counted"], result.Select(track => track.Title));
}
}
}
@@ -33,21 +33,5 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_ListensCount_OnlyCountsCountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(
owner, track,
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.ListensCount);
}
}
}
@@ -45,9 +45,6 @@ namespace Musify.Application.Tests.Tracks
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(owner.Id, history.UserId);
Assert.Equal(track.Id, history.TrackId);
Assert.Equal(history.Id, result.Value.ListenId);
Assert.False(history.IsCounted);
Assert.Null(history.PlayedSeconds);
}
[Fact]
@@ -1,6 +1,5 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
@@ -44,23 +43,5 @@ namespace Musify.Application.Tests.Tracks
var item = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
}
[Fact]
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
{
var owner = TestEntities.User();
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
await SeedAsync(
owner, rock, jazz,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal("Loud", item.Track.Title);
}
}
}
@@ -1,127 +0,0 @@
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,37 +40,6 @@ namespace Musify.Application.Tests.Users
Assert.Equal(500, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: 200);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(212, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.StreakDays);
Assert.Equal(3, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{
+2 -2
View File
@@ -55,7 +55,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass.
- **Icon stroke:** one global rule (`svg.lucide { stroke-width: 1.5 }` in `layout.css`) sets the stroke for every icon. Never pass `strokeWidth` to an icon, since any CSS rule overrides the SVG attribute and it would do nothing. For a different stroke on one icon, use a Tailwind `stroke-*` utility.
## Motion
@@ -63,7 +63,7 @@ Every animation follows one scale of three durations and one easing curve, all d
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (a random 2 to 4 seconds per song change), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
## Auth
+259 -418
View File
File diff suppressed because it is too large Load Diff
@@ -44,7 +44,7 @@
</AccountMenu>
{:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
<LogIn class="size-icon-lg" strokeWidth={2} />
<LogIn class="size-icon-lg" />
</IconButton>
{/if}
</header>
@@ -48,7 +48,7 @@
? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
>
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} />
<link.icon class="size-icon-md shrink-0" />
<span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
@@ -68,7 +68,7 @@
size="xs"
onclick={() => createPlaylistModal.show()}
>
<Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} />
<Plus class="size-icon-sm text-fg-2" />
</IconButton>
{/if}
</div>
@@ -93,7 +93,7 @@
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg-2"
>
<Plus class="size-3.5" strokeWidth={1.8} />
<Plus class="size-3.5" />
Crear tu primera lista
</button>
{/each}
@@ -30,7 +30,7 @@
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="size-4" strokeWidth={1.6} />
<link.icon class="size-4" />
<span class="text-xs font-medium">{link.label}</span>
</a>
{/each}
@@ -62,14 +62,14 @@
size="xs"
onclick={() => history.back()}
>
<ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
<ChevronLeft class="size-icon-sm" />
</IconButton>
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label
class="flex h-9.5 w-full items-center gap-2.5 rounded-control bg-surface-2 px-3.5 transition focus-within:bg-surface-hover"
>
<Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} />
<Search class="size-4 shrink-0 text-fg-2" />
<input
type="text"
bind:this={searchInput}
@@ -6,7 +6,9 @@
import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte';
import { fmtTime } from '$lib/utils/format';
import Volume1 from '@lucide/svelte/icons/volume-1';
import Volume2 from '@lucide/svelte/icons/volume-2';
import VolumeX from '@lucide/svelte/icons/volume-x';
import ListMusic from '@lucide/svelte/icons/list-music';
function seek(value: number) {
@@ -48,9 +50,22 @@
</div>
</div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<Volume2 class="size-icon-sm shrink-0 text-fg-2" strokeWidth={1.8} />
<IconButton
label={player.muted ? 'Activar sonido' : 'Silenciar'}
tone="plain"
size="xs"
onclick={() => player.toggleMute()}
>
{#if player.muted || player.volume === 0}
<VolumeX class="size-icon-sm" />
{:else if player.volume < 50}
<Volume1 class="size-icon-sm" />
{:else}
<Volume2 class="size-icon-sm" />
{/if}
</IconButton>
<Slider
value={player.volume}
value={player.muted ? 0 : player.volume}
max={100}
label="Volumen"
oninput={(value) => player.setVolume(value)}
@@ -63,7 +78,7 @@
pressed={queuePanel.open}
onclick={() => queuePanel.toggle()}
>
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
<ListMusic class="size-icon-sm" />
</IconButton>
</div>
</div>
@@ -6,8 +6,6 @@
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { fmtTime } from '$lib/utils/format';
import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
import { expoOut } from 'svelte/easing';
const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId);
@@ -15,10 +13,13 @@
});
</script>
{#if queuePanel.open}
<div
class="queue-shell max-sm:hidden"
data-open={queuePanel.open ? '' : undefined}
inert={!queuePanel.open}
>
<aside
transition:fly={{ x: 24, duration: 200, easing: expoOut }}
class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex"
class="flex shrink-0 flex-col border-l border-hairline px-4 pt-5"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
>
<div class="mb-5 flex items-center justify-between">
@@ -30,7 +31,7 @@
{#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 rounded-control surface-active p-2.5">
<div class="mb-6 surface-active rounded-control p-2.5">
<MediaIdentity
trackId={player.current.id}
title={player.current.title}
@@ -77,4 +78,4 @@
{/each}
</div>
</aside>
{/if}
</div>
@@ -39,7 +39,7 @@
{:else}
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if}
<div class="min-w-0 flex-1">
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit}
<ExplicitBadge />
@@ -56,11 +56,11 @@
<IconButton
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
tone="plain"
size="xs"
size="md"
pressed={isLiked}
onclick={toggleLike}
>
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} />
</IconButton>
{/if}
</div>
@@ -26,7 +26,7 @@
aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}"
>
<Shuffle class="size-4" strokeWidth={1.8} />
<Shuffle class="size-4" />
</button>
{/if}
@@ -58,7 +58,7 @@
aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}"
>
<Repeat class="size-4" strokeWidth={1.8} />
<Repeat class="size-4" />
</button>
{/if}
</div>
@@ -68,6 +68,6 @@
class="h-full w-full object-cover"
/>
{:else}
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} />
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" />
{/if}
</label>
@@ -95,7 +95,7 @@
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
>
{#if index}
<Hash size={16} strokeWidth={1.5} />
<Hash size={16} />
{/if}
<span>Título</span>
{#each columns as column (column)}
@@ -24,7 +24,7 @@
>
{#if tone === 'danger'}
<span class="mb-4 grid size-10 place-items-center rounded-full bg-danger/12 text-danger-fg">
<CircleAlert class="size-icon-md" strokeWidth={2} />
<CircleAlert class="size-icon-md" />
</span>
{/if}
<h2 class="text-display-3 text-fg">{title}</h2>
@@ -1,6 +1,8 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { pop } from '$lib/utils/transitions';
interface Props {
class?: string;
style?: string;
@@ -12,9 +14,8 @@
</script>
<div
class="glass-panel animate-pop rounded-panel p-1.5 {border
? 'border border-hairline'
: ''} {klass}"
transition:pop|global
class="glass-panel rounded-panel p-1.5 {border ? 'border border-hairline' : ''} {klass}"
{style}
>
{@render children()}
@@ -3,8 +3,7 @@
import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition';
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
import { pop } from '$lib/utils/transitions';
interface Props {
open: boolean;
@@ -48,15 +47,6 @@
);
}
function pop(node: Element, { duration = 200 }: { duration?: number } = {}): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
function onWindowKeydown(event: KeyboardEvent) {
if (!open) return;
if (event.key === 'Escape') {
@@ -90,7 +80,7 @@
<div
bind:this={panel}
transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -22,7 +22,6 @@
/>
<Check
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/>
</span>
<span class="cursor-pointer">{@render children()}</span>
@@ -15,7 +15,7 @@
<div class="flex flex-col items-center rounded-panel px-6 py-16 text-center {klass}">
{#if Icon}
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
<Icon class="size-icon-xl text-fg-2" />
{/if}
{#if title}
<h3 class="mt-6 text-display-3 text-fg">
@@ -24,7 +24,7 @@
oninput={handleInput}
aria-label={label}
class="mf-slider mf-slider-{variant}"
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)"
style="--p:{percent}%"
/>
<style>
@@ -35,6 +35,17 @@
border-radius: 100px;
outline: none;
cursor: pointer;
background-image:
linear-gradient(
to right,
transparent var(--p),
color-mix(in srgb, var(--mf-text) 12%, var(--mf-bg)) var(--p)
),
var(--mf-slider-fill);
background-size:
100% 100%,
300% 100%;
animation: gradient-drift 10s linear infinite alternate;
}
.mf-slider-seek {
width: 100%;
+19 -72
View File
@@ -1,75 +1,22 @@
const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
Pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
Rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
HipHop: { label: 'Hip hop', tagline: 'Rimas con actitud' },
RnB: { label: 'R&B', tagline: 'Ritmo suave y voces con alma' },
Jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
Blues: { label: 'Blues', tagline: 'Sentimiento en cada nota' },
Classical: { label: 'Clásica', tagline: 'Siglos de composición' },
Electronic: { label: 'Electrónica', tagline: 'Beats para perderte' },
House: { label: 'House', tagline: 'Pista de baile sin fin' },
Techno: { label: 'Techno', tagline: 'Pulso hipnótico' },
Trance: { label: 'Trance', tagline: 'Melodías que te elevan' },
Dubstep: { label: 'Dubstep', tagline: 'Bajos que hacen temblar' },
DrumAndBass: { label: 'Drum and bass', tagline: 'Velocidad y graves' },
Metal: { label: 'Metal', tagline: 'Riffs pesados' },
Punk: { label: 'Punk', tagline: 'Rápido, crudo y directo' },
Reggae: { label: 'Reggae', tagline: 'Buenas vibraciones' },
Reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
Country: { label: 'Country', tagline: 'Historias con guitarra' },
Folk: { label: 'Folk', tagline: 'Raíces y acústico' },
Indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
KPop: { label: 'K-pop', tagline: 'Coreografías pegajosas' },
Latin: { label: 'Latina', tagline: 'Sabor y ritmo' },
Soul: { label: 'Soul', tagline: 'Emoción en estado puro' },
Funk: { label: 'Funk', tagline: 'Groove imposible de quedarse quieto' },
Ambient: { label: 'Ambient', tagline: 'Paisajes sonoros' },
Lofi: { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' }
export const GENRE_INFO: Record<string, { label: string; tagline: string }> = {
pop: { label: 'Pop', tagline: 'Éxitos que suenan en todas partes' },
rock: { label: 'Rock', tagline: 'Guitarras, actitud y ruido' },
reggaeton: { label: 'Reggaetón', tagline: 'El ritmo que no para' },
'lo-fi': { label: 'Lo-fi', tagline: 'Para concentrarte o relajarte' },
indie: { label: 'Indie', tagline: 'Voces fuera del radar' },
salsa: { label: 'Salsa', tagline: 'Para mover el cuerpo' },
'k-pop': { label: 'K-pop', tagline: 'Coreografías pegajosas' },
electrónica: { label: 'Electrónica', tagline: 'Beats para perderte' },
jazz: { label: 'Jazz', tagline: 'Improvisación y elegancia' },
baladas: { label: 'Baladas', tagline: 'Para sentir con calma' },
trap: { label: 'Trap', tagline: 'Autotune y bajos pesados' },
cumbia: { label: 'Cumbia', tagline: 'El sabor de siempre' },
'hip hop': { label: 'Hip hop', tagline: 'Rimas con actitud' },
'música clásica': { label: 'Clásica', tagline: 'Siglos de composición' }
};
const KNOWN_GENRES = Object.keys(GENRE_INFO);
function humanize(name: string): string {
return name.replace(/([a-z])([A-Z])/g, '$1 $2');
}
export function genreInfo(genre: string) {
return GENRE_INFO[genre] ?? { label: humanize(genre), tagline: '' };
}
export function genreHue(genre: string): number {
const known = KNOWN_GENRES.indexOf(genre);
if (known >= 0) return Math.round((known * 360) / KNOWN_GENRES.length);
let hash = 0;
for (const char of genre) hash = (hash * 31 + char.charCodeAt(0)) % 360;
return hash;
}
export function genreTiles(genres: { genre: string }[]) {
return genres.map(({ genre }) => ({
genre,
hue: genreHue(genre),
...genreInfo(genre)
export const genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
query,
hue: Math.round((i * 360) / all.length),
...info
}));
}
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
@@ -0,0 +1,56 @@
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;
}
@@ -1,52 +0,0 @@
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;
});
}
}
+30 -39
View File
@@ -3,7 +3,6 @@ import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte';
import { ListenTracker } from './listenTracker';
export interface PlayerTrack {
id: string | number;
@@ -120,6 +119,7 @@ class PlayerState {
playing = $state(false);
progress = $state(0);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false);
loading = $state(false);
recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
@@ -132,7 +132,6 @@ class PlayerState {
#loadToken = 0;
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
#listen = new ListenTracker();
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
@@ -150,32 +149,22 @@ class PlayerState {
const audio = new Audio();
audio.preload = 'auto';
audio.volume = this.volume / 100;
audio.muted = this.muted;
audio.addEventListener('durationchange', () => this.#syncDuration(audio.duration));
audio.addEventListener('loadedmetadata', () => this.#syncDuration(audio.duration));
audio.addEventListener('timeupdate', () => {
if (!audio.paused) this.#listen.tick(audio.currentTime);
});
audio.addEventListener('seeking', () => this.#listen.interrupt());
audio.addEventListener('seeked', () => this.#listen.resync(audio.currentTime));
audio.addEventListener('play', () => {
this.#listen.resync(audio.currentTime);
this.playing = true;
this.#startProgressLoop();
this.#syncMediaSessionPlaybackState();
});
audio.addEventListener('pause', () => {
this.#listen.interrupt();
this.#listen.flush();
this.playing = false;
this.#stopProgressLoop();
this.#syncMediaSessionPlaybackState();
});
audio.addEventListener('ended', () => {
this.#stopProgressLoop();
this.#listen.tick(audio.currentTime);
this.#listen.begin(null);
if (this.repeat) {
this.#beginRepeatListen();
audio.currentTime = 0;
audio.play().catch(() => {});
return;
@@ -189,10 +178,6 @@ class PlayerState {
this.#fail();
});
this.#audio = audio;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.#listen.flush();
});
window.addEventListener('pagehide', () => this.#listen.flush());
this.#setupMediaSession(audio);
return audio;
}
@@ -223,10 +208,19 @@ class PlayerState {
});
}
#tickProgress = () => {
#clockTime = 0;
#clockStamp = 0;
#tickProgress = (now: number) => {
const audio = this.#audio;
if (!audio) return;
this.progress = audio.currentTime;
if (audio.currentTime !== this.#clockTime) {
this.#clockTime = audio.currentTime;
this.#clockStamp = now;
}
const elapsed = Math.min(0.25, (now - this.#clockStamp) / 1000);
const smooth = this.#clockTime + (audio.paused ? 0 : elapsed * audio.playbackRate);
this.progress = Math.min(smooth, Number.isFinite(audio.duration) ? audio.duration : smooth);
this.#rafId = requestAnimationFrame(this.#tickProgress);
};
@@ -283,16 +277,14 @@ class PlayerState {
const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id);
if (track) this.#syncMediaSessionMetadata(track);
this.#listen.begin(null);
const token = ++this.#loadToken;
this.loading = true;
this.progress = 0;
try {
const { src, listenId } = await this.#resolveLocalSrc(String(id));
const src = await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return;
audio.src = src;
audio.volume = this.volume / 100;
this.#listen.begin(listenId);
await audio.play();
this.loading = false;
this.#pushRecent(this.current);
@@ -318,28 +310,14 @@ class PlayerState {
);
}
async #resolveLocalSrc(id: string): Promise<{ src: string; listenId: string | null }> {
async #resolveLocalSrc(id: string): Promise<string> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket, listenId } = (await res.json()) as {
const { manifestUrl, ticket } = (await res.json()) as {
manifestUrl: string;
ticket: string;
listenId?: string | null;
};
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;
}
return this.#withTicket(manifestUrl, ticket);
}
#withTicket(url: string, ticket: string): string {
@@ -434,6 +412,18 @@ class PlayerState {
else audio.pause();
}
toggleMute() {
this.#applyMuted(!this.muted);
if (!this.muted && this.volume === 0) this.setVolume(50);
}
#applyMuted(value: boolean) {
this.muted = value;
const audio = this.#audioEl();
if (audio) audio.muted = value;
if (browser) localStorage.setItem('player.muted', String(value));
}
next() {
if (this.tracks.length === 0) return;
const idx = this.#index();
@@ -492,6 +482,7 @@ class PlayerState {
setVolume(value: number) {
this.volume = Math.min(100, Math.max(0, Math.round(value)));
if (this.muted && this.volume > 0) this.#applyMuted(false);
const audio = this.#audioEl();
if (audio) {
audio.volume = this.volume / 100;
@@ -68,10 +68,6 @@ export function searchHref(query: string): string {
return query ? `/explore?q=${encodeURIComponent(query)}` : '/explore';
}
export function genreHref(genre: string): string {
return `/explore?genre=${encodeURIComponent(genre)}`;
}
export interface AppNavLink {
href: string;
label: string;
+3 -1
View File
@@ -45,11 +45,13 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
'--mf-bg': light ? `oklch(0.975 0.012 ${H})` : `oklch(0.068 0.008 ${H})`,
'--mf-elevated': light ? `oklch(0.99 0.008 ${H})` : `oklch(0.12 0.012 ${H})`,
'--mf-panel-bg': light ? 'rgba(255, 255, 255, 0.28)' : 'rgba(0, 0, 0, 0.16)',
'--mf-bar-bg': light ? `oklch(0.99 0.01 ${H} / 0.86)` : `oklch(0.105 0.012 ${H} / 0.9)`,
'--mf-hairline': light ? `oklch(0.3 0.02 ${H} / 0.06)` : `oklch(0.62 0.02 ${H} / 0.05)`,
'--mf-ambient': light
? `radial-gradient(80% 70% at 12% 0%, ${glow(0.9, 0.05, 0.55)}, transparent 75%), radial-gradient(70% 60% at 88% 8%, ${glow(0.92, 0.035, 0.4)}, transparent 75%), radial-gradient(90% 70% at 50% 110%, ${glow(0.93, 0.03, 0.35)}, transparent 80%), linear-gradient(180deg, ${glow(0.96, 0.02, 0.4)}, transparent 60%)`
: `radial-gradient(75% 65% at 12% 0%, ${glow(0.3, 0.045, 0.5)}, transparent 75%), radial-gradient(65% 55% at 85% 5%, ${glow(0.24, 0.035, 0.35)}, transparent 75%), radial-gradient(90% 70% at 50% 105%, ${glow(0.18, 0.025, 0.3)}, transparent 80%), linear-gradient(180deg, ${glow(0.14, 0.02, 0.35)}, transparent 55%)`,
'--mf-slider-fill': light
? `linear-gradient(90deg, oklch(0.5 0.1 ${H}), oklch(0.68 0.11 ${r(h + 18, 1)}), oklch(0.5 0.1 ${H}))`
: `linear-gradient(90deg, oklch(0.66 0.11 ${H}), oklch(0.84 0.09 ${r(h + 18, 1)}), oklch(0.66 0.11 ${H}))`,
'--mf-hero-tint': light ? `oklch(0.8 0.05 ${H})` : `oklch(0.3 0.04 ${H})`,
'--mf-hero-bg': light
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)`
+14
View File
@@ -0,0 +1,14 @@
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
export function pop(
node: Element,
{ duration = 200 }: { duration?: number } = {}
): TransitionConfig {
return {
duration,
easing: expoOut,
css: (t) =>
`opacity: ${t}; transform: scale(${0.98 + 0.02 * t}) translateY(${0.25 * (1 - t)}rem);`
};
}
@@ -27,7 +27,7 @@
{#snippet actions()}
{#if items.length > 0}
<Button onclick={() => (creating = true)}>
<Plus class="size-4" strokeWidth={2.5} />
<Plus class="size-4" />
Nuevo álbum
</Button>
{/if}
@@ -9,25 +9,13 @@ import {
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? '';
const genreParam = url.searchParams.get('genre')?.trim() || null;
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const user = locals.user;
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const genresRes = await api.GET('/genres');
const genres = genresRes.data ?? [];
const genre = genres.find((item) => item.genre === genreParam)?.genre ?? null;
const tracksPromise = api.GET('/tracks', {
params: {
query: {
name: query || undefined,
genre: genre ?? undefined,
pageNumber: page,
pageSize: PAGE_SIZE
}
}
params: { query: { name: query || undefined, pageNumber: page, pageSize: PAGE_SIZE } }
});
const playlistsPromise = user
@@ -59,8 +47,6 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
return {
query,
genre,
genres,
tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [],
@@ -16,13 +16,13 @@
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import SectionHeading from '$lib/components/ui/layout/SectionHeading.svelte';
import { EXPLORE_PAGE_SIZE } from '$lib/config';
import { genreHref } from '$lib/state/navigation.svelte';
import { searchHref } from '$lib/state/navigation.svelte';
import { createTrackMenu } from '$lib/state/menus.svelte';
import ContextMenu, { contextMenuPosition } from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
import ListEnd from '@lucide/svelte/icons/list-end';
import { appendUnique } from '$lib/data/collections';
import { genreInfo, genreTiles } from '$lib/data/genres';
import { genreTiles } from '$lib/data/genres';
import {
type SearchFilter,
showGroup,
@@ -58,7 +58,7 @@
$effect(() => {
trackMenu.close();
albumMenu = null;
sfilter = data.genre ? 'Canciones' : 'Todo';
sfilter = 'Todo';
localItems = data.tracks.items.map((item) => item.track);
localPage = Number(data.tracks.pageNumber);
hasMore = data.tracks.hasNextPage;
@@ -73,9 +73,6 @@
const items = $derived(songRows.map((row) => row.track));
const tiles = $derived(genreTiles(data.genres));
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
const albums = $derived(data.albums);
const users = $derived(data.users);
@@ -172,7 +169,6 @@
pageSize: String(EXPLORE_PAGE_SIZE)
});
if (data.query) params.set('name', data.query);
if (data.genre) params.set('genre', data.genre);
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
@@ -192,9 +188,7 @@
</script>
<svelte:head>
<title
>{activeGenre ? activeGenre.label : data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title
>
<title>{data.query ? `Buscar «${data.query}»` : 'Descubrir'}</title>
<meta
name="description"
content="Busca canciones, álbumes y personas, o descubre por género en Musify."
@@ -202,9 +196,7 @@
</svelte:head>
<Page>
{#if activeGenre}
<PageHeader eyebrow="Género" title={activeGenre.label} description={activeGenre.tagline} />
{:else if !data.query}
{#if !data.query}
<PageHeader
title="Descubrir"
description="Encuentra tu próxima canción favorita explorando por género."
@@ -308,34 +300,31 @@
por playlist.
</p>
<div class="mt-5.5 flex flex-wrap justify-center gap-2">
{#each tiles.slice(0, 4) as tile (tile.genre)}
<Chip href={genreHref(tile.genre)}>{tile.label}</Chip>
{#each genreTiles.slice(0, 4) as genre (genre.query)}
<Chip href={searchHref(genre.query)}>{genre.label}</Chip>
{/each}
</div>
</div>
{/if}
{#if !data.query && !data.genre}
{#if !data.query}
<div class="grid-wide">
{#each tiles as tile, i (tile.genre)}
{#each genreTiles as genre, i (genre.query)}
<a
href={genreHref(tile.genre)}
href={searchHref(genre.query)}
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:{tile.hue}"
style="--i:{i}; --tile-hue:{genre.hue}"
>
<div class="text-on-art">
{tile.label}
{genre.label}
</div>
<div class="text-xs text-on-art-2">{tile.tagline}</div>
<div class="text-xs text-on-art-2">{genre.tagline}</div>
</a>
{/each}
</div>
{#if tiles.length === 0}
<EmptyState icon={Music} description="Todavía no hay géneros. Sube música para empezar." />
{/if}
{/if}
{#if (data.query || data.genre) && !nothingFound}
{#if data.query && !nothingFound}
<div class="mt-9 flex flex-col gap-9">
{#if showGroup(sfilter, 'Canciones')}
<div>
@@ -375,9 +364,7 @@
{:else}
<EmptyState
icon={Music}
description={activeGenre
? 'Todavía no hay canciones de este género.'
: `No hay canciones que coincidan con «${data.query}».`}
description="No hay canciones que coincidan con «{data.query}»."
/>
{/if}
</div>
@@ -39,7 +39,7 @@
<div
class="grid size-cover-hero shrink-0 place-items-center rounded-art-lg bg-[image:var(--mf-liked-grad)] sm:size-cover-hero-sm"
>
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" strokeWidth={0} />
<Heart class="size-icon-xl text-on-art/90" fill="currentColor" />
</div>
{/snippet}
{#snippet actions()}
@@ -7,18 +7,12 @@ import {
unwrapOrFail
} from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreInfo } from '$lib/data/genres';
import type { components } from '$lib/api/schema';
type Genre = components['schemas']['Genre'];
import { findConflict, genreLabel, isTrackGenre, type TrackGenre } from '$lib/data/trackGenres';
const MAX_TITLE = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
export const load: PageServerLoad = ({ 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 = {
@@ -45,23 +39,20 @@ export const actions: Actions = {
if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' });
}
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))) {
if (!rawTags.every(isTrackGenre)) {
return fail(400, { message: 'Alguno de los géneros no es válido.' });
}
const tags = [...new Set(rawTags)] as Genre[];
const conflict = findConflict(tags, options);
const tags: TrackGenre[] = [...new Set(rawTags)];
const conflict = findConflict(tags);
if (conflict) {
return fail(400, {
message: `Los géneros ${genreInfo(conflict[0]).label} y ${genreInfo(conflict[1]).label} no se pueden combinar.`
message: `Los géneros ${genreLabel(conflict[0])} y ${genreLabel(conflict[1])} no se pueden combinar.`
});
}
const audioContentType = contentTypeOf(audio, AUDIO_TYPES);
const pictureContentType = contentTypeOf(cover, IMAGE_TYPES);
const api = createApiClient({ fetch, accessToken });
const urlsResult = await api.POST('/tracks/upload-urls', {
body: {
+16 -23
View File
@@ -14,15 +14,13 @@
import Checkbox from '$lib/components/ui/primitives/Checkbox.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { genreOptions } from '$lib/data/genres';
import { TRACK_GENRES, areCompatible, type TrackGenre } from '$lib/data/trackGenres';
type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done';
const MAX_TITLE = 100;
let { data } = $props();
let title = $state('');
let audioName = $state('');
let audioSize = $state(0);
@@ -33,7 +31,7 @@
let acceptedTerms = $state(false);
let errorMsg = $state('');
let errorDetail = $state('');
let tags = $state<string[]>([]);
let tags = $state<TrackGenre[]>([]);
let publishedTitle = $state('');
let audioInput = $state<HTMLInputElement>();
@@ -43,15 +41,6 @@
{ key: 'publish', label: 'Publicar', hint: 'Disponible en el catálogo.' }
];
const options = $derived(genreOptions(data.genres));
const blocked = $derived(
new Set(
options
.filter((option) => tags.includes(option.genre))
.flatMap((option) => option.incompatibleWith)
)
);
const busy = $derived(status === 'uploading');
const canPublish = $derived(
@@ -96,10 +85,14 @@
pickAudio(file);
}
function toggleTag(tag: string) {
function toggleTag(tag: TrackGenre) {
tags = tags.includes(tag) ? tags.filter((current) => current !== tag) : [...tags, tag];
}
function tagBlocked(tag: TrackGenre) {
return !tags.includes(tag) && tags.some((current) => !areCompatible(current, tag));
}
function reset() {
title = '';
audioName = '';
@@ -137,7 +130,7 @@
: 'bg-accent-btn text-accent-soft'}"
>
{#if stepStates[i] === 'done'}
<Check class="size-3.5" strokeWidth={2.5} />
<Check class="size-3.5" />
{:else}
{i + 1}
{/if}
@@ -153,7 +146,7 @@
{#if status === 'done'}
<Surface padding="lg" class="mt-10 bg-accent-tint text-center">
<span class="mx-auto grid size-14 place-items-center rounded-full bg-accent-soft text-ink">
<Check class="size-7" strokeWidth={2.5} />
<Check class="size-7" />
</span>
<h2 class="mt-6 font-display text-2xl font-semibold tracking-display text-fg">¡Subida!</h2>
<p class="mt-3 text-sm text-fg-2">
@@ -214,9 +207,9 @@
aria-label="Seleccionar archivo de audio"
/>
{#if audioName === ''}
<Headphones class="size-9 text-fg-2 sm:size-10" strokeWidth={1.4} />
<Headphones class="size-9 text-fg-2 sm:size-10" />
{:else}
<Music class="size-9 text-accent-soft sm:size-10" strokeWidth={1.4} />
<Music class="size-9 text-accent-soft sm:size-10" />
{/if}
{#if audioName === ''}
<p class="mt-4 text-sm font-medium text-fg">
@@ -261,13 +254,13 @@
<Field label="Géneros">
<div class="flex flex-wrap gap-2">
{#each options as option (option.genre)}
{#each TRACK_GENRES as genre (genre.value)}
<Chip
selected={tags.includes(option.genre)}
disabled={blocked.has(option.genre)}
onclick={() => toggleTag(option.genre)}
selected={tags.includes(genre.value)}
disabled={tagBlocked(genre.value)}
onclick={() => toggleTag(genre.value)}
>
{option.label}
{genre.label}
</Chip>
{/each}
</div>
+1 -1
View File
@@ -74,7 +74,7 @@
if (dh > 180) dh -= 360;
else if (dh < -180) dh += 360;
const start = performance.now();
const duration = 600;
const duration = 2000 + Math.random() * 2000;
const tick = (now: number) => {
const t = Math.min(1, (now - start) / duration);
const eased = 1 - Math.pow(1 - t, 3);
+3 -3
View File
@@ -134,11 +134,11 @@
</svelte:head>
<Page>
<div class="relative overflow-hidden rounded-panel-lg hero-surface px-6 py-8 sm:px-9 sm:py-8.5">
<div class="relative hero-surface overflow-hidden rounded-panel-lg px-6 py-8 sm:px-9 sm:py-8.5">
<div
class="relative flex flex-col items-start gap-7 lg:flex-row lg:flex-wrap lg:items-end lg:justify-between"
>
<div class="animate-enter max-w-hero min-w-65">
<div class="animate-enter max-w-hero min-w-65 dark:shadow-accent">
<h1
class="font-display text-3xl leading-tight font-medium tracking-tight text-pretty text-fg sm:text-4xl"
>
@@ -209,7 +209,7 @@
{#if spotlight}
<section>
<div
class="grid grid-cols-1 overflow-hidden rounded-panel-lg spotlight-surface lg:grid-cols-2"
class="grid spotlight-surface grid-cols-1 overflow-hidden rounded-panel-lg lg:grid-cols-2"
>
<div class="flex flex-col justify-center p-8 sm:p-9">
<div
@@ -1,25 +0,0 @@
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 });
};
+1 -2
View File
@@ -5,13 +5,12 @@ import { EXPLORE_PAGE_SIZE } from '$lib/config';
export const GET: RequestHandler = async ({ url, locals, fetch }) => {
const name = url.searchParams.get('name')?.trim() || undefined;
const genre = url.searchParams.get('genre')?.trim() || undefined;
const pageNumber = Math.max(1, Number(url.searchParams.get('pageNumber')) || 1);
const pageSize = Math.max(1, Number(url.searchParams.get('pageSize')) || EXPLORE_PAGE_SIZE);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks', {
params: { query: { name, genre, pageNumber, pageSize } }
params: { query: { name, pageNumber, pageSize } }
});
return json(unwrapOrError(result, 'No se pudieron cargar las canciones.'));
+61 -4
View File
@@ -131,9 +131,8 @@
transition-duration: var(--default-transition-duration);
}
@utility glass-bar {
background-color: var(--mf-bar-bg);
background-color: var(--mf-bg);
border-top: 1px solid var(--mf-hairline);
backdrop-filter: blur(24px);
}
@utility grid-cards {
display: grid;
@@ -169,10 +168,16 @@
@utility app-backdrop {
pointer-events: none;
position: fixed;
inset: 0;
inset: -10%;
z-index: -10;
background-color: var(--mf-bg);
background-image: var(--mf-ambient);
animation: ambient-drift 48s ease-in-out infinite alternate;
will-change: transform;
@media (prefers-reduced-motion: reduce) {
animation: none;
}
}
@utility app-vignette {
pointer-events: none;
@@ -185,6 +190,22 @@
transparent 62%
);
}
@utility queue-shell {
display: flex;
justify-content: flex-end;
flex-shrink: 0;
overflow: hidden;
width: 0;
visibility: hidden;
transition:
width var(--mf-motion-slow) ease-in-out,
visibility var(--mf-motion-slow);
&[data-open] {
width: var(--mf-queue-w);
visibility: visible;
}
}
@utility app-main {
--mf-dock-h: 0px;
padding-bottom: calc(var(--mf-nav-h) + var(--mf-safe-b) + var(--mf-dock-h));
@@ -203,14 +224,24 @@
}
@utility surface-active {
background-image: var(--mf-g-active);
background-size: 180% 100%;
animation: gradient-drift 14s ease-in-out infinite alternate;
}
@utility surface-active-hover {
background-image: var(--mf-g-hover);
background-size: 180% 100%;
animation: gradient-drift 14s ease-in-out infinite alternate;
}
@utility hero-surface {
background-image: var(--mf-hero-bg);
background-size: 180% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate;
}
@utility spotlight-surface {
background-size:
180% 100%,
100% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate;
background-image:
var(--mf-spotlight-bg),
linear-gradient(
@@ -220,13 +251,17 @@
transparent 92.5%
);
}
@utility glow-drift {
background-size: 180% 100%;
animation: gradient-drift 18s ease-in-out infinite alternate;
}
@utility field-control {
@apply w-full rounded-control bg-surface py-3 text-sm text-fg transition placeholder:text-fg-2 focus:bg-surface-2 focus:outline-none;
}
@layer base {
svg.lucide {
stroke-width: 1.8;
stroke-width: 1.5;
}
}
@@ -307,6 +342,27 @@ button:disabled {
}
}
@keyframes ambient-drift {
0% {
transform: translate3d(-3%, -2%, 0) scale(1);
}
50% {
transform: translate3d(2%, 3%, 0) scale(1.06);
}
100% {
transform: translate3d(3%, -1%, 0) scale(1.02);
}
}
@keyframes gradient-drift {
from {
background-position: 0% 50%;
}
to {
background-position: 100% 50%;
}
}
@keyframes sheen {
from {
background-position: 200% 0;
@@ -346,6 +402,7 @@ button:disabled {
*::before,
*::after {
animation-duration: 0.001ms !important;
animation-iteration-count: 1 !important;
animation-delay: 0ms !important;
transition-duration: 0.001ms !important;
scroll-behavior: auto !important;