Author SHA1 Message Date
Admin e616f957e2 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 19:40:40 +02:00
186 changed files with 2580 additions and 4866 deletions
-127
View File
@@ -1,127 +0,0 @@
name: CI/CD
on:
push:
branches: ['**']
pull_request:
branches: ['**']
concurrency:
group: production-deploy
cancel-in-progress: false
jobs:
backend-tests:
name: BackEnd
runs-on: ubuntu-latest
timeout-minutes: 20
defaults:
run:
working-directory: backend
steps:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '10.0.x'
- name: Restore
run: dotnet restore Musify.slnx
- name: Build
run: dotnet build Musify.slnx --no-restore -c Release
- name: Test
run: dotnet test Musify.slnx --no-build -c Release
web-player-checks:
name: WebPlayer
runs-on: ubuntu-latest
timeout-minutes: 15
defaults:
run:
working-directory: web-player
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: '22'
cache: npm
cache-dependency-path: web-player/package-lock.json
- run: npm ci
- name: Type-check
run: npm run check
- name: Lint
run: npm run lint
# Runs on the production host itself, through the existing self-hosted
# Gitea Actions runner (act_runner), which also carries a "production"
# label registered in native (non-docker) mode so its steps see the host's
# real Docker daemon directly (Docker socket + a bind-mounted deploy
# directory — see deploy/README.md for how that runner is set up).
#
# DEPLOY_PATH is the persistent clone already on that host (the one whose
# deploy/.env.prod is filled in and matches SharedServices/.env.prod) —
# deploy reuses it instead of a fresh checkout, since .env.prod is
# untracked and only lives there.
deploy:
name: Deploy
needs: [backend-tests, web-player-checks]
if: github.ref == 'refs/heads/master' && github.event_name == 'push'
runs-on: [production]
timeout-minutes: 20
env:
DEPLOY_PATH: /home/ubuntu/deploys/musify
COMPOSE: docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml
steps:
- name: Pull latest master
run: |
set -euo pipefail
git config --global --add safe.directory "$DEPLOY_PATH"
cd "$DEPLOY_PATH"
git fetch origin master
git reset --hard origin/master
- name: Generate stream-ticket 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
# Both jobs verify themselves through the public auth./s3. URLs, served
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
- name: Create OIDC app and S3 bucket
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE up -d seaweedfs-init zitadel-init
for job in musify-seaweedfs-init musify-zitadel-init; do
code=$(docker wait "$job")
if [ "$code" != "0" ]; then
docker logs "$job"
echo "$job exited with $code"
exit 1
fi
done
- name: Build and start apps
run: |
set -euo pipefail
cd "$DEPLOY_PATH"
$COMPOSE --profile apps up -d --build api worker gateway web-player
+1 -3
View File
@@ -7,9 +7,6 @@ obj/
*.userosscache *.userosscache
.vs/ .vs/
artifacts/ artifacts/
TestResults/
*.trx
*.coverage
# Rider / VS # Rider / VS
.idea/ .idea/
@@ -28,4 +25,5 @@ Thumbs.db
# Deploy secrets and runtime state # Deploy secrets and runtime state
deploy/keys/ deploy/keys/
deploy/secrets/ deploy/secrets/
deploy/nginx/certs/
deploy/zitadel/.output/ deploy/zitadel/.output/
@@ -1,26 +0,0 @@
using Mediator;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
public class GetAvailableGenresQueryHandler
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
{
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
{
var all = Enum.GetValues<Genre>();
IReadOnlyList<AvailableGenreResponse> result = all
.Select(genre => new AvailableGenreResponse(
genre,
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
.ToList();
return ValueTask.FromResult(result);
}
}
}
@@ -1,31 +0,0 @@
using Mediator;
using Microsoft.EntityFrameworkCore;
using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Genres
{
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
public record GenreResponse(Genre Genre, int TrackCount);
public class GetGenresQueryHandler(IDatabase database)
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
{
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
{
var rows = await database.TrackTags
.AsNoTracking()
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
.GroupBy(tag => tag.Tag)
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
.ToListAsync(cancellationToken);
return rows
.OrderByDescending(row => row.TrackCount)
.ThenBy(row => row.Genre)
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
.ToList();
}
}
}
@@ -18,8 +18,7 @@ namespace Musify.Application.Tracks
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags)
bool IsExplicit = false)
: ICommand<ErrorOr<TrackApplicationResponse>>; : ICommand<ErrorOr<TrackApplicationResponse>>;
public class CreateTrackCommandHandler( public class CreateTrackCommandHandler(
@@ -84,7 +83,6 @@ namespace Musify.Application.Tracks
NormalizedTitle = request.Title.ToUpperInvariant(), NormalizedTitle = request.Title.ToUpperInvariant(),
OwnerUserId = user.Id, OwnerUserId = user.Id,
Owner = user, Owner = user,
IsExplicit = request.IsExplicit,
Pictures = new TrackPictures Pictures = new TrackPictures
{ {
OriginalName = pictureIntent.ObjectName, OriginalName = pictureIntent.ObjectName,
@@ -5,11 +5,10 @@ using Musify.Application.Tracks.Responses;
using X.PagedList; using X.PagedList;
using X.PagedList.EF; using X.PagedList.EF;
using Musify.Application.Contracts; using Musify.Application.Contracts;
using Musify.Domain.ValueObjects;
namespace Musify.Application.Tracks namespace Musify.Application.Tracks
{ {
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null) public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
: IQuery<ErrorOr<TracksSearchResponse>>; : IQuery<ErrorOr<TracksSearchResponse>>;
public class GetTracksQueryHandler(IDatabase database) public class GetTracksQueryHandler(IDatabase database)
@@ -29,9 +28,6 @@ namespace Musify.Application.Tracks
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName)); tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
} }
if (request.Genre is { } genre)
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
var totalCount = await tracksQuery.CountAsync(cancellationToken); var totalCount = await tracksQuery.CountAsync(cancellationToken);
var pagedEntities = await tracksQuery var pagedEntities = await tracksQuery
@@ -1,14 +0,0 @@
namespace Musify.Application.Tracks
{
public static class ListeningRules
{
public const double CountedThresholdSeconds = 30;
public const double CountedThresholdFraction = 0.5;
public const double DurationToleranceSeconds = 2;
public const double ClockToleranceSeconds = 5;
public const int UncountedRetentionDays = 8;
public static double CountedThreshold(double durationSeconds) =>
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
}
}
@@ -11,6 +11,18 @@ namespace Musify.Application.Tracks
double PlayedSeconds) double PlayedSeconds)
: ICommand<ErrorOr<Success>>; : ICommand<ErrorOr<Success>>;
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);
}
public class RecordListeningProgressCommandHandler(IDatabase database) public class RecordListeningProgressCommandHandler(IDatabase database)
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>> : ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
{ {
@@ -15,8 +15,7 @@ namespace Musify.Application.Tracks.Responses
DateTime CreatedAt, DateTime CreatedAt,
DateTime UpdatedAt, DateTime UpdatedAt,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId, [property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags)
bool IsExplicit)
{ {
public static TrackApplicationResponse FromEntity(Track track, int listensCount) => public static TrackApplicationResponse FromEntity(Track track, int listensCount) =>
new( new(
@@ -29,7 +28,6 @@ namespace Musify.Application.Tracks.Responses
track.CreatedAt, track.CreatedAt,
track.UpdatedAt, track.UpdatedAt,
track.OwnerUserId, track.OwnerUserId,
track.Tags.Select(tag => tag.Tag).ToList(), track.Tags.Select(tag => tag.Tag).ToList());
track.IsExplicit);
} }
} }
@@ -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;
}
}
}
@@ -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 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( public record UserProfileResponse(
[property: JsonConverter(typeof(LongAsStringConverter))] long Id, [property: JsonConverter(typeof(LongAsStringConverter))] long Id,
@@ -17,9 +17,7 @@ namespace Musify.Application.Users
string? ProfilePictureUrl, string? ProfilePictureUrl,
DateTime CreatedAt, DateTime CreatedAt,
int FollowersCount, int FollowersCount,
int FollowingCount, int FollowingCount);
bool IsFollowing,
bool CanViewFollowers);
public class GetUserProfileQueryHandler(IDatabase database) public class GetUserProfileQueryHandler(IDatabase database)
: IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>> : IQueryHandler<GetUserProfileQuery, ErrorOr<UserProfileResponse>>
@@ -51,14 +49,6 @@ namespace Musify.Application.Users
.AsNoTracking() .AsNoTracking()
.CountAsync(f => f.FollowerId == request.UserId, cancellationToken); .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( return new UserProfileResponse(
user.Id, user.Id,
user.Name, user.Name,
@@ -67,9 +57,7 @@ namespace Musify.Application.Users
user.ProfilePictureUrl, user.ProfilePictureUrl,
user.CreatedAt, user.CreatedAt,
followersCount, followersCount,
followingCount, followingCount);
isFollowing,
canViewFollowers);
} }
} }
} }
@@ -8,13 +8,13 @@ using Musify.Application.Contracts;
namespace Musify.Application.Users namespace Musify.Application.Users
{ {
public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch, long? ViewerId = null) public record GetUsersQuery(int PageNumber, int PageSize, string? UsernameSearch)
: IQuery<ErrorOr<PaginatedResponse<UserSummaryResponse>>>; : IQuery<ErrorOr<PaginatedResponse<UserApplicationResponse>>>;
public class GetUsersQueryHandler(IDatabase database) 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 var usersQuery = database.Users
.AsNoTracking() .AsNoTracking()
@@ -27,19 +27,11 @@ namespace Musify.Application.Users
} }
var totalCount = await usersQuery.CountAsync(cancellationToken); var totalCount = await usersQuery.CountAsync(cancellationToken);
var viewerId = request.ViewerId;
var pagedUsers = await usersQuery var pagedUsers = await usersQuery
.OrderBy(u => u.Name) .Select(u => UserApplicationResponse.FromEntity(u))
.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)))
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken); .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);
}
@@ -31,9 +31,6 @@ namespace Musify.Domain.Entities
[Required] [Required]
public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active; public LifeCycleStatus LifeCycleStatus { get; set; } = LifeCycleStatus.Active;
[Required]
public bool IsExplicit { get; set; }
[Required] [Required]
public DateTime CreatedAt { get; set; } = DateTime.UtcNow; public DateTime CreatedAt { get; set; } = DateTime.UtcNow;
@@ -37,7 +37,7 @@ namespace Musify.Infrastructure.Persistence
} }
else if (entry.State == EntityState.Modified) else if (entry.State == EntityState.Modified)
{ {
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now; entry.Entity.UpdatedAt = now;
} }
} }
} }
@@ -13,13 +13,9 @@ namespace Musify.Infrastructure.Persistence
public class Database(DatabaseConfiguration configuration) public class Database(DatabaseConfiguration configuration)
: DbContext, AppIDatabase : DbContext, AppIDatabase
{ {
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder) protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{ {
optionsBuilder optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
.AddInterceptors(AuditInterceptor);
} }
protected override void OnModelCreating(ModelBuilder modelBuilder) protected override void OnModelCreating(ModelBuilder modelBuilder)
@@ -18,7 +18,10 @@ namespace Musify.Infrastructure.Persistence
serviceDescriptors.AddSingleton(serviceProvider => serviceDescriptors.AddSingleton(serviceProvider =>
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value); serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
serviceDescriptors.AddDbContext<Database>(); serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>()); serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
@@ -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");
}
}
}
@@ -436,9 +436,6 @@ namespace Musify.Infrastructure.Persistence.Migrations
b.Property<double>("DurationSeconds") b.Property<double>("DurationSeconds")
.HasColumnType("double precision"); .HasColumnType("double precision");
b.Property<bool>("IsExplicit")
.HasColumnType("boolean");
b.Property<int>("LifeCycleStatus") b.Property<int>("LifeCycleStatus")
.HasColumnType("integer"); .HasColumnType("integer");
@@ -6,26 +6,21 @@
} }
}, },
"Authentication": { "Authentication": {
"MetadataAddress": "http://host.docker.internal:8080/.well-known/openid-configuration",
"IssuerAddress": "http://host.docker.internal:8080",
"AuthorizationEndpoint": "http://host.docker.internal:8080/oauth/v2/authorize?prompt=login",
"TokenEndpoint": "http://host.docker.internal:8080/oauth/v2/token",
"ScalarRedirectUri": "http://localhost:5111/scalar/",
"Scopes": [ "openid", "profile", "email" ], "Scopes": [ "openid", "profile", "email" ],
"RequireHttpsMetadata": false "RequireHttpsMetadata": false
}, },
"Database": { "Database": {
"ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify" "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
}, },
"MassTransit": { "MassTransit": {
"Address": "rabbitmq://localhost:5672/musify", "Address": "rabbitmq://localhost:5672",
"Username": "musify", "Username": "guest",
"Password": "musify" "Password": "guest"
}, },
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "musify_access_key", "AccessKey": "admin_access_key",
"SecretAccessKey": "musify_secret_key", "SecretAccessKey": "admin_secret_key",
"ForcePathStyle": true, "ForcePathStyle": true,
"UseHttp": true "UseHttp": true
}, },
@@ -6,5 +6,4 @@ public record CreateTrackRequest(
string Title, string Title,
Guid PictureIntentId, Guid PictureIntentId,
Guid AudioIntentId, Guid AudioIntentId,
IReadOnlyCollection<Genre> Tags, IReadOnlyCollection<Genre> Tags);
bool IsExplicit = false);
@@ -1,39 +0,0 @@
using Mediator;
using Musify.Application.Genres;
namespace Musify.Api.Endpoints;
public static class GenreEndpoints
{
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
{
var group = app.MapGroup("/genres")
.WithTags("Genres");
group.MapGet("/", GetGenres)
.WithName("GetGenres")
.WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.")
.Produces<IReadOnlyList<GenreResponse>>();
group.MapGet("/available", GetAvailableGenres)
.WithName("GetAvailableGenres")
.WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.")
.Produces<IReadOnlyList<AvailableGenreResponse>>();
return app;
}
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
}
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
IMediator mediator,
CancellationToken cancellationToken)
{
return await mediator.Send(new GetGenresQuery(), cancellationToken);
}
}
@@ -1,5 +1,4 @@
using Mediator; using Mediator;
using Musify.Domain.ValueObjects;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Application.Tracks.Responses; using Musify.Application.Tracks.Responses;
using Musify.Application.Shared; using Musify.Application.Shared;
@@ -20,9 +19,8 @@ public static class TrackEndpoints
group.MapGet("/", GetTracks) group.MapGet("/", GetTracks)
.WithName("GetTracks") .WithName("GetTracks")
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.") .WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
.Produces<TracksSearchResponse>() .Produces<TracksSearchResponse>();
.ProducesValidationProblem();
group.MapGet("/{id}", GetTrackById) group.MapGet("/{id}", GetTrackById)
.WithName("GetTrackById") .WithName("GetTrackById")
@@ -94,25 +92,10 @@ public static class TrackEndpoints
IMediator mediator, IMediator mediator,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? name, string? name,
string? genre,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) int pageSize = 10)
{ {
Genre? parsedGenre = null; var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
if (!string.IsNullOrWhiteSpace(genre))
{
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
{
return Results.ValidationProblem(new Dictionary<string, string[]>
{
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
});
}
parsedGenre = value;
}
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
return result.ToHttpResult(); return result.ToHttpResult();
} }
@@ -185,7 +168,7 @@ public static class TrackEndpoints
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send( 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); cancellationToken);
return result.ToCreatedResult(track => $"/tracks/{track.Id}"); return result.ToCreatedResult(track => $"/tracks/{track.Id}");
@@ -21,7 +21,7 @@ public static class UserEndpoints
group.MapGet("/", GetUsers) group.MapGet("/", GetUsers)
.WithName("GetUsers") .WithName("GetUsers")
.WithSummary("Get Paginated Users.") .WithSummary("Get Paginated Users.")
.Produces<PaginatedResponse<UserSummaryResponse>>(); .Produces<PaginatedResponse<UserApplicationResponse>>();
group.MapGet("/{id}", GetUserById) group.MapGet("/{id}", GetUserById)
.WithName("GetUserById") .WithName("GetUserById")
@@ -52,19 +52,6 @@ public static class UserEndpoints
.Produces<UserProfileResponse>() .Produces<UserProfileResponse>()
.Produces(StatusCodes.Status404NotFound); .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) group.MapGet("/{id}/is-following", IsFollowing)
.WithName("IsFollowingUser") .WithName("IsFollowingUser")
.WithSummary("Check Whether The Current User Follows Another User.") .WithSummary("Check Whether The Current User Follows Another User.")
@@ -101,13 +88,12 @@ public static class UserEndpoints
private static async Task<IResult> GetUsers( private static async Task<IResult> GetUsers(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
CancellationToken cancellationToken, CancellationToken cancellationToken,
string? usernameSearch, string? usernameSearch,
int pageNumber = 1, int pageNumber = 1,
int pageSize = 10) 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(); return result.ToHttpResult();
} }
@@ -147,35 +133,10 @@ public static class UserEndpoints
private static async Task<IResult> GetUserProfile( private static async Task<IResult> GetUserProfile(
IMediator mediator, IMediator mediator,
CurrentUser currentUser,
long id, long id,
CancellationToken cancellationToken) CancellationToken cancellationToken)
{ {
var result = await mediator.Send(new GetUserProfileQuery(id, currentUser.Id), cancellationToken); var result = await mediator.Send(new GetUserProfileQuery(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);
return result.ToHttpResult(); return result.ToHttpResult();
} }
-1
View File
@@ -40,7 +40,6 @@ app.UseAuthorization();
app app
.MapAlbumEndpoints() .MapAlbumEndpoints()
.MapConfigEndpoints() .MapConfigEndpoints()
.MapGenreEndpoints()
.MapLikeEndpoints() .MapLikeEndpoints()
.MapMixEndpoints() .MapMixEndpoints()
.MapPlayListEndpoints() .MapPlayListEndpoints()
+6
View File
@@ -0,0 +1,6 @@
@WebApi_HostAddress = http://localhost:5111
GET {{WebApi_HostAddress}}/weatherforecast/
Accept: application/json
###
@@ -6,20 +6,20 @@
} }
}, },
"Database": { "Database": {
"ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify" "ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
}, },
"MassTransit": { "MassTransit": {
"Address": "rabbitmq://localhost:5672/musify", "Address": "rabbitmq://localhost:5672",
"Username": "musify", "Username": "guest",
"Password": "musify" "Password": "guest"
}, },
"OpenTelemetry": { "OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317" "OtlpEndpoint": "http://localhost:4317"
}, },
"InfrastructureStorage": { "InfrastructureStorage": {
"Address": "http://localhost:8333", "Address": "http://localhost:8333",
"AccessKey": "musify_access_key", "AccessKey": "admin_access_key",
"SecretAccessKey": "musify_secret_key", "SecretAccessKey": "admin_secret_key",
"ForcePathStyle": true, "ForcePathStyle": true,
"UseHttp": true "UseHttp": true
}, },
+4
View File
@@ -12,6 +12,10 @@ RUN dotnet restore Hosts/Musify.Api/Api.csproj
COPY . . COPY . .
RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore RUN dotnet publish Hosts/Musify.Api/Api.csproj -c Release -o /app --no-restore
# Config files are AppSettings*.json (PascalCase); on case-sensitive Linux the host
# looks for appsettings*.json. Add lowercase copies so they get loaded.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
+3
View File
@@ -11,6 +11,9 @@ RUN dotnet restore Hosts/Musify.Worker/Worker.csproj
COPY . . COPY . .
RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore RUN dotnet publish Hosts/Musify.Worker/Worker.csproj -c Release -o /app --no-restore
# AppSettings*.json (PascalCase) -> lowercase copies for case-sensitive Linux.
RUN cp /app/AppSettings.json /app/appsettings.json \
&& cp /app/AppSettings.Development.json /app/appsettings.Development.json
FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime FROM mcr.microsoft.com/dotnet/aspnet:10.0 AS runtime
WORKDIR /app WORKDIR /app
@@ -1,106 +0,0 @@
using System.Net;
using System.Net.Http.Json;
using System.Text.Json;
using System.Text.Json.Serialization;
using Microsoft.Extensions.DependencyInjection;
using Musify.Api.Tests.TestSupport;
using Musify.Application.Genres;
using Musify.Application.Tracks.Responses;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Musify.Infrastructure.Persistence;
using Xunit;
namespace Musify.Api.Tests.Endpoints
{
[Collection(ApiCollection.Name)]
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
{
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
{
Converters = { new JsonStringEnumConverter() }
};
private async Task<string> SeedTrackAsync(Genre genre)
{
using var scope = fixture.Services.CreateScope();
var database = scope.ServiceProvider.GetRequiredService<Database>();
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
var title = $"Genre track {Guid.NewGuid():N}";
var track = new Track
{
Title = title,
NormalizedTitle = title.ToUpperInvariant(),
OwnerUserId = owner.Id,
Owner = owner,
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
};
foreach (var tag in track.Tags)
tag.TrackId = track.Id;
database.Users.Add(owner);
database.Tracks.Add(track);
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
return title;
}
[Fact]
public async Task GetGenres_Anonymous_ListsGenresInUse()
{
await SeedTrackAsync(Genre.Funk);
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
}
[Fact]
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
{
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
}
[Theory]
[InlineData("Nope")]
[InlineData("999")]
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
{
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
}
[Fact]
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
{
var title = await SeedTrackAsync(Genre.Blues);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
}
[Fact]
public async Task GetTracks_GenreQuery_FiltersByTag()
{
var title = await SeedTrackAsync(Genre.Ambient);
await SeedTrackAsync(Genre.Techno);
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
Assert.Contains(body!.Items, item => item.Track.Title == title);
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
}
}
}
@@ -57,25 +57,5 @@ namespace Musify.Application.Tests.Albums
Assert.False(result.IsError); Assert.False(result.IsError);
Assert.Equal(2, result.Value.Count); Assert.Equal(2, result.Value.Count);
} }
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
var countedTrack = TestEntities.Track(owner, "In counted");
var skippedTrack = TestEntities.Track(owner, "In skipped");
await SeedAsync(
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
}
} }
} }
@@ -1,32 +0,0 @@
using Musify.Application.Genres;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetAvailableGenresQueryHandlerTests
{
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
[Fact]
public async Task Handle_ReturnsEveryGenre()
{
var result = await RunAsync();
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
}
[Fact]
public async Task Handle_ListsIncompatibleGenresSymmetrically()
{
var result = await RunAsync();
var metal = result.Single(r => r.Genre == Genre.Metal);
var lofi = result.Single(r => r.Genre == Genre.Lofi);
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
}
}
}
@@ -1,69 +0,0 @@
using Musify.Application.Genres;
using Musify.Application.Tests.TestSupport;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
using Xunit;
namespace Musify.Application.Tests.Genres
{
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
{
private GetGenresQueryHandler CreateHandler() => new(Database);
[Fact]
public async Task Handle_NoTracks_ReturnsEmpty()
{
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
[Fact]
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
{
var owner = TestEntities.User();
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
await SeedAsync(
owner, rockA, rockB, jazz,
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
result.Select(r => (r.Genre, r.TrackCount)));
}
[Fact]
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
{
var owner = TestEntities.User();
var other = TestEntities.User(id: 2, name: "other-user");
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
await SeedAsync(
owner, other, track,
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Equal(1, Assert.Single(result).TrackCount);
}
[Fact]
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
await SeedAsync(owner, track);
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
Assert.Empty(result);
}
}
}
@@ -72,21 +72,5 @@ namespace Musify.Application.Tests.Mixes
.ToListAsync(TestContext.Current.CancellationToken); .ToListAsync(TestContext.Current.CancellationToken);
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId)); Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
} }
[Fact]
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
{
var user = TestEntities.User();
var skipped = TestEntities.Track(user, "Only skipped");
await SeedAsync(
user, skipped,
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
}
} }
} }
@@ -17,17 +17,6 @@ namespace Musify.Application.Tests.Shared
Assert.Equal("The Owner", response.Artist); Assert.Equal("The Owner", response.Artist);
Assert.Equal(owner.Id, response.OwnerUserId); Assert.Equal(owner.Id, response.OwnerUserId);
Assert.Equal(3, response.ListensCount); 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, TrackPictures? pictures = null,
TrackAudio? audio = null, TrackAudio? audio = null,
LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active, LifeCycleStatus lifeCycleStatus = LifeCycleStatus.Active,
IEnumerable<Genre>? tags = null, IEnumerable<Genre>? tags = null)
bool isExplicit = false)
{ {
var track = new Track var track = new Track
{ {
@@ -65,7 +64,6 @@ namespace Musify.Application.Tests.TestSupport
NormalizedTitle = title.ToUpperInvariant(), NormalizedTitle = title.ToUpperInvariant(),
DurationSeconds = durationSeconds, DurationSeconds = durationSeconds,
LifeCycleStatus = lifeCycleStatus, LifeCycleStatus = lifeCycleStatus,
IsExplicit = isExplicit,
OwnerUserId = owner.Id, OwnerUserId = owner.Id,
Owner = owner, Owner = owner,
Pictures = pictures ?? ProcessedPictures(), Pictures = pictures ?? ProcessedPictures(),
@@ -58,26 +58,6 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ProcessingStatus.Pending, stored.Audio.TranscodeStatus); 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] [Fact]
public async Task Handle_NoTags_ReturnsValidationError() public async Task Handle_NoTags_ReturnsValidationError()
{ {
@@ -1,32 +0,0 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
{
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
[Fact]
public async Task Handle_DeletesOnlyOldUncountedListens()
{
var owner = TestEntities.User();
var track = TestEntities.Track(owner);
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
Assert.Equal(1, deleted);
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
Assert.DoesNotContain(oldUncounted.Id, remaining);
Assert.Contains(oldCounted.Id, remaining);
Assert.Contains(recentUncounted.Id, remaining);
}
}
}
@@ -35,21 +35,5 @@ namespace Musify.Application.Tests.Tracks
Assert.Empty(result); Assert.Empty(result);
} }
[Fact]
public async Task Handle_UncountedListens_AreIgnored()
{
var owner = TestEntities.User();
var counted = TestEntities.Track(owner, "Counted");
var skipped = TestEntities.Track(owner, "Skipped");
await SeedAsync(
owner, counted, skipped,
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["Counted"], result.Select(track => track.Title));
}
} }
} }
@@ -33,21 +33,5 @@ namespace Musify.Application.Tests.Tracks
Assert.Equal(ErrorType.NotFound, result.FirstError.Type); 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);
}
} }
} }
@@ -1,6 +1,5 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks; using Musify.Application.Tracks;
using Musify.Domain.ValueObjects;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Tracks namespace Musify.Application.Tests.Tracks
@@ -44,23 +43,5 @@ namespace Musify.Application.Tests.Tracks
var item = Assert.Single(result.Value.Items); var item = Assert.Single(result.Value.Items);
Assert.Equal("Bohemian Rhapsody", item.Track.Title); Assert.Equal("Bohemian Rhapsody", item.Track.Title);
} }
[Fact]
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
{
var owner = TestEntities.User();
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
await SeedAsync(
owner, rock, jazz,
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
Assert.False(result.IsError);
var item = Assert.Single(result.Value.Items);
Assert.Equal("Loud", item.Track.Title);
}
} }
} }
@@ -0,0 +1,106 @@
using Microsoft.EntityFrameworkCore;
using Musify.Application.Albums;
using Musify.Application.Mixes;
using Musify.Application.Tests.TestSupport;
using Musify.Application.Tracks;
using Musify.Domain.Entities;
using Xunit;
namespace Musify.Application.Tests.Tracks
{
public sealed class UncountedListensTests : HandlerTestBase
{
[Fact]
public async Task ListeningHistory_IgnoresUncountedListens()
{
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 new GetListeningHistoryQueryHandler(Database)
.Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
Assert.Equal(["Counted"], result.Select(track => track.Title));
}
[Fact]
public async Task RecentlyListenedAlbums_IgnoresUncountedListens()
{
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 new GetRecentlyListenedAlbumsQueryHandler(Database)
.Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
}
[Fact]
public async Task Mixes_TreatTracksWithOnlyUncountedListensAsUnheard()
{
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));
var handler = new GenerateMixesForUserCommandHandler(
Database, TestConfigurations.Mix(), NoOpLogger<GenerateMixesForUserCommandHandler>());
await handler.Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
}
[Fact]
public async Task TrackListensCount_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 new GetTrackByIdQueryHandler(Database)
.Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
Assert.Equal(2, result.Value.ListensCount);
}
[Fact]
public async Task Cleanup_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 new DeleteStaleUncountedListensCommandHandler(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>())
.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);
}
}
}
@@ -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); 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] [Fact]
public async Task Handle_UserMissing_ReturnsNotFound() public async Task Handle_UserMissing_ReturnsNotFound()
{ {
@@ -1,6 +1,5 @@
using Musify.Application.Tests.TestSupport; using Musify.Application.Tests.TestSupport;
using Musify.Application.Users; using Musify.Application.Users;
using Musify.Domain.Entities;
using Xunit; using Xunit;
namespace Musify.Application.Tests.Users namespace Musify.Application.Tests.Users
@@ -31,18 +30,5 @@ namespace Musify.Application.Tests.Users
var user = Assert.Single(result.Value.Items); var user = Assert.Single(result.Value.Items);
Assert.Equal(1, user.Id); 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));
}
} }
} }
@@ -1,4 +1,3 @@
using Microsoft.EntityFrameworkCore;
using Musify.Domain.Entities; using Musify.Domain.Entities;
using Musify.Infrastructure.Tests.TestSupport; using Musify.Infrastructure.Tests.TestSupport;
using Xunit; using Xunit;
@@ -44,11 +43,6 @@ namespace Musify.Infrastructure.Tests.Persistence
Assert.Equal(originalCreatedAt, user.CreatedAt); Assert.Equal(originalCreatedAt, user.CreatedAt);
Assert.True(user.UpdatedAt > originalCreatedAt, Assert.True(user.UpdatedAt > originalCreatedAt,
$"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}"); $"original-created-at={originalCreatedAt:O}, final-updated-at={user.UpdatedAt:O}");
await using var reader = fixture.CreateDatabase();
var stored = await reader.Users.SingleAsync(u => u.Id == user.Id, TestContext.Current.CancellationToken);
Assert.True(stored.UpdatedAt > stored.CreatedAt,
$"stored-created-at={stored.CreatedAt:O}, stored-updated-at={stored.UpdatedAt:O}");
} }
} }
} }
+15 -21
View File
@@ -5,10 +5,8 @@
# #
# Requires ../../../Infrastructure running first (Postgres, Zitadel, # Requires ../../../Infrastructure running first (Postgres, Zitadel,
# SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md. # SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md.
# Musify has its own credentials on every shared service (role, vhost, S3 # Everything marked "must match Infrastructure's .env" below has to be the
# identity, Zitadel service account), provisioned by Infrastructure. Every # same value as the one in that file.
# value marked "= Infrastructure's MUSIFY_*" below has to be the same as that
# setting in Infrastructure's .env.
DOTNET_ENVIRONMENT=Development DOTNET_ENVIRONMENT=Development
@@ -23,18 +21,17 @@ PUBLIC_STREAM_URL=http://localhost:8081
PUBLIC_AUTH_URL=http://host.docker.internal:8080 PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# Musify's own role, owner of musify_db only. # POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.
POSTGRES_HOST=host.docker.internal POSTGRES_HOST=host.docker.internal
POSTGRES_USER=musify POSTGRES_USER=postgres
# = Infrastructure's MUSIFY_DB_PASSWORD POSTGRES_PASSWORD=postgres
POSTGRES_PASSWORD=musify
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
# Folder with Musify's own Zitadel PAT (a service account that owns the # Path to the admin service-account PAT Infrastructure's Zitadel writes on
# Musify project only), written by Infrastructure's `provision`. Adjust if # init. Adjust if your Infrastructure checkout lives somewhere else relative
# your Infrastructure checkout lives somewhere else relative to this one. # to this one.
ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
# Allows the OIDC apps this project creates to use plain-http redirect URIs # Allows the OIDC apps this project creates to use plain-http redirect URIs
# (dev only). # (dev only).
ZITADEL_DEV_MODE=true ZITADEL_DEV_MODE=true
@@ -46,19 +43,16 @@ WEB_CLIENT_ID=
WEB_CLIENT_SECRET= WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
# Musify's own user, only on its own vhost. # Must match Infrastructure's .env.
RABBITMQ_HOST=host.docker.internal RABBITMQ_HOST=host.docker.internal
RABBITMQ_VHOST=musify RABBITMQ_DEFAULT_USER=guest
RABBITMQ_USER=musify RABBITMQ_DEFAULT_PASS=guest
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=musify
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
# Musify's own identity, limited to S3_BUCKET. # S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env.
SEAWEEDFS_HOST=host.docker.internal SEAWEEDFS_HOST=host.docker.internal
# = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY S3_ACCESS_KEY=admin_access_key
S3_ACCESS_KEY=musify_access_key S3_SECRET_KEY=admin_secret_key
S3_SECRET_KEY=musify_secret_key
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
S3_PUBLIC_URL=http://host.docker.internal:8333 S3_PUBLIC_URL=http://host.docker.internal:8333
S3_INTERNAL_URL=http://host.docker.internal:8333 S3_INTERNAL_URL=http://host.docker.internal:8333
+16 -23
View File
@@ -1,14 +1,11 @@
# Production configuration for deploy/compose.yml + compose.prod.yml. # Production configuration for deploy/compose.yml + compose.prod.yml.
# Copy to deploy/.env.prod, then: # Copy to deploy/.env.prod, then:
# #
# 1. replace every `*.example.com` with your actual domains (each service # 1. replace every `example.com` with your domain,
# below has its own, independent domain — they don't need to share a
# root, e.g. for DNS providers that don't allow multi-level
# subdomains),
# 2. replace every CHANGE_ME with a strong random value # 2. replace every CHANGE_ME with a strong random value
# (`openssl rand -hex 24`), # (`openssl rand -hex 24`),
# 3. point each domain below at the server and add its vhost to the shared # 3. point these DNS records at the server, all proxied/TLS-terminated the
# edge (Infrastructure's edge/conf.d). # same way: example.com, www, api, auth, stream, s3.
# #
# Requires ../../../Infrastructure running first, on the same Docker host, # Requires ../../../Infrastructure running first, on the same Docker host,
# with its own `infra-net` network already created. See # with its own `infra-net` network already created. See
@@ -17,27 +14,24 @@
DOTNET_ENVIRONMENT=Production DOTNET_ENVIRONMENT=Production
# --- Domains ------------------------------------------------------------- # --- Domain ------------------------------------------------------------------
# Public URLs. They must match the vhosts of the shared edge nginx # ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it.
# (Infrastructure's edge/conf.d), which is what serves them; the domains are ROOT_DOMAIN=example.com
# independent, they don't need to share a root. PUBLIC_WEB_URL=https://example.com
PUBLIC_WEB_URL=https://musify.example.com PUBLIC_API_URL=https://api.example.com
PUBLIC_API_URL=https://musifyapi.example.com PUBLIC_STREAM_URL=https://stream.example.com
PUBLIC_STREAM_URL=https://musifystream.example.com
PUBLIC_AUTH_URL=https://auth.example.com PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com S3_PUBLIC_URL=https://s3.example.com
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) --- # --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# Musify's own role, owner of musify_db only. # POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod.
POSTGRES_HOST=postgres POSTGRES_HOST=postgres
POSTGRES_USER=musify POSTGRES_USER=postgres
# = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=CHANGE_ME POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=musify_db POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) ----------------------------------------- # --- Zitadel (OIDC, shared instance) -----------------------------------------
# Folder with Musify's own Zitadel PAT, written by Infrastructure's `provision`. ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
ZITADEL_DEV_MODE=false ZITADEL_DEV_MODE=false
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills # OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -48,14 +42,11 @@ WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) -------------------------------- # --- RabbitMQ (shared instance, MassTransit) --------------------------------
RABBITMQ_HOST=rabbitmq RABBITMQ_HOST=rabbitmq
RABBITMQ_VHOST=musify RABBITMQ_DEFAULT_USER=musify
RABBITMQ_USER=musify RABBITMQ_DEFAULT_PASS=CHANGE_ME
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=CHANGE_ME
# --- SeaweedFS (S3, shared instance, Musify's own bucket) -------------------- # --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
SEAWEEDFS_HOST=seaweedfs SEAWEEDFS_HOST=seaweedfs
# = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_ACCESS_KEY=CHANGE_ME S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=webapi-storage S3_BUCKET=webapi-storage
@@ -72,3 +63,5 @@ AUTH_ALLOW_INSECURE_HTTP=false
# --- Misc -------------------------------------------------------------------- # --- Misc --------------------------------------------------------------------
AUTH_REQUIRE_HTTPS=true AUTH_REQUIRE_HTTPS=true
# Largest request body nginx accepts (audio uploads go through the web player).
MAX_UPLOAD_SIZE=1024m
+36 -64
View File
@@ -13,7 +13,7 @@ wherever you actually cloned it.
|---|---| |---|---|
| `compose.yml` | Musify's own services, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. | | `compose.yml` | Musify's own services, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. |
| `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. | | `compose.dev.yml` | Development overlay: publishes Musify's ports to the host. |
| `compose.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. | | `compose.prod.yml` | Production overlay: adds the nginx edge and joins Infrastructure's `infra-net` network. |
| `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. | | `.env` / `.env.prod` | The values compose interpolates. Not versioned; templates in `.env.example` / `.env.prod.example`. |
The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind
@@ -68,17 +68,13 @@ dotnet run --project backend/Hosts/Musify.Worker # background
npm --prefix web-player run dev # :5173 npm --prefix web-player run dev # :5173
``` ```
When running the apps from the IDE, neither the .NET hosts nor `web-player` When running the apps from the IDE, `Musify.Api` reads `Authentication:*`
look at `deploy/.env`. The hosts' `appsettings.Development.json` already from user-secrets and `web-player` reads its own `web-player/.env`, neither
point at Infrastructure's dev stack with Musify's own dev credentials (the looks at `deploy/.env`. Copy the four values `zitadel-init` wrote
defaults of its `MUSIFY_*` settings) and at the shared Zitadel. Only the (`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`, `NATIVE_CLIENT_ID`)
OIDC client ids are per-machine: copy the values `zitadel-init` wrote once, into `dotnet user-secrets set Authentication:ClientId <value> --project
`AUTH_CLIENT_ID` into both `Authentication:ClientId` and backend/Hosts/Musify.Api` and into `web-player/.env` once. They stay valid
`Authentication:AudienceAddress` (`dotnet user-secrets set <key> <value> until Infrastructure's Zitadel volume is wiped.
--project backend/Hosts/Musify.Api`), `WEB_CLIENT_ID` and
`WEB_CLIENT_SECRET` into `web-player/.env`, and `NATIVE_CLIENT_ID` into
`mobile/gradle.properties`. They stay valid until Infrastructure's Zitadel
volume is wiped.
### Ports ### Ports
@@ -117,26 +113,26 @@ SeaweedFS is done from the `Infrastructure` repo instead.
## Production ## Production
Musify doesn't run its own nginx: the shared edge in Infrastructure On a server with a domain:
(`edge/`) owns host ports 80/443, terminates TLS for every project's domains
and proxies to Musify's containers over `infra-net`. On a server with
domains:
1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled 1. Bring up the `Infrastructure` repo first, with its own `.env.prod` filled
in. It creates the `infra-net` network this stack joins. in. It creates the `infra-net` network this stack joins.
2. Bring up its `edge/` (see Infrastructure's README) and add Musify's 2. From this repository's root:
vhosts and certificate there (`edge/conf.d/musify.conf`). The five
public hostnames (web, api, stream, auth, s3) are independent: they
don't need to share a root domain.
3. From this repository's root:
```sh ```sh
cp deploy/.env.prod.example deploy/.env.prod cp deploy/.env.prod.example deploy/.env.prod
``` ```
Replace every `*.example.com` with your domains (they must match the Fill it in:
edge's vhosts) and every `CHANGE_ME` with a random value
(`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`. 1. Replace every `example.com` with your domain and every `CHANGE_ME` with a
random value (`openssl rand -hex 24`). `chmod 600 deploy/.env.prod`.
2. Drop **`deploy/nginx/certs/origin.pem` and `origin.key`**, the origin
certificate nginx serves. The stack assumes Cloudflare in *Full (strict)*
mode in front of it, so a Cloudflare Origin Certificate is enough; any
certificate valid for the five hostnames works.
3. Point `example.com`, `www`, `api`, `auth`, `stream` and `s3` at the server,
proxied and TLS-terminated the same way.
Then: Then:
@@ -145,45 +141,14 @@ docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compo
docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
``` ```
The first command starts Musify's bootstrap jobs (`zitadel-init` and The first command starts Musify's bootstrap jobs and the nginx edge; the
`seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.` second builds and starts the applications, already wired to the OIDC client
URLs, so the edge has to be serving them by then); the second builds and ids the first one produced. Both are idempotent, so running them again
starts the applications, already wired to the OIDC client ids the first one deploys a new version (`... --profile apps up -d --build` alone is enough for
produced. Both are idempotent, so running them again deploys a new version an update once the stack already exists).
(`... --profile apps up -d --build` alone is enough for an update once the
stack already exists).
Nothing in this stack publishes a port; everything is reachable only on the Only nginx publishes ports (80/443). Every other Musify service, and all of
internal `infra-net` network, through the edge. Infrastructure's, is reachable only on the internal `infra-net` network.
### Continuous deployment
`.gitea/workflows/ci-cd.yml` runs the backend and web-player checks on every
push/PR, and on a successful push to `master` deploys automatically. The
deploy job runs on `gitea-runner`, the self-hosted Gitea Actions runner on
the production host (`~/gitea/docker-compose.yml`), configured by
`~/gitea/runner-data/config.yaml` (`CONFIG_FILE`): job containers are pinned
to the `gitea_gitea` network so `actions/checkout` can resolve `gitea`,
`ubuntu-latest` is `node:22` (JS actions need Node inside the job
container), and a `production:host` label runs steps natively.
Native mode means the label's steps execute inside the runner container
itself rather than a fresh one. That container has `/var/run/docker.sock`
mounted, so `docker`/`docker compose` there still control the host's real
Docker daemon (sibling containers, "Docker outside of Docker"). Its compose
service also bind-mounts `/home/ubuntu/deploys` into itself at the *same*
path, which matters because compose's own relative bind mounts (`./keys`)
are resolved by the host daemon against that literal path; mounting it
anywhere else would silently bind the wrong (empty) directory.
`DEPLOY_PATH` (hardcoded in the workflow as `/home/ubuntu/deploys/musify`)
is a persistent clone on that host, reused instead of a fresh checkout each
run since `deploy/.env.prod` is untracked and only lives there.
`/home/ubuntu/deploys/SharedServices` is the sibling clone of the
`Infrastructure` repo (shared services + `edge/`), already running; its
`.env.prod` shares the Postgres/RabbitMQ/S3 credentials with Musify's own
`deploy/.env.prod` by design (see that repo's README). The edge is deployed
from that repo, not from this pipeline.
--- ---
@@ -194,9 +159,13 @@ deploy/
├─ compose.yml # Musify's own services: wait-for-postgres, keys-init, ├─ compose.yml # Musify's own services: wait-for-postgres, keys-init,
│ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player │ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player
├─ compose.dev.yml # dev overlay: host ports ├─ compose.dev.yml # dev overlay: host ports
├─ compose.prod.yml # prod overlay: joins infra-net ├─ compose.prod.yml # prod overlay: nginx edge, joins infra-net
├─ .env.example / .env.prod.example ├─ .env.example / .env.prod.example
└─ keys/ # RS256 stream-ticket key pair (generated) ├─ keys/ # RS256 stream-ticket key pair (generated)
└─ nginx/
├─ templates/default.conf.template # vhosts, rendered with the domain by envsubst
├─ snippets/proxy.conf # proxy headers shared by every vhost
└─ certs/ # origin.pem + origin.key (not versioned)
``` ```
The application images are built from `backend/Musify.*.Dockerfile` (build The application images are built from `backend/Musify.*.Dockerfile` (build
@@ -211,6 +180,9 @@ and `web-player/Dockerfile`.
the comments in `.env.example`). the comments in `.env.example`).
- **Zitadel** runs `start-from-init`, which is idempotent: the same command - **Zitadel** runs `start-from-init`, which is idempotent: the same command
works on an empty volume and on an existing one. works on an empty volume and on an existing one.
- **`AppSettings*.json` are PascalCase** in `Musify.Api` and `Musify.Worker`;
on case-sensitive Linux the host looks for `appsettings*.json`, so their
Dockerfiles create lowercase copies at publish time.
- **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they - **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they
reuse the existing project/apps/bucket by name, and `migrate` is a normal reuse the existing project/apps/bucket by name, and `migrate` is a normal
`dotnet ef database update`, a no-op once the schema is current. `dotnet ef database update`, a no-op once the schema is current.
+34 -5
View File
@@ -1,10 +1,11 @@
name: musify-prod name: musify-prod
# Production overlay: joins Infrastructure's shared `infra-net` network so # Production overlay: adds the nginx edge and joins Infrastructure's shared
# this project's containers can reach Postgres, Zitadel, SeaweedFS, RabbitMQ # `infra-net` network so this project's containers can reach Postgres,
# and Jaeger by container name, and so the shared edge nginx (Infrastructure's # Zitadel, SeaweedFS, RabbitMQ and Jaeger by container name. None of them
# `edge/`, which owns host ports 80/443 and terminates TLS for every domain) # run as part of this compose project anymore (see
# can reach api, gateway and web-player. Nothing here publishes a port. # ../../../Infrastructure/README.md, which must already be up). Nothing but
# nginx publishes a port; every other service is reachable only on infra-net.
# #
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
# docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build # docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml --profile apps up -d --build
@@ -19,3 +20,31 @@ services:
zitadel-init: zitadel-init:
volumes: volumes:
- ./.env.prod:/workspace/.env - ./.env.prod:/workspace/.env
# Every vhost proxies through `set $upstream ...; proxy_pass $upstream;`
# with the embedded Docker DNS resolver (see nginx/templates), so nginx
# does not need api/gateway/web-player to exist at its own startup, only
# at request time. That is what lets it start in the FIRST command
# (profile-gated services aren't part of that invocation yet) and still be
# reachable at the auth domain for zitadel-init to provision through.
nginx:
image: nginx:1.27-alpine
container_name: musify-nginx
environment:
# The entrypoint renders /etc/nginx/templates/*.template with envsubst.
# The filter keeps it from eating nginx's own $host / $remote_addr.
MUSIFY_ROOT_DOMAIN: ${ROOT_DOMAIN}
MUSIFY_MAX_UPLOAD_SIZE: ${MAX_UPLOAD_SIZE:-1024m}
NGINX_ENVSUBST_FILTER: ^MUSIFY_
ports:
- "80:80"
- "443:443"
volumes:
- ./nginx/templates:/etc/nginx/templates:ro
- ./nginx/snippets:/etc/nginx/snippets:ro
- ./nginx/certs:/etc/nginx/certs:ro
- nginx_cache:/var/cache/nginx
restart: unless-stopped
volumes:
nginx_cache:
+34 -47
View File
@@ -19,17 +19,16 @@
# file, so the apps start with real values. See deploy/README.md. # file, so the apps start with real values. See deploy/README.md.
# Backend settings that are identical for the API and the Worker. ASP.NET Core # Backend settings that are identical for the API and the Worker. ASP.NET Core
# maps `__` to `:`, so these override the matching appsettings.json sections. # maps `__` to `:`, so these override the matching AppSettings.json sections.
# POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments # POSTGRES_HOST / RABBITMQ_HOST / JAEGER_HOST differ between environments
# (host.docker.internal in dev, the container name over infra-net in prod, # (host.docker.internal in dev, the container name over infra-net in prod,
# see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL # see .env.example / .env.prod.example), same as PUBLIC_AUTH_URL/S3_PUBLIC_URL
# already do. # already do.
x-backend-env: &backend-env x-backend-env: &backend-env
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}" Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
# Musify's own vhost and user on the shared broker (the path is the vhost). MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672"
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672/${RABBITMQ_VHOST}" MassTransit__Username: ${RABBITMQ_DEFAULT_USER}
MassTransit__Username: ${RABBITMQ_USER} MassTransit__Password: ${RABBITMQ_DEFAULT_PASS}
MassTransit__Password: ${RABBITMQ_PASSWORD}
# The public S3 URL, not the internal one: the same address is used to sign # The public S3 URL, not the internal one: the same address is used to sign
# the presigned upload/download URLs the browser has to be able to reach. # the presigned upload/download URLs the browser has to be able to reach.
InfrastructureStorage__Address: ${S3_PUBLIC_URL} InfrastructureStorage__Address: ${S3_PUBLIC_URL}
@@ -136,7 +135,7 @@ services:
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL} PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false} ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
volumes: volumes:
- ${ZITADEL_PAT_DIR}:/output:ro - ${ZITADEL_ADMIN_PAT_DIR}:/output:ro
entrypoint: ["/bin/sh", "-c"] entrypoint: ["/bin/sh", "-c"]
command: command:
- | - |
@@ -164,15 +163,13 @@ services:
fi fi
} }
# Musify's own service account, owner of the Musify project only echo "==> waiting for the Zitadel service-account PAT"
# (created by Infrastructure's `provision`, see its README).
echo "==> waiting for Musify's Zitadel PAT"
deadline=$$(( $$(date +%s) + 180 )) deadline=$$(( $$(date +%s) + 180 ))
while [ ! -s /output/zitadel.pat ]; do while [ ! -f /output/admin-sa.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "no zitadel.pat in ZITADEL_PAT_DIR. Is Infrastructure up, and does ZITADEL_PAT_DIR point at its zitadel/.output/musify? Check 'docker logs infra-provision'." >&2; exit 1; } [ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found under /output. Is Infrastructure's Zitadel running? Check 'docker logs infra-zitadel'." >&2; exit 1; }
sleep 3 sleep 3
done done
PAT=$$(tr -d '\r\n' </output/zitadel.pat) PAT=$$(tr -d '\r\n' </output/admin-sa.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1" MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL" echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
@@ -191,13 +188,21 @@ services:
} }
name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; } name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; }
# `latest` images require the standalone Login UI v2 container, which
# this stack does not run; opting out keeps the built-in /ui/login.
curl -fsS -X PUT "$$PUBLIC_AUTH_URL/v2/features/instance" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" \
-d '{"loginV2":{"required":false}}' >/dev/null 2>&1 || echo "warning: could not disable Login UI v2"
echo "==> project" echo "==> project"
PROJECT_NAME=Musify PROJECT_NAME=Musify
# Created by Infrastructure's `provision`: this account can't create
# projects, only manage this one.
PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty') PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty')
[ -n "$$PROJECT_ID" ] || { echo "Zitadel project $$PROJECT_NAME not found. Is musify in Infrastructure's x-projects?" >&2; exit 1; } if [ -z "$$PROJECT_ID" ]; then
echo " found (id $$PROJECT_ID)" PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id')
echo " created (id $$PROJECT_ID)"
else
echo " reused (id $$PROJECT_ID)"
fi
find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; } find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; }
@@ -260,38 +265,27 @@ services:
fi fi
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)" echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
# Loopback (desktop) and a reverse-domain custom scheme (Android/iOS,
# RFC 8252 §7.1) are both valid for a NATIVE app without devMode, so
# dev and prod share the same config. Unlike the other apps, an
# existing one is also updated, so a change here reaches instances
# created before it.
NATIVE_APP_NAME="Musify Native" NATIVE_APP_NAME="Musify Native"
NATIVE_CONFIG='{ NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
"redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"], if [ -n "$$NATIVE_APP_ID" ]; then
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"], NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
echo " reused (clientId $$NATIVE_CLIENT_ID)"
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{
"name": "Musify Native",
"redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"], "responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"], "grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE", "appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE", "authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"devMode": false, "version": "OIDC_VERSION_1_0",
"devMode": true,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT", "accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true, "accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true, "idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true "idTokenUserinfoAssertion": true
}' }' | jq -r '.clientId')
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
if [ -n "$$NATIVE_APP_ID" ]; then
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
# Zitadel answers 400 "No changes" when the config already matches.
RESULT=$$(curl -sS -X PUT "$$MGMT/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID/oidc_config" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$NATIVE_CONFIG")
case "$$RESULT" in
*'"details"'*'"changeDate"'*) echo " reused (clientId $$NATIVE_CLIENT_ID, config updated)" ;;
*COMMAND-1m88i*) echo " reused (clientId $$NATIVE_CLIENT_ID)" ;;
*) echo "could not update the native app config: $$RESULT" >&2; exit 1 ;;
esac
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(echo "$$NATIVE_CONFIG" | jq --arg name "$$NATIVE_APP_NAME" '. + {name: $$name, version: "OIDC_VERSION_1_0"}')" | jq -r '.clientId')
echo " created (clientId $$NATIVE_CLIENT_ID)" echo " created (clientId $$NATIVE_CLIENT_ID)"
fi fi
@@ -387,10 +381,6 @@ services:
environment: environment:
NODE_ENV: production NODE_ENV: production
PORT: 3000 PORT: 3000
# adapter-node rejects request bodies over 512K by default; audio
# uploads are posted to /upload. Keep in line with the edge's
# client_max_body_size.
BODY_SIZE_LIMIT: 1G
ORIGIN: ${PUBLIC_WEB_URL} ORIGIN: ${PUBLIC_WEB_URL}
ZITADEL_ISSUER: ${PUBLIC_AUTH_URL} ZITADEL_ISSUER: ${PUBLIC_AUTH_URL}
ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID} ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID}
@@ -399,10 +389,7 @@ services:
AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/" AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/"
AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP} AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP}
SESSION_SECRET: ${SESSION_SECRET} SESSION_SECRET: ${SESSION_SECRET}
# The container name, not the `api` service name: in prod every project API_BASE_URL: http://api:5111
# shares infra-net, where a generic name can resolve to another
# project's container.
API_BASE_URL: http://musify-api:5111
# Presigned PUTs are signed for the public S3 host; the container sends # Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge. # them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL} S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
+9
View File
@@ -0,0 +1,9 @@
# Shared proxy settings for every Musify vhost.
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_set_header X-Forwarded-Host $host;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
@@ -0,0 +1,119 @@
# Musify edge. The nginx entrypoint renders this with envsubst into
# /etc/nginx/conf.d/default.conf (replacing the image's stock file) using the
# MUSIFY_* variables set in compose.prod.yml.
#
# Cloudflare (Full strict) terminates the public edge; this box only needs to
# serve the Cloudflare Origin Certificate in nginx/certs/. Each vhost proxies to
# a compose service by name through the embedded Docker DNS resolver, so nginx
# re-resolves it instead of caching the IP of a container that may be recreated.
resolver 127.0.0.11 valid=10s;
map $http_upgrade $connection_upgrade {
default upgrade;
'' close;
}
server {
listen 80;
listen [::]:80;
server_name ${MUSIFY_ROOT_DOMAIN} www.${MUSIFY_ROOT_DOMAIN}
api.${MUSIFY_ROOT_DOMAIN} auth.${MUSIFY_ROOT_DOMAIN}
stream.${MUSIFY_ROOT_DOMAIN} s3.${MUSIFY_ROOT_DOMAIN};
return 301 https://$host$request_uri;
}
# Web player (SvelteKit, adapter-node). Uploads are posted to its own form
# actions, so it needs the large body size too.
server {
listen 443 ssl;
http2 on;
server_name ${MUSIFY_ROOT_DOMAIN};
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://web-player:3000;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
server {
listen 443 ssl;
http2 on;
server_name www.${MUSIFY_ROOT_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
return 301 https://${MUSIFY_ROOT_DOMAIN}$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name api.${MUSIFY_ROOT_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://api:5111;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
}
}
# Audio bytes. Buffering off keeps range requests (seeking) responsive.
server {
listen 443 ssl;
http2 on;
server_name stream.${MUSIFY_ROOT_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://gateway:8081;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
proxy_buffering off;
}
}
server {
listen 443 ssl;
http2 on;
server_name auth.${MUSIFY_ROOT_DOMAIN};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://zitadel:8080;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
grpc_set_header Host $host;
}
}
# Presigned S3 PUT/GET straight from the browser.
server {
listen 443 ssl;
http2 on;
server_name s3.${MUSIFY_ROOT_DOMAIN};
client_max_body_size ${MUSIFY_MAX_UPLOAD_SIZE};
ssl_certificate /etc/nginx/certs/origin.pem;
ssl_certificate_key /etc/nginx/certs/origin.key;
location / {
set $upstream http://seaweedfs:8333;
proxy_pass $upstream;
include /etc/nginx/snippets/proxy.conf;
proxy_request_buffering off;
}
}
+3 -3
View File
@@ -134,10 +134,10 @@ omits the `<Error>` list when there are no errors, unlike AWS S3), and
(load → modify → save) because it ran before EF had detected the pending (load → modify → save) because it ran before EF had detected the pending
changes. changes.
## Config (appsettings) ## Config (AppSettings)
Convention: `appsettings.json` is the template, with empty or neutral Convention: `AppSettings.json` is the template, with empty or neutral
values; `appsettings.Development.json` has the real dev values; in Docker values; `AppSettings.Development.json` has the real dev values; in Docker
everything is overridden with environment variables (`Section__Key`) from everything is overridden with environment variables (`Section__Key`) from
`deploy/compose.yml`. To run `Musify.Api` from the IDE, the `Authentication` `deploy/compose.yml`. To run `Musify.Api` from the IDE, the `Authentication`
values go into user-secrets (not the repo). Copy them from `deploy/.env` values go into user-secrets (not the repo). Copy them from `deploy/.env`
+1 -1
View File
@@ -36,7 +36,7 @@ Grouped by area:
## What the Worker needs configured ## What the Worker needs configured
The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `appsettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`. The Worker registers: storage (S3), transcoder (ffmpeg), pictures, the DB, the MassTransit consumers, and the upload-intent jobs. Its `AppSettings` needs the right sections: `Bucket` (= `webapi-storage`, same as the API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (temp directory), `InfrastructureStorage`, `Database`.
## Background jobs ## Background jobs
+3 -3
View File
@@ -54,7 +54,7 @@ al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`z
Redirect URIs registrados: Redirect URIs registrados:
- Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo). - Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo).
- Android / iOS: `cat.ikerdemo.musify:/callback` (custom scheme con dominio invertido, RFC 8252 §7.1). - Android / iOS: `musify://callback` (custom scheme).
`mobile/gradle.properties` controla a qué backend apunta Android/Desktop: `mobile/gradle.properties` controla a qué backend apunta Android/Desktop:
@@ -125,13 +125,13 @@ Kotlin/Native (`iosMain/`) sigue las APIs estándar de interop (`AVFoundation`,
1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío 1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío
e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`). e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`).
2. Registra el scheme `cat.ikerdemo.musify` en `Info.plist`: 2. Registra el scheme `musify` en `Info.plist`:
```xml ```xml
<key>CFBundleURLTypes</key> <key>CFBundleURLTypes</key>
<array> <array>
<dict> <dict>
<key>CFBundleURLSchemes</key> <key>CFBundleURLSchemes</key>
<array><string>cat.ikerdemo.musify</string></array> <array><string>musify</string></array>
</dict> </dict>
</array> </array>
``` ```
@@ -18,9 +18,7 @@
</intent-filter> </intent-filter>
</activity> </activity>
<!-- Catches the `cat.ikerdemo.musify:/callback` redirect Zitadel sends after <!-- Catches the `musify://callback` redirect Zitadel sends after login/register.
login/register. The URI has no host, so the filter matches on the scheme
alone.
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. --> See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
<activity <activity
android:name=".auth.AuthCallbackActivity" android:name=".auth.AuthCallbackActivity"
@@ -31,7 +29,7 @@
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="cat.ikerdemo.musify" /> <data android:scheme="musify" android:host="callback" />
</intent-filter> </intent-filter>
</activity> </activity>
</application> </application>
@@ -9,5 +9,5 @@ actual object AppConfig {
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and // Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
// the redirect URI registered for the "Musify Native" app in Zitadel. // the redirect URI registered for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback" actual val nativeRedirectUri: String = "musify://callback"
} }
@@ -5,7 +5,7 @@ import android.content.Intent
import android.os.Bundle import android.os.Bundle
/** /**
* Declared in AndroidManifest.xml with an intent-filter for `cat.ikerdemo.musify:/callback`. * Declared in AndroidManifest.xml with an intent-filter for `musify://callback`.
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register * Chrome Custom Tabs redirects here once Zitadel finishes the login/register
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes. * flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
*/ */
@@ -9,8 +9,7 @@ package com.musify.app
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify * `nativeRedirectUri` is the OAuth redirect registered for the "Musify
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed * Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the * loopback port on Desktop (RFC 8252 native-app loopback flow), and the
* `cat.ikerdemo.musify:/callback` reverse-domain custom scheme on Android/iOS * `musify://callback` custom scheme on Android/iOS.
* (RFC 8252 §7.1: based on a domain we own, so no other app plausibly claims it).
*/ */
expect object AppConfig { expect object AppConfig {
val apiBaseUrl: String val apiBaseUrl: String
@@ -12,7 +12,7 @@ package com.musify.app.auth
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in * - Desktop: a one-shot loopback HTTP server on the fixed port registered in
* Zitadel (RFC 8252 §7.3), opened via the OS default browser. * Zitadel (RFC 8252 §7.3), opened via the OS default browser.
* - Android: Chrome Custom Tabs; the redirect is caught by * - Android: Chrome Custom Tabs; the redirect is caught by
* [AuthCallbackActivity] via the `cat.ikerdemo.musify:/callback` intent filter. * [AuthCallbackActivity] via the `musify://callback` intent filter.
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip. * - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
*/ */
expect class BrowserAuthLauncher() { expect class BrowserAuthLauncher() {
@@ -12,5 +12,5 @@ actual object AppConfig {
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered // Matches CFBundleURLTypes in Info.plist and the redirect URI registered
// for the "Musify Native" app in Zitadel. // for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback" actual val nativeRedirectUri: String = "musify://callback"
} }
@@ -23,15 +23,14 @@ private class PresentationContextProvider : NSObject(), ASWebAuthenticationPrese
/** /**
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system * `ASWebAuthenticationSession` owns the whole round trip: it opens a system
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its * browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
* "Regístrate" link) and intercepts the `cat.ikerdemo.musify:` redirect itself, no app * "Regístrate" link) and intercepts the `musify://` redirect itself, no app
* delegate / `onOpenURL` wiring required. * delegate / `onOpenURL` wiring required.
*/ */
@OptIn(ExperimentalForeignApi::class) @OptIn(ExperimentalForeignApi::class)
actual class BrowserAuthLauncher actual constructor() { actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String = actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
suspendCancellableCoroutine { continuation -> suspendCancellableCoroutine { continuation ->
// `scheme:/path` (RFC 8252 private-use scheme): everything before the first ':'. val scheme = redirectUri.substringBefore("://")
val scheme = redirectUri.substringBefore(":")
val contextProvider = PresentationContextProvider() val contextProvider = PresentationContextProvider()
val session = ASWebAuthenticationSession( val session = ASWebAuthenticationSession(
-2
View File
@@ -9,5 +9,3 @@ package
package-lock.json package-lock.json
src/lib/api/schema.d.ts src/lib/api/schema.d.ts
static static
.agents
skills-lock.json
-1
View File
@@ -3,7 +3,6 @@
"singleQuote": true, "singleQuote": true,
"trailingComma": "none", "trailingComma": "none",
"printWidth": 100, "printWidth": 100,
"endOfLine": "auto",
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"], "plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
"tailwindStylesheet": "./src/routes/layout.css", "tailwindStylesheet": "./src/routes/layout.css",
"overrides": [ "overrides": [
+30 -57
View File
@@ -1,83 +1,56 @@
# Musify: web-player # Musify — web-player
Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill. Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**.
## Stack ## Stack
SvelteKit 2 (`adapter-node`), Svelte 5 **runes**, Tailwind 4, TypeScript strict, Zitadel OIDC auth (`openid-client` + `jose`). SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`).
There's no `svelte.config.js` (config is inline in `vite.config.ts`) and no `tailwind.config.js` (config lives in `src/routes/layout.css`). No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`).
## Commands ## Comandos
```sh ```sh
npm run dev # dev server (port 5173) npm run dev # servidor de desarrollo (puerto 5173)
npm run build npm run build
npm run check # svelte-check, must exit 0 errors before closing a change npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
npm run format # prettier --write . npm run format # prettier --write .
npm run lint # prettier --check . && eslint . && npm run lint:tokens npm run lint # prettier --check . && eslint . && npm run lint:tokens
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below) npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo)
``` ```
## Rules ## Reglas
- **No comments** in code unless essential. - **Sin comentarios** en el código salvo que sean imprescindibles.
- `npm run lint` and `npm run check` must exit clean before closing a change. - `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio.
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed. - **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave.
- **Server only code goes under `$lib/server/`**, and it's never imported from client code. - **Server-only bajo `$lib/server/`**; nunca importado desde cliente.
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop. - Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`.
- Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-sveltekit-reload`. - Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
- Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to `$lib/components/ui/`. - Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
## Folder structure ## Estilos
- `src/lib/state/` holds reactive rune based state (`*.svelte.ts` files that don't have a more specific home). Player state stays in `$lib/player/`, theme mode in `$lib/theme/`. El sistema de diseño vive en `src/routes/layout.css` (tokens `@theme` + recetas `@utility`) y `src/lib/theme/` (`theme.css` para neutros estáticos, `tokens.ts` para los tokens dinámicos por hue de usuario). `scripts/check-tokens.mjs` (`npm run lint:tokens`, parte de `lint`) falla si `src/**/*.svelte` reintroduce lo que el refactor de `docs/frontend-refactor-plan.md` (P0–P14) eliminó — léelo antes de añadir un valor arbitrario o un radio/tamaño "de fábrica".
- `src/lib/data/` holds client side domain helpers: albums, collections, genres, mixes, recentlyPlayed, search.
- `src/lib/utils/` holds generic utilities with no domain knowledge, like `format.ts`.
- `src/lib/components/layout/` holds the app chrome: Sidebar, TopBar, TabsBar, MobileHeader.
- `src/lib/components/player/` holds the player UI: PlayerBar, PlayerDock, Queue, TrackInfo, TransportControls.
- `src/lib/components/ui/` holds the design system components, split into five categories:
- `primitives/` for generic controls with no music domain knowledge (Button, Input, Chip, Slider, and so on).
- `overlay/` for anything that floats above the content (Modal, ContextMenu, AccountMenu, and so on).
- `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`).
## Styles - **Radios:** siempre `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (o `rounded-full` para píldoras/círculos). Nunca `rounded-sm/md/lg/xl/2xl/3xl` ni `rounded` a secas.
- **Tamaños cuadrados:** `size-*` en vez de `h-N w-N` repetido (icon: `size-icon-xs/sm/md/lg/xl`; portadas: `size-cover-xs…hero`).
The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utility` recipes) and in `src/lib/theme/` (`theme.css` for the static neutrals, `tokens.ts` for the dynamic tokens that depend on the user's hue). `scripts/check-tokens.mjs` (run as `npm run lint:tokens`, part of `lint`) fails on a stock radius, an `h-N w-N` pair, fractional quarter spacing, a literal hex/`rgba()`, or an arbitrary `-[…]` value outside its whitelist, so check that script before adding one. - **Tipografía:** recetas `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`; tracking con `tracking-display`/`tracking-eyebrow`, no `tracking-[…]` suelto.
- **Espaciado:** escala de Tailwind en pasos de `.5` (evita cuartos como `.25`/`.75`; solo aceptables si el token semántico lo pide, p. ej. `mb-4.5` en `SectionHeading`).
- **Radii:** always `rounded-tag/thumb/control/art/art-lg/panel/panel-lg` (or `rounded-full` for pills and circles). Never `rounded-sm/md/lg/xl/2xl/3xl` or a bare `rounded`. - **Colores:** siempre `--mf-*`/`text-*`/`bg-*`/`border-*` de `theme.css`/`tokens.ts`. Nunca hex ni `rgba()` literales en un `.svelte` (esos literales solo existen como fuente de verdad dentro de `theme.css`/`tokens.ts`).
- **Square sizes:** use `size-*` instead of repeating `h-N w-N` (icons: `size-icon-xs/sm/md/lg/xl`; covers: `size-cover-xs` through `size-cover-hero`). - **`style="…"` y arbitrarios `-[…]`:** solo la lista blanca de B.4 en `docs/frontend-refactor-plan.md` (stagger `--i`, hue de tile, posición de menú, anchos/alturas dinámicos vía `var(--mf-*)`, `text-[clamp(…)]`, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, paddings en `vh` para centrado vertical). Todo lo demás, a un token.
- **Typography:** use the recipes `text-display-1/2/3/4`, `text-eyebrow`, `text-body`, `text-count`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`. - Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
- **Spacing:** stick to the Tailwind scale in `.5` steps and avoid quarters like `.25`/`.75`, unless the semantic token calls for it (for example `mb-4.5` in `SectionHeading`). - Gaps conocidos y aceptados (documentados en `scripts/check-tokens.mjs`): `strokeWidth={n}` en iconos no está unificado (13 valores distintos repartidos por casi todos los componentes; migrarlo a las recetas `stroke-thin/regular/bold` de forma segura requeriría poder verificar visualmente que `lucide-svelte` respeta `stroke-width` por CSS, así que se dejó fuera de esta pasada).
- **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`.
## 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.
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`.
## Auth ## Auth
`hooks.server.ts` validates the encrypted session cookie (`mf_session`), refreshes the `access_token`, and fills `locals.user` and `locals.accessToken`. To protect a route, check `locals.user` in its `load` and redirect to `/auth?returnTo=…`. When talking to the backend, send `locals.accessToken` as a Bearer token to `API_BASE_URL`. `hooks.server.ts` valida la cookie de sesión cifrada (`mf_session`), refresca el `access_token` y puebla `locals.user` / `locals.accessToken`. Para proteger una ruta, comprueba `locals.user` en su `load` y redirige a `/login?returnTo=…`. Para el backend, envía `locals.accessToken` como Bearer hacia `API_BASE_URL`.
Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`. Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
## Backend API ## Backend API
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action: Cliente tipado con `openapi-fetch` sobre el OpenAPI de `Musify.Api`. En un `load`/action server:
```ts ```ts
import { createApiClient } from '$lib/server/api'; import { createApiClient } from '$lib/server/api';
@@ -85,4 +58,4 @@ const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefine
const { data, error: err } = await api.GET('/tracks'); const { data, error: err } = await api.GET('/tracks');
``` ```
It returns `{ data, error }` (ErrorOr style) and never throws. The types live in `src/lib/api/schema.d.ts` and get regenerated with `npm run gen:api` (this needs the dev API running at `API_BASE_URL`, which only exposes `/openapi/v1.json` in Development). Devuelve `{ data, error }` (estilo ErrorOr), no lanza. Los tipos viven en `src/lib/api/schema.d.ts` y se regeneran con `npm run gen:api` (requiere la API dev levantada en `API_BASE_URL`, que expone `/openapi/v1.json` solo en Development).
+931
View File
@@ -0,0 +1,931 @@
# Plan — Refactor y unificación del frontend (web-player)
> Plan de trabajo para agentes/IA con contexto limitado (~128k). Sustituye a
> `design-system-refactor-plan.md` (cuyo contenido ya está mayormente aplicado).
> Basado en una lectura completa de **todos** los `.svelte`, `.css` y `.ts` de cliente
> de `web-player/src` (2026-09-18, rama `design-prot`).
## Cómo usar este documento
1. **Lee siempre la sección A (Contexto común)** — es corta y todas las partes la asumen.
2. Elige **una parte** (P0…P14). Cada parte indica: objetivo, prerrequisitos, archivos
a leer, tareas, API exacta de lo que se crea, tabla de mapeo y criterios de cierre.
3. Una parte = un commit/PR. No mezcles partes.
4. Si una parte necesita algo de otra (p. ej. un token o un helper) y **no existe
todavía**, créalo tú siguiendo la especificación de la sección B — están descritos con
ruta y API exactas para que cualquier parte pueda crearlos de forma idéntica.
5. Al terminar una parte, marca su casilla en la sección D (Registro).
Orden recomendado (las flechas son dependencias **blandas**, ver punto 4):
```
P0 → P1 → P2
↘ P3 (Artwork/Avatar) ─┐
↘ P4 (Botones) ───────┤
↘ P5 (Helpers TS) ─────┼→ P6 (Menús) → P7 (Filas) → P8 (Tablas) → P9 (Tarjetas)
└→ P10 (Cabeceras) → P11 (Formularios) → P12 (Chrome) → P13 (Páginas) → P14 (Barrido final)
```
---
## A. Contexto común (leer siempre)
### A.1 Stack y reglas del proyecto (de `web-player/CLAUDE.md`)
- SvelteKit 2 + **Svelte 5 runes** (`$props()` con `interface Props`, `$state`,
`$derived`, `$effect`; nada de `export let` ni `$:`; `{#each}` siempre con clave).
- **Tailwind 4**: no hay `tailwind.config.js`; la config vive en `src/routes/layout.css`
(`@theme`, `@utility`). Tokens de color crudos en `src/lib/theme/theme.css` (`--mf-*`),
tokens dinámicos por hue en `src/lib/theme/tokens.ts` (`buildThemeTokens`).
- Sin comentarios en código salvo imprescindibles.
- Cierre de cada parte: `npm run check` y `npm run lint` **limpios** (desde `web-player/`).
- Iconos: `@lucide/svelte/icons/<nombre>`.
### A.2 Gotchas conocidos (no reintroducirlos)
1. **Tokens animados por hue** (`--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`,
`--mf-hairline`, `--mf-hero-bg`, `--mf-modal-glow`, `--mf-spotlight-bg`,
`--mf-logo-grad`, `--mf-cover-grad`, `--mf-accent*`, `--mf-ambient`) se aplican como
**estilo inline** en `<html>` desde `+layout.svelte`. Un override en
`:root[data-theme='light']` **no gana**: la variante clara va en `buildThemeTokens()`.
2. **Conflictos de clases Tailwind**: nunca `class="bg-a {cond ? 'bg-b' : ''}"`. Siempre
mutuamente excluyente: `class={cond ? 'bg-b' : 'bg-a'}`. Overrides de la clase base de
un componente compartido → prop/variante, no `!important`.
3. Texto sin clase de tamaño hereda 16px del navegador. Todo texto visible debe tener una
receta tipográfica (ver B.1.6).
4. `--mf-ink` es "texto sobre CTA" y se invierte en claro; `--mf-tile-shade`,
`--mf-cover-grad`, `--mf-liked-grad` son "arte" (no invertir).
5. Referencia visual: **Inicio** (`src/routes/+page.svelte`). Un cambio de refactor no
debe cambiar el aspecto salvo donde la parte lo diga explícitamente (columna "Δ").
### A.3 Verificación de cada parte
```sh
cd web-player
npm run check
npm run lint
```
Visual (Browser pane, `.claude/launch.json` → `musify-web`; login de dev en la memoria del
proyecto): abre cada página tocada **y** Inicio; prueba estado activo (una canción
sonando), hover, móvil (375px) y modo claro (menú de cuenta → "Modo blanco").
### A.4 Línea base medida (para saber si avanzas)
| Métrica | Hoy |
| ------------------------------------------------- | --------------------------------------- |
| Componentes `.svelte` (lib + rutas `components/`) | 61 |
| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 |
| Clases arbitrarias `x-[…]` | 44 |
| Espaciados fraccionarios (`h-3.75`, `gap-2.75`…) | ~190 apariciones, 60 valores distintos |
| Radios distintos usados | 13 (6 tokens + 7 de Tailwind por defecto) |
| `strokeWidth` distintos | 13 valores |
| Atributos `style="…"` | 24 |
| Pluralizaciones a mano `=== 1 ?` | 15 |
| Errores de `eslint` | 2 (`liked/+page.svelte`) |
Objetivo global: ~61 → ~45 componentes, −1.500/−2.000 líneas, 0 arbitrarios fuera de una
lista blanca, 1 escala por dimensión.
---
## B. Especificaciones compartidas (fuente única)
Cualquier parte que necesite algo de aquí y no exista, lo crea **exactamente así**.
### B.1 Tokens (en `src/routes/layout.css`, dentro de `@theme`)
Tailwind 4 genera utilidades a partir de estos namespaces: `--radius-*` → `rounded-*`,
`--spacing-*` → `size-* w-* h-* p-* m-* gap-*`, `--container-*` → `max-w-*`,
`--tracking-*` → `tracking-*`, `--leading-*` → `leading-*`.
#### B.1.1 Radios (`--radius-*`)
| Token | Valor | Para qué |
| ----------------- | ---------- | ----------------------------------------- |
| `rounded-tag` | `0.25rem` | badges, celdas mini (ExplicitBadge, índice) |
| `rounded-thumb` | `0.5rem` | carátulas ≤ 36px, logo |
| `rounded-control` | `0.625rem` | inputs, filas, items de menú, botones |
| `rounded-art` | `0.8125rem`| carátulas 42–88px, tarjetas de grid |
| `rounded-art-lg` | `1.125rem` | carátulas ≥ 136px, dropzones |
| `rounded-panel` | `1.125rem` | contenedores, menús, modales, tiles |
| `rounded-panel-lg`| `1.375rem` | hero, spotlight, "mejor resultado" |
| `rounded-full` | (Tailwind) | pastillas, avatares, botones redondos |
Eliminar `--radius-art-round` (0 usos). Mapeo de lo existente:
| Hoy | px | Usos | Pasa a | Δ |
| ---------------------------- | -- | ---- | ----------------- | ---- |
| `rounded-sm`, `rounded` | 4 | 2 | `rounded-tag` | 0 |
| `rounded-md` | 6 | 1 | `rounded-thumb` | +2px |
| `rounded-lg` | 8 | 4 | `rounded-thumb` | 0 |
| `rounded-xl` | 12 | 6 | `rounded-control` | −2px |
| `rounded-2xl` | 16 | 8 | `rounded-art-lg` (arte) / `rounded-panel` (contenedor) | +2px |
| `rounded-3xl` | 24 | 1 | `rounded-panel-lg`| −2px |
#### B.1.2 Tamaños de carátula/arte (`--spacing-cover-*`)
| Token | Valor | Usos actuales que absorbe (Δ) |
| ---------------- | ------------------------------ | --------------------------------------------------------------- |
| `cover-xs` | `2.25rem` (36) | thumbs de menús `h-9`; sidebar `h-8.5` (+2) |
| `cover-sm` | `2.625rem` (42) | filas de tabla `h-10.5`; cola y "Populares" `h-10` (+2); player móvil `h-11` (−2) |
| `cover-md` | `3rem` (48) | player escritorio `md:h-12` |
| `cover-lg` | `3.5rem` (56) | "Continuar" `h-14`; filas de búsqueda `h-13` (+4); tarjeta playlist de Inicio `h-15.5` (−6) |
| `cover-xl` | `5.5rem` (88) | "Mejor resultado" `h-22` |
| `cover-2xl` | `8.5rem` (136) | spotlight de Inicio `h-34` |
| `cover-hero` | `9rem` (144) | cabeceras de colección, dropzones, avatar de perfil |
| `cover-hero-sm` | `10.25rem` (164) | cabeceras de colección a partir de `sm:` |
Uso: `size-cover-sm` (Tailwind 4 `size-*` = `width`+`height`). **Nunca** `h-X w-X` en
pareja: siempre `size-*`.
#### B.1.3 Iconos (`--spacing-icon-*`) y trazo
| Token | Valor | Absorbe |
| ---------- | ---------- | ---------------------------------------------- |
| `icon-xs` | `0.875rem` | `h-3.5` (14) |
| `icon-sm` | `1rem` | `h-3.75` (15), `h-4` (16), `h-4.25` (17) |
| `icon-md` | `1.125rem` | `h-4.5` (18) |
| `icon-lg` | `1.25rem` | `h-5` (20), `h-6` (24, login móvil) |
| `icon-xl` | `2.25rem` | `h-9`/`h-10` (estados vacíos, dropzone, Me gusta) |
Trazo: en `layout.css`, fuera de `@theme`:
```css
@layer base {
svg.lucide {
stroke-width: 1.8;
}
}
@utility stroke-thin { stroke-width: 1.3; }
@utility stroke-regular { stroke-width: 1.8; }
@utility stroke-bold { stroke-width: 2.5; }
```
La propiedad CSS `stroke-width` gana al atributo de presentación que pone Lucide, así que
el prop `strokeWidth` deja de tener efecto: **se elimina de todos los usos**. Mapeo:
`1–1.4` → `stroke-thin`, `1.5–2` → (nada, default), `2.5–3` → `stroke-bold`,
`strokeWidth={0}` + `fill` → clase `stroke-0`. Verifica en el navegador (DevTools →
Computed `stroke-width`) que el default aplica antes de migrar en masa.
#### B.1.4 Anchos (`--container-*`) y layout
| Token | Valor | Absorbe |
| --------------------- | -------- | --------------------------------------------------------- |
| `max-w-prose-sm` | `26rem` | `max-w-105` (error), `max-w-108` (hero p), `max-w-110`, `max-w-115` (sin resultados) |
| `max-w-hero` | `34rem` | `max-w-135` (hero, seekbar) |
| `max-w-search` | `30rem` | `max-w-120` (buscador TopBar) |
| `max-w-cta` | `20rem` | `max-w-80` (botones login) |
Ya existen y se mantienen en `theme.css`: `--mf-sidebar-w`, `--mf-queue-w`, `--mf-nav-h`,
`--mf-player-h`, `--mf-safe-b`. Añadir:
```css
--mf-player-side-w: 16rem; /* w-64 de PlayerBar/PlayerExtras */
--mf-menu-w: 15rem; /* w-60 de menús contextuales (= MENU_WIDTH 240) */
--mf-submenu-w: 16rem; /* w-64 (= SUBMENU_WIDTH 256) */
--mf-range-h: 3.5px; /* barra de progreso y volumen */
--mf-range-thumb: 11px;
--mf-volume-w: 84px;
```
Las constantes TS `MENU_WIDTH`/`SUBMENU_WIDTH` (duplicadas hoy en `TrackContextMenu` y
`AlbumContextMenu`) pasan a `src/lib/config.ts` con un comentario-no: el nombre basta.
#### B.1.5 Capas (`z-index`)
En `theme.css` `:root`: `--z-raised: 10; --z-sticky: 20; --z-backdrop: 30; --z-menu: 40;
--z-submenu: 50; --z-modal: 60; --z-grain: 80;`. Uso: `z-(--z-menu)`. Mapeo 1:1 con los
valores actuales (`z-10`,`z-20`,`z-30`,`z-40`,`z-50`,`z-[60]`, `body::after` 80). Nota:
`MobileHeader` y `PlayerDock` usan `z-40` pero son "sticky", no menú → `--z-sticky`
(verificar que el menú de cuenta sigue encima del header: menú 40 > sticky 20).
#### B.1.6 Tipografía (recetas `@utility` en `layout.css`)
Escala tipográfica = la de Tailwind (`text-xs`…`text-4xl`), **sin tamaños arbitrarios**.
Añadir en `@theme`: `--tracking-display: -0.02em; --tracking-eyebrow: 0.1em;
--leading-body: 1.65;`. Recetas (el color **no** va en la receta, se añade aparte):
```css
@utility text-display-1 { @apply font-display text-3xl font-medium tracking-tight sm:text-4xl; }
@utility text-display-2 { @apply font-display text-2xl font-medium tracking-tight; }
@utility text-display-3 { @apply font-display text-xl font-medium tracking-display; }
@utility text-display-4 { @apply font-display text-lg font-medium tracking-tight; }
@utility text-eyebrow { @apply text-xs font-medium tracking-widest uppercase; }
@utility text-body { @apply text-sm leading-body; }
@utility text-count { @apply text-xs tabular-nums; }
```
> Si `@apply` con variante `sm:` dentro de `@utility` no compila, usa
> `@media (width >= 40rem) { font-size: var(--text-4xl); line-height: var(--text-4xl--line-height); }`
> como hace `page-x`.
Mapeo de lo existente:
| Patrón hoy | Receta |
| -------------------------------------------------------------------------------------------- | ------------------------------ |
| `font-display text-3xl … sm:text-4xl` (PageHeader, CollectionHeader, hero Inicio, h1 búsqueda `text-2xl sm:text-3xl font-semibold tracking-[-0.03em]`) | `text-display-1` (Δ búsqueda: +1 paso) |
| `font-display text-2xl` (stats Inicio, título spotlight, "¡Subida!") | `text-display-2` |
| `font-display text-xl … tracking-[-0.02em]` (Modal, EmptyState, +error, "Sin resultados", diálogos de borrado) | `text-display-3` (todos `font-medium`) |
| `font-display text-lg` (SectionHeading, "Mejor resultado", Queue "En cola" `text-base`) | `text-display-4` |
| `text-xs font-medium tracking-widest uppercase` (+ variantes `tracking-[0.14em]`, `[0.16em]`, `tracking-wider`, `text-sm font-semibold`) | `text-eyebrow` |
| `text-sm leading-[1.65] text-fg-2` | `text-body text-fg-2` |
| `text-xs text-muted tabular-nums` | `text-count text-muted` |
| `tracking-[-0.01em]` (MixTile) | quitar |
#### B.1.7 Recetas de superficie y movimiento (`@utility` en `layout.css`)
```css
@utility theme-transition { transition: background 0.5s ease-out; } /* 8× transition-[background] duration-500 */
@utility glass-bar { /* PlayerBar ×2, TabsBar */
background-image: var(--mf-bar-bg);
border-top: 1px solid var(--mf-hairline);
backdrop-filter: blur(24px);
transition: background 0.5s ease-out;
}
@utility grid-cards { /* MediaGrid por defecto: 150/165 */
display: grid;
gap: --spacing(3);
grid-template-columns: repeat(auto-fill, minmax(9.375rem, 1fr));
@media (width >= 40rem) {
gap: --spacing(4);
grid-template-columns: repeat(auto-fill, minmax(10.3125rem, 1fr));
}
}
@utility grid-cards-lg { /* /playlists y /u/[id]: 180/210 */
display: grid;
gap: --spacing(3);
grid-template-columns: repeat(auto-fill, minmax(11.25rem, 1fr));
@media (width >= 40rem) {
gap: --spacing(4);
grid-template-columns: repeat(auto-fill, minmax(13.125rem, 1fr));
}
}
@utility grid-tiles { /* mixes de Inicio: 164 */
display: grid;
gap: --spacing(5);
grid-template-columns: repeat(auto-fill, minmax(10.25rem, 1fr));
}
@utility grid-wide { /* playlists de Inicio (258) y géneros (268) → 16.5rem, Δ ±6px */
display: grid;
gap: --spacing(3.5);
grid-template-columns: repeat(auto-fill, minmax(16.5rem, 1fr));
}
```
`.glass-panel` ya existe; `Modal` debe usarla en lugar de su `style=` inline duplicado.
Stagger (sustituye los 5 `style="animation-delay:…"` con 40 **o** 45ms):
```css
.animate-enter { animation-delay: calc(min(var(--i, 0), 10) * 45ms); }
```
Uso: `style="--i:{i}"`. Único `style=` permitido junto con los de B.4.
#### B.1.8 Colores: limpieza
- Borrar utilidades sin uso: `--color-accent-dim`, `--color-accent-glow`,
`--color-accent-hair`, `--color-accent-line`, `--color-accent-muted` (0 usos como
clase; `--mf-accent-dim` sí se usa en `::selection`, se mantiene la variable `--mf-`).
- `--color-on-accent` es alias de `--mf-on-art` (igual que `--color-on-art`) → borrar y
usar `on-art`. `--color-hover` = `--mf-surface-2` (igual que `surface-2`) → mantener
**solo** `hover` como semántico de hover y dejar `surface-2` para fondos en reposo.
- `--mf-sidebar-bg` no lo usa ningún componente (Sidebar usa `bg-bg`) → borrar de
`theme.css` y de `buildThemeTokens`.
- Botón: `--mf-btn-bg` = `--mf-surface-2`; `--mf-btn-secondary-bg` solo difiere en modo
claro. Exponer en `@theme` `--color-btn`, `--color-btn-hover`, `--color-btn-glass`,
`--color-btn-glass-hover` y eliminar los `bg-[var(--mf-btn-…)]` de `Button.svelte`.
- Scrollbar: `rgba(255,255,255,.18/.32)` fijo en `layout.css` (roto en claro) → tokens
`--mf-scrollbar`, `--mf-scrollbar-hover` con variante en `:root[data-theme='light']`.
- `MixTile`: `to-black/42`, `bg-ink/76`, `border-on-art/12` → `to-scrim/40`, receta de
`PlayButton` (B.3).
### B.2 Helpers TypeScript (fuente única)
| Archivo (nuevo) | API | Sustituye |
| ----------------------------------- | ------------------------------------------------------------------------------------------------------ | --------------------------------------------------------------------- |
| `src/lib/actions/pressable.ts` | `pressable(node, fn)` → añade `role="button"`, `tabindex=0`, click + Enter/Espacio | 7 copias de `onkeydown` Enter/Space (Inicio ×5, Queue, TrackTitleCell) |
| `src/lib/actions/clickOutside.ts` | `clickOutside(node, fn)` | `onDocumentClick` de `AccountMenu` |
| `src/lib/toggle.svelte.ts` | `createToggle(initial=false)` → `{ get open, show(), close(), toggle() }` | `playlists.svelte.ts` y `player/queuePanel.svelte.ts` (idénticos) |
| `src/lib/format.ts` (ampliar) | `plural(n, one, many)` → `"1 canción"`; `fmtCount(n,'canción','canciones')`; **minúscula siempre** | 15 pluralizaciones a mano, con mayúsculas inconsistentes ("Canción"/"canción") |
| `src/lib/navigation.svelte.ts` (ampliar) | `searchHref(q)`; `appNavLinks(user, counts?)` → `{href,label,icon,count?,primary:boolean}[]` | `buildHref` en TopBar y explore; listas de links de Sidebar y TabsBar |
| `src/lib/player/player.svelte.ts` (ampliar) | `isQueueCurrent(items)`, `playAllOrToggle(items)`, `playShuffled(items)`; renombrar `addToQueue` → `playNextItem` | `playAll`/`isCurrentQueue` en álbum, playlist, liked, mixes, spotlight de Inicio (5 copias) |
| `src/lib/player/player.svelte.ts` | `toQueueItem(track)` único (normaliza `duration`/`listensCount` a `number`) | mapeos a mano en `recentlyPlayed.ts` (×2), `liked.svelte.ts` (×2), `liked/+page`, `tracks.ts#targetForQueueItem` y los `Number(…)` dispersos |
| `src/lib/menus.svelte.ts` | `createTrackMenu()` → `{ state, open(e, track), close(), playNext() }` | estado + handler de menú contextual en Inicio, Explorar, Mezcla |
### B.3 Componentes nuevos (API)
| Componente (`$lib/components/ui/`) | Props | Sustituye / absorbe |
| ---------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------- | ------------------- |
| `Artwork.svelte` | `src?`, `trackIds?: (string\|number)[]`, `size: 'xs'\|'sm'\|'md'\|'lg'\|'xl'\|'2xl'\|'hero'`, `shape?: 'auto'\|'round'`, `imageSize?: 'small'\|'medium'\|'large'` (auto por `size`), `fallback?: 'music'\|'gradient'\|'none'`, `class?`, `children?` (overlay) | `Cover`, `PlaylistArt`, `MixArt`; radio automático por tamaño (xs→thumb, sm–xl→art/control, 2xl/hero→art-lg). Prioridad: `src` → mosaico si ≥4 `trackIds` → primera carátula → fallback. Un único `onerror`. |
| `Avatar.svelte` | `name`, `src?`, `size: 'sm'\|'md'\|'lg'\|'hero'`, `class?` | iniciales/imagen en TopBar, MobileHeader, Explorar (×2), `ArtistAvatar` |
| `ListRow.svelte` | `href?` \| `onclick?`, `oncontextmenu?`, `active?`, `size?: 'sm'\|'md'\|'lg'`, `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `meta?`, `leading?: Snippet`, `art: Snippet<[string]>` \| artwork props, `trailing?: Snippet` | `SearchResultRow` + 7 filas a mano (ver P7) |
| `MediaIdentity.svelte` | `title`, `subtitle?`, `subtitleHref?`, `explicit?`, `active?`, `art?: Snippet` \| `artwork` props, `titleClass?` | `TrackTitleCell` (renombrado + generalizado) y el bloque de `player/TrackInfo` |
| `TrackList.svelte` | `tracks: TrackLike[]`, `columns?: ('album'\|'added'\|'uploaded'\|'plays')[]`, `index?`, `onPlay(i)`, `rowAction?: {action, icon, label}` \| `rowActionSnippet?`, `oncontextmenu?(e, track)` | `TrackTable`+`TrackRow`+`TrackIndexCell`+`TrackMeta` y las 6 tablas (P8) |
| `ContextMenu.svelte` | `x`, `y`, `openLeft`, `onClose`, `items: {icon,label,onclick}[]`, `playlistAction?: {action: string, fields: Record<string,string>, label}`, `playlists` | `TrackContextMenu` + `AlbumContextMenu` (90% idénticos) |
| `PlayButton.svelte` | `playing?`, `size: 'sm'\|'md'\|'lg'`, `variant: 'strong'\|'glass'`, `label`, `onclick?`, `as?: 'button'\|'span'` | play de TransportControls, "Mejor resultado", overlay de MixTile |
| `ConfirmDialog.svelte` | `open`, `onClose`, `title`, `description`, `confirmLabel`, `action` (form POST) | 2 modales de borrado con estilos distintos |
| `Slider.svelte` | `value`, `max`, `label`, `oninput`, `variant: 'seek'\|'volume'` | `SeekBar` (click-only) + `VolumeControl` (`<style>` propio) |
| `Logo.svelte` | `size: 'sm'\|'lg'` | logo en Sidebar (sin marca), MobileHeader, login |
| `Chip.svelte` | `selected?`, `count?`, `href?`\|`onclick?` | chips de filtro y de géneros sugeridos en Explorar |
### B.4 Lista blanca de `style=` y arbitrarios (tras el refactor)
Permitidos: `style="--i:{i}"` (stagger), gradientes de tile por hue en Explorar
(`--tile-hue`), anchos de progreso (`width:{pct}%`), posición de menú (`left/top`).
Arbitrarios permitidos: `text-[clamp(…)]` de login, `grid-cols-[auto_1fr]`,
`max-h-[85dvh]`. Todo lo demás, a token.
---
## C. Partes
### P0 — Línea base y bugs sueltos (independiente, ~30 min)
**Objetivo:** dejar `lint` en verde y corregir bugs detectados que no requieren refactor.
Tareas:
1. `src/routes/(app)/liked/+page.svelte`: quitar imports `Play` y `Pause` (errores eslint).
2. `src/lib/components/ui/MediaCard.svelte`: el título no tiene tamaño (hereda 16px) →
`text-sm` (igual que `MixTile`).
3. `src/routes/+page.svelte`, filas del spotlight: `hover:bg-hover … {cond ? 'bg-accent-tint' : ''}`
→ excluyente (`{cond ? 'bg-accent-tint' : 'hover:bg-hover'}`), igual que las otras filas.
4. `src/routes/(app)/albums/[id]/+page.svelte` modal de borrado: `text-base text-fg-3` →
`text-sm leading-[1.65] text-fg-2` y título igual al de playlists (se unificará en P10).
5. `Sidebar.svelte` (botón `+`) y `Queue.svelte` (cerrar): quitar `text-base` inútil.
6. Borrar `src/lib/components/ui/Badge.svelte` (0 importadores).
7. `Button.svelte`: `primary` y `secondary` son la **misma** clase. No cambiar aspecto;
solo dejar `const variants = { primary: subtle, secondary: subtle, … }` con un único
`subtle` y anotar en D que P4 decide si `primary` debe diferenciarse (**decisión de
producto, preguntar al usuario**).
Cierre: `npm run lint` 0 errores.
---
### P1 — Tokens base (prerrequisito de P3–P14)
**Objetivo:** crear todo B.1 de forma **aditiva** (sin migrar componentes todavía, salvo
lo indicado). Tras esta parte, nada cambia visualmente.
Leer: `src/routes/layout.css`, `src/lib/theme/theme.css`, `src/lib/theme/tokens.ts`.
Tareas:
1. Añadir en `@theme`: radios B.1.1 (y borrar `--radius-art-round`), `--spacing-cover-*`
B.1.2, `--spacing-icon-*` B.1.3, `--container-*` B.1.4, `--tracking-*`/`--leading-*`
B.1.6, `--color-btn*` B.1.8.
2. Añadir en `theme.css` `:root`: variables de layout B.1.4 y z-index B.1.5.
3. Añadir `@utility` de B.1.3 (stroke), B.1.6 (tipografía), B.1.7 (superficies, grids);
stagger en `.animate-enter`.
4. Scrollbar a tokens con variante clara (B.1.8).
5. Borrar utilidades de color sin uso y `--mf-sidebar-bg` (B.1.8). Antes de borrar cada
una: `grep -rn "<nombre>" src` = 0.
6. Crear una página de prueba temporal **no commiteada** o usar DevTools para comprobar
que `size-cover-sm`, `rounded-thumb`, `max-w-hero`, `text-display-1`, `z-(--z-menu)`
y `stroke-thin` generan CSS (Tailwind 4 solo genera lo que ve usado: prueba con una
clase en un componente y revierte).
Cierre: check+lint; captura de Inicio antes/después idéntica.
---
### P2 — Tema: una sola fuente de verdad + sin parpadeo en modo claro
**Objetivo:** eliminar la duplicación `theme.css` ↔ `tokens.ts` y el flash oscuro al
cargar con modo claro guardado.
Hallazgos:
- Los valores por defecto de los ~15 tokens dinámicos están escritos **dos veces**: en
`theme.css` (`:root`, hue 145) y en `buildThemeTokens()`. El bloque claro de
`theme.css` repite `--mf-bg`, `--mf-elevated`, `--mf-panel-bg`, `--mf-bar-bg`,
`--mf-hairline`, `--mf-hero-bg`, `--mf-spotlight-bg`, `--mf-modal-glow`, que luego
`tokens.ts` pisa inline.
- `mode.svelte.ts` aplica `data-theme` solo tras hidratar → flash oscuro.
Tareas:
1. `src/app.html`: script inline en `<head>` que lea `localStorage['musify.theme']` y
ponga `document.documentElement.dataset.theme` antes del primer paint (try/catch).
2. `+layout.svelte`: en `<svelte:head>`, emitir
`<style>:root{…buildThemeTokens(ACCENT_HUE,'dark')}:root[data-theme=light]{…buildThemeTokens(ACCENT_HUE,'light')}</style>`
(serializado con un helper `tokensToCss(tokens)` en `tokens.ts`).
3. Borrar de `theme.css` todos los tokens que genera `buildThemeTokens` (dark y light).
`theme.css` queda solo con neutrales fijos, superficies, texto, layout y z-index.
4. `+layout.svelte`: los dos `div` de fondo con `style=` → `@utility app-backdrop` /
`app-vignette` en `layout.css`. `main` con `padding-bottom: calc(…)` inline → clase
con `--mf-dock-h` que se activa con `data-has-track` en el propio `main`.
Cierre: con modo claro guardado, recarga dura sin flash; cambio de pista sigue animando
el hue; check+lint.
---
### P3 — `Artwork` y `Avatar` (arte unificado)
**Objetivo:** un único componente de carátula y uno de avatar.
Hallazgos:
- `Cover`, `PlaylistArt` y `MixArt` implementan la misma cascada (imagen → mosaico 2×2 →
primera carátula → placeholder). `MixArt` ≡ `PlaylistArt` con
`trackIds = items.map(i => i.trackId)`.
- `AlbumCard` pasa el **id del álbum** como `trackId` a `Cover` solo para poder usar `src`
(hack).
- Avatar "imagen o inicial" reimplementado en `TopBar`, `MobileHeader`, Explorar (mejor
resultado + fila de usuario) y `ArtistAvatar` (con rayado).
- Cada uso decide su propio radio (`rounded-lg`, `rounded-xl`, `rounded-2xl`,
`rounded-control`, `rounded-art-lg`…) y su tamaño (`h-8.5`, `h-9`, `h-10`, `h-10.5`,
`h-11 md:h-12`, `h-13`, `h-14`, `h-15.5`, `h-22`, `h-34`, `h-36 sm:h-41`).
Tareas:
1. Crear `Artwork.svelte` y `Avatar.svelte` según B.3 usando tokens B.1.1/B.1.2.
`size` decide dimensión **y** radio; `class` solo para layout (`shrink-0`, márgenes).
2. Migrar **todos** los usos (buscar `Cover`, `PlaylistArt`, `MixArt`, `ArtistAvatar`,
`user.picture`, `profilePictureUrl`) aplicando la tabla B.1.2.
3. `ArtistAvatar` → en `u/[id]/+page.svelte` usar `<Avatar size="hero">` (el nombre ya lo
muestra la cabecera; el label bajo el avatar era redundante — **Δ visible**, confirmar).
4. Borrar `Cover.svelte`, `PlaylistArt.svelte`, `MixArt.svelte`, `ArtistAvatar.svelte`.
Cierre: `grep -rn "Cover\b\|PlaylistArt\|MixArt\|ArtistAvatar" src` solo en `Artwork`;
visual en Inicio, Explorar (búsqueda), Sidebar, Cola, player, cabeceras.
---
### P4 — Botones: `Button`, `IconButton`, `PlayButton`
**Objetivo:** que no quede ningún `<button>` estilado a mano.
Hallazgos (botones hechos a mano): TopBar atrás/adelante (círculos `h-7.5`), limpiar
búsqueda, login (`bg-accent-soft rounded-full`), MobileHeader login, Sidebar `+` ×2, Cola
cerrar/"Vaciar", PlayerExtras cola, TransportControls (shuffle, prev, play, next, repeat),
TrackInfo corazón, "Mejor resultado" (círculo `h-11.5`), MixTile overlay (`h-9.5`),
`BackLink`. Overrides con `!`: Inicio `!bg-cta-strong !text-ink hover:!brightness-95` y
`!font-normal`.
Tareas:
1. `Button.svelte`: variantes `subtle` (hoy primary/secondary), `glass` (hoy
`secondaryOnGlow`), `accent`, `strong` (cta-strong/ink — sustituye los `!`), `danger`.
Mantener alias `primary`/`secondary` solo si P0 no obtuvo decisión. Radio →
`rounded-control`. Quitar necesidad de `!font-normal` (peso normal en `glass`, o prop
`weight`).
2. `IconButton.svelte`: añadir `shape: 'square'|'round'`, `size: 'xs'|'sm'|'md'|'lg'`,
`tone: 'muted'|'subtle'|'plain'`, `pressed?: boolean` (→ `aria-pressed` + `text-accent`),
`href?`, `surface?: boolean` (fondo `surface-2`). Iconos con `size-icon-*`.
3. Crear `PlayButton.svelte` (B.3); iconos play/pausa SVG de `TransportControls` pasan a
él (un único sitio para esos `<svg>`).
4. Migrar todos los botones listados. `TopBar` "adelante" es un `<span>` decorativo
deshabilitado y `PlayerExtras` tiene un icono `AlignJustify` decorativo sin función:
**preguntar** si se eliminan (código muerto visual) o se mantienen por fidelidad al
prototipo.
Cierre: `grep -rn "<button" src | grep -v "ui/\(Button\|IconButton\|PlayButton\|Chip\|ListRow\)"`
→ solo casos justificados (dropzones, selector segmentado).
---
### P5 — Helpers TypeScript
**Objetivo:** crear B.2 y migrar sus usos (lógica, no estilos).
Tareas:
1. Crear `actions/pressable.ts`, `actions/clickOutside.ts`, `toggle.svelte.ts`.
`createPlaylistModal` y `queuePanel` → `createToggle()`; borrar los dos archivos viejos
o reducirlos a `export const queuePanel = createToggle()`.
2. `format.ts`: `plural()`; migrar las 15 pluralizaciones (`grep -rn "=== 1 ?" src`).
Unificar a minúscula ("3 canciones") — **Δ visible** en Playlists/Inicio/liked.
3. `player.svelte.ts`: `toQueueItem`, `isQueueCurrent`, `playAllOrToggle`,
`playShuffled`; renombrar `addToQueue` → `playNextItem` (el nombre actual confunde:
inserta a continuación, no al final). Migrar álbum, playlist, liked, mixes, Inicio.
4. `recentlyPlayed.ts` y `liked.svelte.ts`: usar `toQueueItem`.
5. `tracks.ts`: el envoltorio `TrackTarget = { track }` + 8 accesores (`targetTitle`,
`targetArtist`, …) no aporta nada. Sustituir por el `ApiTrackLike` directo y borrar
accesores; dejar solo `isCurrent(track)` si hace falta. (Toca Inicio, Explorar,
Mezcla, menús: hacerlo en esta parte para no dejar mezcla de estilos.)
6. `navigation.svelte.ts`: `searchHref`, `appNavLinks`.
7. `menus.svelte.ts`: `createTrackMenu()`; migrar Inicio, Explorar, Mezcla.
Cierre: check+lint; reproducir desde álbum, playlist, liked, mezcla, spotlight; menú
contextual "Reproducir a continuación" sigue funcionando.
---
### P6 — Menús: `ContextMenu` + `AccountMenu` + `GlassMenu`
**Objetivo:** un solo menú contextual.
Hallazgos: `TrackContextMenu` (109 líneas) y `AlbumContextMenu` (119) son copias:
misma función de posicionamiento con las mismas constantes, mismo backdrop, mismo Escape,
mismo submenú "Añadir a playlist" con `<form use:enhance>`; solo cambian los items y los
campos ocultos. Sus items reimplementan a mano las clases de `MenuItem` (con
`strokeWidth={2}` en vez de `1.8`). El item de playlist reimplementa `ListRow`.
Tareas:
1. Crear `ContextMenu.svelte` (B.3) usando `GlassMenu` + `MenuItem` + `Artwork size="xs"`
y anchos `--mf-menu-w`/`--mf-submenu-w`; `contextMenuPosition(event)` exportada del
módulo usando `MENU_WIDTH`/`SUBMENU_WIDTH` de `config.ts`.
2. Sustituir usos en Inicio, Explorar (pista y álbum), Mezcla. Borrar los dos menús viejos.
3. `AccountMenu`: usar `clickOutside`; eliminar el `div.relative` duplicado que
`TopBar`/`MobileHeader` ponen alrededor; `panelClass` → prop `width: 'sm'|'md'`.
4. Añadir Escape a `AccountMenu` (hoy no cierra con Escape — mejora a11y).
Cierre: menú de pista y de álbum en Explorar, submenú abre a izquierda cerca del borde,
móvil (submenú debajo), Escape cierra.
---
### P7 — Filas: `ListRow` + `MediaIdentity` + `EqBars`
**Objetivo:** una sola fila compacta para todas las listas que no son tabla.
Filas reimplementadas hoy (todas: arte + título + subtítulo + meta + estado activo):
| Dónde | Tamaño |
| --------------------------------------- | -------- |
| `SearchResultRow` (Explorar ×4 grupos) | lg |
| Inicio "Continuar escuchando" (tarjeta) | lg, card |
| Inicio "Populares" (índice/EQ + meta) | sm |
| Inicio spotlight (índice, sin arte) | sm |
| Inicio "Tus playlists" (tarjeta) | lg, card |
| `Queue` "Reproduciendo" + "A continuación" | sm |
| `Sidebar` playlists | xs |
| items de playlist de `ContextMenu` | xs |
Tareas:
1. Renombrar/generalizar `TrackTitleCell` → `MediaIdentity` (B.3). Usar en
`player/TrackInfo` (sustituye su bloque cover+título+artista).
2. Crear `ListRow` (B.3) sobre `MediaIdentity` + `pressable`; variante `card` = padding
`p-2.25 pr-3.5 rounded-art` (Continuar/Tus playlists).
3. `NowPlaying` ≡ `EqBars` con overlay: añadir a `EqBars` prop `overlay` (fondo
`scrim/45`, color `on-art`) y borrar `NowPlaying`.
4. Migrar todas las filas de la tabla. Borrar `SearchResultRow`.
Cierre: Inicio, Explorar, Cola, Sidebar sin cambios visibles salvo Δ de B.1.2; teclado
(Tab + Enter) reproduce en todas las filas.
---
### P8 — Tablas de pistas: `TrackList`
**Objetivo:** tablas declarativas; borrar 3 celdas y 3 componentes de ruta.
Hallazgos:
- 6 tablas (`library`, `liked`, `mixes/[id]`, `AlbumTrackTable`, `PlaylistTrackTable`,
`LibraryPicker`) repiten: `{@const active}`, `TrackIndexCell`, `TrackTitleCell`, N
`TrackMeta`, y un `<form method=POST use:enhance><input hidden trackId><IconButton revealOnHover>`.
- Anchos de columna pasados desde cada página e **inconsistentes**: "Escuchas" `106px` en
unas y `100px` en otras.
- La columna **"Álbum" siempre pinta "—"** (library, liked, playlist): placeholder sin
datos. **Decisión**: eliminarla (recomendado) o dejarla en el preset.
Tareas:
1. Crear `TrackList.svelte` (B.3) con presets de columna en el propio módulo:
`{ album: {label:'Álbum', width:'76px'}, added: {'Añadida','124px'}, uploaded: {'Subida','124px'}, plays: {'Escuchas','104px'} }`
y columnas fijas (`index 32px/28px`, título `minmax(180px,1fr)/minmax(120px,1fr)`,
duración `104px/52px`, acción `36px/32px`) como constantes del módulo.
2. `rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}`
genera el form+IconButton; para liked (`onclick`) aceptar `rowAction.onclick`.
3. Migrar las 6 tablas; inlinear `AlbumTrackTable`, `PlaylistTrackTable`, `LibraryPicker`
en sus páginas y borrarlos. Borrar `TrackRow`, `TrackIndexCell`, `TrackMeta`,
`TrackTable`.
4. `LibraryPicker` usa overlay `NowPlaying` en vez de índice: `TrackList` con
`index={false}` muestra EQ sobre el arte (vía `Artwork` children).
Cierre: las 6 páginas; hover revela acción; activo muestra EQ; móvil oculta metas.
---
### P9 — Tarjetas y grids
**Objetivo:** una tarjeta, cero wrappers.
Hallazgos: `AlbumCard` y `PlaylistCard` solo construyen el subtítulo y eligen el arte;
`MixTile` es una `MediaCard` con overlay de play y tipografía distinta; `MediaGrid` es un
`<div>` con variables inline; Inicio (mixes 164px, playlists 258px) y Explorar (géneros
268px) escriben su propio `grid-cols-[repeat(auto-fill,minmax(…))]`.
Tareas:
1. `MediaCard`: props `href`, `title`, `subtitle?`, `onPlay?` (overlay `PlayButton
variant="glass"`), `art` snippet **o** props de `Artwork`, `oncontextmenu?`, `index`.
Tipografía fija (`text-sm text-fg` / `text-xs text-fg-3`).
2. Helpers en `format.ts`: `albumMeta(album)`, `playlistMeta(playlist)`.
3. Sustituir `AlbumCard`, `PlaylistCard`, `MixTile` por `MediaCard`; `MediaGrid` por
`grid-cards`/`grid-cards-lg`/`grid-tiles`/`grid-wide` (B.1.7). Borrar los 4 archivos.
4. Tile de género de Explorar: `style` con `--tile-hue` y el gradiente en un `@utility
genre-tile` que lea `var(--tile-hue)`.
Cierre: /albums, /playlists, /u/[id], Inicio (mixes), Explorar (géneros).
---
### P10 — Cabeceras, diálogos y tipografía
**Objetivo:** 1 cabecera de página, 1 de sección, 1 diálogo de confirmación.
Hallazgos:
- `PageHeader` ≡ `CollectionHeader` sin portada/eyebrow. La cabecera de búsqueda de
Explorar reimplementa ambos (eyebrow `text-sm font-semibold tracking-[0.16em]`).
- `AlbumHeader`/`PlaylistHeader` (rutas) solo montan el meta y los botones.
- Cada página decide el margen tras la cabecera (`mt-7 sm:mt-8`, `mt-6 sm:mt-8`, `mt-9`,
`mt-10 sm:mt-12`…).
- 2 diálogos de borrado con tipografías distintas.
- `EmptyState` duplicado literalmente en Inicio ("Todavía no has escuchado nada" ×2).
Tareas:
1. Fusionar en `PageHeader`: `title`, `eyebrow?`, `description?`, `meta?`, `cover?`,
`actions?`, `align?`. **La cabecera fija su propio margen inferior** (`mb-7 sm:mb-8`);
quitar los `mt-*` de lo que venga después. Borrar `CollectionHeader`.
2. Inlinear `AlbumHeader`/`PlaylistHeader` en sus páginas (con `plural()` y el `BackLink`)
y borrarlos. Iconos de botones: unificar (álbum tiene iconos, playlist no) →
**decisión**: con iconos en ambos (recomendado) o sin ellos.
3. `SectionHeading`: prop `count?` que pinte la línea + "N resultados" (hoy repetido 4× en
Explorar) y `href?`/`linkLabel?` para "Ver todas".
4. `ConfirmDialog` (B.3); migrar álbum y playlist. `Modal` usa `.glass-panel` y
`z-(--z-modal)`; su eyebrow y título con recetas.
5. Aplicar recetas B.1.6 en: `EmptyState`, `+error.svelte`, "Sin resultados", hero de
Inicio, stats, `Queue`, `Sidebar` ("Tus playlists"), `PlaylistForm` (visibilidad).
6. Inicio: estado vacío repetido → snippet local.
Cierre: todas las páginas con cabecera; diálogos de borrado idénticos.
---
### P11 — Formularios
**Objetivo:** un único esqueleto de formulario con portada.
Hallazgos:
- `AlbumForm` y `PlaylistForm` comparten: `use:enhance` con `submitting`,
`ImageDropzone gradient class="h-36 w-36 rounded-2xl"`, `Field`/`Input`/`Textarea`,
`Alert`, pie Cancelar/Enviar.
- `upload/+page.svelte` reimplementa el selector de portada (label + input + preview +
gradiente) que ya hace `ImageDropzone`.
- `Input` y `Textarea` repiten la misma cadena de clases.
- Toggle Privada/Pública (`w-[calc(50%-4px)]`) y chips de Explorar: selectores de una
opción con estilos propios.
Tareas:
1. `CoverForm.svelte` (esqueleto): props `action`, `coverFallbackUrl?`, `coverIcon`,
`requireCover?`, `formMessage?`, `submitLabel`, `submittingLabel`, `canSubmit`,
`helperText?`, `onCancel`, `onSuccess?`, snippet `fields`. `AlbumForm` y
`PlaylistForm` quedan como ~30 líneas de campos cada uno (o se inlinean en sus
páginas si solo se usan en un sitio: `AlbumForm` se usa en 2, `PlaylistForm` en 2 →
mantener).
2. `ImageDropzone`: `size="hero"` usando `size-cover-hero` + `rounded-art-lg`; upload la
usa (Δ: icono de 64px → `icon-xl`).
3. `@utility field-control` en `layout.css` con la cadena común de `Input`/`Textarea`.
4. `SegmentedControl.svelte` (`options`, `value` bindable) para Privada/Pública; `Chip`
(B.3) para Explorar.
Cierre: crear/editar álbum y playlist, subir canción (drag&drop incluido).
---
### P12 — Chrome de la app (layout, navegación, reproductor)
**Objetivo:** una sola definición de navegación y un reproductor más pequeño.
Hallazgos:
- `Sidebar` y `TabsBar` definen cada uno su lista de links + `isActive`.
- `MobileHeader` compensa los links que faltan en `TabsBar` con items extra en el menú.
- Logo: gradiente + texto en MobileHeader y login; solo texto en Sidebar.
- `PlayerBar` repite dos veces la cadena de "barra de cristal" (igual que `TabsBar`).
- `PlayerExtras` (22 líneas) solo compone volumen + botón de cola.
- `SeekBar` es un `<button>` de click (sin teclado ni arrastre) con `h-[3.5px]`;
`VolumeControl` es un `<input range>` con `<style>` propio (`84px`, `11px`, `3.5px`).
- `TrackInfo` duplica el bloque identidad de pista (→ `MediaIdentity`, P7).
Tareas:
1. `appNavLinks()` (B.2) en Sidebar y TabsBar (`primary` = los 4 de TabsBar).
2. `Logo.svelte`; usar en Sidebar, MobileHeader y login (Δ: Sidebar gana el cuadrado
de gradiente — **confirmar**).
3. `glass-bar` en PlayerBar ×2 y TabsBar; `theme-transition` en Sidebar, Queue, etc.
4. Fusionar `PlayerExtras` dentro de `PlayerBar`; anchos laterales `--mf-player-side-w`.
5. `Slider.svelte` (B.3) con `<input type="range">` estilado vía tokens
`--mf-range-h/--mf-range-thumb/--mf-volume-w`; sustituye `SeekBar` y `VolumeControl`
(mejora: arrastre y teclado en la barra de progreso). Borrar ambos.
6. `TopBar`: usar `searchHref`, `IconButton`, `Avatar`; `max-w-search`.
7. z-index de header/dock/menús con B.1.5.
Cierre: navegación desktop/tablet/móvil, cola abre/cierra, seek por click, arrastre y
flechas, volumen.
---
### P13 — Páginas grandes: Explorar (602 líneas) e Inicio (466)
**Objetivo:** que las páginas sean composición, no lógica ni marcado repetido. Hacer
**después** de P7–P10 (usa `ListRow`, `SectionHeading count`, `PageHeader`).
Explorar:
1. `GENRE_INFO` + `genreTiles` → `src/lib/genres.ts`.
2. Lógica de búsqueda (`topResult`, `searchCounts`, `searchChips`, `capped`,
`showGroup`, `playlistMatches`) → `src/lib/search.ts` (funciones puras).
3. Los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) → un `{#each groups}`
con `SectionHeading count` + `ListRow`.
4. "Mejor resultado": un solo marcado con `Artwork`/`Avatar` según tipo + `PlayButton
as="span"`; eliminar el snippet duplicado button/a usando `ListRow`-like o
`<svelte:element this={href ? 'a' : 'button'}>`.
5. Paginación `loadMore` → reutilizable si otra página la necesita; si no, dejar.
Inicio:
1. Usar `Page` en vez de `page-x pt-3 sm:pt-4 …` a mano; `gap-11.5` → token de sección
(`--spacing-section: 2.875rem`, `gap-section`).
2. Stats ×3 idénticos → `{#each stats}`.
3. Hero/spotlight: `style=` de fondo → `@utility hero-surface` / `spotlight-surface`.
4. Filas → `ListRow` (si P7 no lo hizo ya).
Cierre: Explorar sin query, con query, filtros, sin resultados, scroll infinito; Inicio
completo con y sin datos.
---
### P14 — Barrido final y guardarraíles
**Objetivo:** que no vuelva a aparecer lo que se ha limpiado.
Tareas:
1. Script `web-player/scripts/check-tokens.mjs` + `npm run lint:tokens` (añadir a
`lint`). Falla si encuentra en `src/**/*.svelte`:
- `-\[` arbitrario fuera de la lista blanca B.4;
- `rounded-(sm|md|lg|xl|2xl|3xl)\b` o `rounded\b` a secas;
- `\b[hw]-\d` con su pareja en el mismo elemento (debe ser `size-*`);
- espaciado fraccionario `-\d+\.\d+` en `h-`/`w-`/`size-` (tamaños → tokens);
- `#[0-9a-f]{3,8}` en clases, `rgba(`, `strokeWidth=`, `text-base` sin receta;
- `style="` que no sea de la lista blanca.
2. Espaciados fraccionarios restantes (`gap-2.75`, `py-1.75`, `mt-3.25`, `p-2.25`…):
redondear al paso de 0.5 más cercano **o** a la escala entera cuando el Δ ≤ 1px.
Tabla guía: `.25/.75` → `.5` más cercano; `mt-0.75`→`mt-1`; `gap-1.75`→`gap-2`;
`p-4.25`→`p-4`; `mb-4.5` (SectionHeading) se mantiene (Δ 2px visible en todas las
secciones).
3. Detector de huérfanos (repetir y borrar lo que salga):
```bash
for f in $(find src/lib/components -name "*.svelte"); do n=$(basename "$f" .svelte); c=$(grep -rlw "$n" --include=*.svelte --include=*.ts src | grep -v "/$n.svelte$" | wc -l); [ "$c" -eq 0 ] && echo "$f"; done
```
4. Actualizar `web-player/CLAUDE.md` → sección "Estilos" con: B.4, "usa `size-cover-*`,
`size-icon-*`, recetas `text-display-*`/`text-eyebrow`/`text-body`/`text-count`,
radios semánticos; componentes: `Artwork`, `Avatar`, `ListRow`, `TrackList`,
`MediaCard`, `PageHeader`, `ContextMenu`…". Borrar `docs/design-system-refactor-plan.md`.
5. Recalcular la tabla A.4 y anotarla en D.
---
## D. Registro
| Parte | Estado | Commit | Notas / decisiones tomadas |
| ----- | ------ | ------ | -------------------------- |
| P0 | ✅ | (pendiente) | Button primary/secondary se mantienen iguales (decisión del usuario) |
| P1 | ✅ | (pendiente) | Verificado con `build` (Tailwind compila `@apply sm:` dentro de `@utility`); sin QA visual autenticada (Docker no disponible en esta sesión) |
| P2 | ✅ | (pendiente) | `<style>{expr}</style>` no interpola en Svelte (se trata como CSS del componente); solución: `{@html \`<style>${css}</style>\`}` |
| P3 | ✅ | (pendiente) | Se quitó el nombre bajo el avatar de perfil (decisión tomada: redundante con el título de CollectionHeader). Se añadió `size="fill"` a Artwork (no está en la spec) para las tarjetas de grid fluidas (AlbumCard/PlaylistCard/MixTile), ya que P9 (MediaCard) aún no existe. SearchResultRow perdió su prop `kind`/radio-por-tipo (ahora el radio lo decide Artwork/Avatar). Sin QA visual autenticada (Docker no disponible) |
| P4 | ✅ | (pendiente) | Decisión tomada sin bloquear: se elimina el botón "adelante" decorativo (TopBar) y el icono `AlignJustify` decorativo (PlayerExtras) por ser código muerto visual. `TransportControls` (shuffle/prev/next/repeat) se deja sin migrar a IconButton a propósito: son iconos sin chrome (sin fondo/hit-box), migrarlos habría añadido una píldora de hover no presente hoy — riesgo visual no verificable sin Docker |
| P5 | 🟡 | (pendiente) | Hecho: pressable/clickOutside, toggle.svelte.ts, plural() (+minúsculas, Δ visible), helpers de player.svelte.ts (toQueueItem/isQueueCurrent/playAllOrToggle/playShuffled, addToQueue→playNextItem), navigation.svelte.ts (searchHref/appNavLinks — appNavLinks creado pero su migración a Sidebar/TabsBar se deja para P12 tal como indica esa parte), menus.svelte.ts (createTrackMenu, migrado en Inicio/Explorar/Mezcla). **Pendiente a propósito**: la tarea 5 (eliminar el envoltorio `TrackTarget` de tracks.ts y sus 8 accesores) se difiere — es el cambio de mayor riesgo del plan (toca tipos en Inicio/Explorar/Mezcla/menús de forma transversal) y no se pudo verificar visualmente sin Docker; abordarlo junto con P13 cuando se reescriban esas páginas |
| P6 | ✅ | (pendiente) | z-index de AccountMenu unificado a z-(--z-menu) (antes z-20, ahora al mismo nivel que ContextMenu — Δ menor, mejora consistencia); sin QA visual autenticada (Docker no disponible) |
| P7 | ✅ | (pendiente) | MediaIdentity queda "puro" (sin onClick) según spec; las 6 tablas de pista (P8) envuelven su propio `use:pressable` hasta que P8 las sustituya por TrackList. Deltas documentados: fila activa de Sidebar ahora usa bg-accent-tint/text-accent-soft (antes gris neutro, ahora consistente con el resto de filas activas); tarjeta "Tus playlists" de Inicio pierde la línea "por {tu nombre}" (redundante, siempre el propio usuario) y su radio pasa de rounded-panel a rounded-art (consistencia con la tarjeta "Continuar"); filas de Queue "A continuación" pierden el opacity-80 del arte (ListRow no expone esa personalización). Sin QA visual autenticada (Docker no disponible) |
| P8 | ✅ | (pendiente) | Decisión tomada (recomendada por el propio plan, sin bloquear): se elimina la columna "Álbum" que siempre mostraba "—". TrackList unifica TrackTable+TrackRow+TrackIndexCell+TrackMeta; AlbumTrackTable/PlaylistTrackTable/LibraryPicker se inlinean en sus páginas y se borran. Sin QA visual autenticada (Docker no disponible) |
| P9 | ✅ | (pendiente) | MediaCard gana hover-zoom del arte y scrim+PlayButton condicionados a `onPlay` (antes exclusivos de MixTile); ahora se aplicarían a cualquier card futura con onPlay, consistencia intencional. Sin QA visual autenticada (Docker no disponible) |
| P10 | ✅ | (pendiente) | Decisión tomada (recomendada, sin bloquear): iconos en los botones de cabecera de playlist (antes solo álbum tenía). PlaylistHeader pierde su prop `id` sin usar tras inlinearlo. Explorar: la cabecera de "Resultados para…" ahora usa el `mb-7/8` de PageHeader en vez de un `mt-5` ajustado a mano antes de los chips de filtro — gap ligeramente mayor entre el resumen y los chips (Δ menor, documentado). Sin QA visual autenticada (Docker no disponible) |
| P11 | ✅ | (pendiente) | CoverForm añade snippet `extra` (no está en la spec literal, solo `fields`) para el contenido a ancho completo bajo la fila portada+campos (descripción de álbum, selector de visibilidad de playlist) que no cabía en una única columna. Upload usa ImageDropzone con `onselect` (prop nueva) y se envuelve en `{#key}` para poder resetear tras publicar — Δ: el contenedor de portada pasa de 144/176px (móvil/desktop) a 144px fijo (size-cover-hero, sin variante desktop mayor), y el icono de posición vacía de 64px a 36px (icon-xl), tal como recomienda el plan. Sin QA visual autenticada (Docker no disponible) |
| P12 | ✅ | (pendiente) | PlayerExtras se fusiona en PlayerBar (volumen/cola inline); Slider sustituye a SeekBar+VolumeControl (confirmado por grep que el modo `compact` de SeekBar era código muerto). Sidebar y TabsBar migran a `appNavLinks()` (P5). Decisión tomada (sin bloquear): Sidebar gana la marca-gradiente `Logo` que no tenía antes, para consistencia con MobileHeader/login. z-index de PlayerDock/MobileHeader corregido a `z-(--z-sticky)` (son barras fijas, no menús) y SegmentedControl a `z-(--z-raised)`, tal como indica B.1.5. `glass-bar`/`theme-transition` aplicados en PlayerBar (ambas variantes), TabsBar, Queue e Inicio (hero/spotlight), sustituyendo estilos duplicados. Sin QA visual autenticada (Docker no disponible); smoke test de `/login` sin errores de consola |
| P13 | ✅ | (pendiente) | Se aborda junto con esta parte la tarea 5 diferida en P5: se elimina el envoltorio `TrackTarget` de `tracks.ts` y sus 8 accesores (`targetId`/`targetTitle`/`targetArtist`/`targetOwnerUserId`/`targetExplicit`/`targetListensCount`/`isTargetCurrent`/`queueItemForTarget`/`targetForQueueItem`); Explorar, Inicio, Mezcla, `menus.svelte.ts` y `mixes.ts` pasan a usar los objetos de pista (`ApiTrackLike`/`LocalTrack`) directamente, con `toQueueItem`/`trackFromQueueItem` (nuevo, en `player.svelte.ts`) como únicos conversores. TypeScript reveló que el campo `isExplicit` no existe en el schema real de `/tracks` — el badge "explicit" de Explorar ya era un no-op antes del refactor (siempre `undefined ?? false`); se retira el binding en vez de fingir soporte. Explorar: `GENRE_INFO`/`genreTiles` → `src/lib/genres.ts`; lógica de búsqueda (`showGroup`/`capped`/`matchPlaylists`/`searchCounts`/`searchChips`/`findTopResult`) → `src/lib/search.ts` (funciones puras, parametrizadas por `sfilter`/`query` en vez de cerrar sobre el estado del componente); "Mejor resultado" unifica el botón/enlace duplicado con `<svelte:element this={...}>`. Decisión tomada sin bloquear: **no** se colapsan los 4 bloques de grupo (Canciones/Álbumes/Usuarios/Playlists) en un único `{#each groups}` — cada grupo tiene snippets, contextmenu y props de `ListRow` genuinamente distintos (menú de pista vs. de álbum, overlay solo en canciones, trailing con formato distinto), y forzar una configuración genérica habría añadido más indirección de la que elimina. Inicio: usa `Page` (antes `page-x pt-3 sm:pt-4` a mano); stats ×3 → `{#each}`; fondos de hero/spotlight (`style=` con `var(--mf-hero-bg)`/`var(--mf-spotlight-bg)`) → utilities `hero-surface`/`spotlight-surface` (nuevas, en `layout.css`) y token `--spacing-section: 2.875rem` → `gap-section` sustituye `gap-11.5`. Sin QA visual autenticada (Docker no disponible); `/explore` sin sesión redirige a `/login` porque `+layout.server.ts` exige `allowAnonymousListening` desde el backend (inalcanzable sin Docker) — comportamiento preexistente, no una regresión; smoke test de `/login` sin errores de consola |
| P14 | ✅ | (pendiente) | `scripts/check-tokens.mjs` (`npm run lint:tokens`, añadido a `lint`) escanea `src/**/*.svelte` y falla en: arbitrarios `-[…]` fuera de la lista blanca de B.4, `rounded-(sm/md/lg/xl/2xl/3xl)`/`rounded` a secas, pares `h-N w-N` (deberían ser `size-N`), espaciado fraccionario en cuartos (`.25`/`.75`), hex/`rgba()` literales. Se limpió el repo hasta dejarlo en 0 violaciones: ~40 pares `h-N w-N` → `size-N` (icons sobre todo, vía script puntual), ~26 espaciados en cuartos redondeados al entero más cercano (Δ≤1px, p. ej. `py-2.75`→`py-3`, `gap-1.75`→`gap-2`), 3 `rounded-sm/lg` + 1 `rounded` a secas → tokens (`rounded-tag`/`rounded-thumb`/`rounded-control`), 2 `tracking-[-0.02em]` → `tracking-display` (coincidían exactamente con el token ya existente y no se habían migrado), 1 `tracking-[0.14em]` en Explorar → receta `text-eyebrow`, `rounded-3xl` del hero de Inicio → `rounded-panel-lg` (consistente con la tarjeta de playlist destacada). Se retiraron las recetas `stroke-thin/regular/bold` (creadas en P1, 0 consumidores) — **decisión tomada, sin bloquear**: no se migra `strokeWidth={n}` (13 valores, en casi todos los componentes) a esas recetas por CSS porque no se puede verificar sin Docker que `lucide-svelte` respete `stroke-width` por clase en vez del prop, y una regresión ahí sería silenciosa en toda la app; el script documenta este gap explícitamente y no lo exige. Se abordó también junto con la tarea 5 diferida de P5 (ver P13). Detector de huérfanos: 0 componentes sin importadores (los 47 de `src/lib/components/` están todos en uso). `docs/design-system-refactor-plan.md` (plan anterior, superseded) borrado. `web-player/CLAUDE.md` gana sección "Estilos" con la lista blanca B.4, componentes base a reutilizar y el gap de `strokeWidth` documentado. Línea base recalculada (ver tabla debajo) |
Decisiones de producto pendientes (preguntar al usuario antes de la parte indicada):
- P0/P4: ¿`Button` primary debe verse distinto de secondary?
- P3: ¿quitar el nombre bajo el avatar de perfil?
- P4: ¿eliminar el botón "adelante" decorativo y el icono `AlignJustify` del player?
- P5: pluralizaciones en minúscula en toda la app.
- P8: ¿eliminar la columna "Álbum" que siempre muestra "—"?
- P10: ¿iconos en los botones de cabecera de álbum **y** playlist?
- P12: ¿logo con cuadrado de gradiente también en la Sidebar?
### D.1 Línea base recalculada (tras P14, cierre del refactor)
Recalculada con los mismos criterios que A.4 (grep sobre `src/`), en la misma sesión de
cierre de P14.
| Métrica | A.4 (antes) | Tras P14 |
| -------------------------------------------------- | ----------- | ------------------------------------------ |
| Componentes `.svelte` en `src/lib/components/` | 61 | 47 |
| Líneas `.svelte`/`.css`/`.ts` cliente | ~8.700 | ~8.310 (−~390; el objetivo de −1.500/−2.000 ya se había alcanzado en P3–P11, antes de que P13 añadiera `genres.ts`/`search.ts` como código nuevo bien separado) |
| Clases arbitrarias `x-[…]` | 44 | 11 (las 11 restantes, en la lista blanca de B.4) |
| Espaciados fraccionarios en cuartos (`.25`/`.75`) | ~190 apariciones | 0 (redondeados al entero más cercano, Δ≤1px) |
| Radios distintos usados | 13 | 8 (7 tokens B.1.1 + `rounded-full`) |
| `strokeWidth` distintos | 13 | 12 (fuera de alcance, ver Registro P14 y CLAUDE.md) |
| Atributos `style="…"` | 24 | 16 (en 12 archivos; todos dinámicos por instancia — stagger, posición de menú, `var(--mf-*)`, animación — ninguno con color/medida literal) |
| Pluralizaciones a mano `=== 1 ?` | 15 | 2 (1 es la propia `plural()` en `format.ts`; 1 es concordancia de participio en `library` que `plural()` no puede cubrir) |
| Errores de `eslint` | 2 | 0 |
| `npm run lint:tokens` (nuevo, P14) | — | 0 violaciones |
---
## E. Inventario completo (veredicto por archivo)
Leyenda: **=** se mantiene · **~** se modifica · **→X** se fusiona en X · **✕** se borra.
### `src/lib/components/ui/`
| Archivo | Líneas | Veredicto | Parte | Motivo |
| -------------------- | ------ | --------- | ----- | ------ |
| AccountMenu | 73 | ~ | P6 | `clickOutside`, Escape, ancho por prop |
| AlbumCard | 62 | ✕ →MediaCard | P9 | wrapper de meta + hack `trackId=albumId` |
| AlbumForm | 123 | ~ (CoverForm)| P11 | esqueleto compartido |
| Alert | 20 | = | | |
| ArtistAvatar | 36 | ✕ →Avatar | P3 | |
| BackLink | 33 | ~ | P4 | `IconButton`/tokens; quitar `hover:text-accent-soft` duplicado |
| Badge | 23 | ✕ | P0 | 0 usos |
| Button | 66 | ~ | P0/P4 | variantes duplicadas, overrides `!` |
| Checkbox | 29 | ~ | P14 | `size-*`, tokens |
| CollectionHeader | 45 | ✕ →PageHeader| P10 | |
| Cover | 47 | ✕ →Artwork | P3 | |
| EmptyState | 31 | ~ | P10 | recetas |
| EqBars | 23 | ~ | P7 | absorbe `NowPlaying` |
| ExplicitBadge | 13 | ~ | P1/P14 | `rounded-tag`, `size-4` |
| Field | 27 | = | | |
| GlassMenu | 15 | = | | base de menús |
| IconButton | 39 | ~ | P4 | shape/size/tone/pressed/href |
| ImageDropzone | 62 | ~ | P11 | `size="hero"`; usado por upload |
| InfiniteScroll | 39 | = | | |
| Input | 59 | ~ | P11 | `field-control` |
| MediaCard | 32 | ~ | P0/P9 | bug 16px; absorbe MixTile |
| MediaGrid | 27 | ✕ →`grid-*` | P9 | |
| MenuItem | 48 | ~ | P6 | iconos `size-icon-md` |
| MixArt | 25 | ✕ →Artwork | P3 | |
| MixTile | 51 | ✕ →MediaCard | P9 | |
| Modal | 106 | ~ | P10 | `.glass-panel`, z-token, recetas |
| NowPlaying | 20 | ✕ →EqBars | P7 | |
| Page | 14 | = | | Inicio debe usarlo (P13) |
| PageHeader | 26 | ~ | P10 | absorbe CollectionHeader |
| PlaylistArt | 61 | ✕ →Artwork | P3 | |
| PlaylistCard | 23 | ✕ →MediaCard | P9 | |
| PlaylistForm | 161 | ~ (CoverForm)| P11 | + SegmentedControl |
| SearchResultRow | 78 | ✕ →ListRow | P7 | |
| SectionHeading | 32 | ~ | P10 | `count`, "ver todas" |
| Surface | 17 | = | | (solo upload; aceptable) |
| Textarea | 28 | ~ | P11 | `field-control` |
| TrackIndexCell | 27 | ✕ →TrackList | P8 | |
| TrackMeta | 14 | ✕ →TrackList | P8 | |
| TrackRow | 27 | ✕ →TrackList | P8 | |
| TrackTable | 79 | ✕ →TrackList | P8 | |
| TrackTitleCell | 77 | → MediaIdentity | P7 | renombrar + generalizar |
| **Nuevos** | | Artwork, Avatar, ListRow, MediaIdentity, TrackList, ContextMenu, PlayButton, ConfirmDialog, Slider, Logo, Chip, SegmentedControl, CoverForm | | |
### `src/lib/components/` y `player/`
| Archivo | Líneas | Veredicto | Parte | Motivo |
| ------------------- | ------ | --------- | ----- | ------ |
| AlbumContextMenu | 119 | ✕ →ContextMenu | P6 | copia de TrackContextMenu |
| TrackContextMenu | 109 | ✕ →ContextMenu | P6 | |
| MobileHeader | 70 | ~ | P6/P12 | Avatar, Logo, sin wrapper duplicado |
| Sidebar | 125 | ~ | P7/P12 | `appNavLinks`, `ListRow` |
| TabsBar | 50 | ~ | P12 | `appNavLinks`, `glass-bar` |
| TopBar | 140 | ~ | P4/P12 | IconButton, Avatar, `searchHref` |
| player/PlayerBar | 28 | ~ | P12 | `glass-bar`, absorbe PlayerExtras |
| player/PlayerDock | 29 | ~ | P12 | z-token |
| player/PlayerExtras | 22 | ✕ →PlayerBar | P12 | |
| player/Queue | 111 | ~ | P7 | `ListRow`, recetas, `pressable` |
| player/SeekBar | 48 | ✕ →Slider | P12 | |
| player/TrackInfo | 65 | ~ | P7 | `MediaIdentity`, IconButton pressed |
| player/TransportControls | 75 | ~ | P4 | `PlayButton`, `IconButton pressed` |
| player/VolumeControl| 52 | ✕ →Slider | P12 | |
### Rutas
| Archivo | Líneas | Veredicto | Parte | Motivo |
| ------- | ------ | --------- | ----- | ------ |
| `+layout.svelte` | 142 | ~ | P2 | tokens SSR, fondos a utilidades, padding del dock |
| `+page.svelte` (Inicio) | 466 | ~ | P0/P5/P7/P13 | 4 filas a mano, stats ×3, vacío ×2, `style=` |
| `+error.svelte` | 16 | ~ | P10 | recetas, `max-w-prose-sm` |
| `login/+page.svelte` | 50 | ~ | P12 | `Logo`, `max-w-cta` |
| `(app)/explore/+page.svelte` | 602 | ~ | P13 | dividir datos/lógica; 4 grupos → each |
| `(app)/upload/+page.svelte` | 288 | ~ | P11 | `ImageDropzone`, recetas |
| `(app)/library/+page.svelte` | 108 | ~ | P8 | `TrackList` |
| `(app)/liked/+page.svelte` | 123 | ~ | P0/P5/P8/P10 | imports muertos, `playAllOrToggle`, `TrackList` |
| `(app)/mixes/[id]/+page.svelte` | 134 | ~ | P5/P8/P10 | menú, `TrackList`, `PageHeader` |
| `(app)/albums/+page.svelte` | 74 | ~ | P9 | `MediaCard` + `grid-cards` |
| `(app)/albums/[id]/+page.svelte` | 91 | ~ | P8/P10 | inline header/tablas, `ConfirmDialog` |
| `albums/[id]/components/AlbumHeader` | 82 | ✕ (inline) | P10 | |
| `albums/[id]/components/AlbumTrackTable` | 64 | ✕ (inline) | P8 | |
| `albums/[id]/components/LibraryPicker` | 65 | ✕ (inline) | P8 | |
| `(app)/playlists/+page.svelte` | 62 | ~ | P9 | |
| `(app)/playlists/[id]/+page.svelte` | 95 | ~ | P8/P10 | |
| `playlists/[id]/components/PlaylistHeader` | 62 | ✕ (inline) | P10 | |
| `playlists/[id]/components/PlaylistTrackTable` | 71 | ✕ (inline) | P8 | |
| `(app)/u/[id]/+page.svelte` | 97 | ~ | P3/P9/P10 | Avatar, grid |
| `routes/layout.css` | 238 | ~ | P1 | todos los tokens y recetas |
### `src/lib/` (cliente)
| Archivo | Veredicto | Parte | Motivo |
| ------- | --------- | ----- | ------ |
| `theme/theme.css` | ~ | P1/P2 | quitar duplicados de `tokens.ts`, añadir layout/z |
| `theme/tokens.ts` | ~ | P2 | `tokensToCss`, sin `--mf-sidebar-bg` |
| `theme/palette.ts`, `theme/color.ts`, `theme/mode.svelte.ts` | = | | (`mode` pierde el flash con P2) |
| `tracks.ts` | ~ | P5 | quitar envoltorio `TrackTarget` + 8 accesores |
| `recentlyPlayed.ts` | ~ | P5 | `toQueueItem` |
| `player/player.svelte.ts` | ~ | P5 | helpers de cola, `toQueueItem`, renombrar `addToQueue` |
| `player/liked.svelte.ts` | ~ | P5 | `toQueueItem` |
| `player/queuePanel.svelte.ts`, `playlists.svelte.ts` | → `toggle.svelte.ts` | P5 | idénticos |
| `format.ts` | ~ | P5/P9 | `plural`, `albumMeta`, `playlistMeta` |
| `navigation.svelte.ts` | ~ | P5 | `searchHref`, `appNavLinks` |
| `config.ts` | ~ | P6 | `MENU_WIDTH`, `SUBMENU_WIDTH` |
| `mixes.ts`, `albums.ts`, `collections.ts`, `types.ts` | = | | |
| `lib/server/**`, `+page.server.ts`, `routes/api/**`, `auth/**`, `hooks.server.ts` | fuera de alcance | | no contienen UI ni estilos |
+109 -78
View File
@@ -1,128 +1,159 @@
# Musify, page guide # Musify — Guía de páginas
Living document describing what each page of the web frontend should contain. It works as a map for prioritizing features. The design system (tokens, recipes, components) lives in the `musify-web` skill; here we describe **content and structure**, not styling. Documento vivo de qué debe contener cada página del frontend web. Sirve como mapa
para priorizar features. El sistema de diseño (tokens, recetas, componentes) vive en
el skill `musify-web`; aquí describimos **contenido y estructura**, no estilos.
Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending. Leyenda de estado: ✅ hecho · 🟡 parcial / maqueta · ⬜ pendiente.
> **Note (app redesign):** the `Musify.dc.html` prototype is being ported over (an app with a sidebar, views, and a persistent player with a dynamic accent per track). The marketing landing page was removed. The sections below still describe the older design and are pending an update to match the app views (Home, Search, Library, Upload, Import, Downloads, Playlists). The **player** is already built as components in `$lib/components/player/`, with its theme in `$lib/theme/` (`theme.css` for the palette, `color.ts` for the accent and gradient). > **Nota (rediseño app):** se está portando el prototipo `Musify.dc.html` (app: sidebar +
> vistas + player persistente con acento dinámico por pista). La landing de marketing se
> eliminó. Las secciones de abajo describen el diseño anterior y quedan pendientes de
> actualizar a las vistas de la app (Inicio, Buscar, Biblioteca, Subir, Importar, Descargas,
> Playlists). El **player** ya está hecho por componentes en `$lib/components/player/` con el
> tema en `$lib/theme/` (`theme.css` paleta + `color.ts` acento/gradiente).
## Home (app) `/` 🟡 ## Inicio (app) `/` 🟡
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue. - **Hero**: saludo + "Tu música. Sin límites." con color-wash del hue de la pista actual.
- **Quick access, meaning recently played** (`GET /users/{id}/listening-history` merged with `player.recentlyPlayed` via `mergeRecentlyPlayed`). Empty state with an icon and an "Explore" CTA. - **Acceso rápido = escuchado recientemente** (`player.recentlyPlayed`, hoy en cliente).
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state. Estado vacío con icono + CTA «Explorar».
- **Resume on load**: the root layout fetches `GET /users/{id}/last-listened-track` and hydrates the player (`player.hydrate`) with it, paused, so the player bar isn't empty on a fresh session. Only applies when nothing is already loaded (won't override an in-progress session). - **Tus playlists**: tarjetas mosaico de las playlists (hoy datos de muestra). Estado vacío.
### Pending backend work (ASP.NET) for Home ### Backend pendiente (ASP.NET) para Inicio
- **Playlists by last listen**: add `lastPlayedAt` to the playlist (the schema currently only has `createdAt`/`updatedAt`) and allow sorting `GET /playlists/users/{userId}` by that field (for example `?sort=lastPlayed`), to show "your most recently played" playlists. - **Escuchado recientemente**: registrar reproducciones por usuario (tabla de historial:
- Tracks will need an **artist** and a **color/hue** (or a real cover to extract one from) for the artwork and the dynamic accent. Right now `TrackApplicationResponse` carries neither artist nor color. `userId`, `trackId`, `playedAt`) y exponer `GET /me/recently-played?limit=` (o
`/users/{id}/recently-played`) devolviendo pistas ordenadas por `playedAt` desc, sin
duplicados. El front lo cargaría en un `load` y lo pasaría al store en vez de `recentlyPlayed`.
- **Playlists por última escucha**: añadir `lastPlayedAt` a la playlist (hoy el schema solo
tiene `createdAt`/`updatedAt`) y permitir ordenar `GET /playlists/users/{userId}` por ese
campo (p. ej. `?sort=lastPlayed`), para mostrar «tus últimas escuchadas».
- Las pistas necesitarán **artista** y un **color/hue** (o portada real de la que extraerlo)
para el arte y el acento dinámico; hoy `TrackApplicationResponse` no trae artista ni color.
## Route structure ## Estructura de rutas
Routes are grouped to separate the public experience from the authenticated one (the groups don't change the URL): Rutas agrupadas para separar lo público de lo autenticado (los grupos no cambian la URL):
``` ```
src/routes/ src/routes/
(marketing)/ Public, no session required (marketing)/ Público, sin sesión obligatoria
+page.svelte / Landing ✅ +page.svelte / Landing ✅
(app)/ The app experience (app)/ Experiencia de la app
explore/ /explore Explore / search ✅ explore/ /explore Explorar / buscar ✅
upload/ /upload Upload music 🟡 mockup upload/ /upload Subir música 🟡 maqueta
library/ /library Your library ⬜ library/ /library Tu biblioteca ⬜
playlists/ /playlists Your playlists ⬜ playlists/ /playlists Tus playlists ⬜
[id]/ /playlists/:id Playlist detail ⬜ [id]/ /playlists/:id Detalle playlist ⬜
auth auth/login auth/logout auth/callback Auth (page + endpoints) ✅ login logout auth/callback Auth (endpoints) ✅
api/tracks/[id]/stream Stream proxy ✅ api/tracks/[id]/stream Proxy de stream ✅
``` ```
Global components (present on every page): **Navbar**, **Footer**, and **PlayerBar** (the fixed playback bar, shown while something is playing). Componentes globales (en todas las páginas): **Navbar**, **Footer** y **PlayerBar**
(barra de reproducción fija, aparece al reproducir).
--- ---
## 1. Landing `/` ✅ (marketing) ## 1. Landing `/` ✅ (marketing)
The entry page for logged out visitors. Its job is to sell the product and drive people to sign up or explore. Página de entrada para no autenticados. Vender el producto y llevar a registro/explorar.
- **Hero**: a status badge (beta), a title, a subtitle, a primary CTA (Start for free, leading to sign up) and a secondary one (Explore songs). - **Hero**: badge de estado (beta), título, subtítulo, CTAs primario (Empezar gratis →
- **Features**: three cards covering listening, playlists, and uploading. registro) y secundario (Explorar canciones).
- **Suggested next steps**: a screenshots or demo section, social proof, a short FAQ. - **Features**: 3 cards (escuchar, playlists, subir).
- **Pendiente sugerido**: sección de capturas/demo, prueba social, FAQ corta.
## 2. Login / Auth ✅ (endpoints) ## 2. Login / Auth ✅ (endpoints)
`/auth` is Musify's login screen; its buttons lead to `/auth/login`, which kicks off the Zitadel OIDC flow and redirects. `?mode=register` starts on the sign up variant, and `?returnTo=` sends the user back to the route they came from once they're in. `/auth/logout` ends the session. `/auth/callback` exchanges the code and creates the encrypted `mf_session` cookie. The credentials UI itself is served by Zitadel, not by Musify. No es una página visual propia: `/login` inicia el flujo OIDC de Zitadel y redirige.
`?mode=register` arranca en registro; `?returnTo=` vuelve a la ruta pedida tras entrar.
`/logout` cierra sesión. `/auth/callback` intercambia el código y crea la cookie
cifrada `mf_session`. La UI de credenciales la sirve Zitadel, no Musify.
## 3. Explore / Search `/explore` ✅ (app) ## 3. Explorar / Buscar `/explore` ✅ (app)
Discovery and search over the catalog. Descubrimiento y búsqueda de canciones del catálogo.
- **Search box**: a text input (`?q=`) that filters by name on the backend. - **Buscador**: input de texto (`?q=`) que filtra por nombre en el backend.
- **Results count** and a **track grid** (2 to 4 columns): cover, title, date, and a play/pause button on hover that queues and plays through the global player. - **Contador de resultados** y **grid de tracks** (2→4 columnas): portada, título, fecha,
- **Pagination**: previous/next (`?page=`, 24 per page). botón play/pausa en hover que encola y reproduce vía el player global.
- **Empty state**: no results, or an empty catalog. - **Paginación** anterior/siguiente (`?page=`, 24 por página).
- **Suggested next steps**: filters (most recent, by user), loading skeletons, a real track cover (the listing API doesn't currently return the image key). - **Estado vacío**: sin resultados / catálogo vacío.
- **Pendiente sugerido**: filtros (más recientes, por usuario), skeletons de carga,
portada real del track (hoy la API de listado no devuelve la key de imagen).
## 4. Upload music `/upload` 🟡 mockup (app) ## 4. Subir música `/upload` 🟡 maqueta (app)
Adding a new song. The real backend flow, for when it gets wired up: Alta de una canción nueva. Flujo real de backend (para cuando se cablee):
1. `POST /tracks/upload-urls` returns presigned URLs for cover and audio, plus `intentId`s. 1. `POST /tracks/upload-urls` → URLs presignadas para portada y audio + `intentId`s.
2. `PUT` each file to its presigned URL (a direct upload to storage). 2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento).
3. `POST /tracks` with `title`, `pictureIntentId`, `audioIntentId`. 3. `POST /tracks` con `title`, `pictureIntentId`, `audioIntentId`.
4. The backend processes the audio asynchronously (transcoding it to DASH). 4. El backend procesa el audio (transcodifica a DASH) de forma asíncrona.
What the page needs: Contenido de la página:
- **Audio zone**: drag and drop or a file picker, showing the file's name and size, with type (audio) and size validation. - **Zona de audio**: drag & drop o selector; muestra nombre y tamaño del archivo, validación
- **Cover**: an image picker with a square preview. de tipo (audio) y tamaño.
- **Metadata**: title (required), with room for future fields like a description. - **Portada**: selector de imagen con vista previa cuadrada.
- **Visible pipeline**: the Upload, Process, Publish steps and their status. - **Metadatos**: título (obligatorio). Espacio para futuros campos (descripción, etc.).
- **Progress bar** for the upload and the processing state. - **Pipeline visible**: pasos Subir → Procesar → Publicar con su estado.
- **States**: empty, file selected, uploading, processing, published, error. - **Barra de progreso** de subida y estado de procesado.
- **Requires a session** (once wired up: redirect to `/auth?returnTo=/upload`). - **Estados**: vacío, archivo seleccionado, subiendo, procesando, publicado, error.
- **Requiere sesión** (al cablear: redirigir a `/login?returnTo=/upload`).
## 5. Your library `/library` ⬜ (app) ## 5. Tu biblioteca `/library` ⬜ (app)
The authenticated user's personal panel, everything of theirs in one place. Panel personal del usuario autenticado: lo suyo en un sitio.
- **Tabs / sections**: "Your songs" (`GET /tracks/users/{userId}`) and "Your playlists" (`GET /playlists/users/{userId}`). - **Tabs / secciones**: «Tus canciones» (`GET /tracks/users/{userId}`) y «Tus playlists»
- **Your songs**: a list with play, plus management actions (delete via `DELETE /tracks/{id}`, add to a playlist). (`GET /playlists/users/{userId}`).
- **Your playlists**: a grid of covers with a "Create playlist" button. - **Tus canciones**: lista con play, y acciones de gestión (borrar `DELETE /tracks/{id}`,
- **Empty state** with a CTA to upload or create. añadir a playlist).
- **Requires a session.** - **Tus playlists**: grid de portadas + botón «Crear playlist».
- **Estado vacío** con CTA a subir / crear.
- **Requiere sesión**.
## 6. Playlists `/playlists` ⬜ (app) ## 6. Playlists `/playlists` ⬜ (app)
A listing of the user's playlists. Listado de las playlists del usuario.
- **Grid** of playlists (cover, name, track count), linking to the detail page. - **Grid** de playlists (portada, nombre, nº de tracks) → enlaza al detalle.
- **Create playlist**: a modal or page with a name, description, and cover (`POST /playlists`, cover via `POST /playlists/upload-picture`). - **Crear playlist**: modal/página con nombre, descripción y portada
- **Empty state** with a CTA. (`POST /playlists`, portada vía `POST /playlists/upload-picture`).
- **Requires a session.** - **Estado vacío** con CTA.
- **Requiere sesión**.
## 7. Playlist detail `/playlists/[id]` ⬜ (app) ## 7. Detalle de playlist `/playlists/[id]` ⬜ (app)
- **Header**: a large cover, name, description, track count, and a "Play all" button. - **Cabecera**: portada grande, nombre, descripción, nº de tracks, botón «Reproducir todo».
- **Track list** (`GET /playlists/{id}/tracks`, paginated): index, title, date, play, and remove from the playlist (`DELETE /playlists/{id}/tracks/{trackId}`). - **Lista de tracks** (`GET /playlists/{id}/tracks`, paginada): índice, título, fecha,
- **Owner actions**: edit (`PUT`), delete (`DELETE`) the playlist, reorder (a future addition). play, quitar de la playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
- **Add tracks**: from explore or the library (`POST /playlists/{id}/tracks/{trackId}`). - **Acciones del dueño**: editar (`PUT`), borrar (`DELETE`) la playlist, reordenar (futuro).
- **Añadir tracks**: desde explorar/biblioteca (`POST /playlists/{id}/tracks/{trackId}`).
## 8. User profile `/user/[id]` ⬜ (future) ## 8. Perfil de usuario `/users/[id]` ⬜ (futuro)
A public view of a user: avatar, name, their public songs and playlists (`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`). Vista pública de un usuario: avatar, nombre, sus canciones y playlists públicas
(`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
--- ---
## Global player (PlayerBar) ✅ ## Player global (PlayerBar) ✅
A fixed bottom bar, visible while something is playing. Shows track info, transport controls (previous, play/pause, next), a progress bar, and volume control. Queues lists from any grid (`player.playQueue`). Plays DASH through the `/api/tracks/:id/stream` proxy, which requests a manifest and a signed ticket from the backend. Barra fija inferior visible al reproducir. Info de la pista, transporte
(anterior/play-pausa/siguiente), barra de progreso y control de volumen. Encola listas
desde cualquier grid (`player.playQueue`). Reproduce DASH vía el proxy
`/api/tracks/:id/stream`, que pide manifiesto + ticket firmado al backend.
## Available backend (summary) ## Backend disponible (resumen)
| Resource | Endpoints | | Recurso | Endpoints |
| --------- | ----------------------------------------------------------------- | | --------- | ----------------------------------------------------------------------- |
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls | | Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls |
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture | | Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture |
| Users | list/search, detail, create | | Users | listar/buscar, detalle, crear |
Typed client in `$lib/server/api.ts` (`openapi-fetch`, ErrorOr style). Use it inside `load`/server actions, never from the client. Types live in `src/lib/api/schema.d.ts`. Cliente tipado en `$lib/server/api.ts` (`openapi-fetch`, estilo ErrorOr). Consumir en
`load`/actions server, nunca desde cliente. Tipos en `src/lib/api/schema.d.ts`.
+7 -1
View File
@@ -36,6 +36,12 @@ export default ts.config(
} }
}, },
{ {
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts'] ignores: [
'build/',
'.svelte-kit/',
'static/',
'src/lib/api/schema.d.ts',
'design_handoff_musify_player/'
]
} }
); );
+14 -55
View File
@@ -39,40 +39,6 @@
"vite": "^8.0.16" "vite": "^8.0.16"
} }
}, },
"node_modules/@emnapi/core": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.11.3.tgz",
"integrity": "sha512-zLpS5asjEb7lq8jYLq37N6XKaE41DIexlY1rF/z4/tIl3wo13Sqm28fRyfIsKZD+NZ8mM5RoKkpW/rBcuoSZSg==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.3",
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/core/node_modules/@emnapi/wasi-threads": {
"version": "1.2.3",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.3.tgz",
"integrity": "sha512-ELEBe8PsLvvJ6QMr0zLt8ffvOHW/dc1m3CEzNMg7aJUv3bMaoDtw2TXyDAwkYBuroxxuHEwhRTLJSe5sya547g==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"peer": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@emnapi/wasi-threads": { "node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz", "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
@@ -1152,6 +1118,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==", "integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@standard-schema/spec": "^1.0.0", "@standard-schema/spec": "^1.0.0",
"@sveltejs/acorn-typescript": "^1.0.9", "@sveltejs/acorn-typescript": "^1.0.9",
@@ -1203,6 +1170,7 @@
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz", "resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==", "integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"deepmerge": "^4.3.1", "deepmerge": "^4.3.1",
"magic-string": "^0.30.21", "magic-string": "^0.30.21",
@@ -1440,27 +1408,6 @@
"node": ">=14.0.0" "node": ">=14.0.0"
} }
}, },
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/core": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"@emnapi/wasi-threads": "1.2.2",
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/runtime": {
"version": "1.11.1",
"dev": true,
"inBundle": true,
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": { "node_modules/@tailwindcss/oxide-wasm32-wasi/node_modules/@emnapi/wasi-threads": {
"version": "1.2.2", "version": "1.2.2",
"dev": true, "dev": true,
@@ -1597,6 +1544,7 @@
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==", "integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"undici-types": "~6.21.0" "undici-types": "~6.21.0"
} }
@@ -1658,6 +1606,7 @@
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==", "integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@typescript-eslint/scope-manager": "8.64.0", "@typescript-eslint/scope-manager": "8.64.0",
"@typescript-eslint/types": "8.64.0", "@typescript-eslint/types": "8.64.0",
@@ -1765,6 +1714,7 @@
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==", "integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"devOptional": true, "devOptional": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0" "node": "^18.18.0 || ^20.9.0 || >=21.1.0"
}, },
@@ -1848,6 +1798,7 @@
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"acorn": "bin/acorn" "acorn": "bin/acorn"
}, },
@@ -2082,6 +2033,7 @@
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==", "integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"workspaces": [ "workspaces": [
"packages/*" "packages/*"
], ],
@@ -3160,6 +3112,7 @@
} }
], ],
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"nanoid": "^3.3.12", "nanoid": "^3.3.12",
"picocolors": "^1.1.1", "picocolors": "^1.1.1",
@@ -3293,6 +3246,7 @@
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==", "integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"bin": { "bin": {
"prettier": "bin/prettier.cjs" "prettier": "bin/prettier.cjs"
}, },
@@ -3309,6 +3263,7 @@
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==", "integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
"dev": true, "dev": true,
"license": "MIT", "license": "MIT",
"peer": true,
"engines": { "engines": {
"node": ">=20" "node": ">=20"
}, },
@@ -3479,6 +3434,7 @@
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz", "resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==", "integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@types/estree": "1.0.9" "@types/estree": "1.0.9"
}, },
@@ -3613,6 +3569,7 @@
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz", "resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==", "integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"@jridgewell/remapping": "^2.3.4", "@jridgewell/remapping": "^2.3.4",
"@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/sourcemap-codec": "^1.5.0",
@@ -3824,6 +3781,7 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true, "devOptional": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"peer": true,
"bin": { "bin": {
"tsc": "bin/tsc", "tsc": "bin/tsc",
"tsserver": "bin/tsserver" "tsserver": "bin/tsserver"
@@ -3885,6 +3843,7 @@
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz", "resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==", "integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
"license": "MIT", "license": "MIT",
"peer": true,
"dependencies": { "dependencies": {
"lightningcss": "^1.32.0", "lightningcss": "^1.32.0",
"picomatch": "^4.0.4", "picomatch": "^4.0.4",
+1 -1
View File
@@ -71,7 +71,7 @@ for (const file of files) {
if (violations.length > 0) { if (violations.length > 0) {
console.error(`\n${violations.length} violación(es) del sistema de diseño:\n`); console.error(`\n${violations.length} violación(es) del sistema de diseño:\n`);
for (const v of violations) console.error(` ${v}`); for (const v of violations) console.error(` ${v}`);
console.error('\nVer la lista blanca (ARBITRARY_WHITELIST) en este script, o CLAUDE.md.\n'); console.error('\nVer docs/frontend-refactor-plan.md, sección B.4.\n');
process.exit(1); process.exit(1);
} }
+1 -1
View File
@@ -1,5 +1,5 @@
<!doctype html> <!doctype html>
<html lang="en"> <html lang="en" class="scrollbar-stable">
<head> <head>
<meta charset="utf-8" /> <meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" /> <meta name="viewport" content="width=device-width, initial-scale=1" />
+9 -6
View File
@@ -1,16 +1,15 @@
import type { Handle } from '@sveltejs/kit'; import type { Handle } from '@sveltejs/kit';
import { import {
clearSessionCookie,
decodeSession, decodeSession,
encodeSession, encodeSession,
readSessionCookie,
refreshSession, refreshSession,
writeSessionCookie sessionCookieOptions,
SESSION_COOKIE
} from '$lib/server/auth'; } from '$lib/server/auth';
import { authConfig } from '$lib/server/config'; import { authConfig } from '$lib/server/config';
export const handle: Handle = async ({ event, resolve }) => { export const handle: Handle = async ({ event, resolve }) => {
let session = await decodeSession(readSessionCookie(event.cookies)); let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
if ( if (
session && session &&
@@ -18,9 +17,13 @@ export const handle: Handle = async ({ event, resolve }) => {
) { ) {
session = await refreshSession(session); session = await refreshSession(session);
if (session) { if (session) {
writeSessionCookie(event.cookies, await encodeSession(session), event.url); event.cookies.set(
SESSION_COOKIE,
await encodeSession(session),
sessionCookieOptions(event.url)
);
} else { } else {
clearSessionCookie(event.cookies); event.cookies.delete(SESSION_COOKIE, { path: '/' });
} }
} }
+279 -561
View File
File diff suppressed because it is too large Load Diff
@@ -3,11 +3,11 @@
import Upload from '@lucide/svelte/icons/upload'; import Upload from '@lucide/svelte/icons/upload';
import Folder from '@lucide/svelte/icons/folder'; import Folder from '@lucide/svelte/icons/folder';
import LogIn from '@lucide/svelte/icons/log-in'; import LogIn from '@lucide/svelte/icons/log-in';
import MenuItem from '../ui/overlay/MenuItem.svelte'; import MenuItem from './ui/MenuItem.svelte';
import AccountMenu from '../ui/overlay/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from '../ui/media/Avatar.svelte'; import Avatar from './ui/Avatar.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from './ui/IconButton.svelte';
import Logo from '../ui/primitives/Logo.svelte'; import Logo from './ui/Logo.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -18,7 +18,7 @@
</script> </script>
<header <header
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between bg-transparent page-x backdrop-blur-md sm:hidden" class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between border-b border-hairline bg-bg/85 page-x backdrop-blur-md sm:hidden"
> >
<a href="/"> <a href="/">
<Logo size="sm" /> <Logo size="sm" />
@@ -44,7 +44,7 @@
</AccountMenu> </AccountMenu>
{:else} {:else}
<IconButton href="/auth/login" reload label="Iniciar sesión" shape="round" size="md"> <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> </IconButton>
{/if} {/if}
</header> </header>
@@ -1,13 +1,13 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte'; import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import { createPlaylistModal } from '$lib/state/playlists.svelte'; import { createPlaylistModal } from '$lib/playlists.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import ListRow from '../ui/media/ListRow.svelte'; import ListRow from './ui/ListRow.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from './ui/IconButton.svelte';
import Logo from './ui/Logo.svelte';
import Plus from '@lucide/svelte/icons/plus'; import Plus from '@lucide/svelte/icons/plus';
import Logo from '../ui/primitives/Logo.svelte';
interface SidebarPlaylist { interface SidebarPlaylist {
id: string; id: string;
@@ -31,10 +31,10 @@
</script> </script>
<aside <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 bg-bg p-5 theme-transition lg:flex"
style="width:var(--mf-sidebar-w)" style="width:var(--mf-sidebar-w)"
> >
<a href="/" class="mb-4"> <a href="/" class="px-3 pb-3">
<Logo size="sm" /> <Logo size="sm" />
</a> </a>
@@ -44,14 +44,14 @@
<a <a
href={link.href} href={link.href}
aria-current={active ? 'page' : undefined} aria-current={active ? 'page' : undefined}
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors {active class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors duration-150 {active
? 'bg-surface-2 text-fg' ? 'bg-surface-2 text-fg'
: 'text-fg-2 hover:bg-hover hover:text-fg'}" : 'text-fg-2 hover:bg-hover hover:text-fg'}"
> >
<link.icon class="size-icon-md shrink-0" /> <link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
<span class="flex-1 truncate">{link.label}</span> <span class="flex-1 truncate">{link.label}</span>
{#if link.count} {#if link.count}
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span> <span class="text-xs text-muted tabular-nums">{link.count}</span>
{/if} {/if}
</a> </a>
{/each} {/each}
@@ -60,7 +60,7 @@
{#if user} {#if user}
<div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div> <div class="mx-3 mt-6 mb-3.5 h-px bg-line"></div>
<div class="flex items-center justify-between px-3 pb-1.5"> <div class="flex items-center justify-between px-3 pb-1.5">
<span class="text-eyebrow text-fg-2">Tus playlists</span> <span class="text-eyebrow text-muted">Tus playlists</span>
{#if playlists.length > 0} {#if playlists.length > 0}
<IconButton <IconButton
label="Crear playlist" label="Crear playlist"
@@ -68,12 +68,12 @@
size="xs" size="xs"
onclick={() => createPlaylistModal.show()} onclick={() => createPlaylistModal.show()}
> >
<Plus class="size-icon-sm text-fg-2" /> <Plus class="size-icon-sm" strokeWidth={1.25} />
</IconButton> </IconButton>
{/if} {/if}
</div> </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-4.5">
{#each playlists as playlist (playlist.id)} {#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`} {@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow <ListRow
@@ -91,9 +91,9 @@
<button <button
type="button" type="button"
onclick={() => createPlaylistModal.show()} 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" class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-muted 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 Crear tu primera lista
</button> </button>
{/each} {/each}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { page } from '$app/state'; import { page } from '$app/state';
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte'; import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
interface Props { interface Props {
@@ -18,7 +18,7 @@
</script> </script>
<nav <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))" style="padding-bottom:calc(0.4375rem + var(--mf-safe-b))"
> >
{#each links as link (link.href)} {#each links as link (link.href)}
@@ -30,7 +30,7 @@
? 'text-fg' ? 'text-fg'
: 'text-fg-2'}" : '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> <span class="text-xs font-medium">{link.label}</span>
</a> </a>
{/each} {/each}
@@ -3,14 +3,14 @@
import { page } from '$app/state'; import { page } from '$app/state';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config'; import { SEARCH_DEBOUNCE_MS, SEARCH_MIN_LENGTH } from '$lib/config';
import { searchHref } from '$lib/state/navigation.svelte'; import { searchHref } from '$lib/navigation.svelte';
import Search from '@lucide/svelte/icons/search'; import Search from '@lucide/svelte/icons/search';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import ChevronLeft from '@lucide/svelte/icons/chevron-left'; import ChevronLeft from '@lucide/svelte/icons/chevron-left';
import AccountMenu from '../ui/overlay/AccountMenu.svelte'; import AccountMenu from './ui/AccountMenu.svelte';
import Avatar from '../ui/media/Avatar.svelte'; import Avatar from './ui/Avatar.svelte';
import IconButton from '../ui/primitives/IconButton.svelte'; import IconButton from './ui/IconButton.svelte';
import Button from '../ui/primitives/Button.svelte'; import Button from './ui/Button.svelte';
interface Props { interface Props {
user: SessionUser | null; user: SessionUser | null;
@@ -62,28 +62,28 @@
size="xs" size="xs"
onclick={() => history.back()} onclick={() => history.back()}
> >
<ChevronLeft class="size-icon-sm" /> <ChevronLeft class="size-icon-sm" strokeWidth={1.9} />
</IconButton> </IconButton>
<form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1"> <form onsubmit={onSubmit} class="max-w-search min-w-0 flex-1">
<label <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" 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-3" strokeWidth={1.8} />
<input <input
type="text" type="text"
bind:this={searchInput} bind:this={searchInput}
bind:value={query} bind:value={query}
oninput={onInput} oninput={onInput}
placeholder="Canciones, artistas, álbumes o playlists" placeholder="Canciones, artistas, álbumes o playlists"
class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-fg-2 focus:outline-none" class="w-full min-w-0 bg-transparent text-sm text-fg placeholder:text-muted focus:outline-none"
/> />
{#if query} {#if query}
<button <button
type="button" type="button"
onclick={clearQuery} onclick={clearQuery}
aria-label="Borrar búsqueda" aria-label="Borrar búsqueda"
class="grid size-5 shrink-0 place-items-center rounded-full text-fg-2 transition hover:text-fg" class="grid size-5 shrink-0 place-items-center rounded-full text-muted transition hover:text-fg"
> >
<X class="size-3.5" /> <X class="size-3.5" />
</button> </button>
@@ -1,14 +1,12 @@
<script lang="ts"> <script lang="ts">
import TrackInfo from './TrackInfo.svelte'; import TrackInfo from './TrackInfo.svelte';
import TransportControls from './TransportControls.svelte'; import TransportControls from './TransportControls.svelte';
import Slider from '$lib/components/ui/primitives/Slider.svelte'; import Slider from '$lib/components/ui/Slider.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/IconButton.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/format';
import Volume1 from '@lucide/svelte/icons/volume-1';
import Volume2 from '@lucide/svelte/icons/volume-2'; import Volume2 from '@lucide/svelte/icons/volume-2';
import VolumeX from '@lucide/svelte/icons/volume-x';
import ListMusic from '@lucide/svelte/icons/list-music'; import ListMusic from '@lucide/svelte/icons/list-music';
function seek(value: number) { function seek(value: number) {
@@ -17,7 +15,7 @@
</script> </script>
<div <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"> <div class="min-w-0 flex-1">
<TrackInfo compact /> <TrackInfo compact />
@@ -26,7 +24,7 @@
</div> </div>
<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"> <div class="w-(--mf-player-side-w) min-w-0 shrink-0">
<TrackInfo /> <TrackInfo />
@@ -34,7 +32,7 @@
<div class="flex min-w-0 flex-1 flex-col items-center gap-2"> <div class="flex min-w-0 flex-1 flex-col items-center gap-2">
<TransportControls /> <TransportControls />
<div class="flex w-full max-w-hero items-center gap-3"> <div class="flex w-full max-w-hero items-center gap-3">
<span class="min-w-8 text-right text-xs text-fg-2 tabular-nums"> <span class="min-w-8 text-right text-xs text-fg-3 tabular-nums">
{fmtTime(player.progress)} {fmtTime(player.progress)}
</span> </span>
<Slider <Slider
@@ -44,28 +42,15 @@
oninput={seek} oninput={seek}
variant="seek" variant="seek"
/> />
<span class="min-w-8 text-xs text-fg-2 tabular-nums"> <span class="min-w-8 text-xs text-fg-3 tabular-nums">
{fmtTime(player.current.duration)} {fmtTime(player.current.duration)}
</span> </span>
</div> </div>
</div> </div>
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex"> <div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<IconButton <Volume2 class="size-icon-sm shrink-0 text-fg-3" strokeWidth={1.8} />
label={player.muted ? 'Activar sonido' : 'Silenciar'}
tone="plain"
size="xs"
onclick={() => player.toggleMute()}
>
{#if player.muted || player.volume === 0}
<VolumeX class="size-icon-sm" />
{:else if player.volume < 50}
<Volume1 class="size-icon-sm" />
{:else}
<Volume2 class="size-icon-sm" />
{/if}
</IconButton>
<Slider <Slider
value={player.muted ? 0 : player.volume} value={player.volume}
max={100} max={100}
label="Volumen" label="Volumen"
oninput={(value) => player.setVolume(value)} oninput={(value) => player.setVolume(value)}
@@ -78,7 +63,7 @@
pressed={queuePanel.open} pressed={queuePanel.open}
onclick={() => queuePanel.toggle()} onclick={() => queuePanel.toggle()}
> >
<ListMusic class="size-icon-sm" /> <ListMusic class="size-icon-sm" strokeWidth={1.8} />
</IconButton> </IconButton>
</div> </div>
</div> </div>
@@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import PlayerBar from './PlayerBar.svelte'; import PlayerBar from './PlayerBar.svelte';
import TabsBar from '../layout/TabsBar.svelte'; import TabsBar from '../TabsBar.svelte';
import Alert from '$lib/components/ui/Alert.svelte';
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import type { SessionUser } from '$lib/types'; import type { SessionUser } from '$lib/types';
@@ -13,7 +14,14 @@
const hasTrack = $derived(player.currentId !== null); const hasTrack = $derived(player.currentId !== null);
</script> </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 player.error}
<div class="pointer-events-auto px-4 pb-2.5">
<Alert tone="danger">{player.error}</Alert>
</div>
{/if}
{#if hasTrack} {#if hasTrack}
<PlayerBar /> <PlayerBar />
{/if} {/if}
@@ -1,50 +1,25 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { queuePanel } from '$lib/player/queuePanel.svelte'; import { queuePanel } from '$lib/player/queuePanel.svelte';
import MediaIdentity from '$lib/components/ui/media/MediaIdentity.svelte'; import MediaIdentity from '$lib/components/ui/MediaIdentity.svelte';
import ListRow from '$lib/components/ui/media/ListRow.svelte'; import ListRow from '$lib/components/ui/ListRow.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/IconButton.svelte';
import { fmtTime } from '$lib/utils/format'; import { fmtTime } from '$lib/format';
import { flip } from 'svelte/animate';
import { expoOut } from 'svelte/easing';
import X from '@lucide/svelte/icons/x'; import X from '@lucide/svelte/icons/x';
import { fly } from 'svelte/transition';
import { cubicOut } from 'svelte/easing';
const upcoming = $derived.by(() => { const upcoming = $derived.by(() => {
const idx = player.tracks.findIndex((t) => t.id === player.currentId); const idx = player.tracks.findIndex((t) => t.id === player.currentId);
return idx === -1 ? player.tracks : player.tracks.slice(idx + 1); 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> </script>
<div {#if queuePanel.open}
class="queue-shell max-sm:hidden"
data-open={queuePanel.open ? '' : undefined}
inert={!queuePanel.open}
>
<aside <aside
class="flex min-h-0 shrink-0 flex-col border-l border-hairline px-4 pt-5 pb-5" transition:fly={{ x: 24, duration: 220, easing: cubicOut }}
style="width:var(--mf-queue-w)" class="hidden shrink-0 flex-col border-l border-hairline bg-bg px-4 pt-5 theme-transition 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"> <div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2> <h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
@@ -54,92 +29,53 @@
</div> </div>
{#if player.current.id} {#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p> <p class="mb-3 text-eyebrow text-fg-3">Reproduciendo</p>
<div class="mb-6 surface-active rounded-control p-2.5"> <div class="mb-6 rounded-control bg-accent-tint p-2.5">
<MediaIdentity <MediaIdentity
trackId={player.current.id} trackId={player.current.id}
title={player.current.title} title={player.current.title}
artist={player.current.artist || '—'} artist={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
explicit={player.current.explicit}
active active
/> />
</div> </div>
{/if} {/if}
<div class="mb-3 flex items-center justify-between gap-3"> <div class="mb-3 flex items-center justify-between gap-3">
<span class="min-w-0 flex-1 truncate text-eyebrow text-fg-2"> A continuación </span> <span class="min-w-0 flex-1 truncate text-eyebrow text-fg-3"> A continuación </span>
{#if upcoming.length > 0} {#if upcoming.length > 0}
<button <button
type="button" type="button"
onclick={() => player.clearUpcoming()} onclick={() => player.clearUpcoming()}
class="shrink-0 text-xs text-fg-2 transition-colors hover:text-fg" class="shrink-0 text-xs text-fg-3 transition-colors hover:text-fg"
> >
Vaciar Vaciar
</button> </button>
{/if} {/if}
</div> </div>
<!-- svelte-ignore a11y_no_static_element_interactions --> <div class="flex flex-1 flex-col gap-px overflow-y-auto">
<div
class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto"
ondragover={(event) => dragFrom !== null && event.preventDefault()}
ondrop={onDrop}
>
{#each upcoming as track, i (track.id)} {#each upcoming as track, i (track.id)}
{@const index = player.tracks.length - upcoming.length + i} {@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 }}
>
{#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 <ListRow
onclick={() => player.playQueueIndex(index)} onclick={() => player.playQueueIndex(index)}
size="sm" size="sm"
title={track.title} title={track.title}
subtitle={track.artist || '—'} subtitle={track.artist || '—'}
subtitleHref={track.ownerUserId} subtitleHref={track.ownerUserId}
explicit={track.explicit}
trackId={track.id} trackId={track.id}
class="p-2" class="p-2"
> >
{#snippet trailing()} {#snippet trailing()}
{#if track.duration} {#if track.duration}
<span class="shrink-0 text-xs text-fg-2 tabular-nums" <span class="shrink-0 text-xs text-muted tabular-nums">{fmtTime(track.duration)}</span
>{fmtTime(track.duration)}</span
> >
{/if} {/if}
{/snippet} {/snippet}
</ListRow> </ListRow>
</div>
{:else} {:else}
<p class="px-2 py-6 text-center text-xs text-fg-2">No hay más canciones en la cola.</p> <p class="px-2 py-6 text-center text-xs text-fg-3">No hay más canciones en la cola.</p>
{/each} {/each}
</div> </div>
</aside> </aside>
</div> {/if}
@@ -1,10 +1,10 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import { liked } from '$lib/player/liked.svelte'; import { liked } from '$lib/player/liked.svelte';
import Artwork from '$lib/components/ui/media/Artwork.svelte'; import Artwork from '$lib/components/ui/Artwork.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte'; import ExplicitBadge from '$lib/components/ui/ExplicitBadge.svelte';
import ArtistLink from '$lib/components/ui/media/ArtistLink.svelte'; import ArtistLink from '$lib/components/ui/ArtistLink.svelte';
import IconButton from '$lib/components/ui/primitives/IconButton.svelte'; import IconButton from '$lib/components/ui/IconButton.svelte';
import Heart from '@lucide/svelte/icons/heart'; import Heart from '@lucide/svelte/icons/heart';
interface Props { interface Props {
@@ -39,7 +39,7 @@
{:else} {:else}
<div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div> <div class="size-cover-sm shrink-0 rounded-art bg-surface md:size-cover-md"></div>
{/if} {/if}
<div class="min-w-0"> <div class="min-w-0 flex-1">
<div class="flex min-w-0 items-center gap-1.5"> <div class="flex min-w-0 items-center gap-1.5">
{#if player.current.explicit} {#if player.current.explicit}
<ExplicitBadge /> <ExplicitBadge />
@@ -49,18 +49,18 @@
<ArtistLink <ArtistLink
name={player.current.artist || '—'} name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId} ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-2" class="mt-0.5 text-xs text-fg-3"
/> />
</div> </div>
{#if !compact} {#if !compact}
<IconButton <IconButton
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'} label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
tone="plain" tone="plain"
size="md" size="xs"
pressed={isLiked} pressed={isLiked}
onclick={toggleLike} onclick={toggleLike}
> >
<Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} /> <Heart class="size-icon-md" fill={isLiked ? 'currentColor' : 'none'} strokeWidth={1.7} />
</IconButton> </IconButton>
{/if} {/if}
</div> </div>
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import { player } from '$lib/player/player.svelte'; import { player } from '$lib/player/player.svelte';
import PlayButton from '$lib/components/ui/media/PlayButton.svelte'; import PlayButton from '$lib/components/ui/PlayButton.svelte';
import Shuffle from '@lucide/svelte/icons/shuffle'; import Shuffle from '@lucide/svelte/icons/shuffle';
import Repeat from '@lucide/svelte/icons/repeat'; import Repeat from '@lucide/svelte/icons/repeat';
@@ -13,7 +13,7 @@
const sideClass = $derived( const sideClass = $derived(
compact compact
? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90' ? 'grid size-10 place-items-center text-fg opacity-70 transition active:scale-90'
: 'flex text-fg-2 transition hover:text-fg active:scale-90' : 'flex text-fg-2 transition-colors hover:text-fg active:scale-90'
); );
</script> </script>
@@ -24,9 +24,9 @@
onclick={() => player.toggleShuffle()} onclick={() => player.toggleShuffle()}
aria-pressed={player.shuffle} aria-pressed={player.shuffle}
aria-label="Aleatorio" aria-label="Aleatorio"
class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-2'}" class="transition-colors hover:text-fg {player.shuffle ? 'text-accent' : 'text-fg-3'}"
> >
<Shuffle class="size-4" /> <Shuffle class="size-4" strokeWidth={1.8} />
</button> </button>
{/if} {/if}
@@ -56,9 +56,9 @@
onclick={() => player.toggleRepeat()} onclick={() => player.toggleRepeat()}
aria-pressed={player.repeat} aria-pressed={player.repeat}
aria-label="Repetir" aria-label="Repetir"
class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-2'}" class="transition-colors hover:text-fg {player.repeat ? 'text-accent' : 'text-fg-3'}"
> >
<Repeat class="size-4" /> <Repeat class="size-4" strokeWidth={1.8} />
</button> </button>
{/if} {/if}
</div> </div>
@@ -44,11 +44,8 @@
{@render trigger({ toggle, open })} {@render trigger({ toggle, open })}
{#if open} {#if open}
<GlassMenu <GlassMenu class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}">
border={false} <MenuItem icon={User} label="Ver perfil" href="/u/{user.sub}" onclick={close} />
class="absolute top-full right-0 z-(--z-menu) mt-2 overflow-hidden {WIDTH[width]}"
>
<MenuItem icon={User} label="Ver perfil" href="/user/{user.sub}" onclick={close} />
{@render extraItems?.({ close })} {@render extraItems?.({ close })}
{#if accountUrl} {#if accountUrl}
<MenuItem <MenuItem
@@ -64,7 +61,7 @@
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'} label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()} onclick={() => themeMode.toggle()}
/> />
<form method="POST" action="/auth/logout" data-sveltekit-reload> <form method="POST" action="/logout" data-sveltekit-reload>
<MenuItem icon={LogOut} label="Cerrar sesión" type="submit" /> <MenuItem icon={LogOut} label="Cerrar sesión" type="submit" />
</form> </form>
</GlassMenu> </GlassMenu>
@@ -2,9 +2,9 @@
import { untrack } from 'svelte'; import { untrack } from 'svelte';
import Disc3 from '@lucide/svelte/icons/disc-3'; import Disc3 from '@lucide/svelte/icons/disc-3';
import CoverForm from './CoverForm.svelte'; import CoverForm from './CoverForm.svelte';
import Field from '../primitives/Field.svelte'; import Field from './Field.svelte';
import Input from '../primitives/Input.svelte'; import Input from './Input.svelte';
import Textarea from '../primitives/Textarea.svelte'; import Textarea from './Textarea.svelte';
import { ALBUM_EARLIEST_YEAR } from '$lib/config'; import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props { interface Props {
@@ -11,7 +11,7 @@
{#if name} {#if name}
{#if ownerUserId} {#if ownerUserId}
<a <a
href="/user/{ownerUserId}" href="/u/{ownerUserId}"
onclick={(e) => e.stopPropagation()} onclick={(e) => e.stopPropagation()}
class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}" class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
> >
@@ -118,14 +118,14 @@
/> />
{:else if fallback === 'music'} {:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface"> <div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2"> <svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-3">
<path <path
d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z" d="M19.952 1.651a.75.75 0 0 1 .298.599V16.303a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.401-4.909l2.311-.66a1.5 1.5 0 0 0 1.088-1.442V6.994l-9 2.572v9.737a3 3 0 0 1-2.176 2.884l-1.32.377a2.553 2.553 0 1 1-1.402-4.909l2.312-.66a1.5 1.5 0 0 0 1.088-1.442V5.25a.75.75 0 0 1 .544-.721l10.5-3a.75.75 0 0 1 .754.122Z"
/> />
</svg> </svg>
</div> </div>
{:else if fallback === 'gradient'} {: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} {:else}
<div class="h-full w-full bg-surface"></div> <div class="h-full w-full bg-surface"></div>
{/if} {/if}
@@ -1,6 +1,6 @@
<script lang="ts"> <script lang="ts">
import ArrowLeft from '@lucide/svelte/icons/arrow-left'; import ArrowLeft from '@lucide/svelte/icons/arrow-left';
import { previousPage } from '$lib/state/navigation.svelte'; import { previousPage } from '$lib/navigation.svelte';
interface Props { interface Props {
href: string; href: string;
@@ -24,7 +24,7 @@
<a <a
href={target.url} href={target.url}
{onclick} {onclick}
class="inline-flex items-center text-fg-2 transition-colors hover:text-accent-soft" class="inline-flex items-center text-fg-3 transition-colors hover:text-accent-soft"
> >
<span class="grid size-8 place-items-center"> <span class="grid size-8 place-items-center">
<ArrowLeft class="size-icon-sm" /> <ArrowLeft class="size-icon-sm" />
@@ -3,7 +3,7 @@
import LoaderCircle from '@lucide/svelte/icons/loader-circle'; import LoaderCircle from '@lucide/svelte/icons/loader-circle';
interface Props { interface Props {
variant?: 'primary' | 'secondary' | 'accent' | 'danger'; variant?: 'primary' | 'secondary' | 'glass' | 'accent' | 'strong' | 'danger';
size?: 'sm' | 'md' | 'lg'; size?: 'sm' | 'md' | 'lg';
type?: 'button' | 'submit'; type?: 'button' | 'submit';
href?: string; href?: string;
@@ -29,20 +29,25 @@
}: Props = $props(); }: Props = $props();
const sizes = { const sizes = {
sm: 'rounded-control px-4 py-2 text-xs', sm: 'px-4 py-2.5 text-xs',
md: 'rounded-btn-sm px-4.5 py-2.5 text-control-sm', md: 'px-5 py-3 text-sm',
lg: 'rounded-btn px-6 py-3.5 text-sm' lg: 'px-6 py-3.5 text-sm'
}; };
const subtle = 'bg-btn text-fg-2 hover:bg-btn-hover hover:text-fg';
const glass = 'font-normal bg-btn-glass text-fg-2 hover:bg-btn-glass-hover hover:text-fg';
const variants = { const variants = {
primary: 'bg-cta-strong text-ink hover:bg-cta-strong-hover', primary: subtle,
secondary: 'bg-btn text-fg hover:bg-btn-hover', secondary: subtle,
accent: 'surface-active text-accent-soft hover:surface-active-hover', glass,
accent: 'bg-accent-btn text-accent-soft hover:bg-accent-btn-hover',
strong: 'bg-cta-strong text-ink hover:brightness-95',
danger: 'bg-danger text-on-art hover:brightness-110' danger: 'bg-danger text-on-art hover:brightness-110'
}; };
const classes = $derived( const classes = $derived(
`inline-flex items-center justify-center gap-2 font-medium btn-transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}` `inline-flex items-center justify-center gap-2 rounded-control transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:ring-offset-2 focus-visible:ring-offset-bg focus-visible:outline-none disabled:opacity-40 ${sizes[size]} ${variants[variant]} ${klass}`
); );
</script> </script>
@@ -22,6 +22,7 @@
/> />
<Check <Check
class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100" class="pointer-events-none absolute size-3.5 text-ink opacity-0 transition peer-checked:opacity-100"
strokeWidth={3}
/> />
</span> </span>
<span class="cursor-pointer">{@render children()}</span> <span class="cursor-pointer">{@render children()}</span>

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