Author SHA1 Message Date
NotS1mp c4ab5dc388 Merge branch 'master' into dynamic-genres
CI/CD / BackEnd (push) Canceled after 0s
CI/CD / WebPlayer (push) Canceled after 0s
CI/CD / Deploy (push) Canceled after 0s
CI/CD / BackEnd (pull_request) Successful in 1m20s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
2026-09-23 22:15:22 +00:00
NotS1mp 920dcc6779 Merge pull request 'Listening time accuracy' (#19) from listening-time-accuracy into master
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 39s
CI/CD / Deploy (push) Successful in 1m40s
Reviewed-on: #19
2026-09-23 22:15:10 +00:00
Admin b61ff1ebec Make Discover and upload genres dynamic
CI/CD / BackEnd (push) Successful in 1m22s
CI/CD / WebPlayer (push) Successful in 38s
CI/CD / Deploy (push) Skipped
CI/CD / BackEnd (pull_request) Successful in 1m21s
CI/CD / WebPlayer (pull_request) Successful in 40s
CI/CD / Deploy (pull_request) Skipped
Discover used a hardcoded list of genre tiles that searched track titles,
and the upload form kept its own copy of the genre list and compatibility
rules. Both now come from the backend.

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

web-player:
- Discover tiles are built from /genres; each opens /explore?genre=X, a
  song list filtered by that tag. Unknown genres fall back to a label
  derived from their name.
- The upload form loads /genres/available, disables incompatible genres
  as you pick, and the server validates before reserving upload URLs.
- lib/data/trackGenres.ts is removed; its helpers merge into
  lib/data/genres.ts.
- Regenerate the typed API client.
2026-09-23 20:05:30 +02:00
Admin 8870c65c6f Move ListeningRules to its own file and split listening tests by subject
CI/CD / BackEnd (push) Successful in 1m16s
CI/CD / BackEnd (pull_request) Canceled after 0s
CI/CD / WebPlayer (pull_request) Canceled after 0s
CI/CD / Deploy (pull_request) Canceled after 0s
CI/CD / WebPlayer (push) Successful in 40s
CI/CD / Deploy (push) Skipped
The counted-listen tests lived in one catch-all UncountedListensTests
file. Each now sits next to the handler it covers, and the cleanup
handler gets its own test file.
2026-09-23 20:02:50 +02:00
Admin 5df5e635f3 Count real listening time instead of full track duration
"Listening time this week" summed the full duration of every track whose
stream ticket was issued, so pressing play and skipping counted the whole
song. Playback is now measured on the client and reported to the API.

Backend:
- ListeningHistory gains PlayedSeconds, LastProgressAt and IsCounted
  (migration TrackPlayedSeconds marks existing rows as counted).
- /tracks/{id}/stream returns a listenId; new
  PUT /tracks/listens/{listenId}/progress stores the accumulated seconds,
  capped by track duration and elapsed wall-clock time, monotonic and
  owner-only.
- A listen counts once it reaches min(30s, 50% of the track). Weekly
  stats, streak, recent history/albums, mixes and listensCount only use
  counted listens; seconds sum PlayedSeconds.
- ListeningHistoryCleanupJob (daily) deletes uncounted listens older
  than 8 days.

web-player:
- ListenTracker accumulates played time (seeks are ignored) and flushes
  on pause, track change, ended, every 30s, and on pagehide.
- Proxy route /api/listens/[id]/progress; repeat starts a new listen per
  loop.
2026-09-23 20:00:45 +02:00
118 changed files with 1727 additions and 1609 deletions
+2 -14
View File
@@ -86,23 +86,11 @@ jobs:
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket keys
- name: Migrate database, generate keys
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE run --rm --no-deps keys-init
# The migrations are baked into the migrator image, so it is rebuilt on
# every deploy (plain `up` would reuse the stale image and re-apply the
# old migrations). `run --rm` starts wait-for-postgres, blocks until the
# migration finishes and propagates its exit code, so a failed migration
# aborts the deploy before the new API is started against an old schema.
- name: Migrate database
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE build migrate
$COMPOSE run --rm migrate
$COMPOSE up -d wait-for-postgres keys-init migrate
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
var pagedEntities = await tracksQuery
.OrderBy(albumTrack => albumTrack.TrackNumber)
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
{
var listenedTrackIds = database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == request.UserId);
.Where(history => history.UserId == request.UserId && history.IsCounted);
var ranked = await database.AlbumHasTracks
.AsNoTracking()
@@ -0,0 +1,26 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -0,0 +1,31 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -30,7 +30,7 @@ namespace Musify.Application.Likes
var pagedEntities = await likesQuery
.OrderByDescending(like => like.CreatedAt)
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -38,7 +38,7 @@ namespace Musify.Application.Mixes
var historyTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.UserId == userId)
.Where(history => history.UserId == userId && history.IsCounted)
.Select(history => history.TrackId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -19,7 +19,7 @@ namespace Musify.Application.Mixes
.AsNoTracking()
.Include(track => track.Owner)
.Where(track => trackIds.Contains(track.Id))
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
return orderedItems
@@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists
var pagedEntities = await tracksQuery
.OrderBy(plt => plt.Position)
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -30,6 +30,7 @@ public sealed class TrackStreamIssuer(
folderPath,
streamGatewayConfiguration.AudioFileName);
Guid? listenId = null;
if (userId != null)
{
var newListeningHistory = new ListeningHistory
@@ -39,9 +40,10 @@ public sealed class TrackStreamIssuer(
};
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
await database.SaveChangesAsync(cancellationToken);
listenId = newListeningHistory.Id;
}
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
}
private long? AnonymousFragmentBytes(long? userId)
@@ -18,8 +18,7 @@ namespace Musify.Application.Tracks
string Title,
Guid PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false)
IReadOnlyCollection<Genre> Tags)
: ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler(
@@ -84,7 +83,6 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id,
Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures
{
OriginalName = pictureIntent.ObjectName,
@@ -0,0 +1,33 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.Logging;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record DeleteStaleUncountedListensCommand : ICommand<int>;
public class DeleteStaleUncountedListensCommandHandler(
IDatabase database,
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
{
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
{
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
var stale = await database.ListeningHistories
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
.ToListAsync(cancellationToken);
if (stale.Count == 0)
return 0;
database.ListeningHistories.RemoveRange(stale);
await database.SaveChangesAsync(cancellationToken);
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
return stale.Count;
}
}
}
@@ -19,7 +19,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Track.Tags)
.Where(t => t.UserId == query.UserId)
.OrderByDescending(t => t.ListenedAt)
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
.FirstOrDefaultAsync(cancellationToken);
return track == null
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
{
var recentTrackIds = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == query.UserId)
.Where(l => l.UserId == query.UserId && l.IsCounted)
.GroupBy(l => l.TrackId)
.OrderByDescending(g => g.Max(l => l.ListenedAt))
.Select(g => g.Key)
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => recentTrackIds.Contains(t.Id))
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count })
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.ToDictionaryAsync(t => t.Id, cancellationToken);
return recentTrackIds
@@ -18,7 +18,7 @@ namespace Musify.Application.Tracks
.Include(t => t.Owner)
.Include(t => t.Tags)
.Where(t => t.Id == request.TrackId)
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
.SingleOrDefaultAsync(cancellationToken);
if (entity == null)
@@ -5,10 +5,11 @@ 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)
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
: IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database)
@@ -28,12 +29,15 @@ 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 })
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var items = pagedEntities
@@ -40,7 +40,7 @@ namespace Musify.Application.Tracks
var pagedEntities = await tracksQuery
.OrderBy(t => t.Track.CreatedAt)
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
@@ -0,0 +1,14 @@
namespace Musify.Application.Tracks
{
public static class ListeningRules
{
public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) =>
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
}
}
@@ -0,0 +1,44 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Tracks
{
public record RecordListeningProgressCommand(
long UserId,
Guid ListenId,
double PlayedSeconds)
: ICommand<ErrorOr<Success>>;
public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
{
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
return Error.Validation(description: "playedSeconds must be a non-negative number.");
var listen = await database.ListeningHistories
.Include(l => l.Track)
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
if (listen == null)
return Error.NotFound();
var now = DateTime.UtcNow;
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
listen.LastProgressAt = now;
listen.IsCounted = listen.IsCounted
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
await database.SaveChangesAsync(cancellationToken);
return Result.Success;
}
}
}
@@ -15,8 +15,7 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt,
DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit)
IReadOnlyCollection<Genre> Tags)
{
public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new(
@@ -29,7 +28,6 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt,
track.UpdatedAt,
track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(),
track.IsExplicit);
track.Tags.Select(tag => tag.Tag).ToList());
}
}
@@ -3,5 +3,6 @@ namespace Musify.Application.Tracks.Responses
public record TrackStreamResponse(
string ManifestUrl,
string Ticket,
int ExpiresInSeconds);
int ExpiresInSeconds,
Guid? ListenId = null);
}
@@ -1,30 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public static class FollowVisibility
{
public static async Task<bool> CanViewFollowersAsync(
IDatabase database,
long? viewerId,
long userId,
CancellationToken cancellationToken)
{
if (viewerId is not { } viewer)
return false;
if (viewer == userId)
return true;
var mutualRows = await database.UserFollows
.AsNoTracking()
.CountAsync(f =>
(f.FollowerId == viewer && f.FollowedId == userId) ||
(f.FollowerId == userId && f.FollowedId == viewer),
cancellationToken);
return mutualRows == 2;
}
}
}
@@ -20,17 +20,18 @@ namespace Musify.Application.Users
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
var tracksThisWeek = await thisWeekHistory
.Where(l => l.IsCounted)
.Select(l => l.TrackId)
.Distinct()
.CountAsync(cancellationToken);
var secondsThisWeek = await thisWeekHistory
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
.SumAsync(cancellationToken);
var listenedDates = await database.ListeningHistories
.AsNoTracking()
.Where(l => l.UserId == request.UserId)
.Where(l => l.UserId == request.UserId && l.IsCounted)
.Select(l => l.ListenedAt.Date)
.Distinct()
.ToListAsync(cancellationToken);
@@ -1,46 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowersQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowersQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowersQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
if (!await FollowVisibility.CanViewFollowersAsync(database, request.ViewerId, request.UserId, cancellationToken))
return Error.Forbidden();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowedId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Follower.Id,
f.Follower.Name,
f.Follower.FirstName,
f.Follower.SecondName,
f.Follower.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowerId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -1,43 +0,0 @@
using ErrorOr;
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Application.Shared;
using Musify.Application.Users.Responses;
using X.PagedList.EF;
namespace Musify.Application.Users
{
public record GetUserFollowingQuery(long? ViewerId, long UserId, int PageNumber, int PageSize)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public class GetUserFollowingQueryHandler(IDatabase database)
: IQueryHandler<GetUserFollowingQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUserFollowingQuery request, CancellationToken cancellationToken)
{
if (!await database.Users.AnyAsync(u => u.Id == request.UserId, cancellationToken))
return Error.NotFound();
var followsQuery = database.UserFollows
.AsNoTracking()
.Where(f => f.FollowerId == request.UserId);
var totalCount = await followsQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var page = await followsQuery
.OrderByDescending(f => f.CreatedAt)
.Select(f => new UserSummaryResponse(
f.Followed.Id,
f.Followed.Name,
f.Followed.FirstName,
f.Followed.SecondName,
f.Followed.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(v => v.FollowerId == viewerId && v.FollowedId == f.FollowedId)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(page);
}
}
}
@@ -7,7 +7,7 @@ using Musify.Application.Serialization;
namespace Musify.Application.Users
{
public record GetUserProfileQuery(long UserId, long? ViewerId = null) : IQuery<ErrorOr<UserProfileResponse>>;
public record GetUserProfileQuery(long UserId) : IQuery<ErrorOr<UserProfileResponse>>;
public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
@@ -17,9 +17,7 @@ namespace Musify.Application.Users
string? ProfilePictureUrl,
DateTime CreatedAt,
int FollowersCount,
int FollowingCount,
bool IsFollowing,
bool CanViewFollowers);
int FollowingCount);
public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
@@ -51,14 +49,6 @@ namespace Musify.Application.Users
.AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken);
var isFollowing = request.ViewerId is { } viewerId && viewerId != request.UserId &&
await database.UserFollows
.AsNoTracking()
.AnyAsync(f => f.FollowerId == viewerId && f.FollowedId == request.UserId, cancellationToken);
var canViewFollowers = await FollowVisibility.CanViewFollowersAsync(
database, request.ViewerId, request.UserId, cancellationToken);
return new UserProfileResponse(
user.Id,
user.Name,
@@ -67,9 +57,7 @@ namespace Musify.Application.Users
user.ProfilePictureUrl,
user.CreatedAt,
followersCount,
followingCount,
isFollowing,
canViewFollowers);
followingCount);
}
}
}
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
namespace Musify.Application.Users
{
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>;
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
: IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>;
public class GetUsersQueryHandler(IDatabase database)
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserSummaryResponse>>>
: IQueryHandler<GetUsersQuery, ErrorOr<PaginatedResponse<UserApplicationResponse>>>
{
public async ValueTask<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
public async ValueTask<ErrorOr<PaginatedResponse<UserApplicationResponse>>> Handle(GetUsersQuery request, CancellationToken cancellationToken)
{
var usersQuery = database.Users
.AsNoTracking()
@@ -27,19 +27,11 @@ namespace Musify.Application.Users
}
var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery
.OrderBy(u => u.Name)
.Select(u => new UserSummaryResponse(
u.Id,
u.Name,
u.FirstName,
u.SecondName,
u.ProfilePictureUrl,
viewerId != null && database.UserFollows.Any(f => f.FollowerId == viewerId && f.FollowedId == u.Id)))
.Select(u => UserApplicationResponse.FromEntity(u))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
return PaginatedResponse<UserSummaryResponse>.FromPagedList(pagedUsers);
return PaginatedResponse<UserApplicationResponse>.FromPagedList(pagedUsers);
}
}
}
@@ -1,13 +0,0 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
namespace Musify.Application.Users.Responses
{
public record UserSummaryResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id,
string Name,
string? FirstName,
string? SecondName,
string? ProfilePictureUrl,
bool IsFollowedByViewer);
}
@@ -19,6 +19,12 @@ namespace Musify.Domain.Entities
[Required]
public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
public double? PlayedSeconds { get; set; }
public DateTime? LastProgressAt { get; set; }
public bool IsCounted { get; set; }
[ForeignKey(nameof(UserId) )]
public User User { get; set; }
@@ -31,9 +31,6 @@ namespace Musify.Domain.Entities
[Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Jobs
{
var userIds = await database.ListeningHistories
.AsNoTracking()
.Where(history => history.IsCounted)
.Select(history => history.UserId)
.Distinct()
.ToListAsync(cancellationToken);
@@ -0,0 +1,13 @@
using Mediator;
using Musify.Application.Tracks;
namespace Musify.Infrastructure.Jobs
{
public class ListeningHistoryCleanupJob(IMediator mediator)
{
public async Task RunAsync(CancellationToken cancellationToken)
{
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
}
}
}
@@ -13,8 +13,8 @@ using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
namespace Musify.Infrastructure.Persistence.Migrations
{
[DbContext(typeof(Database))]
[Migration("20260923203135_AddTrackIsExplicit")]
partial class AddTrackIsExplicit
[Migration("20260923172640_TrackPlayedSeconds")]
partial class TrackPlayedSeconds
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
@@ -271,9 +271,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsCounted")
.HasColumnType("boolean");
b.Property<DateTime?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("PlayedSeconds")
.HasColumnType("double precision");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
@@ -430,9 +439,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<double>("DurationSeconds")
.HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
@@ -0,0 +1,52 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class TrackPlayedSeconds : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsCounted",
table: "ListeningHistory",
type: "boolean",
nullable: false,
defaultValue: false);
migrationBuilder.AddColumn<DateTime>(
name: "LastProgressAt",
table: "ListeningHistory",
type: "timestamp with time zone",
nullable: true);
migrationBuilder.AddColumn<double>(
name: "PlayedSeconds",
table: "ListeningHistory",
type: "double precision",
nullable: true);
migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsCounted",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "LastProgressAt",
table: "ListeningHistory");
migrationBuilder.DropColumn(
name: "PlayedSeconds",
table: "ListeningHistory");
}
}
}
@@ -1,29 +0,0 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace Musify.Infrastructure.Persistence.Migrations
{
/// <inheritdoc />
public partial class AddTrackIsExplicit : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsExplicit",
table: "Tracks",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsExplicit",
table: "Tracks");
}
}
}
@@ -1,4 +1,4 @@
// <auto-generated />
using System;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
@@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Delivered");
b.ToTable("InboxState", (string)null);
b.ToTable("InboxState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
.IsUnique();
b.ToTable("OutboxMessage", (string)null);
b.ToTable("OutboxMessage");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
@@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("Created");
b.ToTable("OutboxState", (string)null);
b.ToTable("OutboxState");
});
modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
@@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId");
b.ToTable("Albums", (string)null);
b.ToTable("Albums");
});
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
@@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("AlbumId", "TrackId")
.IsUnique();
b.ToTable("AlbumHasTrack", (string)null);
b.ToTable("AlbumHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
@@ -268,9 +268,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
.ValueGeneratedOnAdd()
.HasColumnType("uuid");
b.Property<bool>("IsCounted")
.HasColumnType("boolean");
b.Property<DateTime?>("LastProgressAt")
.HasColumnType("timestamp with time zone");
b.Property<DateTime>("ListenedAt")
.HasColumnType("timestamp with time zone");
b.Property<double?>("PlayedSeconds")
.HasColumnType("double precision");
b.Property<Guid>("TrackId")
.HasColumnType("uuid");
@@ -283,7 +292,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("ListeningHistory", (string)null);
b.ToTable("ListeningHistory");
});
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
@@ -317,7 +326,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "Position");
b.ToTable("Mixes", (string)null);
b.ToTable("Mixes");
});
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
@@ -341,7 +350,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("MixId", "Position");
b.ToTable("MixItems", (string)null);
b.ToTable("MixItems");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
@@ -385,7 +394,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("PlayLists", (string)null);
b.ToTable("PlayLists");
});
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
@@ -412,7 +421,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId");
b.ToTable("PlayListHasTrack", (string)null);
b.ToTable("PlayListHasTrack");
});
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
@@ -427,9 +436,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<double>("DurationSeconds")
.HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus")
.HasColumnType("integer");
@@ -453,7 +459,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("OwnerUserId");
b.ToTable("Tracks", (string)null);
b.ToTable("Tracks");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
@@ -478,7 +484,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId", "TrackId")
.IsUnique();
b.ToTable("TrackLikes", (string)null);
b.ToTable("TrackLikes");
});
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
@@ -498,7 +504,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("TrackId", "Tag")
.IsUnique();
b.ToTable("TrackTags", (string)null);
b.ToTable("TrackTags");
});
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
@@ -527,7 +533,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.ToTable("Upload", (string)null);
b.ToTable("Upload");
});
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
@@ -578,7 +584,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("UploadIntents", (string)null);
b.ToTable("UploadIntents");
});
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
@@ -616,7 +622,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("Id");
b.ToTable("User", (string)null);
b.ToTable("User");
});
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
@@ -641,7 +647,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("FollowerId", "FollowedId")
.IsUnique();
b.ToTable("UserFollows", (string)null);
b.ToTable("UserFollows");
});
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
@@ -662,7 +668,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasIndex("UserId");
b.ToTable("UserHasTrack", (string)null);
b.ToTable("UserHasTrack");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
@@ -689,7 +695,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId");
b.ToTable("AlbumProcessingState", (string)null);
b.ToTable("AlbumProcessingState");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
@@ -716,7 +722,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId");
b.ToTable("PlayListProcessingState", (string)null);
b.ToTable("PlayListProcessingState");
});
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
@@ -752,7 +758,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.HasKey("CorrelationId");
b.ToTable("TrackProcessingState", (string)null);
b.ToTable("TrackProcessingState");
});
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
@@ -806,7 +812,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("AlbumId");
b1.ToTable("Albums", (string)null);
b1.ToTable("Albums");
b1.WithOwner()
.HasForeignKey("AlbumId");
@@ -899,32 +905,28 @@ namespace Musify.Infrastructure.Persistence.Migrations
.HasColumnType("uuid");
b1.Property<string>("LargeName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("LargePictureName");
b1.Property<string>("MediumName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("MediumPictureName");
b1.Property<string>("OriginalName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("OriginalPictureName");
b1.Property<string>("SmallName")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("character varying(64)")
.HasColumnName("SmallPictureName");
b1.HasKey("PlayListId");
b1.ToTable("PlayLists", (string)null);
b1.ToTable("PlayLists");
b1.WithOwner()
.HasForeignKey("PlayListId");
@@ -995,7 +997,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId");
b1.ToTable("Tracks", (string)null);
b1.ToTable("Tracks");
b1.WithOwner()
.HasForeignKey("TrackId");
@@ -1032,7 +1034,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
b1.HasKey("TrackId");
b1.ToTable("Tracks", (string)null);
b1.ToTable("Tracks");
b1.WithOwner()
.HasForeignKey("TrackId");
@@ -74,6 +74,7 @@ namespace Musify.Infrastructure.Services
serviceDescriptors.AddHangfireServer();
serviceDescriptors.AddScoped<DailyMixGenerationJob>();
serviceDescriptors.AddScoped<ListeningHistoryCleanupJob>();
return serviceDescriptors;
}
@@ -6,5 +6,4 @@ public record CreateTrackRequest(
string Title,
Guid PictureIntentId,
Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags,
bool IsExplicit = false);
IReadOnlyCollection<Genre> Tags);
@@ -0,0 +1,3 @@
namespace Musify.Api.DataTransferObjects.Tracks;
public record RecordListeningProgressRequest(double PlayedSeconds);
@@ -0,0 +1,39 @@
using Mediator;
using Musify.Application.Genres;
namespace Musify.Api.Endpoints;
public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/genres")
.WithTags("Genres");
group.MapGet("/", GetGenres)
.WithName("GetGenres")
.WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.")
.Produces<IReadOnlyList<GenreResponse>>();
group.MapGet("/available", GetAvailableGenres)
.WithName("GetAvailableGenres")
.WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.")
.Produces<IReadOnlyList<AvailableGenreResponse>>();
return app;
}
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
}
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetGenresQuery(), cancellationToken);
}
}
@@ -1,4 +1,5 @@
using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses;
using Musify.Application.Shared;
@@ -19,8 +20,9 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks)
.WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
.Produces<TracksSearchResponse>();
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
.Produces<TracksSearchResponse>()
.ProducesValidationProblem();
group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById")
@@ -41,6 +43,14 @@ public static class TrackEndpoints
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapPut("/listens/{listenId}/progress", RecordListeningProgress)
.WithName("RecordListeningProgress")
.WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.")
.RequireAuthorization()
.Produces(StatusCodes.Status204NoContent)
.Produces(StatusCodes.Status401Unauthorized)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/users/{userId}", GetTracksByUserId)
.WithName("GetTracksByUserId")
.WithSummary("Get Paginated Tracks For A User.")
@@ -84,10 +94,25 @@ public static class TrackEndpoints
IMediator mediator,
CancellationToken cancellationToken,
string? name,
string? genre,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
Genre? parsedGenre = null;
if (!string.IsNullOrWhiteSpace(genre))
{
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
});
}
parsedGenre = value;
}
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
return result.ToHttpResult();
}
@@ -128,6 +153,19 @@ public static class TrackEndpoints
return result.ToHttpResult();
}
private static async Task<IResult> RecordListeningProgress(
IMediator mediator,
CurrentUser currentUser,
Guid listenId,
RecordListeningProgressRequest request,
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetTracksByUserId(
IMediator mediator,
long userId,
@@ -147,7 +185,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken)
{
var result = await mediator.Send(
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags, request.IsExplicit),
new CreateTrackCommand(currentUser.RequiredId, request.Title, request.PictureIntentId, request.AudioIntentId, request.Tags),
cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -21,7 +21,7 @@ public static class UserEndpoints
group.MapGet("/", GetUsers)
.WithName("GetUsers")
.WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserSummaryResponse>>();
.Produces<PaginatedResponse<UserApplicationResponse>>();
group.MapGet("/{id}", GetUserById)
.WithName("GetUserById")
@@ -52,19 +52,6 @@ public static class UserEndpoints
.Produces<UserProfileResponse>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/followers", GetFollowers)
.WithName("GetUserFollowers")
.WithSummary("Get The Users Following A User. Visible To The User And To Mutual Followers.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
.Produces(StatusCodes.Status403Forbidden)
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/following", GetFollowing)
.WithName("GetUserFollowing")
.WithSummary("Get The Users A User Follows.")
.Produces<PaginatedResponse<UserSummaryResponse>>()
.Produces(StatusCodes.Status404NotFound);
group.MapGet("/{id}/is-following", IsFollowing)
.WithName("IsFollowingUser")
.WithSummary("Check Whether The Current User Follows Another User.")
@@ -101,13 +88,12 @@ public static class UserEndpoints
private static async Task<IResult> GetUsers(
IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken,
string? usernameSearch,
int pageNumber = 1,
int pageSize = 10)
{
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch, currentUser.Id), cancellationToken);
var result = await mediator.Send(new GetUsersQuery(pageNumber, pageSize, usernameSearch), cancellationToken);
return result.ToHttpResult();
}
@@ -147,35 +133,10 @@ public static class UserEndpoints
private static async Task<IResult> GetUserProfile(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken)
{
var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetFollowers(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
{
var result = await mediator.Send(new GetUserFollowersQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
return result.ToHttpResult();
}
private static async Task<IResult> GetFollowing(
IMediator mediator,
CurrentUser currentUser,
long id,
CancellationToken cancellationToken,
int pageNumber = 1,
int pageSize = 20)
{
var result = await mediator.Send(new GetUserFollowingQuery(currentUser.Id, id, pageNumber, pageSize), cancellationToken);
var result = await mediator.Send(new GetUserProfileQuery(id), cancellationToken);
return result.ToHttpResult();
}
+1
View File
@@ -40,6 +40,7 @@ app.UseAuthorization();
app
.MapAlbumEndpoints()
.MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints()
.MapMixEndpoints()
.MapPlayListEndpoints()
+5
View File
@@ -41,6 +41,11 @@ using (var scope = host.Services.CreateScope())
job => job.RunAsync(CancellationToken.None),
Cron.Daily(),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
"listening-history-cleanup",
job => job.RunAsync(CancellationToken.None),
Cron.Daily(3),
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
}
host.Run();
@@ -0,0 +1,106 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Genres;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private async Task<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
var title = $"Genre track {Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
}
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
[Theory]
[InlineData("Nope")]
[InlineData("999")]
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
{
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
}
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
}
}
}
@@ -0,0 +1,109 @@
using System.Net;
using System.Net.Http.Json;
using Microsoft.EntityFrameworkCore;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.DataTransferObjects.Tracks;
using Musify.Api.DataTransferObjects.Users;
using Musify.Domain.Entities;
using Musify.Infrastructure.Persistence;
using Xunit;
using Musify.Api.Tests.TestSupport;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
{
private async Task<long> CreateUserAsync()
{
var userId = Random.Shared.NextInt64(1, long.MaxValue);
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
response.EnsureSuccessStatusCode();
return userId;
}
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken);
var track = new Track
{
Title = "Progress track",
NormalizedTitle = "PROGRESS TRACK",
OwnerUserId = owner.Id,
Owner = owner,
DurationSeconds = durationSeconds
};
var listen = new ListeningHistory
{
UserId = userId,
TrackId = track.Id,
ListenedAt = DateTime.UtcNow.AddMinutes(-10)
};
database.Tracks.Add(track);
database.ListeningHistories.Add(listen);
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return listen.Id;
}
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
return await database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken);
}
[Fact]
public async Task PutProgress_Anonymous_ReturnsUnauthorized()
{
var response = await fixture.CreateAnonymousClient().PutAsJsonAsync(
$"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
}
[Fact]
public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
var stored = await LoadListenAsync(listenId);
Assert.Equal(45, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task PutProgress_OtherUsersListen_ReturnsNotFound()
{
var ownerId = await CreateUserAsync();
var otherId = await CreateUserAsync();
var listenId = await SeedListenAsync(ownerId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds);
}
[Fact]
public async Task PutProgress_NegativeSeconds_ReturnsBadRequest()
{
var userId = await CreateUserAsync();
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
}
}
@@ -57,5 +57,25 @@ namespace Musify.Application.Tests.Albums
Assert.False(result.IsError);
Assert.Equal(2, result.Value.Count);
}
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
var countedTrack = TestEntities.Track(owner, "In counted");
var skippedTrack = TestEntities.Track(owner, "In skipped");
await SeedAsync(
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
}
}
}
@@ -0,0 +1,32 @@
using Musify.Application.Genres;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetAvailableGenresQueryHandlerTests
{
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
}
[Fact]
public async Task Handle_ListsIncompatibleGenresSymmetrically()
{
var result = await RunAsync();
var metal = result.Single(r => r.Genre == Genre.Metal);
var lofi = result.Single(r => r.Genre == Genre.Lofi);
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
}
}
}
@@ -0,0 +1,69 @@
using Musify.Application.Genres;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
{
private GetGenresQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTracks_ReturnsEmpty()
{
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
[Fact]
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
{
var owner = TestEntities.User();
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
await SeedAsync(
owner, rockA, rockB, jazz,
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
result.Select(r => (r.Genre, r.TrackCount)));
}
[Fact]
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
{
var owner = TestEntities.User();
var other = TestEntities.User(id: 2, name: "other-user");
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
await SeedAsync(
owner, other, track,
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(1, Assert.Single(result).TrackCount);
}
[Fact]
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
}
}
@@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes
.ToListAsync(TestContext.Current.CancellationToken);
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");
}
}
}
@@ -17,17 +17,6 @@ namespace Musify.Application.Tests.Shared
Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount);
Assert.False(response.IsExplicit);
}
[Fact]
public void FromEntity_CopiesExplicitFlag()
{
var track = TestEntities.Track(TestEntities.User(), isExplicit: true);
var response = TrackApplicationResponse.FromEntity(track, listensCount: 0);
Assert.True(response.IsExplicit);
}
}
}
@@ -56,8 +56,7 @@ namespace Musify.Application.Tests.TestSupport
TrackPictures? pictures = null,
TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null,
bool isExplicit = false)
IEnumerable<Genre>? tags = null)
{
var track = new Track
{
@@ -65,7 +64,6 @@ namespace Musify.Application.Tests.TestSupport
NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id,
Owner = owner,
Pictures = pictures ?? ProcessedPictures(),
@@ -179,12 +177,16 @@ namespace Musify.Application.Tests.TestSupport
public static ListeningHistory ListeningHistory(
long userId,
Guid trackId,
DateTime? listenedAt = null) =>
DateTime? listenedAt = null,
double? playedSeconds = null,
bool isCounted = true) =>
new()
{
UserId = userId,
TrackId = trackId,
ListenedAt = listenedAt ?? DateTime.UtcNow
ListenedAt = listenedAt ?? DateTime.UtcNow,
PlayedSeconds = playedSeconds,
IsCounted = isCounted
};
}
}
@@ -58,26 +58,6 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus);
}
[Theory]
[InlineData(true)]
[InlineData(false)]
public async Task Handle_StoresExplicitFlag(bool isExplicit)
{
var user = TestEntities.User();
var (picture, audio) = SeedIntents(user.Id);
await SeedAsync(user, picture, audio);
var command = new CreateTrackCommand(user.Id, "My Song", picture.Id, audio.Id, [Genre.Pop], isExplicit);
var result = await CreateHandler().Handle(command, TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(isExplicit, result.Value.IsExplicit);
var stored = await Database.Tracks.FindAsync([result.Value.Id], TestContext.Current.CancellationToken);
Assert.NotNull(stored);
Assert.Equal(isExplicit, stored.IsExplicit);
}
[Fact]
public async Task Handle_NoTags_ReturnsValidationError()
{
@@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
{
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
[Fact]
public async Task Handle_DeletesOnlyOldUncountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
Assert.Equal(1, deleted);
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(oldUncounted.Id, remaining);
Assert.Contains(oldCounted.Id, remaining);
Assert.Contains(recentUncounted.Id, remaining);
}
}
}
@@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks
Assert.Empty(result);
}
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var counted = TestEntities.Track(owner, "Counted");
var skipped = TestEntities.Track(owner, "Skipped");
await SeedAsync(
owner, counted, skipped,
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["Counted"], result.Select(track => track.Title));
}
}
}
@@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Fact]
public async Task Handle_ListensCount_OnlyCountsCountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
await SeedAsync(
owner, track,
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.ListensCount);
}
}
}
@@ -45,6 +45,9 @@ namespace Musify.Application.Tests.Tracks
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
Assert.Equal(owner.Id, history.UserId);
Assert.Equal(track.Id, history.TrackId);
Assert.Equal(history.Id, result.Value.ListenId);
Assert.False(history.IsCounted);
Assert.Null(history.PlayedSeconds);
}
[Fact]
@@ -1,5 +1,6 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Tracks
@@ -43,5 +44,23 @@ 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);
}
}
}
@@ -0,0 +1,127 @@
using ErrorOr;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase
{
private RecordListeningProgressCommandHandler CreateHandler() => new(Database);
private async Task<ListeningHistory> ReloadAsync(Guid id) =>
await Database.ListeningHistories.AsNoTracking()
.SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken);
private async Task<(User User, ListeningHistory Listen)> SeedListenAsync(
double durationSeconds, DateTime listenedAt)
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: durationSeconds);
var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false);
await SeedAsync(user, track, listen);
return (user, listen);
}
[Fact]
public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(10, stored.PlayedSeconds);
Assert.False(stored.IsCounted);
}
[Fact]
public async Task Handle_ReachesThirtySeconds_MarksAsCounted()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_ShortTrack_CountsAtHalfDuration()
{
var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
}
[Fact]
public async Task Handle_LowerValueThanStored_KeepsHighest()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var handler = CreateHandler();
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken);
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.Equal(40, stored.PlayedSeconds);
Assert.True(stored.IsCounted);
}
[Fact]
public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime()
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken);
var stored = await ReloadAsync(listen.Id);
Assert.InRange(stored.PlayedSeconds!.Value, 24, 27);
}
[Fact]
public async Task Handle_MoreThanTrackDuration_IsCappedToDuration()
{
var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30));
await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken);
Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds);
}
[Fact]
public async Task Handle_OtherUsersListen_ReturnsNotFound()
{
var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken);
Assert.True(result.IsError);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
[Theory]
[InlineData(-1)]
[InlineData(double.NaN)]
[InlineData(double.PositiveInfinity)]
public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds)
{
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
var result = await CreateHandler().Handle(
new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
}
}
}
@@ -40,6 +40,37 @@ namespace Musify.Application.Tests.Users
Assert.Equal(500, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
{
var user = TestEntities.User();
var track = TestEntities.Track(user, durationSeconds: 200);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(212, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
{
var user = TestEntities.User();
var track = TestEntities.Track(user);
await SeedAsync(
user, track,
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
Assert.Equal(0, result.TracksThisWeek);
Assert.Equal(0, result.StreakDays);
Assert.Equal(3, result.SecondsThisWeek);
}
[Fact]
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
{
@@ -1,95 +0,0 @@
using ErrorOr;
using Musify.Application.Shared;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Application.Users.Responses;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetUserFollowersQueryHandlerTests : HandlerTestBase
{
private GetUserFollowersQueryHandler CreateHandler() => new(Database);
private async Task<ErrorOr<PaginatedResponse<UserSummaryResponse>>> Send(long? viewerId, long userId) =>
await CreateHandler().Handle(new GetUserFollowersQuery(viewerId, userId, 1, 20), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_OwnProfile_ReturnsFollowers()
{
var user = TestEntities.User(1, "user");
var follower = TestEntities.User(2, "follower");
await SeedAsync(user, follower, new UserFollow { FollowerId = follower.Id, FollowedId = user.Id });
var result = await Send(user.Id, user.Id);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal(follower.Id, item.Id);
Assert.False(item.IsFollowedByViewer);
}
[Fact]
public async Task Handle_MutualFollow_ReturnsFollowersWithViewerFollowState()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var other = TestEntities.User(3, "other");
await SeedAsync(
viewer, user, other,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id },
new UserFollow { FollowerId = other.Id, FollowedId = user.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = other.Id });
var result = await Send(viewer.Id, user.Id);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == other.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_ViewerFollowsButNotFollowedBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_FollowedByUserButNotFollowingBack_ReturnsForbidden()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await Send(viewer.Id, user.Id);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_Anonymous_ReturnsForbidden()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await Send(null, 1);
Assert.Equal(ErrorType.Forbidden, result.FirstError.Type);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await Send(1, 404);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -1,75 +0,0 @@
using ErrorOr;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
{
public sealed class GetUserFollowingQueryHandlerTests : HandlerTestBase
{
private GetUserFollowingQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_Anonymous_ReturnsFollowedUsers()
{
var user = TestEntities.User(1, "user");
var followedA = TestEntities.User(2, "followedA");
var followedB = TestEntities.User(3, "followedB");
await SeedAsync(
user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, user.Id, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Equal(2, result.Value.TotalItemCount);
Assert.All(result.Value.Items, item => Assert.False(item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_Viewer_MarksUsersTheViewerFollows()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
var followedA = TestEntities.User(3, "followedA");
var followedB = TestEntities.User(4, "followedB");
await SeedAsync(
viewer, user, followedA, followedB,
new UserFollow { FollowerId = user.Id, FollowedId = followedA.Id },
new UserFollow { FollowerId = user.Id, FollowedId = followedB.Id },
new UserFollow { FollowerId = viewer.Id, FollowedId = followedA.Id });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(viewer.Id, user.Id, 1, 20), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == followedA.Id, item.IsFollowedByViewer));
}
[Fact]
public async Task Handle_Pagination_ReturnsRequestedPage()
{
await SeedAsync(
TestEntities.User(1, "user"), TestEntities.User(2, "a"), TestEntities.User(3, "b"), TestEntities.User(4, "c"),
new UserFollow { FollowerId = 1, FollowedId = 2 },
new UserFollow { FollowerId = 1, FollowedId = 3 },
new UserFollow { FollowerId = 1, FollowedId = 4 });
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 1, 2, 2), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.Single(result.Value.Items);
Assert.Equal(3, result.Value.TotalItemCount);
Assert.False(result.Value.HasNextPage);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
var result = await CreateHandler().Handle(new GetUserFollowingQuery(null, 404, 1, 20), TestContext.Current.CancellationToken);
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
}
}
}
@@ -30,46 +30,6 @@ namespace Musify.Application.Tests.Users
Assert.Equal(1, result.Value.FollowingCount);
}
[Fact]
public async Task Handle_MutualFollow_ViewerCanSeeFollowersAndIsFollowing()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(
viewer, user,
new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id },
new UserFollow { FollowerId = user.Id, FollowedId = viewer.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
Assert.True(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_OneWayFollow_ViewerCannotSeeFollowers()
{
var viewer = TestEntities.User(1, "viewer");
var user = TestEntities.User(2, "user");
await SeedAsync(viewer, user, new UserFollow { FollowerId = viewer.Id, FollowedId = user.Id });
var result = await CreateHandler().Handle(new GetUserProfileQuery(user.Id, viewer.Id), TestContext.Current.CancellationToken);
Assert.True(result.Value.IsFollowing);
Assert.False(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_OwnProfile_CanSeeFollowersButIsNotFollowing()
{
await SeedAsync(TestEntities.User(1, "user"));
var result = await CreateHandler().Handle(new GetUserProfileQuery(1, 1), TestContext.Current.CancellationToken);
Assert.False(result.Value.IsFollowing);
Assert.True(result.Value.CanViewFollowers);
}
[Fact]
public async Task Handle_UserMissing_ReturnsNotFound()
{
@@ -1,6 +1,5 @@
using Musify.Application.Tests.TestSupport;
using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Users
@@ -31,18 +30,5 @@ namespace Musify.Application.Tests.Users
var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id);
}
[Fact]
public async Task Handle_Viewer_MarksFollowedUsers()
{
await SeedAsync(
TestEntities.User(1, "viewer"), TestEntities.User(2, "alice"), TestEntities.User(3, "bob"),
new UserFollow { FollowerId = 1, FollowedId = 2 });
var result = await CreateHandler().Handle(new GetUsersQuery(PageNumber: 1, PageSize: 10, UsernameSearch: null, ViewerId: 1), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
Assert.All(result.Value.Items, item => Assert.Equal(item.Id == 2, item.IsFollowedByViewer));
}
}
}
+4 -8
View File
@@ -42,7 +42,7 @@ npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail
- `media/` for music domain display components (Artwork, TrackList, MediaCard, PlayButton, and so on).
- `forms/` for composite entity forms (AlbumForm, PlaylistForm, CoverForm, ImageDropzone).
- `layout/` for page level scaffolding (Page, PageHeader, SectionHeading). This is different from `components/layout/` above, which is the global app chrome.
- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`, and its followers/following lists share one route, `user/[id]/[list=followList]/` (matcher in `src/params/followList.ts`).
- Auth routes live under `src/routes/auth/`: `+page.svelte` is the login screen, and `login/`, `logout/`, `callback/` are the OIDC endpoints. The public user profile route is `src/routes/(app)/user/[id]/`.
## Styles
@@ -55,19 +55,15 @@ 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`.
- **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.
## App shell
The shell in `src/routes/+layout.svelte` is a fixed `h-dvh` frame: Sidebar | (content column over PlayerDock) | Queue. Only the content column (`app-scroll`) scrolls, so nothing ever passes under the dock and the chrome can stay transparent over `app-backdrop`. Because the window never scrolls, scroll reset/restore on navigation is handled by `restoreScroll()` in `$lib/state/navigation.svelte.ts`; don't use `window.scrollTo`/`scrollY`.
- 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.
## Motion
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover.
Every animation follows one scale of three durations and one easing curve, all defined in `theme.css`. Fast (`--mf-motion-fast`, 150ms) is for hover, focus and press feedback: color, opacity and small scale changes. Plain `transition` or `transition-colors` already uses it because it is the Tailwind default, so never write `duration-150`. Base (`--mf-motion-base`, 200ms) is for things that appear on top of the page or move inside a control: modals, menus, the queue panel, `animate-pop`, `animate-fade` and the segmented control thumb. Slow (`--mf-motion-slow`, 300ms) is for content entering the page and larger movement: `animate-enter`, cover zoom on hover and the player dock padding.
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` (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`.
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`.
## Auth
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html>
<html lang="en">
<html lang="en" class="scrollbar-stable">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
+158 -122
View File
@@ -177,6 +177,40 @@ export interface paths {
patch?: never;
trace?: never;
};
"/genres": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously. */
get: operations["GetGenres"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/genres/available": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously. */
get: operations["GetAvailableGenres"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/likes": {
parameters: {
query?: never;
@@ -391,7 +425,7 @@ export interface paths {
path?: never;
cookie?: never;
};
/** Get Paginated Tracks, Optionally Filtered By Name. */
/** Get Paginated Tracks, Optionally Filtered By Name And Genre. */
get: operations["GetTracks"];
put?: never;
/** Create A New Track. */
@@ -453,6 +487,23 @@ export interface paths {
patch?: never;
trace?: never;
};
"/tracks/listens/{listenId}/progress": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
get?: never;
/** Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint. */
put: operations["RecordListeningProgress"];
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/tracks/users/{userId}": {
parameters: {
query?: never;
@@ -607,40 +658,6 @@ export interface paths {
patch?: never;
trace?: never;
};
"/users/{id}/followers": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Users Following A User. Visible To The User And To Mutual Followers. */
get: operations["GetUserFollowers"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/following": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/** Get The Users A User Follows. */
get: operations["GetUserFollowing"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/users/{id}/is-following": {
parameters: {
query?: never;
@@ -730,6 +747,10 @@ export interface components {
hasPreviousPage: boolean;
hasNextPage: boolean;
};
AvailableGenreResponse: {
genre: components["schemas"]["Genre"];
incompatibleWith: components["schemas"]["Genre"][];
};
CreateAlbumRequest: {
title: string;
description: null | string;
@@ -752,8 +773,6 @@ export interface components {
/** Format: uuid */
audioIntentId: string;
tags: components["schemas"]["Genre"][];
/** @default false */
isExplicit: boolean;
};
CreateUserRequest: {
/** Format: int64 */
@@ -764,6 +783,11 @@ export interface components {
};
/** @enum {unknown} */
Genre: "Pop" | "Rock" | "HipHop" | "RnB" | "Jazz" | "Blues" | "Classical" | "Electronic" | "House" | "Techno" | "Trance" | "Dubstep" | "DrumAndBass" | "Metal" | "Punk" | "Reggae" | "Reggaeton" | "Country" | "Folk" | "Indie" | "KPop" | "Latin" | "Soul" | "Funk" | "Ambient" | "Lofi";
GenreResponse: {
genre: components["schemas"]["Genre"];
/** Format: int32 */
trackCount: number | string;
};
HttpValidationProblemDetails: {
type?: null | string;
title?: null | string;
@@ -841,8 +865,8 @@ export interface components {
hasNextPage: boolean;
hasPreviousPage: boolean;
};
PaginatedResponseOfUserSummaryResponse: {
items: components["schemas"]["UserSummaryResponse"][];
PaginatedResponseOfUserApplicationResponse: {
items: components["schemas"]["UserApplicationResponse"][];
/** Format: int32 */
pageNumber: number | string;
/** Format: int32 */
@@ -889,6 +913,10 @@ export interface components {
PlaylistVisibility: "Private" | "Public";
/** @enum {unknown} */
ProcessingStatus: "Pending" | "Processing" | "Completed" | "Failed";
RecordListeningProgressRequest: {
/** Format: double */
playedSeconds: number | string;
};
RequestAlbumPictureUploadRequest: {
fileType: string;
contentType: string;
@@ -931,7 +959,6 @@ export interface components {
updatedAt: string;
ownerUserId: string;
tags: components["schemas"]["Genre"][];
isExplicit: boolean;
};
TrackSearchItemResponse: {
track: components["schemas"]["TrackApplicationResponse"];
@@ -954,6 +981,8 @@ export interface components {
ticket: string;
/** Format: int32 */
expiresInSeconds: number | string;
/** Format: uuid */
listenId?: null | string;
};
TrackUploadUrlsResponse: {
/** Format: uuid */
@@ -1010,16 +1039,6 @@ export interface components {
followersCount: number | string;
/** Format: int32 */
followingCount: number | string;
isFollowing: boolean;
canViewFollowers: boolean;
};
UserSummaryResponse: {
id: string;
name: string;
firstName: null | string;
secondName: null | string;
profilePictureUrl: null | string;
isFollowedByViewer: boolean;
};
};
responses: never;
@@ -1479,6 +1498,46 @@ export interface operations {
};
};
};
GetGenres: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["GenreResponse"][];
};
};
};
};
GetAvailableGenres: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["AvailableGenreResponse"][];
};
};
};
};
GetLikedTracks: {
parameters: {
query?: {
@@ -2013,6 +2072,7 @@ export interface operations {
parameters: {
query?: {
name?: string;
genre?: string;
pageNumber?: number | string;
pageSize?: number | string;
};
@@ -2031,6 +2091,15 @@ export interface operations {
"application/json": components["schemas"]["TracksSearchResponse"];
};
};
/** @description Bad Request */
400: {
headers: {
[name: string]: unknown;
};
content: {
"application/problem+json": components["schemas"]["HttpValidationProblemDetails"];
};
};
};
};
CreateTrack: {
@@ -2181,6 +2250,44 @@ export interface operations {
};
};
};
RecordListeningProgress: {
parameters: {
query?: never;
header?: never;
path: {
listenId: string;
};
cookie?: never;
};
requestBody: {
content: {
"application/json": components["schemas"]["RecordListeningProgressRequest"];
};
};
responses: {
/** @description No Content */
204: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Unauthorized */
401: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetTracksByUserId: {
parameters: {
query?: {
@@ -2321,7 +2428,7 @@ export interface operations {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
"application/json": components["schemas"]["PaginatedResponseOfUserApplicationResponse"];
};
};
};
@@ -2504,77 +2611,6 @@ export interface operations {
};
};
};
GetUserFollowers: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
};
};
/** @description Forbidden */
403: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
GetUserFollowing: {
parameters: {
query?: {
pageNumber?: number | string;
pageSize?: number | string;
};
header?: never;
path: {
id: number | string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description OK */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["PaginatedResponseOfUserSummaryResponse"];
};
};
/** @description Not Found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
};
};
IsFollowingUser: {
parameters: {
query?: never;
@@ -44,7 +44,7 @@
</AccountMenu>
{:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md">
<LogIn class="size-icon-lg" />
<LogIn class="size-icon-lg" strokeWidth={2} />
</IconButton>
{/if}
</header>
@@ -31,7 +31,7 @@
</script>
<aside
class="hidden min-h-0 shrink-0 flex-col border-r border-hairline p-5 lg:flex"
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
style="width:var(--mf-sidebar-w)"
>
<a href="/" class="mb-4">
@@ -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" />
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span>
{#if link.count}
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
@@ -68,12 +68,12 @@
size="xs"
onclick={() => createPlaylistModal.show()}
>
<Plus class="size-icon-sm text-fg-2" />
<Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} />
</IconButton>
{/if}
</div>
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow
@@ -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" />
<Plus class="size-3.5" strokeWidth={1.8} />
Crear tu primera lista
</button>
{/each}
@@ -18,7 +18,7 @@
</script>
<nav
class="pointer-events-auto flex items-center justify-around px-4 py-2 glass-bar lg:hidden {klass}"
class="pointer-events-auto flex items-center justify-around glass-bar px-4 py-2 lg:hidden {klass}"
style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
>
{#each links as link (link.href)}
@@ -30,7 +30,7 @@
? 'text-fg'
: 'text-fg-2'}"
>
<link.icon class="size-4" />
<link.icon class="size-4" strokeWidth={1.6} />
<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" />
<ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
</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" />
<Search class="size-4 shrink-0 text-fg-2" strokeWidth={1.8} />
<input
type="text"
bind:this={searchInput}
@@ -6,9 +6,7 @@
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) {
@@ -17,7 +15,7 @@
</script>
<div
class="animate-enter pointer-events-auto flex items-center gap-icon-md px-4 py-3 glass-bar sm:hidden"
class="animate-enter pointer-events-auto flex items-center gap-icon-md glass-bar px-4 py-3 sm:hidden"
>
<div class="min-w-0 flex-1">
<TrackInfo compact />
@@ -26,7 +24,7 @@
</div>
<div
class="animate-enter pointer-events-auto hidden items-center gap-5 px-4 py-3 glass-bar sm:flex"
class="animate-enter pointer-events-auto hidden items-center gap-5 glass-bar px-4 py-3 sm:flex"
>
<div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo />
@@ -50,22 +48,9 @@
</div>
</div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<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>
<Volume2 class="size-icon-sm shrink-0 text-fg-2" strokeWidth={1.8} />
<Slider
value={player.muted ? 0 : player.volume}
value={player.volume}
max={100}
label="Volumen"
oninput={(value) => player.setVolume(value)}
@@ -78,7 +63,7 @@
pressed={queuePanel.open}
onclick={() => queuePanel.toggle()}
>
<ListMusic class="size-icon-sm" />
<ListMusic class="size-icon-sm" strokeWidth={1.8} />
</IconButton>
</div>
</div>
@@ -13,7 +13,9 @@
const hasTrack = $derived(player.currentId !== null);
</script>
<div class="flex shrink-0 flex-col">
<div
class="pointer-events-none fixed inset-x-0 bottom-0 z-(--z-sticky) flex flex-col lg:left-(--mf-sidebar-w)"
>
{#if hasTrack}
<PlayerBar />
{/if}
@@ -5,46 +5,21 @@
import ListRow from '$lib/components/ui/media/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte';
import { fmtTime } from '$lib/utils/format';
import { flip } from 'svelte/animate';
import { expoOut } from 'svelte/easing';
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);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1);
});
let dragFrom = $state<number | null>(null);
let dropAt = $state<number | null>(null);
function onDragOver(event: DragEvent, index: number) {
if (dragFrom === null) return;
event.preventDefault();
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
const rect = (event.currentTarget as HTMLElement).getBoundingClientRect();
dropAt = event.clientY < rect.top + rect.height / 2 ? index : index + 1;
}
function onDrop(event: DragEvent) {
event.preventDefault();
if (dragFrom !== null && dropAt !== null) player.moveQueueItem(dragFrom, dropAt);
endDrag();
}
function endDrag() {
dragFrom = null;
dropAt = null;
}
</script>
<div
class="queue-shell max-sm:hidden"
data-open={queuePanel.open ? '' : undefined}
inert={!queuePanel.open}
>
{#if queuePanel.open}
<aside
class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5"
style="width:var(--mf-queue-w)"
transition:fly={{ x: 24, duration: 200, easing: expoOut }}
class="hidden shrink-0 flex-col border-l border-hairline px-4 pt-5 sm:flex"
style="width:var(--mf-queue-w); padding-bottom:calc(var(--mf-player-h) + 2rem)"
>
<div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
@@ -55,13 +30,12 @@
{#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 surface-active rounded-control p-2.5">
<div class="mb-6 rounded-control surface-active p-2.5">
<MediaIdentity
trackId={player.current.id}
title={player.current.title}
artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
explicit={player.current.explicit}
active
/>
</div>
@@ -80,66 +54,27 @@
{/if}
</div>
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
ondragover={(event) => dragFrom !== null && event.preventDefault()}
ondrop={onDrop}
>
<div class="flex flex-1 flex-col gap-px overflow-y-auto">
{#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
draggable="true"
ondragstart={(event) => {
dragFrom = index;
if (event.dataTransfer) event.dataTransfer.effectAllowed = 'move';
}}
ondragover={(event) => onDragOver(event, index)}
ondrop={onDrop}
ondragend={endDrag}
class="queue-row"
data-dragging={dragFrom === index ? '' : undefined}
data-drop={dragFrom === null
? undefined
: dropAt === index
? 'before'
: dropAt === index + 1 && index === player.tracks.length - 1
? 'after'
: undefined}
animate:flip={{ duration: 300, easing: expoOut }}
<ListRow
onclick={() => player.playQueueIndex(index)}
size="sm"
title={track.title}
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
trackId={track.id}
class="p-2"
>
{#if dropAt === index && dragFrom !== null}
<div
class="pointer-events-none absolute inset-x-2 -top-7 h-0.5 rounded-full bg-accent"
></div>
{:else if dropAt === index + 1 && dropAt === player.tracks.length && dragFrom !== null}
<div
class="pointer-events-none absolute inset-x-2 -bottom-7 h-0.5 rounded-full bg-accent"
></div>
{/if}
<ListRow
onclick={() => player.playQueueIndex(index)}
size="sm"
title={track.title}
subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId}
explicit={track.explicit}
trackId={track.id}
class="p-2"
>
{#snippet trailing()}
{#if track.duration}
<span class="shrink-0 text-xs text-fg-2 tabular-nums"
>{fmtTime(track.duration)}</span
>
{/if}
{/snippet}
</ListRow>
</div>
{#snippet trailing()}
{#if track.duration}
<span class="shrink-0 text-xs text-fg-2 tabular-nums">{fmtTime(track.duration)}</span>
{/if}
{/snippet}
</ListRow>
{:else}
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p>
{/each}
</div>
</aside>
</div>
{/if}
@@ -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">
<div class="min-w-0 flex-1">
<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="md"
size="xs"
pressed={isLiked}
onclick={toggleLike}
>
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} />
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
</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" />
<Shuffle class="size-4" strokeWidth={1.8} />
</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" />
<Repeat class="size-4" strokeWidth={1.8} />
</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'}" />
<Icon class="size-icon-xl {gradient ? 'text-on-art/70' : 'text-fg-2'}" strokeWidth={1.3} />
{/if}
</label>
@@ -5,7 +5,7 @@
title: string;
eyebrow?: string;
description?: string;
meta?: string | Snippet;
meta?: string;
cover?: Snippet;
actions?: Snippet;
align?: 'center' | 'end';
@@ -45,10 +45,8 @@
{#if description}
<p class="mt-3 max-w-2xl text-body text-fg-2">{description}</p>
{/if}
{#if typeof meta === 'string'}
{#if meta}
<p class="mt-3 text-sm text-fg-2">{meta}</p>
{:else if meta}
<p class="mt-3 text-sm text-fg-2">{@render meta()}</p>
{/if}
</div>
{#if actions && !cover}
@@ -125,7 +125,7 @@
</svg>
</div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
<div class="h-full w-full bg-[image:var(--mf-cover-grad)]"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if}
@@ -7,7 +7,7 @@
</script>
<span
class="grid size-4 shrink-0 place-items-center rounded-tag border border-accent/30 bg-accent-tint pt-px text-xs leading-none font-normal text-accent-soft {klass}"
class="grid size-4 shrink-0 place-items-center rounded-tag bg-fg-2 text-xs leading-4 font-bold text-bg {klass}"
title="Contenido explícito"
aria-label="Contenido explícito">E</span
>
@@ -1,44 +0,0 @@
<script lang="ts">
import { untrack } from 'svelte';
import { enhance } from '$app/forms';
import Button from '../primitives/Button.svelte';
interface Props {
following: boolean;
userId?: string;
invalidateAll?: boolean;
class?: string;
}
let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial));
let submitting = $state(false);
</script>
<form
method="POST"
action={following ? '?/unfollow' : '?/follow'}
class={klass}
use:enhance={() => {
submitting = true;
return async ({ result, update }) => {
if (result.type === 'success' && typeof result.data?.following === 'boolean')
following = result.data.following;
await update({ reset: false, invalidateAll });
submitting = false;
};
}}
>
{#if userId}
<input type="hidden" name="userId" value={userId} />
{/if}
<Button
type="submit"
size="sm"
variant={following ? 'secondary' : 'primary'}
disabled={submitting}
>
{following ? 'Dejar de seguir' : 'Seguir'}
</Button>
</form>
@@ -95,7 +95,7 @@
style="--mf-track-cols:{gridColumns}; --mf-track-cols-mobile:{gridColumnsMobile}"
>
{#if index}
<Hash size={16} />
<Hash size={16} strokeWidth={1.5} />
{/if}
<span>Título</span>
{#each columns as column (column)}
@@ -1,20 +0,0 @@
<script lang="ts">
import ListRow from './ListRow.svelte';
import Avatar from './Avatar.svelte';
interface Props {
user: {
id: string;
name: string;
profilePictureUrl: string | null;
};
}
let { user }: Props = $props();
</script>
<ListRow title={user.name} href="/user/{user.id}" size="lg" class="min-w-0 flex-1">
{#snippet art()}
<Avatar name={user.name} src={user.profilePictureUrl} size="md" />
{/snippet}
</ListRow>
@@ -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" />
<CircleAlert class="size-icon-md" strokeWidth={2} />
</span>
{/if}
<h2 class="text-display-3 text-fg">{title}</h2>
@@ -1,8 +1,6 @@
<script lang="ts">
import type { Snippet } from 'svelte';
import { pop } from '$lib/utils/transitions';
interface Props {
class?: string;
style?: string;
@@ -14,8 +12,9 @@
</script>
<div
transition:pop|global
class="glass-panel rounded-panel p-1.5 {border ? 'border border-hairline' : ''} {klass}"
class="glass-panel animate-pop rounded-panel p-1.5 {border
? 'border border-hairline'
: ''} {klass}"
{style}
>
{@render children()}
@@ -3,7 +3,8 @@
import X from '@lucide/svelte/icons/x';
import IconButton from '../primitives/IconButton.svelte';
import { fade } from 'svelte/transition';
import { pop } from '$lib/utils/transitions';
import { expoOut } from 'svelte/easing';
import type { TransitionConfig } from 'svelte/transition';
interface Props {
open: boolean;
@@ -47,6 +48,15 @@
);
}
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') {
@@ -80,7 +90,7 @@
<div
bind:this={panel}
transition:pop={{ duration: 200 }}
class="glass-panel max-h-[85dvh] w-full glow-drift overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
class="glass-panel max-h-[85dvh] w-full overflow-y-auto {maxWidth} rounded-panel p-5 outline-none sm:p-6 {panelClass}"
role="dialog"
aria-modal="true"
aria-label={title}
@@ -22,6 +22,7 @@
/>
<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" />
<Icon class="size-icon-xl text-fg-2" strokeWidth={1.25} />
{/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="--p:{percent}%"
style="background:linear-gradient(to right, var(--mf-text-2) {percent}%, var(--mf-track) {percent}%)"
/>
<style>
@@ -35,17 +35,6 @@
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%;
-1
View File
@@ -17,6 +17,5 @@ export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12;
export const FOLLOW_LIST_PAGE_SIZE = 30;
export const MENU_WIDTH = 240;
export const SUBMENU_WIDTH = 256;
+73 -20
View File
@@ -1,22 +1,75 @@
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 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 genreTiles = Object.entries(GENRE_INFO).map(([query, info], i, all) => ({
query,
hue: Math.round((i * 360) / all.length),
...info
}));
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 function genreOptions(genres: { genre: string; incompatibleWith: string[] }[]) {
return genres.map(({ genre, incompatibleWith }) => ({
genre,
incompatibleWith,
label: genreInfo(genre).label
}));
}
export function findConflict(
tags: string[],
options: { genre: string; incompatibleWith: string[] }[]
): [string, string] | undefined {
const byGenre = new Map(options.map((option) => [option.genre, option.incompatibleWith]));
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
if (byGenre.get(tags[i])?.includes(tags[j])) return [tags[i], tags[j]];
}
}
return undefined;
}
-56
View File
@@ -1,56 +0,0 @@
export const TRACK_GENRES = [
{ value: 'Pop', label: 'Pop' },
{ value: 'Rock', label: 'Rock' },
{ value: 'HipHop', label: 'Hip-hop' },
{ value: 'RnB', label: 'R&B' },
{ value: 'Jazz', label: 'Jazz' },
{ value: 'Blues', label: 'Blues' },
{ value: 'Classical', label: 'Clásica' },
{ value: 'Electronic', label: 'Electrónica' },
{ value: 'House', label: 'House' },
{ value: 'Techno', label: 'Techno' },
{ value: 'Trance', label: 'Trance' },
{ value: 'Dubstep', label: 'Dubstep' },
{ value: 'DrumAndBass', label: 'Drum and bass' },
{ value: 'Metal', label: 'Metal' },
{ value: 'Punk', label: 'Punk' },
{ value: 'Reggae', label: 'Reggae' },
{ value: 'Reggaeton', label: 'Reggaetón' },
{ value: 'Country', label: 'Country' },
{ value: 'Folk', label: 'Folk' },
{ value: 'Indie', label: 'Indie' },
{ value: 'KPop', label: 'K-pop' },
{ value: 'Latin', label: 'Latina' },
{ value: 'Soul', label: 'Soul' },
{ value: 'Funk', label: 'Funk' },
{ value: 'Ambient', label: 'Ambient' },
{ value: 'Lofi', label: 'Lo-fi' }
] as const;
export type TrackGenre = (typeof TRACK_GENRES)[number]['value'];
const GENRE_VALUES: ReadonlySet<string> = new Set(TRACK_GENRES.map((genre) => genre.value));
const INTENSE: ReadonlySet<TrackGenre> = new Set(['Metal', 'Punk', 'Dubstep', 'DrumAndBass']);
const MELLOW: ReadonlySet<TrackGenre> = new Set(['Classical', 'Ambient', 'Lofi']);
export function isTrackGenre(value: string): value is TrackGenre {
return GENRE_VALUES.has(value);
}
export function areCompatible(a: TrackGenre, b: TrackGenre): boolean {
return !((INTENSE.has(a) && MELLOW.has(b)) || (INTENSE.has(b) && MELLOW.has(a)));
}
export function genreLabel(value: TrackGenre): string {
return TRACK_GENRES.find((genre) => genre.value === value)?.label ?? value;
}
export function findConflict(tags: readonly TrackGenre[]): [TrackGenre, TrackGenre] | undefined {
for (let i = 0; i < tags.length; i++) {
for (let j = i + 1; j < tags.length; j++) {
if (!areCompatible(tags[i], tags[j])) return [tags[i], tags[j]];
}
}
return undefined;
}
@@ -0,0 +1,52 @@
const MAX_TICK_GAP_SECONDS = 2.5;
const FLUSH_EVERY_SECONDS = 30;
export class ListenTracker {
#listenId: string | null = null;
#played = 0;
#sent = 0;
#lastTime: number | null = null;
#lastFlushAt = 0;
begin(listenId: string | null | undefined) {
this.flush();
this.#listenId = listenId ?? null;
this.#played = 0;
this.#sent = 0;
this.#lastTime = null;
this.#lastFlushAt = Date.now();
}
resync(currentTime: number) {
this.#lastTime = currentTime;
}
interrupt() {
this.#lastTime = null;
}
tick(currentTime: number) {
if (this.#listenId === null) return;
const last = this.#lastTime;
this.#lastTime = currentTime;
if (last === null) return;
const delta = currentTime - last;
if (delta > 0 && delta <= MAX_TICK_GAP_SECONDS) this.#played += delta;
if (Date.now() - this.#lastFlushAt >= FLUSH_EVERY_SECONDS * 1000) this.flush();
}
flush() {
const id = this.#listenId;
if (id === null || this.#played <= this.#sent) return;
this.#sent = this.#played;
this.#lastFlushAt = Date.now();
fetch(`/api/listens/${id}/progress`, {
method: 'PUT',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ playedSeconds: this.#played }),
keepalive: true
}).catch(() => {
this.#sent = 0;
});
}
}
+39 -40
View File
@@ -3,6 +3,7 @@ import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte';
import { ListenTracker } from './listenTracker';
export interface PlayerTrack {
id: string | number;
@@ -119,7 +120,6 @@ 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,6 +132,7 @@ class PlayerState {
#loadToken = 0;
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
#listen = new ListenTracker();
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
@@ -149,22 +150,32 @@ 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;
@@ -178,6 +189,10 @@ class PlayerState {
this.#fail();
});
this.#audio = audio;
document.addEventListener('visibilitychange', () => {
if (document.visibilityState === 'hidden') this.#listen.flush();
});
window.addEventListener('pagehide', () => this.#listen.flush());
this.#setupMediaSession(audio);
return audio;
}
@@ -208,19 +223,10 @@ class PlayerState {
});
}
#clockTime = 0;
#clockStamp = 0;
#tickProgress = (now: number) => {
#tickProgress = () => {
const audio = this.#audio;
if (!audio) return;
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.progress = audio.currentTime;
this.#rafId = requestAnimationFrame(this.#tickProgress);
};
@@ -277,14 +283,16 @@ class PlayerState {
const track = this.tracks.find((t) => t.id === id);
this.#applyAccent(id);
if (track) this.#syncMediaSessionMetadata(track);
this.#listen.begin(null);
const token = ++this.#loadToken;
this.loading = true;
this.progress = 0;
try {
const src = await this.#resolveLocalSrc(String(id));
const { src, listenId } = await this.#resolveLocalSrc(String(id));
if (token !== this.#loadToken) return;
audio.src = src;
audio.volume = this.volume / 100;
this.#listen.begin(listenId);
await audio.play();
this.loading = false;
this.#pushRecent(this.current);
@@ -310,14 +318,28 @@ class PlayerState {
);
}
async #resolveLocalSrc(id: string): Promise<string> {
async #resolveLocalSrc(id: string): Promise<{ src: string; listenId: string | null }> {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) throw new Error(await readErrorMessage(res));
const { manifestUrl, ticket } = (await res.json()) as {
const { manifestUrl, ticket, listenId } = (await res.json()) as {
manifestUrl: string;
ticket: string;
listenId?: string | null;
};
return this.#withTicket(manifestUrl, ticket);
return { src: this.#withTicket(manifestUrl, ticket), listenId: listenId ?? null };
}
async #beginRepeatListen() {
const id = this.currentId;
if (id === null) return;
try {
const res = await fetch(`/api/tracks/${id}/stream`);
if (!res.ok) return;
const { listenId } = (await res.json()) as { listenId?: string | null };
if (this.currentId === id) this.#listen.begin(listenId ?? null);
} catch {
return;
}
}
#withTicket(url: string, ticket: string): string {
@@ -412,18 +434,6 @@ 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();
@@ -456,16 +466,6 @@ class PlayerState {
this.#loadCurrent();
}
moveQueueItem(from: number, insertAt: number) {
if (from < 0 || from >= this.tracks.length) return;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return;
const tracks = [...this.tracks];
const [item] = tracks.splice(from, 1);
tracks.splice(target, 0, item);
this.tracks = tracks;
}
clearUpcoming() {
this.tracks = this.currentId === null ? [] : [this.current];
}
@@ -492,7 +492,6 @@ 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;
@@ -1,28 +0,0 @@
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' });
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
return { following: follow, userId: id };
}
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
-17
View File
@@ -1,17 +0,0 @@
import type { createApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList(
api: ApiClient,
list: FollowList,
id: string,
pageNumber: number,
pageSize: number
) {
const options = { params: { path: { id }, query: { pageNumber, pageSize } } };
return list === 'followers'
? api.GET('/users/{id}/followers', options)
: api.GET('/users/{id}/following', options);
}
+51 -55
View File
@@ -17,72 +17,69 @@ const BIRTHDAY_MESSAGE = 'Hoy toca celebrar. Tú eliges la música.';
const HOUR_SLOTS = [6, 9, 12, 15, 18, 21, 24];
const WEEKLY_SCHEDULE: [string, string][][] = [
const WEEKLY_SCHEDULE: string[][] = [
[
['Domingo de madrugada.', 'Madrugada de domingo. Todo va más lento.'],
['Buenos días. Tómate el domingo con calma.', 'Buenos días. Hoy nadie tiene prisa.'],
['Domingo. Empieza despacio.', 'Un domingo así pide algo tranquilo.'],
['Una mañana tranquila merece buena música.', 'Domingo al mediodía. Pon algo que abrace.'],
[
'Domingo por la tarde. Hora de bajar el ritmo.',
'La tarde del domingo se disfruta con calma.'
],
['La semana empieza a despedirse.', 'El domingo se va apagando. Deja que suene.'],
['Una última canción antes de volver a empezar.', 'Cierra el domingo con algo que te guste.']
'Domingo de madrugada.',
'Buenos días. Tómate el domingo con calma.',
'Domingo. Empieza despacio.',
'Una mañana tranquila merece buena música.',
'Domingo por la tarde. Hora de bajar el ritmo.',
'La semana empieza a despedirse.',
'Una última canción antes de volver a empezar.'
],
[
['La semana acaba de empezar.', 'Ya es lunes, aunque aún sea de noche.'],
['Buenos días. Empezamos otra semana.', 'Lunes por la mañana. Arranca con buen pie.'],
['Lunes. Dale una banda sonora al día.', 'El lunes se lleva mejor con música.'],
['Una pausa musical para este lunes.', 'Mediodía de lunes. Te has ganado un respiro.'],
['El lunes todavía no ha terminado.', 'Media tarde de lunes. Aguanta un poco más.'],
['El lunes ya empieza a quedar atrás.', 'Casi lo has conseguido. Un lunes menos.'],
['Sobrevive al lunes con una última canción.', 'Lunes superado. Ahora, lo que tú quieras.']
'La semana acaba de empezar.',
'Buenos días. Empezamos otra semana.',
'Lunes. Dale una banda sonora al día.',
'Una pausa musical para este lunes.',
'El lunes todavía no ha terminado.',
'El lunes ya empieza a quedar atrás.',
'Sobrevive al lunes con una última canción.'
],
[
['La noche sigue sonando.', 'Martes de madrugada. La música no duerme.'],
['Buenos días. Martes en marcha.', 'Martes. Ya estás más despierto que ayer.'],
['La semana ya está cogiendo ritmo.', 'El martes ya tiene su propio compás.'],
['Martes al mediodía. ¿Qué escuchamos?', 'A mitad del martes. Una canción de por medio.'],
['Una tarde cualquiera necesita música.', 'Tarde de martes. Ponle algo con ritmo.'],
['El día empieza a bajar el ritmo.', 'Cae la tarde. Es buen momento para poner algo.'],
['Un martes más llega a su fin.', 'El martes se acaba. Ciérralo con música.']
'La noche sigue sonando.',
'Buenos días. Martes en marcha.',
'La semana ya está cogiendo ritmo.',
'Martes al mediodía. ¿Qué escuchamos?',
'Una tarde cualquiera necesita música.',
'El día empieza a bajar el ritmo.',
'Un martes más llega a su fin.'
],
[
['Mitad de semana, mitad de noche.', 'Miércoles de madrugada. Justo en el centro.'],
['Buenos días. Ya es miércoles.', 'Miércoles. La semana ya está de tu lado.'],
['Ya estamos en el ecuador de la semana.', 'Ecuador de la semana. Lo peor ya pasó.'],
['Miércoles al mediodía. Un poco de música.', 'Un descanso en pleno miércoles.'],
['La semana ya va cuesta abajo.', 'Miércoles por la tarde. Todo empieza a rodar.'],
['Unas canciones para terminar el miércoles.', 'El miércoles se despide. Ponle banda sonora.'],
['Mitad de semana superada.', 'Ya solo queda bajar la cuesta.']
'Mitad de semana, mitad de noche.',
'Buenos días. Ya es miércoles.',
'Ya estamos en el ecuador de la semana.',
'Miércoles al mediodía. Un poco de música.',
'La semana ya va cuesta abajo.',
'Unas canciones para terminar el miércoles.',
'Mitad de semana superada.'
],
[
['Una noche más antes del fin de semana.', 'Jueves de madrugada. Queda menos.'],
['Buenos días. Ya casi estamos.', 'Jueves. Hoy se huele el viernes.'],
['Jueves. El fin de semana se acerca.', 'Un jueves con buena música se pasa volando.'],
['Una pausa musical antes de seguir.', 'Mediodía de jueves. Toca recargar pilas.'],
['Queda poco para desconectar.', 'Tarde de jueves. El fin de semana asoma.'],
['Ya se empieza a notar el viernes.', 'El jueves ya está de salida.'],
['Una noche más y llega el fin de semana.', 'Última noche de la semana laboral. Que suene.']
'Una noche más antes del fin de semana.',
'Buenos días. Ya casi estamos.',
'Jueves. El fin de semana se acerca.',
'Una pausa musical antes de seguir.',
'Queda poco para desconectar.',
'Ya se empieza a notar el viernes.',
'Una noche más y llega el fin de semana.'
],
[
['La semana ha terminado. La noche continúa.', 'Viernes de madrugada. Aún queda noche.'],
['Buenos días. Es viernes.', 'Viernes. Hoy ya se nota el ambiente.'],
['Viernes. Hoy suena diferente.', 'Es viernes. Sube el volumen.'],
['Ya huele a fin de semana.', 'Mediodía de viernes. Casi libre.'],
['Últimas horas de la semana.', 'Tarde de viernes. La semana ya es historia.'],
['Se acabó la semana. Dale al play.', 'Empieza el fin de semana. Que empiece con música.'],
['Viernes por la noche. Tú eliges la banda sonora.', 'Noche de viernes. Que no falte música.']
'La semana ha terminado. La noche continúa.',
'Buenos días. Es viernes.',
'Viernes. Hoy suena diferente.',
'Ya huele a fin de semana.',
'Últimas horas de la semana.',
'Se acabó la semana. Dale al play.',
'Viernes por la noche. Tú eliges la banda sonora.'
],
[
['La noche es joven. Que siga sonando.', 'Sábado de madrugada. La fiesta no acaba.'],
['Buenos días. Hoy no hay prisa.', 'Sábado por la mañana. Sin alarma y con música.'],
['Sábado. Empieza el día a tu ritmo.', 'Hoy manda el sábado. Tú pones el ritmo.'],
['Mediodía de sábado. ¿Qué ponemos?', 'Sábado al mediodía. Hora de elegir banda sonora.'],
['Una tarde de sábado pide música.', 'Tarde de sábado. Sin planes también se disfruta.'],
['El sábado todavía tiene mucho por delante.', 'Cae el sábado y aún queda mucho por sonar.'],
['Esta noche puede sonar cualquier cosa.', 'Noche de sábado. Todo vale, elige tú.']
'La noche es joven. Que siga sonando.',
'Buenos días. Hoy no hay prisa.',
'Sábado. Empieza el día a tu ritmo.',
'Mediodía de sábado. ¿Qué ponemos?',
'Una tarde de sábado pide música.',
'El sábado todavía tiene mucho por delante.',
'Esta noche puede sonar cualquier cosa.'
]
];
@@ -102,6 +99,5 @@ export function pickGreeting(date: Date, birthday?: Birthday | null): string {
const hour = date.getHours();
const slot = HOUR_SLOTS.findIndex((until) => hour < until);
const variant = Math.floor(date.getTime() / 86_400_000) % 2;
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot][variant];
return WEEKLY_SCHEDULE[date.getDay()][slot === -1 ? 0 : slot];
}
+5 -21
View File
@@ -1,4 +1,4 @@
import { afterNavigate, beforeNavigate } from '$app/navigation';
import { afterNavigate } from '$app/navigation';
import type { LucideIcon } from '@lucide/svelte';
import Home from '@lucide/svelte/icons/house';
import Compass from '@lucide/svelte/icons/compass';
@@ -55,26 +55,6 @@ export function trackNavigation() {
});
}
const scrollPositions = new Map<number, number>();
export function restoreScroll(getScroller: () => HTMLElement | undefined) {
let current: number | null = null;
beforeNavigate(() => {
const scroller = getScroller();
if (current !== null && scroller) scrollPositions.set(current, scroller.scrollTop);
});
afterNavigate((nav) => {
current = historyIndex();
const scroller = getScroller();
if (!scroller || nav.type === 'enter' || nav.to?.url.hash) return;
const saved =
nav.type === 'popstate' && current !== null ? scrollPositions.get(current) : undefined;
scroller.scrollTop = saved ?? 0;
});
}
export function previousPage(): PageRef | null {
return visited.get(index - 1) ?? null;
}
@@ -88,6 +68,10 @@ 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;
+9 -11
View File
@@ -22,10 +22,10 @@ export function buildThemeTokens(hue: number, mode: 'dark' | 'light' = 'dark'):
const g = (l1: number, c1: number, a1: number, l2: number, c2: number, a2: number) =>
`linear-gradient(90deg, oklch(${l1} ${c1} ${H} / ${a1}), oklch(${l2} ${c2} ${H} / ${a2}))`;
const gActive = light
? g(0.86, 0.04, 0.45, 0.9, 0.03, 0.28)
? g(0.82, 0.05, 0.55, 0.87, 0.035, 0.36)
: g(0.46, 0.06, 0.34, 0.4, 0.045, 0.22);
const gHover = light
? g(0.9, 0.03, 0.34, 0.93, 0.02, 0.2)
? g(0.86, 0.035, 0.38, 0.9, 0.025, 0.24)
: g(0.42, 0.045, 0.26, 0.38, 0.035, 0.17);
const glow = (l: number, c: number, a: number) => `oklch(${l} ${c} ${H} / ${a})`;
// Fixed danger hue so error dialogs read red whatever the theme hue is.
@@ -45,26 +45,24 @@ 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-c1': light ? glow(0.9, 0.04, 0.6) : glow(0.24, 0.04, 0.55),
'--mf-hero-c2': light ? glow(0.96, 0.02, 0.4) : glow(0.14, 0.02, 0.35),
'--mf-hero-c3': light ? glow(0.96, 0.02, 0.4) : glow(0.12, 0.015, 0.25),
'--mf-hero-bg': light
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.6)}, ${glow(0.96, 0.02, 0.4)} 60%)`
: `linear-gradient(120deg, ${glow(0.24, 0.04, 0.55)}, ${glow(0.14, 0.02, 0.35)} 60%, ${glow(0.12, 0.015, 0.25)})`,
'--mf-modal-glow': light
? `linear-gradient(135deg, ${glow(0.88, 0.05, 0.5)} 0%, ${glow(0.93, 0.03, 0.2)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${glow(0.3, 0.045, 0.4)} 0%, ${glow(0.2, 0.03, 0.15)} 35%, transparent 70%)`,
'--mf-modal-glow-danger': light
? `linear-gradient(135deg, ${dangerGlow(0.86, 0.07, 0.55)} 0%, ${dangerGlow(0.92, 0.04, 0.22)} 40%, transparent 75%)`
: `linear-gradient(135deg, ${dangerGlow(0.34, 0.1, 0.45)} 0%, ${dangerGlow(0.22, 0.06, 0.18)} 35%, transparent 70%)`,
'--mf-spotlight-c1': light ? glow(0.9, 0.04, 0.55) : glow(0.22, 0.04, 0.5),
'--mf-spotlight-c2': light ? glow(0.96, 0.02, 0.35) : glow(0.13, 0.02, 0.3),
'--mf-spotlight-c3': light ? glow(0.96, 0.02, 0.35) : glow(0.11, 0.015, 0.2),
'--mf-spotlight-bg': light
? `linear-gradient(120deg, ${glow(0.9, 0.04, 0.55)}, ${glow(0.96, 0.02, 0.35)} 55%)`
: `linear-gradient(120deg, ${glow(0.22, 0.04, 0.5)}, ${glow(0.13, 0.02, 0.3)} 55%, ${glow(0.11, 0.015, 0.2)})`,
'--mf-cover-grad': light
? `linear-gradient(150deg, oklch(0.84 0.03 ${H}), oklch(0.74 0.02 ${H}))`
: `linear-gradient(150deg, oklch(0.34 0.03 ${H}), oklch(0.18 0.015 ${H}))`
-2
View File
@@ -4,5 +4,3 @@ export interface SessionUser {
email: string;
picture: string;
}
export type FollowList = 'followers' | 'following';

Some files were not shown because too many files have changed in this diff Show More