Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c4ab5dc388 | ||
|
|
920dcc6779 | ||
|
|
b61ff1ebec | ||
|
|
8870c65c6f | ||
|
|
5df5e635f3 | ||
|
|
d5e3cadc43 | ||
|
|
505ae05c53 | ||
|
|
a7929c0ac9 | ||
|
|
4d39008eab | ||
|
|
5efd59826b | ||
|
|
8970f29410 | ||
|
|
523318fc06 | ||
|
|
ba531b4f61 | ||
|
|
3d51f08e0b | ||
|
|
2660c124d5 | ||
|
|
d7199b4d1d | ||
|
|
c60704080b | ||
|
|
1e3e46cfdc | ||
|
|
93d4220f3a | ||
|
|
8a748f2b3a | ||
|
|
2138a4a890 | ||
|
|
94a1981a4c | ||
|
|
2272a401bb | ||
|
|
3ab43a2ef6 | ||
|
|
79698f2265 | ||
|
|
94f80c03c5 | ||
|
|
d98d01ab20 | ||
|
|
6be5c43e6d | ||
|
|
8deb762a52 | ||
|
|
fe21c19810 | ||
|
|
6e86c3d2c5 | ||
|
|
aca4b73fac | ||
|
|
078f7ac6f6 | ||
|
|
b168df6ca6 | ||
|
|
8d178884d4 | ||
|
|
cd2131ef6f | ||
|
|
d6bff15f11 | ||
|
|
b82fca0bc2 | ||
|
|
4fbd4fa81d | ||
|
|
68b5709d88 | ||
|
|
434b2c1305 | ||
|
|
9064ef687c | ||
|
|
c93f3d352c | ||
|
|
a9bfcbbe2f | ||
|
|
717e939038 | ||
|
|
dd1cb8a5d1 | ||
|
|
7c9399a342 | ||
|
|
a3ec9d19d3 | ||
|
|
5f2b5bd5a2 | ||
|
|
3bb4e66ad0 | ||
|
|
fbb102ffbd | ||
|
|
76f7535ffa | ||
|
|
7fcf0645da | ||
|
|
50618865e7 | ||
|
|
fe2f6a785a | ||
|
|
53fc24d4bc | ||
|
|
ff5fa501af | ||
|
|
6e9883018a | ||
|
|
a985ff4fb3 | ||
|
|
63018a7c72 | ||
|
|
c5748e9765 | ||
|
|
23e30d96fc | ||
|
|
3f8d517ee3 |
@@ -0,0 +1,115 @@
|
||||
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: Migrate database, generate keys
|
||||
run: |
|
||||
set -euo pipefail
|
||||
cd "$DEPLOY_PATH"
|
||||
$COMPOSE up -d wait-for-postgres keys-init migrate
|
||||
|
||||
# Both jobs verify themselves through the public auth./s3. URLs, served
|
||||
# by the shared edge nginx (Infrastructure's edge/), and are idempotent.
|
||||
- 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
|
||||
@@ -7,6 +7,9 @@ obj/
|
||||
*.userosscache
|
||||
.vs/
|
||||
artifacts/
|
||||
TestResults/
|
||||
*.trx
|
||||
*.coverage
|
||||
|
||||
# Rider / VS
|
||||
.idea/
|
||||
@@ -25,5 +28,4 @@ Thumbs.db
|
||||
# Deploy secrets and runtime state
|
||||
deploy/keys/
|
||||
deploy/secrets/
|
||||
deploy/nginx/certs/
|
||||
deploy/zitadel/.output/
|
||||
|
||||
@@ -1,57 +1,55 @@
|
||||
# Musify
|
||||
|
||||
Music streaming platform: upload your own tracks, process them asynchronously
|
||||
(audio transcoded to `.m4a`, thumbnails generated) and stream them without the
|
||||
bytes ever going through the API.
|
||||
A music streaming app. Upload your own tracks, organize them into playlists
|
||||
and albums, and stream them back with a web player built around your
|
||||
listening habits: auto-generated mixes, a home feed, search, likes, and an
|
||||
Explore page for finding something new.
|
||||
|
||||
## What it does
|
||||
|
||||
- **Upload and organize your own music**: tracks, albums, cover art, artist credits.
|
||||
- **Playlists**, likes, and a "recently played" history.
|
||||
- **Mixes**, playlists generated automatically from what you actually listen to.
|
||||
- **Explore**, genre browsing and search across tracks, albums, playlists and people.
|
||||
- **Real streaming**, not just file downloads: seek, queue, shuffle, repeat.
|
||||
- **Accounts via OIDC login**, with an optional anonymous listening mode.
|
||||
- Light and dark themes, and a layout that works on both desktop and mobile.
|
||||
|
||||
## How it's built
|
||||
|
||||
**Backend**: .NET 10, split into a REST API, a background worker, and a
|
||||
dedicated streaming gateway.
|
||||
- Clean architecture (Domain / Application / Infrastructure) with CQRS (Mediator) and `ErrorOr` results.
|
||||
- PostgreSQL via EF Core for metadata. RabbitMQ and MassTransit handle async processing (audio transcoding, thumbnails) with saga-based rollback.
|
||||
- SeaweedFS (S3-compatible) for file storage. Uploads and downloads go straight between the client and storage, never through the API.
|
||||
- ffmpeg for audio transcoding, ImageSharp for thumbnails.
|
||||
- A YARP reverse proxy that streams the audio and checks a signed, short-lived ticket on every request.
|
||||
- Zitadel for login (OIDC/OAuth2).
|
||||
|
||||
**Web client**: SvelteKit 2 on Svelte 5 (runes), TypeScript, Tailwind 4.
|
||||
The session lives on the server, so the backend access token never reaches the browser.
|
||||
|
||||
**Infrastructure**: Musify's own apps run from Docker Compose (dev and
|
||||
production overlays). Postgres, RabbitMQ, SeaweedFS, Zitadel and Jaeger are
|
||||
shared infrastructure that lives in a separate `Infrastructure` git
|
||||
repository, used by Musify and by other projects that need the same OIDC
|
||||
provider and object storage.
|
||||
|
||||
## Project layout
|
||||
|
||||
```
|
||||
backend/ .NET 10 — Domain / Application / Infrastructure / Api / Worker / StreamingGateway / Tests
|
||||
web-player/ SvelteKit 2 + Svelte 5 + Tailwind 4 web client
|
||||
deploy/ Docker Compose stacks — self-contained, no wrapper scripts
|
||||
docs/ What each piece does and why (in Spanish)
|
||||
backend/ .NET solution: Api, Worker, StreamingGateway, and the shared Domain/Application/Infrastructure
|
||||
web-player/ the SvelteKit web client
|
||||
deploy/ Docker Compose stacks for dev and production
|
||||
docs/ architecture notes, one file per area
|
||||
```
|
||||
|
||||
```sh
|
||||
dotnet test backend/Musify.slnx # backend/Tests/ — Docker running (Testcontainers: Postgres, SeaweedFS)
|
||||
```
|
||||
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not part of this
|
||||
layout. They live in the separate `Infrastructure` repository instead.
|
||||
|
||||
## Run it
|
||||
## Digging deeper
|
||||
|
||||
Development (Docker Desktop):
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
||||
```
|
||||
|
||||
Infrastructure plus stream-ticket keys, database migrations and Zitadel OIDC
|
||||
provisioning — all as one-shot jobs baked into the compose file. Apps run from
|
||||
the IDE by default; add `--profile apps up -d --build` as a second command to
|
||||
run them in Docker instead.
|
||||
|
||||
Production, on a server with a domain:
|
||||
|
||||
```sh
|
||||
cp deploy/.env.prod.example deploy/.env.prod # fill in the domain and secrets
|
||||
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
|
||||
```
|
||||
|
||||
Idempotent — run the same commands again to deploy an update. See
|
||||
[deploy/README.md](deploy/README.md) for the details, the port map and the
|
||||
production prerequisites.
|
||||
|
||||
## Components
|
||||
|
||||
| Service | Port (dev) | Role |
|
||||
|---|---|---|
|
||||
| `Musify.Api` | 5111 | REST API, issues presigned upload URLs and stream tickets |
|
||||
| `Musify.StreamingGateway` | 8081 | YARP proxy that validates the stream ticket and serves the audio |
|
||||
| `Musify.Worker` | — | MassTransit consumers: transcoding, thumbnails |
|
||||
| `web-player` | 5173 / 3000 | Web client (SvelteKit, server-side OIDC session) |
|
||||
| Zitadel | 8080 | Identity (OIDC/OAuth2) |
|
||||
| PostgreSQL / RabbitMQ / SeaweedFS / Jaeger | 59000 / 5672 / 8333 / 16686 | Metadata, messaging, object storage, traces |
|
||||
|
||||
More in [docs/](docs/README.md): [architecture](docs/architecture.md),
|
||||
[authentication](docs/authentication.md), [storage](docs/storage.md),
|
||||
[streaming](docs/streaming.md), [media processing](docs/media-processing.md),
|
||||
[development](docs/development.md).
|
||||
[docs/](docs/README.md) covers how each piece works and why: architecture,
|
||||
authentication, storage, streaming, media processing, and the local dev
|
||||
setup. [deploy/README.md](deploy/README.md) has the full instructions for
|
||||
running the stack, in development or on a production server.
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace Musify.Application.Albums
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
.OrderBy(albumTrack => albumTrack.TrackNumber)
|
||||
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count })
|
||||
.Select(albumTrack => new { albumTrack.Track, ListensCount = albumTrack.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||
|
||||
@@ -16,7 +16,7 @@ namespace Musify.Application.Albums
|
||||
{
|
||||
var listenedTrackIds = database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == request.UserId);
|
||||
.Where(history => history.UserId == request.UserId && history.IsCounted);
|
||||
|
||||
var ranked = await database.AlbumHasTracks
|
||||
.AsNoTracking()
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
using Mediator;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres
|
||||
{
|
||||
public record GetAvailableGenresQuery : IQuery<IReadOnlyList<AvailableGenreResponse>>;
|
||||
|
||||
public record AvailableGenreResponse(Genre Genre, IReadOnlyList<Genre> IncompatibleWith);
|
||||
|
||||
public class GetAvailableGenresQueryHandler
|
||||
: IQueryHandler<GetAvailableGenresQuery, IReadOnlyList<AvailableGenreResponse>>
|
||||
{
|
||||
public ValueTask<IReadOnlyList<AvailableGenreResponse>> Handle(GetAvailableGenresQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var all = Enum.GetValues<Genre>();
|
||||
|
||||
IReadOnlyList<AvailableGenreResponse> result = all
|
||||
.Select(genre => new AvailableGenreResponse(
|
||||
genre,
|
||||
all.Where(other => !GenreCompatibility.AreCompatible(genre, other)).ToList()))
|
||||
.ToList();
|
||||
|
||||
return ValueTask.FromResult(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Genres
|
||||
{
|
||||
public record GetGenresQuery : IQuery<IReadOnlyList<GenreResponse>>;
|
||||
|
||||
public record GenreResponse(Genre Genre, int TrackCount);
|
||||
|
||||
public class GetGenresQueryHandler(IDatabase database)
|
||||
: IQueryHandler<GetGenresQuery, IReadOnlyList<GenreResponse>>
|
||||
{
|
||||
public async ValueTask<IReadOnlyList<GenreResponse>> Handle(GetGenresQuery request, CancellationToken cancellationToken)
|
||||
{
|
||||
var rows = await database.TrackTags
|
||||
.AsNoTracking()
|
||||
.Where(tag => database.UserHasTracks.Any(userTrack => userTrack.TrackId == tag.TrackId))
|
||||
.GroupBy(tag => tag.Tag)
|
||||
.Select(group => new { Genre = group.Key, TrackCount = group.Select(tag => tag.TrackId).Distinct().Count() })
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
return rows
|
||||
.OrderByDescending(row => row.TrackCount)
|
||||
.ThenBy(row => row.Genre)
|
||||
.Select(row => new GenreResponse(row.Genre, row.TrackCount))
|
||||
.ToList();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -30,7 +30,7 @@ namespace Musify.Application.Likes
|
||||
|
||||
var pagedEntities = await likesQuery
|
||||
.OrderByDescending(like => like.CreatedAt)
|
||||
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count })
|
||||
.Select(like => new { like.Track, ListensCount = like.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||
|
||||
@@ -38,7 +38,7 @@ namespace Musify.Application.Mixes
|
||||
|
||||
var historyTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.UserId == userId)
|
||||
.Where(history => history.UserId == userId && history.IsCounted)
|
||||
.Select(history => history.TrackId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -19,7 +19,7 @@ namespace Musify.Application.Mixes
|
||||
.AsNoTracking()
|
||||
.Include(track => track.Owner)
|
||||
.Where(track => trackIds.Contains(track.Id))
|
||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count })
|
||||
.Select(track => new { Track = track, ListensCount = track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToDictionaryAsync(x => x.Track.Id, cancellationToken);
|
||||
|
||||
return orderedItems
|
||||
|
||||
@@ -36,7 +36,7 @@ namespace Musify.Application.PlayLists
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
.OrderBy(plt => plt.Position)
|
||||
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count })
|
||||
.Select(plt => new { plt.Track, ListensCount = plt.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||
|
||||
@@ -30,6 +30,7 @@ public sealed class TrackStreamIssuer(
|
||||
folderPath,
|
||||
streamGatewayConfiguration.AudioFileName);
|
||||
|
||||
Guid? listenId = null;
|
||||
if (userId != null)
|
||||
{
|
||||
var newListeningHistory = new ListeningHistory
|
||||
@@ -39,9 +40,10 @@ public sealed class TrackStreamIssuer(
|
||||
};
|
||||
await database.ListeningHistories.AddAsync(newListeningHistory, cancellationToken);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
listenId = newListeningHistory.Id;
|
||||
}
|
||||
|
||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds);
|
||||
return new TrackStreamResponse(manifestUrl, ticket.Token, ticket.ExpiresInSeconds, listenId);
|
||||
}
|
||||
|
||||
private long? AnonymousFragmentBytes(long? userId)
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.Logging;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record DeleteStaleUncountedListensCommand : ICommand<int>;
|
||||
|
||||
public class DeleteStaleUncountedListensCommandHandler(
|
||||
IDatabase database,
|
||||
ILogger<DeleteStaleUncountedListensCommandHandler> logger)
|
||||
: ICommandHandler<DeleteStaleUncountedListensCommand, int>
|
||||
{
|
||||
public async ValueTask<int> Handle(DeleteStaleUncountedListensCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
var cutoff = DateTime.UtcNow.AddDays(-ListeningRules.UncountedRetentionDays);
|
||||
|
||||
var stale = await database.ListeningHistories
|
||||
.Where(l => !l.IsCounted && l.ListenedAt < cutoff)
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
if (stale.Count == 0)
|
||||
return 0;
|
||||
|
||||
database.ListeningHistories.RemoveRange(stale);
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogInformation("Deleted {Count} uncounted listens older than {Days} days", stale.Count, ListeningRules.UncountedRetentionDays);
|
||||
return stale.Count;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ namespace Musify.Application.Tracks
|
||||
.Include(t => t.Track.Tags)
|
||||
.Where(t => t.UserId == query.UserId)
|
||||
.OrderByDescending(t => t.ListenedAt)
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count))
|
||||
.Select(t => TrackApplicationResponse.FromEntity(t.Track, t.Track.ListeningHistories.Count(l => l.IsCounted)))
|
||||
.FirstOrDefaultAsync(cancellationToken);
|
||||
|
||||
return track == null
|
||||
|
||||
@@ -17,7 +17,7 @@ namespace Musify.Application.Tracks
|
||||
{
|
||||
var recentTrackIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == query.UserId)
|
||||
.Where(l => l.UserId == query.UserId && l.IsCounted)
|
||||
.GroupBy(l => l.TrackId)
|
||||
.OrderByDescending(g => g.Max(l => l.ListenedAt))
|
||||
.Select(g => g.Key)
|
||||
@@ -29,7 +29,7 @@ namespace Musify.Application.Tracks
|
||||
.Include(t => t.Owner)
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => recentTrackIds.Contains(t.Id))
|
||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count })
|
||||
.Select(t => new { t.Id, Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToDictionaryAsync(t => t.Id, cancellationToken);
|
||||
|
||||
return recentTrackIds
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace Musify.Application.Tracks
|
||||
.Include(t => t.Owner)
|
||||
.Include(t => t.Tags)
|
||||
.Where(t => t.Id == request.TrackId)
|
||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count })
|
||||
.Select(t => new { Track = t, ListensCount = t.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.SingleOrDefaultAsync(cancellationToken);
|
||||
|
||||
if (entity == null)
|
||||
|
||||
@@ -5,10 +5,11 @@ using Musify.Application.Tracks.Responses;
|
||||
using X.PagedList;
|
||||
using X.PagedList.EF;
|
||||
using Musify.Application.Contracts;
|
||||
using Musify.Domain.ValueObjects;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize)
|
||||
public record GetTracksQuery(string? Name, int PageNumber, int PageSize, Genre? Genre = null)
|
||||
: IQuery<ErrorOr<TracksSearchResponse>>;
|
||||
|
||||
public class GetTracksQueryHandler(IDatabase database)
|
||||
@@ -28,12 +29,15 @@ namespace Musify.Application.Tracks
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.NormalizedTitle.Contains(normalizedName));
|
||||
}
|
||||
|
||||
if (request.Genre is { } genre)
|
||||
tracksQuery = tracksQuery.Where(t => t.Track.Tags.Any(tag => tag.Tag == genre));
|
||||
|
||||
var totalCount = await tracksQuery.CountAsync(cancellationToken);
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
.OrderByDescending(t => t.Track.CreatedAt)
|
||||
.ThenBy(t => t.Id)
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
|
||||
|
||||
var items = pagedEntities
|
||||
|
||||
@@ -40,7 +40,7 @@ namespace Musify.Application.Tracks
|
||||
|
||||
var pagedEntities = await tracksQuery
|
||||
.OrderBy(t => t.Track.CreatedAt)
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count })
|
||||
.Select(t => new { t.Track, ListensCount = t.Track.ListeningHistories.Count(l => l.IsCounted) })
|
||||
.ToPagedListAsync(pageNumber, pageSize, totalCount, cancellationToken);
|
||||
|
||||
var pagedTracks = new StaticPagedList<TrackApplicationResponse>(
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public static class ListeningRules
|
||||
{
|
||||
public const double CountedThresholdSeconds = 30;
|
||||
public const double CountedThresholdFraction = 0.5;
|
||||
public const double DurationToleranceSeconds = 2;
|
||||
public const double ClockToleranceSeconds = 5;
|
||||
public const int UncountedRetentionDays = 8;
|
||||
|
||||
public static double CountedThreshold(double durationSeconds) =>
|
||||
Math.Min(CountedThresholdSeconds, durationSeconds * CountedThresholdFraction);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
using ErrorOr;
|
||||
using Mediator;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Contracts;
|
||||
|
||||
namespace Musify.Application.Tracks
|
||||
{
|
||||
public record RecordListeningProgressCommand(
|
||||
long UserId,
|
||||
Guid ListenId,
|
||||
double PlayedSeconds)
|
||||
: ICommand<ErrorOr<Success>>;
|
||||
|
||||
public class RecordListeningProgressCommandHandler(IDatabase database)
|
||||
: ICommandHandler<RecordListeningProgressCommand, ErrorOr<Success>>
|
||||
{
|
||||
public async ValueTask<ErrorOr<Success>> Handle(RecordListeningProgressCommand request, CancellationToken cancellationToken)
|
||||
{
|
||||
if (!double.IsFinite(request.PlayedSeconds) || request.PlayedSeconds < 0)
|
||||
return Error.Validation(description: "playedSeconds must be a non-negative number.");
|
||||
|
||||
var listen = await database.ListeningHistories
|
||||
.Include(l => l.Track)
|
||||
.SingleOrDefaultAsync(l => l.Id == request.ListenId && l.UserId == request.UserId, cancellationToken);
|
||||
|
||||
if (listen == null)
|
||||
return Error.NotFound();
|
||||
|
||||
var now = DateTime.UtcNow;
|
||||
var durationCap = listen.Track.DurationSeconds + ListeningRules.DurationToleranceSeconds;
|
||||
var clockCap = (now - listen.ListenedAt).TotalSeconds + ListeningRules.ClockToleranceSeconds;
|
||||
var accepted = Math.Min(request.PlayedSeconds, Math.Min(durationCap, clockCap));
|
||||
|
||||
listen.PlayedSeconds = Math.Max(listen.PlayedSeconds ?? 0, accepted);
|
||||
listen.LastProgressAt = now;
|
||||
listen.IsCounted = listen.IsCounted
|
||||
|| listen.PlayedSeconds >= ListeningRules.CountedThreshold(listen.Track.DurationSeconds);
|
||||
|
||||
await database.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return Result.Success;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -3,5 +3,6 @@ namespace Musify.Application.Tracks.Responses
|
||||
public record TrackStreamResponse(
|
||||
string ManifestUrl,
|
||||
string Ticket,
|
||||
int ExpiresInSeconds);
|
||||
int ExpiresInSeconds,
|
||||
Guid? ListenId = null);
|
||||
}
|
||||
|
||||
@@ -20,17 +20,18 @@ namespace Musify.Application.Users
|
||||
.Where(l => l.UserId == request.UserId && l.ListenedAt >= weekAgo);
|
||||
|
||||
var tracksThisWeek = await thisWeekHistory
|
||||
.Where(l => l.IsCounted)
|
||||
.Select(l => l.TrackId)
|
||||
.Distinct()
|
||||
.CountAsync(cancellationToken);
|
||||
|
||||
var secondsThisWeek = await thisWeekHistory
|
||||
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => t.DurationSeconds)
|
||||
.Join(database.Tracks.AsNoTracking(), l => l.TrackId, t => t.Id, (l, t) => l.PlayedSeconds ?? (l.IsCounted ? t.DurationSeconds : 0))
|
||||
.SumAsync(cancellationToken);
|
||||
|
||||
var listenedDates = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(l => l.UserId == request.UserId)
|
||||
.Where(l => l.UserId == request.UserId && l.IsCounted)
|
||||
.Select(l => l.ListenedAt.Date)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -19,6 +19,12 @@ namespace Musify.Domain.Entities
|
||||
[Required]
|
||||
public DateTime ListenedAt { get; set; } = DateTime.UtcNow;
|
||||
|
||||
public double? PlayedSeconds { get; set; }
|
||||
|
||||
public DateTime? LastProgressAt { get; set; }
|
||||
|
||||
public bool IsCounted { get; set; }
|
||||
|
||||
[ForeignKey(nameof(UserId) )]
|
||||
public User User { get; set; }
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ namespace Musify.Infrastructure.Jobs
|
||||
{
|
||||
var userIds = await database.ListeningHistories
|
||||
.AsNoTracking()
|
||||
.Where(history => history.IsCounted)
|
||||
.Select(history => history.UserId)
|
||||
.Distinct()
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
@@ -0,0 +1,13 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Tracks;
|
||||
|
||||
namespace Musify.Infrastructure.Jobs
|
||||
{
|
||||
public class ListeningHistoryCleanupJob(IMediator mediator)
|
||||
{
|
||||
public async Task RunAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
await mediator.Send(new DeleteStaleUncountedListensCommand(), cancellationToken);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ namespace Musify.Infrastructure.Persistence
|
||||
}
|
||||
else if (entry.State == EntityState.Modified)
|
||||
{
|
||||
entry.Entity.UpdatedAt = now;
|
||||
entry.Property(nameof(IAuditable.UpdatedAt)).CurrentValue = now;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -13,9 +13,13 @@ namespace Musify.Infrastructure.Persistence
|
||||
public class Database(DatabaseConfiguration configuration)
|
||||
: DbContext, AppIDatabase
|
||||
{
|
||||
private static readonly AuditableEntityInterceptor AuditInterceptor = new();
|
||||
|
||||
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
|
||||
{
|
||||
optionsBuilder.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"));
|
||||
optionsBuilder
|
||||
.UseNpgsql(configuration.ConnectionString, o => o.MapEnum<Genre>("genre"))
|
||||
.AddInterceptors(AuditInterceptor);
|
||||
}
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||
|
||||
@@ -18,10 +18,7 @@ namespace Musify.Infrastructure.Persistence
|
||||
serviceDescriptors.AddSingleton(serviceProvider =>
|
||||
serviceProvider.GetRequiredService<IOptions<DatabaseConfiguration>>().Value);
|
||||
|
||||
serviceDescriptors.AddSingleton<AuditableEntityInterceptor>();
|
||||
|
||||
serviceDescriptors.AddDbContext<Database>((serviceProvider, options) =>
|
||||
options.AddInterceptors(serviceProvider.GetRequiredService<AuditableEntityInterceptor>()));
|
||||
serviceDescriptors.AddDbContext<Database>();
|
||||
|
||||
serviceDescriptors.AddScoped<IDatabase>(serviceProvider => serviceProvider.GetRequiredService<Database>());
|
||||
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Musify.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class TrackPlayedSeconds : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IsCounted",
|
||||
table: "ListeningHistory",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
|
||||
migrationBuilder.AddColumn<DateTime>(
|
||||
name: "LastProgressAt",
|
||||
table: "ListeningHistory",
|
||||
type: "timestamp with time zone",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.AddColumn<double>(
|
||||
name: "PlayedSeconds",
|
||||
table: "ListeningHistory",
|
||||
type: "double precision",
|
||||
nullable: true);
|
||||
|
||||
migrationBuilder.Sql("UPDATE \"ListeningHistory\" SET \"IsCounted\" = TRUE;");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IsCounted",
|
||||
table: "ListeningHistory");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "LastProgressAt",
|
||||
table: "ListeningHistory");
|
||||
|
||||
migrationBuilder.DropColumn(
|
||||
name: "PlayedSeconds",
|
||||
table: "ListeningHistory");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
@@ -68,7 +68,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("Delivered");
|
||||
|
||||
b.ToTable("InboxState", (string)null);
|
||||
b.ToTable("InboxState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
@@ -159,7 +159,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("InboxMessageId", "InboxConsumerId", "SequenceNumber")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("OutboxMessage", (string)null);
|
||||
b.ToTable("OutboxMessage");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxState", b =>
|
||||
@@ -189,7 +189,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("Created");
|
||||
|
||||
b.ToTable("OutboxState", (string)null);
|
||||
b.ToTable("OutboxState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Album", b =>
|
||||
@@ -231,7 +231,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Albums", (string)null);
|
||||
b.ToTable("Albums");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.AlbumHasTrack", b =>
|
||||
@@ -259,7 +259,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("AlbumId", "TrackId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("AlbumHasTrack", (string)null);
|
||||
b.ToTable("AlbumHasTrack");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.ListeningHistory", b =>
|
||||
@@ -268,9 +268,18 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<bool>("IsCounted")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<DateTime?>("LastProgressAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<DateTime>("ListenedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double?>("PlayedSeconds")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<Guid>("TrackId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
@@ -283,7 +292,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("ListeningHistory", (string)null);
|
||||
b.ToTable("ListeningHistory");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Mix", b =>
|
||||
@@ -317,7 +326,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("UserId", "Position");
|
||||
|
||||
b.ToTable("Mixes", (string)null);
|
||||
b.ToTable("Mixes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.MixItem", b =>
|
||||
@@ -341,7 +350,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("MixId", "Position");
|
||||
|
||||
b.ToTable("MixItems", (string)null);
|
||||
b.ToTable("MixItems");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.PlayList", b =>
|
||||
@@ -385,7 +394,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("PlayLists", (string)null);
|
||||
b.ToTable("PlayLists");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.PlayListHasTrack", b =>
|
||||
@@ -412,7 +421,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("TrackId");
|
||||
|
||||
b.ToTable("PlayListHasTrack", (string)null);
|
||||
b.ToTable("PlayListHasTrack");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Track", b =>
|
||||
@@ -450,7 +459,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("OwnerUserId");
|
||||
|
||||
b.ToTable("Tracks", (string)null);
|
||||
b.ToTable("Tracks");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.TrackLike", b =>
|
||||
@@ -475,7 +484,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("UserId", "TrackId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TrackLikes", (string)null);
|
||||
b.ToTable("TrackLikes");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.TrackTag", b =>
|
||||
@@ -495,7 +504,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("TrackId", "Tag")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("TrackTags", (string)null);
|
||||
b.ToTable("TrackTags");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.Upload", b =>
|
||||
@@ -524,7 +533,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Upload", (string)null);
|
||||
b.ToTable("Upload");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.UploadIntent", b =>
|
||||
@@ -575,7 +584,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UploadIntents", (string)null);
|
||||
b.ToTable("UploadIntents");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.User", b =>
|
||||
@@ -613,7 +622,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("User", (string)null);
|
||||
b.ToTable("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.UserFollow", b =>
|
||||
@@ -638,7 +647,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
b.HasIndex("FollowerId", "FollowedId")
|
||||
.IsUnique();
|
||||
|
||||
b.ToTable("UserFollows", (string)null);
|
||||
b.ToTable("UserFollows");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Domain.Entities.UserHasTrack", b =>
|
||||
@@ -659,7 +668,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("UserHasTrack", (string)null);
|
||||
b.ToTable("UserHasTrack");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.AlbumProcessingState", b =>
|
||||
@@ -686,7 +695,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("CorrelationId");
|
||||
|
||||
b.ToTable("AlbumProcessingState", (string)null);
|
||||
b.ToTable("AlbumProcessingState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.PlayListProcessingState", b =>
|
||||
@@ -713,7 +722,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("CorrelationId");
|
||||
|
||||
b.ToTable("PlayListProcessingState", (string)null);
|
||||
b.ToTable("PlayListProcessingState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Musify.Infrastructure.MassTransit.Sagas.TrackProcessingState", b =>
|
||||
@@ -749,7 +758,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b.HasKey("CorrelationId");
|
||||
|
||||
b.ToTable("TrackProcessingState", (string)null);
|
||||
b.ToTable("TrackProcessingState");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("MassTransit.EntityFrameworkCoreIntegration.OutboxMessage", b =>
|
||||
@@ -803,7 +812,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b1.HasKey("AlbumId");
|
||||
|
||||
b1.ToTable("Albums", (string)null);
|
||||
b1.ToTable("Albums");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("AlbumId");
|
||||
@@ -896,32 +905,28 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b1.Property<string>("LargeName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("LargePictureName");
|
||||
|
||||
b1.Property<string>("MediumName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("MediumPictureName");
|
||||
|
||||
b1.Property<string>("OriginalName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("OriginalPictureName");
|
||||
|
||||
b1.Property<string>("SmallName")
|
||||
.IsRequired()
|
||||
.HasMaxLength(64)
|
||||
.HasColumnType("character varying(64)")
|
||||
.HasColumnName("SmallPictureName");
|
||||
|
||||
b1.HasKey("PlayListId");
|
||||
|
||||
b1.ToTable("PlayLists", (string)null);
|
||||
b1.ToTable("PlayLists");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("PlayListId");
|
||||
@@ -992,7 +997,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b1.HasKey("TrackId");
|
||||
|
||||
b1.ToTable("Tracks", (string)null);
|
||||
b1.ToTable("Tracks");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("TrackId");
|
||||
@@ -1029,7 +1034,7 @@ namespace Musify.Infrastructure.Persistence.Migrations
|
||||
|
||||
b1.HasKey("TrackId");
|
||||
|
||||
b1.ToTable("Tracks", (string)null);
|
||||
b1.ToTable("Tracks");
|
||||
|
||||
b1.WithOwner()
|
||||
.HasForeignKey("TrackId");
|
||||
|
||||
@@ -74,6 +74,7 @@ namespace Musify.Infrastructure.Services
|
||||
|
||||
serviceDescriptors.AddHangfireServer();
|
||||
serviceDescriptors.AddScoped<DailyMixGenerationJob>();
|
||||
serviceDescriptors.AddScoped<ListeningHistoryCleanupJob>();
|
||||
|
||||
return serviceDescriptors;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
namespace Musify.Api.DataTransferObjects.Tracks;
|
||||
|
||||
public record RecordListeningProgressRequest(double PlayedSeconds);
|
||||
@@ -0,0 +1,39 @@
|
||||
using Mediator;
|
||||
using Musify.Application.Genres;
|
||||
|
||||
namespace Musify.Api.Endpoints;
|
||||
|
||||
public static class GenreEndpoints
|
||||
{
|
||||
public static IEndpointRouteBuilder MapGenreEndpoints(this IEndpointRouteBuilder app)
|
||||
{
|
||||
var group = app.MapGroup("/genres")
|
||||
.WithTags("Genres");
|
||||
|
||||
group.MapGet("/", GetGenres)
|
||||
.WithName("GetGenres")
|
||||
.WithSummary("Get The Genres That Have At Least One Track, Most Populated First. Works Anonymously.")
|
||||
.Produces<IReadOnlyList<GenreResponse>>();
|
||||
|
||||
group.MapGet("/available", GetAvailableGenres)
|
||||
.WithName("GetAvailableGenres")
|
||||
.WithSummary("Get Every Genre A Track Can Be Tagged With, Including The Genres It Cannot Be Combined With. Works Anonymously.")
|
||||
.Produces<IReadOnlyList<AvailableGenreResponse>>();
|
||||
|
||||
return app;
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<AvailableGenreResponse>> GetAvailableGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await mediator.Send(new GetAvailableGenresQuery(), cancellationToken);
|
||||
}
|
||||
|
||||
private static async Task<IReadOnlyList<GenreResponse>> GetGenres(
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
return await mediator.Send(new GetGenresQuery(), cancellationToken);
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using Mediator;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Application.Shared;
|
||||
@@ -19,8 +20,9 @@ public static class TrackEndpoints
|
||||
|
||||
group.MapGet("/", GetTracks)
|
||||
.WithName("GetTracks")
|
||||
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name.")
|
||||
.Produces<TracksSearchResponse>();
|
||||
.WithSummary("Get Paginated Tracks, Optionally Filtered By Name And Genre.")
|
||||
.Produces<TracksSearchResponse>()
|
||||
.ProducesValidationProblem();
|
||||
|
||||
group.MapGet("/{id}", GetTrackById)
|
||||
.WithName("GetTrackById")
|
||||
@@ -41,6 +43,14 @@ public static class TrackEndpoints
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapPut("/listens/{listenId}/progress", RecordListeningProgress)
|
||||
.WithName("RecordListeningProgress")
|
||||
.WithSummary("Report The Total Seconds Actually Played For A Listen Started By The Stream Endpoint.")
|
||||
.RequireAuthorization()
|
||||
.Produces(StatusCodes.Status204NoContent)
|
||||
.Produces(StatusCodes.Status401Unauthorized)
|
||||
.Produces(StatusCodes.Status404NotFound);
|
||||
|
||||
group.MapGet("/users/{userId}", GetTracksByUserId)
|
||||
.WithName("GetTracksByUserId")
|
||||
.WithSummary("Get Paginated Tracks For A User.")
|
||||
@@ -84,10 +94,25 @@ public static class TrackEndpoints
|
||||
IMediator mediator,
|
||||
CancellationToken cancellationToken,
|
||||
string? name,
|
||||
string? genre,
|
||||
int pageNumber = 1,
|
||||
int pageSize = 10)
|
||||
{
|
||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize), cancellationToken);
|
||||
Genre? parsedGenre = null;
|
||||
if (!string.IsNullOrWhiteSpace(genre))
|
||||
{
|
||||
if (!Enum.TryParse<Genre>(genre, ignoreCase: true, out var value) || !Enum.IsDefined(value))
|
||||
{
|
||||
return Results.ValidationProblem(new Dictionary<string, string[]>
|
||||
{
|
||||
[nameof(genre)] = [$"'{genre}' is not a valid genre."]
|
||||
});
|
||||
}
|
||||
|
||||
parsedGenre = value;
|
||||
}
|
||||
|
||||
var result = await mediator.Send(new GetTracksQuery(name, pageNumber, pageSize, parsedGenre), cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
@@ -128,6 +153,19 @@ public static class TrackEndpoints
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> RecordListeningProgress(
|
||||
IMediator mediator,
|
||||
CurrentUser currentUser,
|
||||
Guid listenId,
|
||||
RecordListeningProgressRequest request,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
var result = await mediator.Send(
|
||||
new RecordListeningProgressCommand(currentUser.RequiredId, listenId, request.PlayedSeconds),
|
||||
cancellationToken);
|
||||
return result.ToHttpResult();
|
||||
}
|
||||
|
||||
private static async Task<IResult> GetTracksByUserId(
|
||||
IMediator mediator,
|
||||
long userId,
|
||||
|
||||
@@ -40,6 +40,7 @@ app.UseAuthorization();
|
||||
app
|
||||
.MapAlbumEndpoints()
|
||||
.MapConfigEndpoints()
|
||||
.MapGenreEndpoints()
|
||||
.MapLikeEndpoints()
|
||||
.MapMixEndpoints()
|
||||
.MapPlayListEndpoints()
|
||||
|
||||
@@ -1,6 +0,0 @@
|
||||
@WebApi_HostAddress = http://localhost:5111
|
||||
|
||||
GET {{WebApi_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
@@ -41,6 +41,11 @@ using (var scope = host.Services.CreateScope())
|
||||
job => job.RunAsync(CancellationToken.None),
|
||||
Cron.Daily(),
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
recurringJobManager.AddOrUpdate<ListeningHistoryCleanupJob>(
|
||||
"listening-history-cleanup",
|
||||
job => job.RunAsync(CancellationToken.None),
|
||||
Cron.Daily(3),
|
||||
new RecurringJobOptions { TimeZone = TimeZoneInfo.Utc });
|
||||
}
|
||||
|
||||
host.Run();
|
||||
|
||||
@@ -12,10 +12,6 @@ RUN dotnet restore Hosts/Musify.Api/Api.csproj
|
||||
|
||||
COPY . .
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
@@ -11,9 +11,6 @@ RUN dotnet restore Hosts/Musify.Worker/Worker.csproj
|
||||
|
||||
COPY . .
|
||||
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
|
||||
WORKDIR /app
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using System.Text.Json.Serialization;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tracks.Responses;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class GenreEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private static readonly JsonSerializerOptions JsonOptions = new(JsonSerializerDefaults.Web)
|
||||
{
|
||||
Converters = { new JsonStringEnumConverter() }
|
||||
};
|
||||
|
||||
private async Task<string> SeedTrackAsync(Genre genre)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var owner = new User { Id = Random.Shared.NextInt64(1, long.MaxValue), Name = "genre-owner", NormalizedName = "GENRE-OWNER" };
|
||||
var title = $"Genre track {Guid.NewGuid():N}";
|
||||
var track = new Track
|
||||
{
|
||||
Title = title,
|
||||
NormalizedTitle = title.ToUpperInvariant(),
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
Tags = [new TrackTag { TrackId = Guid.Empty, Tag = genre }]
|
||||
};
|
||||
foreach (var tag in track.Tags)
|
||||
tag.TrackId = track.Id;
|
||||
database.Users.Add(owner);
|
||||
database.Tracks.Add(track);
|
||||
database.UserHasTracks.Add(new UserHasTrack { UserId = owner.Id, TrackId = track.Id });
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return title;
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetGenres_Anonymous_ListsGenresInUse()
|
||||
{
|
||||
await SeedTrackAsync(Genre.Funk);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<GenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!, g => g.Genre == Genre.Funk && g.TrackCount >= 1);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetAvailableGenres_Anonymous_ListsEveryGenreWithIncompatibilities()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/genres/available", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<List<AvailableGenreResponse>>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Equal(Enum.GetValues<Genre>().Length, body!.Count);
|
||||
Assert.Contains(Genre.Lofi, body.Single(g => g.Genre == Genre.Metal).IncompatibleWith);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData("Nope")]
|
||||
[InlineData("999")]
|
||||
public async Task GetTracks_UnknownGenre_ReturnsBadRequest(string genre)
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync($"/tracks?genre={genre}", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_IsCaseInsensitive()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Blues);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=blues&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task GetTracks_GenreQuery_FiltersByTag()
|
||||
{
|
||||
var title = await SeedTrackAsync(Genre.Ambient);
|
||||
await SeedTrackAsync(Genre.Techno);
|
||||
|
||||
var response = await fixture.CreateAnonymousClient().GetAsync("/tracks?genre=Ambient&pageSize=100", TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.OK, response.StatusCode);
|
||||
var body = await response.Content.ReadFromJsonAsync<TracksSearchResponse>(JsonOptions, TestContext.Current.CancellationToken);
|
||||
Assert.Contains(body!.Items, item => item.Track.Title == title);
|
||||
Assert.All(body.Items, item => Assert.Contains(Genre.Ambient, item.Track.Tags));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
using System.Net;
|
||||
using System.Net.Http.Json;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.Extensions.DependencyInjection;
|
||||
using Musify.Api.DataTransferObjects.Tracks;
|
||||
using Musify.Api.DataTransferObjects.Users;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Infrastructure.Persistence;
|
||||
using Xunit;
|
||||
using Musify.Api.Tests.TestSupport;
|
||||
|
||||
namespace Musify.Api.Tests.Endpoints
|
||||
{
|
||||
[Collection(ApiCollection.Name)]
|
||||
public sealed class ListeningProgressEndpointsTests(ApiTestFixture fixture)
|
||||
{
|
||||
private async Task<long> CreateUserAsync()
|
||||
{
|
||||
var userId = Random.Shared.NextInt64(1, long.MaxValue);
|
||||
var response = await fixture.CreateAnonymousClient().PostAsJsonAsync(
|
||||
"/users", new CreateUserRequest(userId, $"user-{userId}", null, null), TestContext.Current.CancellationToken);
|
||||
response.EnsureSuccessStatusCode();
|
||||
return userId;
|
||||
}
|
||||
|
||||
private async Task<Guid> SeedListenAsync(long userId, double durationSeconds)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
var owner = await database.Users.SingleAsync(u => u.Id == userId, TestContext.Current.CancellationToken);
|
||||
var track = new Track
|
||||
{
|
||||
Title = "Progress track",
|
||||
NormalizedTitle = "PROGRESS TRACK",
|
||||
OwnerUserId = owner.Id,
|
||||
Owner = owner,
|
||||
DurationSeconds = durationSeconds
|
||||
};
|
||||
var listen = new ListeningHistory
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = track.Id,
|
||||
ListenedAt = DateTime.UtcNow.AddMinutes(-10)
|
||||
};
|
||||
database.Tracks.Add(track);
|
||||
database.ListeningHistories.Add(listen);
|
||||
await database.SaveChangesAsync(TestContext.Current.CancellationToken);
|
||||
return listen.Id;
|
||||
}
|
||||
|
||||
private async Task<ListeningHistory> LoadListenAsync(Guid listenId)
|
||||
{
|
||||
using var scope = fixture.Services.CreateScope();
|
||||
var database = scope.ServiceProvider.GetRequiredService<Database>();
|
||||
return await database.ListeningHistories.AsNoTracking()
|
||||
.SingleAsync(l => l.Id == listenId, TestContext.Current.CancellationToken);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_Anonymous_ReturnsUnauthorized()
|
||||
{
|
||||
var response = await fixture.CreateAnonymousClient().PutAsJsonAsync(
|
||||
$"/tracks/listens/{Guid.NewGuid()}/progress", new RecordListeningProgressRequest(10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.Unauthorized, response.StatusCode);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_OwnListen_StoresSecondsAndMarksCounted()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
|
||||
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NoContent, response.StatusCode);
|
||||
var stored = await LoadListenAsync(listenId);
|
||||
Assert.Equal(45, stored.PlayedSeconds);
|
||||
Assert.True(stored.IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_OtherUsersListen_ReturnsNotFound()
|
||||
{
|
||||
var ownerId = await CreateUserAsync();
|
||||
var otherId = await CreateUserAsync();
|
||||
var listenId = await SeedListenAsync(ownerId, durationSeconds: 200);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(otherId).PutAsJsonAsync(
|
||||
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(45), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.NotFound, response.StatusCode);
|
||||
Assert.Null((await LoadListenAsync(listenId)).PlayedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task PutProgress_NegativeSeconds_ReturnsBadRequest()
|
||||
{
|
||||
var userId = await CreateUserAsync();
|
||||
var listenId = await SeedListenAsync(userId, durationSeconds: 200);
|
||||
|
||||
var response = await fixture.CreateAuthenticatedClient(userId).PutAsJsonAsync(
|
||||
$"/tracks/listens/{listenId}/progress", new RecordListeningProgressRequest(-5), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -57,5 +57,25 @@ namespace Musify.Application.Tests.Albums
|
||||
Assert.False(result.IsError);
|
||||
Assert.Equal(2, result.Value.Count);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var countedAlbum = TestEntities.Album(owner.Id, "Counted album");
|
||||
var skippedAlbum = TestEntities.Album(owner.Id, "Skipped album");
|
||||
var countedTrack = TestEntities.Track(owner, "In counted");
|
||||
var skippedTrack = TestEntities.Track(owner, "In skipped");
|
||||
await SeedAsync(
|
||||
owner, countedAlbum, skippedAlbum, countedTrack, skippedTrack,
|
||||
new AlbumHasTrack { AlbumId = countedAlbum.Id, TrackId = countedTrack.Id, TrackNumber = 1 },
|
||||
new AlbumHasTrack { AlbumId = skippedAlbum.Id, TrackId = skippedTrack.Id, TrackNumber = 1 },
|
||||
TestEntities.ListeningHistory(owner.Id, countedTrack.Id, DateTime.UtcNow.AddDays(-1), playedSeconds: 120),
|
||||
TestEntities.ListeningHistory(owner.Id, skippedTrack.Id, DateTime.UtcNow, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetRecentlyListenedAlbumsQuery(owner.Id, Limit: 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted album"], result.Value.Select(album => album.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetAvailableGenresQueryHandlerTests
|
||||
{
|
||||
private static async Task<IReadOnlyList<AvailableGenreResponse>> RunAsync() =>
|
||||
await new GetAvailableGenresQueryHandler().Handle(new GetAvailableGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsEveryGenre()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
Assert.Equal(Enum.GetValues<Genre>(), result.Select(r => r.Genre));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListsIncompatibleGenresSymmetrically()
|
||||
{
|
||||
var result = await RunAsync();
|
||||
|
||||
var metal = result.Single(r => r.Genre == Genre.Metal);
|
||||
var lofi = result.Single(r => r.Genre == Genre.Lofi);
|
||||
Assert.Contains(Genre.Lofi, metal.IncompatibleWith);
|
||||
Assert.Contains(Genre.Metal, lofi.IncompatibleWith);
|
||||
Assert.Empty(result.Single(r => r.Genre == Genre.Pop).IncompatibleWith);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using Musify.Application.Genres;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Genres
|
||||
{
|
||||
public sealed class GetGenresQueryHandlerTests : HandlerTestBase
|
||||
{
|
||||
private GetGenresQueryHandler CreateHandler() => new(Database);
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_NoTracks_ReturnsEmpty()
|
||||
{
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReturnsOnlyGenresInUse_MostPopulatedFirst()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rockA = TestEntities.Track(owner, "Rock A", tags: [Genre.Rock]);
|
||||
var rockB = TestEntities.Track(owner, "Rock B", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Jazz", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rockA, rockB, jazz,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockA.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = rockB.Id },
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(
|
||||
[(Genre.Rock, 2), (Genre.Jazz, 1), (Genre.Indie, 1)],
|
||||
result.Select(r => (r.Genre, r.TrackCount)));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackInSeveralLibraries_IsCountedOnce()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var other = TestEntities.User(id: 2, name: "other-user");
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Pop]);
|
||||
await SeedAsync(
|
||||
owner, other, track,
|
||||
new UserHasTrack { UserId = owner.Id, TrackId = track.Id },
|
||||
new UserHasTrack { UserId = other.Id, TrackId = track.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, Assert.Single(result).TrackCount);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackNotInAnyLibrary_IsIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner, tags: [Genre.Metal]);
|
||||
await SeedAsync(owner, track);
|
||||
|
||||
var result = await CreateHandler().Handle(new GetGenresQuery(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -72,5 +72,21 @@ namespace Musify.Application.Tests.Mixes
|
||||
.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.All(discoveryItems, item => Assert.Equal(unheardTrack.Id, item.TrackId));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_TrackWithOnlyUncountedListens_IsTreatedAsUnheard()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var skipped = TestEntities.Track(user, "Only skipped");
|
||||
await SeedAsync(
|
||||
user, skipped,
|
||||
new UserHasTrack { UserId = user.Id, TrackId = skipped.Id },
|
||||
TestEntities.ListeningHistory(user.Id, skipped.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
await CreateHandler().Handle(new GenerateMixesForUserCommand(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
var mixes = await Database.Mixes.ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.Contains(mixes, mix => mix.Title == "Descubrimiento");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -177,12 +177,16 @@ namespace Musify.Application.Tests.TestSupport
|
||||
public static ListeningHistory ListeningHistory(
|
||||
long userId,
|
||||
Guid trackId,
|
||||
DateTime? listenedAt = null) =>
|
||||
DateTime? listenedAt = null,
|
||||
double? playedSeconds = null,
|
||||
bool isCounted = true) =>
|
||||
new()
|
||||
{
|
||||
UserId = userId,
|
||||
TrackId = trackId,
|
||||
ListenedAt = listenedAt ?? DateTime.UtcNow
|
||||
ListenedAt = listenedAt ?? DateTime.UtcNow,
|
||||
PlayedSeconds = playedSeconds,
|
||||
IsCounted = isCounted
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class DeleteStaleUncountedListensCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private DeleteStaleUncountedListensCommandHandler CreateHandler() =>
|
||||
new(Database, NoOpLogger<DeleteStaleUncountedListensCommandHandler>());
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_DeletesOnlyOldUncountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
var oldUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 2, isCounted: false);
|
||||
var oldCounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-9), playedSeconds: 100);
|
||||
var recentUncounted = TestEntities.ListeningHistory(owner.Id, track.Id, DateTime.UtcNow.AddDays(-2), playedSeconds: 2, isCounted: false);
|
||||
await SeedAsync(owner, track, oldUncounted, oldCounted, recentUncounted);
|
||||
|
||||
var deleted = await CreateHandler().Handle(new DeleteStaleUncountedListensCommand(), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(1, deleted);
|
||||
var remaining = await Database.ListeningHistories.Select(l => l.Id).ToListAsync(TestContext.Current.CancellationToken);
|
||||
Assert.DoesNotContain(oldUncounted.Id, remaining);
|
||||
Assert.Contains(oldCounted.Id, remaining);
|
||||
Assert.Contains(recentUncounted.Id, remaining);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -35,5 +35,21 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.Empty(result);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreIgnored()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var counted = TestEntities.Track(owner, "Counted");
|
||||
var skipped = TestEntities.Track(owner, "Skipped");
|
||||
await SeedAsync(
|
||||
owner, counted, skipped,
|
||||
TestEntities.ListeningHistory(owner.Id, counted.Id, DateTime.UtcNow.AddMinutes(-5), playedSeconds: 90),
|
||||
TestEntities.ListeningHistory(owner.Id, skipped.Id, DateTime.UtcNow, playedSeconds: 2, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningHistoryQuery(owner.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(["Counted"], result.Select(track => track.Title));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -33,5 +33,21 @@ namespace Musify.Application.Tests.Tracks
|
||||
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListensCount_OnlyCountsCountedListens()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var track = TestEntities.Track(owner);
|
||||
await SeedAsync(
|
||||
owner, track,
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 100),
|
||||
TestEntities.ListeningHistory(owner.Id, track.Id, playedSeconds: 1, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTrackByIdQuery(track.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(2, result.Value.ListensCount);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -45,6 +45,9 @@ namespace Musify.Application.Tests.Tracks
|
||||
var history = Assert.Single(await Database.ListeningHistories.ToListAsync(TestContext.Current.CancellationToken));
|
||||
Assert.Equal(owner.Id, history.UserId);
|
||||
Assert.Equal(track.Id, history.TrackId);
|
||||
Assert.Equal(history.Id, result.Value.ListenId);
|
||||
Assert.False(history.IsCounted);
|
||||
Assert.Null(history.PlayedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.ValueObjects;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
@@ -43,5 +44,23 @@ namespace Musify.Application.Tests.Tracks
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Bohemian Rhapsody", item.Track.Title);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_GenreFilter_ReturnsOnlyTracksWithThatTag()
|
||||
{
|
||||
var owner = TestEntities.User();
|
||||
var rock = TestEntities.Track(owner, "Loud", tags: [Genre.Rock, Genre.Indie]);
|
||||
var jazz = TestEntities.Track(owner, "Smooth", tags: [Genre.Jazz]);
|
||||
await SeedAsync(
|
||||
owner, rock, jazz,
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = rock.Id },
|
||||
new Musify.Domain.Entities.UserHasTrack { UserId = owner.Id, TrackId = jazz.Id });
|
||||
|
||||
var result = await CreateHandler().Handle(new GetTracksQuery(null, 1, 10, Genre.Indie), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var item = Assert.Single(result.Value.Items);
|
||||
Assert.Equal("Loud", item.Track.Title);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
using ErrorOr;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Application.Tests.TestSupport;
|
||||
using Musify.Application.Tracks;
|
||||
using Musify.Domain.Entities;
|
||||
using Xunit;
|
||||
|
||||
namespace Musify.Application.Tests.Tracks
|
||||
{
|
||||
public sealed class RecordListeningProgressCommandHandlerTests : HandlerTestBase
|
||||
{
|
||||
private RecordListeningProgressCommandHandler CreateHandler() => new(Database);
|
||||
|
||||
private async Task<ListeningHistory> ReloadAsync(Guid id) =>
|
||||
await Database.ListeningHistories.AsNoTracking()
|
||||
.SingleAsync(l => l.Id == id, TestContext.Current.CancellationToken);
|
||||
|
||||
private async Task<(User User, ListeningHistory Listen)> SeedListenAsync(
|
||||
double durationSeconds, DateTime listenedAt)
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user, durationSeconds: durationSeconds);
|
||||
var listen = TestEntities.ListeningHistory(user.Id, track.Id, listenedAt, isCounted: false);
|
||||
await SeedAsync(user, track, listen);
|
||||
return (user, listen);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_BelowThreshold_StoresSecondsWithoutCounting()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.False(result.IsError);
|
||||
var stored = await ReloadAsync(listen.Id);
|
||||
Assert.Equal(10, stored.PlayedSeconds);
|
||||
Assert.False(stored.IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ReachesThirtySeconds_MarksAsCounted()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 30), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ShortTrack_CountsAtHalfDuration()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(20, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True((await ReloadAsync(listen.Id)).IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_LowerValueThanStored_KeepsHighest()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
var handler = CreateHandler();
|
||||
|
||||
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 40), TestContext.Current.CancellationToken);
|
||||
await handler.Handle(new RecordListeningProgressCommand(user.Id, listen.Id, 15), TestContext.Current.CancellationToken);
|
||||
|
||||
var stored = await ReloadAsync(listen.Id);
|
||||
Assert.Equal(40, stored.PlayedSeconds);
|
||||
Assert.True(stored.IsCounted);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MoreThanElapsedClock_IsCappedToElapsedTime()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-20));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 150), TestContext.Current.CancellationToken);
|
||||
|
||||
var stored = await ReloadAsync(listen.Id);
|
||||
Assert.InRange(stored.PlayedSeconds!.Value, 24, 27);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_MoreThanTrackDuration_IsCappedToDuration()
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(50, DateTime.UtcNow.AddMinutes(-30));
|
||||
|
||||
await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, 900), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(52, (await ReloadAsync(listen.Id)).PlayedSeconds);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_OtherUsersListen_ReturnsNotFound()
|
||||
{
|
||||
var (_, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(long.MaxValue, listen.Id, 10), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.True(result.IsError);
|
||||
Assert.Equal(ErrorType.NotFound, result.FirstError.Type);
|
||||
}
|
||||
|
||||
[Theory]
|
||||
[InlineData(-1)]
|
||||
[InlineData(double.NaN)]
|
||||
[InlineData(double.PositiveInfinity)]
|
||||
public async Task Handle_InvalidSeconds_ReturnsValidationError(double seconds)
|
||||
{
|
||||
var (user, listen) = await SeedListenAsync(200, DateTime.UtcNow.AddSeconds(-60));
|
||||
|
||||
var result = await CreateHandler().Handle(
|
||||
new RecordListeningProgressCommand(user.Id, listen.Id, seconds), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(ErrorType.Validation, result.FirstError.Type);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -40,6 +40,37 @@ namespace Musify.Application.Tests.Users
|
||||
Assert.Equal(500, result.SecondsThisWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_PlayedSeconds_SumsPlayedTimeInsteadOfDuration()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user, durationSeconds: 200);
|
||||
await SeedAsync(
|
||||
user, track,
|
||||
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 200),
|
||||
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 12, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(212, result.SecondsThisWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_UncountedListens_AreExcludedFromTracksAndStreak()
|
||||
{
|
||||
var user = TestEntities.User();
|
||||
var track = TestEntities.Track(user);
|
||||
await SeedAsync(
|
||||
user, track,
|
||||
TestEntities.ListeningHistory(user.Id, track.Id, playedSeconds: 3, isCounted: false));
|
||||
|
||||
var result = await CreateHandler().Handle(new GetListeningStatsQuery(user.Id), TestContext.Current.CancellationToken);
|
||||
|
||||
Assert.Equal(0, result.TracksThisWeek);
|
||||
Assert.Equal(0, result.StreakDays);
|
||||
Assert.Equal(3, result.SecondsThisWeek);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Handle_ListenedTodayAndYesterday_StreakIsTwo()
|
||||
{
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Musify.Domain.Entities;
|
||||
using Musify.Infrastructure.Tests.TestSupport;
|
||||
using Xunit;
|
||||
@@ -43,6 +44,11 @@ namespace Musify.Infrastructure.Tests.Persistence
|
||||
Assert.Equal(originalCreatedAt, user.CreatedAt);
|
||||
Assert.True(user.UpdatedAt > originalCreatedAt,
|
||||
$"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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,39 +1,39 @@
|
||||
# Development configuration for deploy/compose.yml + compose.dev.yml.
|
||||
# Copy to deploy/.env and adjust. Not versioned.
|
||||
# Copy to deploy/.env and adjust.
|
||||
#
|
||||
# cp deploy/.env.example deploy/.env
|
||||
#
|
||||
# Requires ../../../Infrastructure running first (Postgres, Zitadel,
|
||||
# SeaweedFS, RabbitMQ, Jaeger). See ../../../Infrastructure/README.md.
|
||||
# Everything marked "must match Infrastructure's .env" below has to be the
|
||||
# same value as the one in that file.
|
||||
|
||||
DOTNET_ENVIRONMENT=Development
|
||||
|
||||
# --- Public URLs -------------------------------------------------------------
|
||||
# How each service is reached from *outside* Docker. `host.docker.internal`
|
||||
# resolves to the same address from the browser, from apps running on the host
|
||||
# and from inside the containers, which is what makes the OIDC issuer and the
|
||||
# presigned S3 URLs valid in both run modes.
|
||||
# resolves to the same address from the browser, from apps running on the
|
||||
# host and from inside the containers, which is what makes the OIDC issuer
|
||||
# and the presigned S3 URLs valid in both run modes.
|
||||
PUBLIC_WEB_URL=http://localhost:3000
|
||||
PUBLIC_API_URL=http://localhost:5111
|
||||
PUBLIC_STREAM_URL=http://localhost:8081
|
||||
PUBLIC_AUTH_URL=http://host.docker.internal:8080
|
||||
|
||||
# --- PostgreSQL (shared by Musify and Zitadel) -------------------------------
|
||||
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
|
||||
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.
|
||||
POSTGRES_HOST=host.docker.internal
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=postgres
|
||||
POSTGRES_DB=musify_db
|
||||
POSTGRES_HOST_PORT=59000
|
||||
|
||||
# --- Zitadel (OIDC) ----------------------------------------------------------
|
||||
# The masterkey MUST be exactly 32 characters. The external domain/port form
|
||||
# the token issuer, so they have to match PUBLIC_AUTH_URL. Changing them needs
|
||||
# a re-init: docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v
|
||||
ZITADEL_MASTERKEY=MasterkeyNeedsToHave32Characters
|
||||
ZITADEL_EXTERNAL_DOMAIN=host.docker.internal
|
||||
ZITADEL_EXTERNAL_PORT=8080
|
||||
ZITADEL_EXTERNAL_SECURE=false
|
||||
ZITADEL_DATABASE_PASSWORD=zitadel
|
||||
# Console login: <username>@zitadel.<external domain>
|
||||
ZITADEL_ADMIN_USERNAME=admin
|
||||
ZITADEL_ADMIN_PASSWORD=Password1!
|
||||
# Allows the OIDC apps to use plain-http redirect URIs (dev only).
|
||||
# --- Zitadel (OIDC, shared instance) -----------------------------------------
|
||||
# Path to the admin service-account PAT Infrastructure's Zitadel writes on
|
||||
# init. Adjust if your Infrastructure checkout lives somewhere else relative
|
||||
# to this one.
|
||||
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
|
||||
# Allows the OIDC apps this project creates to use plain-http redirect URIs
|
||||
# (dev only).
|
||||
ZITADEL_DEV_MODE=true
|
||||
|
||||
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
|
||||
@@ -42,17 +42,25 @@ AUTH_CLIENT_ID=
|
||||
WEB_CLIENT_ID=
|
||||
WEB_CLIENT_SECRET=
|
||||
|
||||
# --- RabbitMQ (MassTransit) --------------------------------------------------
|
||||
# --- RabbitMQ (shared instance, MassTransit) --------------------------------
|
||||
# Must match Infrastructure's .env.
|
||||
RABBITMQ_HOST=host.docker.internal
|
||||
RABBITMQ_DEFAULT_USER=guest
|
||||
RABBITMQ_DEFAULT_PASS=guest
|
||||
|
||||
# --- SeaweedFS (S3) ----------------------------------------------------------
|
||||
# --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
|
||||
# S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env.
|
||||
SEAWEEDFS_HOST=host.docker.internal
|
||||
S3_ACCESS_KEY=admin_access_key
|
||||
S3_SECRET_KEY=admin_secret_key
|
||||
S3_BUCKET=webapi-storage
|
||||
S3_PUBLIC_URL=http://host.docker.internal:8333
|
||||
S3_INTERNAL_URL=http://host.docker.internal:8333
|
||||
S3_USE_HTTP=true
|
||||
|
||||
# --- Jaeger (shared instance, tracing) ---------------------------------------
|
||||
JAEGER_HOST=host.docker.internal
|
||||
|
||||
# --- Web player --------------------------------------------------------------
|
||||
# Key material for the encrypted session cookie: any long random string.
|
||||
SESSION_SECRET=dev-session-secret-change-me-0123456789abcdef
|
||||
@@ -61,6 +69,3 @@ AUTH_ALLOW_INSECURE_HTTP=true
|
||||
|
||||
# --- Misc --------------------------------------------------------------------
|
||||
AUTH_REQUIRE_HTTPS=false
|
||||
PGADMIN_DEFAULT_EMAIL=admin@musify.dev
|
||||
PGADMIN_DEFAULT_PASSWORD=admin
|
||||
PGADMIN_HOST_PORT=5050
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
# Production configuration for deploy/compose.yml + compose.prod.yml.
|
||||
# Copy to deploy/.env.prod, then:
|
||||
#
|
||||
# 1. replace every `example.com` with your domain,
|
||||
# 1. replace every `*.example.com` with your actual domains (each service
|
||||
# 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
|
||||
# (`openssl rand -hex 24`; the Zitadel masterkey needs exactly 32 chars),
|
||||
# 3. point these DNS records at the server, all proxied/TLS-terminated the
|
||||
# same way: example.com, www, api, auth, stream, s3.
|
||||
# (`openssl rand -hex 24`),
|
||||
# 3. point each domain below at the server and add its vhost to the shared
|
||||
# edge (Infrastructure's edge/conf.d).
|
||||
#
|
||||
# Not versioned. Keep it readable only by the deploy user (chmod 600).
|
||||
# Requires ../../../Infrastructure running first, on the same Docker host,
|
||||
# with its own `infra-net` network already created. See
|
||||
# ../../../Infrastructure/README.md. Not versioned. Keep it readable only by
|
||||
# the deploy user (chmod 600).
|
||||
|
||||
DOTNET_ENVIRONMENT=Production
|
||||
|
||||
# --- Domain ------------------------------------------------------------------
|
||||
# ROOT_DOMAIN drives the nginx vhosts; the URLs below must agree with it.
|
||||
ROOT_DOMAIN=example.com
|
||||
PUBLIC_WEB_URL=https://example.com
|
||||
PUBLIC_API_URL=https://api.example.com
|
||||
PUBLIC_STREAM_URL=https://stream.example.com
|
||||
# --- Domains -------------------------------------------------------------
|
||||
# Public URLs. They must match the vhosts of the shared edge nginx
|
||||
# (Infrastructure's edge/conf.d), which is what serves them; the domains are
|
||||
# independent, they don't need to share a root.
|
||||
PUBLIC_WEB_URL=https://musify.example.com
|
||||
PUBLIC_API_URL=https://musifyapi.example.com
|
||||
PUBLIC_STREAM_URL=https://musifystream.example.com
|
||||
PUBLIC_AUTH_URL=https://auth.example.com
|
||||
S3_PUBLIC_URL=https://s3.example.com
|
||||
|
||||
# --- PostgreSQL (shared by Musify and Zitadel) -------------------------------
|
||||
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
|
||||
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod.
|
||||
POSTGRES_HOST=postgres
|
||||
POSTGRES_USER=postgres
|
||||
POSTGRES_PASSWORD=CHANGE_ME
|
||||
POSTGRES_DB=musify_db
|
||||
|
||||
# --- Zitadel (OIDC) ----------------------------------------------------------
|
||||
# The masterkey MUST be exactly 32 characters and can never be rotated in place.
|
||||
ZITADEL_MASTERKEY=CHANGE_ME_32_CHARACTERS_EXACTLY_
|
||||
ZITADEL_EXTERNAL_DOMAIN=auth.example.com
|
||||
ZITADEL_EXTERNAL_PORT=443
|
||||
ZITADEL_EXTERNAL_SECURE=true
|
||||
ZITADEL_DATABASE_PASSWORD=CHANGE_ME
|
||||
# Console login: <username>@zitadel.<external domain>
|
||||
ZITADEL_ADMIN_USERNAME=admin
|
||||
ZITADEL_ADMIN_PASSWORD=CHANGE_ME
|
||||
# Keep OIDC apps strict about https redirect URIs in production.
|
||||
# --- Zitadel (OIDC, shared instance) -----------------------------------------
|
||||
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
|
||||
ZITADEL_DEV_MODE=false
|
||||
|
||||
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
|
||||
@@ -44,16 +44,22 @@ AUTH_CLIENT_ID=
|
||||
WEB_CLIENT_ID=
|
||||
WEB_CLIENT_SECRET=
|
||||
|
||||
# --- RabbitMQ (MassTransit) --------------------------------------------------
|
||||
# --- RabbitMQ (shared instance, MassTransit) --------------------------------
|
||||
RABBITMQ_HOST=rabbitmq
|
||||
RABBITMQ_DEFAULT_USER=musify
|
||||
RABBITMQ_DEFAULT_PASS=CHANGE_ME
|
||||
|
||||
# --- SeaweedFS (S3) ----------------------------------------------------------
|
||||
# --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
|
||||
SEAWEEDFS_HOST=seaweedfs
|
||||
S3_ACCESS_KEY=CHANGE_ME
|
||||
S3_SECRET_KEY=CHANGE_ME
|
||||
S3_BUCKET=webapi-storage
|
||||
S3_INTERNAL_URL=http://seaweedfs:8333
|
||||
S3_USE_HTTP=false
|
||||
|
||||
# --- Jaeger (shared instance, tracing) ---------------------------------------
|
||||
JAEGER_HOST=jaeger
|
||||
|
||||
# --- Web player --------------------------------------------------------------
|
||||
# Key material for the encrypted session cookie. Rotating it logs everyone out.
|
||||
SESSION_SECRET=CHANGE_ME
|
||||
@@ -61,5 +67,3 @@ AUTH_ALLOW_INSECURE_HTTP=false
|
||||
|
||||
# --- Misc --------------------------------------------------------------------
|
||||
AUTH_REQUIRE_HTTPS=true
|
||||
# Largest request body nginx accepts (audio uploads go through the web player).
|
||||
MAX_UPLOAD_SIZE=1024m
|
||||
|
||||
@@ -1,51 +1,65 @@
|
||||
# Deploy
|
||||
|
||||
Everything needed to run Musify, in development and in production, from the
|
||||
same set of compose files — no wrapper scripts, just `docker compose`.
|
||||
Everything needed to run Musify itself, in development and in production,
|
||||
from the same set of compose files, no wrapper scripts, just `docker
|
||||
compose`. Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are **not**
|
||||
started here: they're shared infrastructure that lives in the separate
|
||||
`Infrastructure` repository and has to already be running (see its own
|
||||
README). `../../Infrastructure` below is just an example path, assuming
|
||||
that repo happens to be checked out as a sibling of this one. Adjust it to
|
||||
wherever you actually cloned it.
|
||||
|
||||
| File | Role |
|
||||
|---|---|
|
||||
| `compose.yml` | Every service, including the one-shot bootstrap jobs. No published ports, no domains, no environment-specific values. |
|
||||
| `compose.dev.yml` | Development overlay: publishes the ports to the host, adds pgAdmin. |
|
||||
| `compose.prod.yml` | Production overlay: adds the nginx edge. |
|
||||
| `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.prod.yml` | Production overlay: joins Infrastructure's `infra-net` network. |
|
||||
| `.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
|
||||
`apps` compose profile, so a plain `up` starts infrastructure only — the usual
|
||||
development setup, with the apps running from the IDE.
|
||||
The four applications (`api`, `worker`, `gateway`, `web-player`) sit behind
|
||||
the `apps` compose profile, so a plain `up` starts only the one-shot
|
||||
bootstrap jobs, the usual development setup, with the apps running from the
|
||||
IDE.
|
||||
|
||||
### The one-shot bootstrap jobs
|
||||
|
||||
Three services in `compose.yml` run once and exit, so a plain `docker compose
|
||||
Four services in `compose.yml` run once and exit, so a plain `docker compose
|
||||
up -d` leaves the stack fully ready with no manual steps:
|
||||
|
||||
| Service | Does |
|
||||
|---|---|
|
||||
| `wait-for-postgres` | Waits for Infrastructure's Postgres to accept connections. Replaces an in-file health-check `depends_on`, which only works within the same compose file. |
|
||||
| `keys-init` | Generates the RS256 stream-ticket key pair in `./keys`, if it is not there already. |
|
||||
| `migrate` | Applies the EF Core migrations (built from `backend/Musify.Migrator.Dockerfile`). |
|
||||
| `zitadel-init` | Creates the Musify project and its two OIDC apps in Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` into the env file. |
|
||||
| `seaweedfs-init` | Creates Musify's own bucket on the shared SeaweedFS, if it doesn't exist yet. |
|
||||
| `zitadel-init` | Creates the Musify project and its OIDC apps in the shared Zitadel (or reuses them), then writes `AUTH_CLIENT_ID` / `WEB_CLIENT_ID` / `WEB_CLIENT_SECRET` / `NATIVE_CLIENT_ID` into the env file. |
|
||||
|
||||
`api` and `worker` wait for `migrate`; `api` and `gateway` wait for
|
||||
`keys-init` (`depends_on: condition: service_completed_successfully`). They do
|
||||
**not** wait for `zitadel-init` — compose interpolates `${AUTH_CLIENT_ID}` once,
|
||||
when a command starts, so a container that starts in the *same* `up` as
|
||||
`zitadel-init` would still get the empty value it had before provisioning ran.
|
||||
That's why bringing up the apps is a second command: by the time you run it,
|
||||
`api` and `worker` wait for `migrate`; `api` waits for `keys-init`
|
||||
(`depends_on: condition: service_completed_successfully`). Nothing waits on
|
||||
`zitadel-init`, `seaweedfs-init` or the shared infrastructure services
|
||||
directly. Compose interpolates `${AUTH_CLIENT_ID}` once, when a command
|
||||
starts, so a container that started in the *same* `up` as `zitadel-init`
|
||||
would still get the empty value it had before provisioning ran. That's why
|
||||
bringing up the apps is a second command: by the time you run it,
|
||||
`zitadel-init` has already finished and the env file has real values.
|
||||
|
||||
---
|
||||
|
||||
## Development
|
||||
|
||||
Start the separate `Infrastructure` repository first, from wherever you
|
||||
cloned it (see its own README for the exact command). Then, from this
|
||||
repository's root:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
|
||||
```
|
||||
|
||||
The first command starts the infrastructure and runs the bootstrap jobs
|
||||
(keys, migrations, Zitadel project/apps). The second builds and runs `api`,
|
||||
`worker`, `gateway` and `web-player` in Docker too — skip it to run the apps
|
||||
from the IDE instead (the usual dev loop):
|
||||
The first command starts Musify's own bootstrap jobs (Postgres wait, keys,
|
||||
migrations, the bucket, the Zitadel project/apps). The second builds and runs
|
||||
`api`, `worker`, `gateway` and `web-player` in Docker too. Skip it to run the
|
||||
apps from the IDE instead (the usual dev loop):
|
||||
|
||||
```sh
|
||||
dotnet run --project backend/Hosts/Musify.Api # :5111
|
||||
@@ -55,86 +69,117 @@ npm --prefix web-player run dev # :5173
|
||||
```
|
||||
|
||||
When running the apps from the IDE, `Musify.Api` reads `Authentication:*`
|
||||
from user-secrets and `web-player` reads its own `web-player/.env` — neither
|
||||
looks at `deploy/.env`. Copy the three values `zitadel-init` wrote
|
||||
(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`) into
|
||||
`dotnet user-secrets set Authentication:ClientId <value> --project
|
||||
backend/Hosts/Musify.Api` and into `web-player/.env` once; they stay valid
|
||||
until you wipe the Zitadel volume.
|
||||
|
||||
Add `--profile tools` to also start pgAdmin.
|
||||
from user-secrets and `web-player` reads its own `web-player/.env`, neither
|
||||
looks at `deploy/.env`. Copy the four values `zitadel-init` wrote
|
||||
(`AUTH_CLIENT_ID`, `WEB_CLIENT_ID`, `WEB_CLIENT_SECRET`, `NATIVE_CLIENT_ID`)
|
||||
into `dotnet user-secrets set Authentication:ClientId <value> --project
|
||||
backend/Hosts/Musify.Api` and into `web-player/.env` once. They stay valid
|
||||
until Infrastructure's Zitadel volume is wiped.
|
||||
|
||||
### Ports
|
||||
|
||||
| Service | Port | Used by |
|
||||
|---|---|---|
|
||||
| PostgreSQL | `59000` → 5432 | API + Worker (EF Core) and Zitadel |
|
||||
| Zitadel | `8080` | OIDC/OAuth2, console at `/ui/console` |
|
||||
| RabbitMQ | `5672` / `15672` | MassTransit / management UI |
|
||||
| SeaweedFS | `8333` / `8888` / `9333` | S3 / filer (→ gateway) / master |
|
||||
| Jaeger | `16686` / `4317` / `4318` | traces UI / OTLP gRPC / OTLP HTTP |
|
||||
| pgAdmin (`--profile tools`) | `5050` | Postgres UI |
|
||||
| API, gateway, web player (`--profile apps`) | `5111` / `8081` / `3000` | |
|
||||
| Musify.Api | `5111` | `/scalar/v1`, `/openapi/v1.json` |
|
||||
| Musify.StreamingGateway | `8081` | `/health`, `/media/...` |
|
||||
| web-player | `5173` (`npm run dev`) or `3000` (container) | |
|
||||
|
||||
Host requirements: .NET 10 SDK, Node 22, Docker Desktop. (ffmpeg is only
|
||||
needed on the host if you run the Worker outside Docker — inside Docker its
|
||||
image already has it.)
|
||||
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger ports come from
|
||||
Infrastructure. See its own README for that table.
|
||||
|
||||
Host requirements: .NET 10 SDK, Node 22, Docker Desktop. ffmpeg is only
|
||||
needed on the host if you run the Worker outside Docker; inside Docker its
|
||||
image already has it.
|
||||
|
||||
### Why `host.docker.internal`
|
||||
|
||||
`PUBLIC_AUTH_URL` and `S3_PUBLIC_URL` point at `host.docker.internal`, which
|
||||
resolves to the same address from the browser, from apps running on the host
|
||||
and from inside the containers. That keeps the OIDC token issuer and the
|
||||
presigned S3 URLs valid whichever way the apps are running. Zitadel routes by
|
||||
the `Host` header, so the domain has to be identical everywhere; changing it
|
||||
requires wiping the Postgres/Zitadel volumes and starting over (`docker
|
||||
compose -f compose.yml -f compose.dev.yml down -v`, then `up -d` again).
|
||||
presigned S3 URLs valid whichever way the apps are running, and is the same
|
||||
address Infrastructure's own dev ports are published on.
|
||||
|
||||
### Resetting
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down # stop, keep data
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe volumes
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml down -v # stop, wipe Musify's own volumes
|
||||
```
|
||||
|
||||
This only touches Musify's own containers and volumes. Resetting Zitadel or
|
||||
SeaweedFS is done from the `Infrastructure` repo instead.
|
||||
|
||||
---
|
||||
|
||||
## Production
|
||||
|
||||
On a server with a domain, from the repository root:
|
||||
Musify doesn't run its own nginx: the shared edge in Infrastructure
|
||||
(`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
|
||||
in. It creates the `infra-net` network this stack joins.
|
||||
2. Bring up its `edge/` (see Infrastructure's README) and add Musify's
|
||||
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
|
||||
cp deploy/.env.prod.example deploy/.env.prod
|
||||
```
|
||||
|
||||
Fill it in:
|
||||
Replace every `*.example.com` with your domains (they must match the
|
||||
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`; the Zitadel masterkey needs exactly
|
||||
32 characters). `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/TLS-terminated the same way.
|
||||
|
||||
Then, same two commands as development, with the prod env file and overlay:
|
||||
Then:
|
||||
|
||||
```sh
|
||||
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
|
||||
```
|
||||
|
||||
The first command starts the infrastructure, the nginx edge and the bootstrap
|
||||
jobs; the second builds and starts the applications, already wired to the
|
||||
OIDC client ids the first one produced. Both are idempotent — run them again
|
||||
to deploy a new version (`docker compose ... --profile apps up -d --build`
|
||||
alone is enough for an update once the stack already exists; it reuses the
|
||||
infra containers and only rebuilds and restarts the apps).
|
||||
The first command starts Musify's bootstrap jobs (`zitadel-init` and
|
||||
`seaweedfs-init` reach Zitadel/SeaweedFS through the public `auth.`/`s3.`
|
||||
URLs, so the edge has to be serving them by then); the second builds and
|
||||
starts the applications, already wired to the OIDC client ids the first one
|
||||
produced. Both are idempotent, so running them again deploys a new version
|
||||
(`... --profile apps up -d --build` alone is enough for an update once the
|
||||
stack already exists).
|
||||
|
||||
Only nginx publishes ports (80/443); everything else is reachable only on the
|
||||
internal compose network.
|
||||
Nothing in this stack publishes a port; everything is reachable only on the
|
||||
internal `infra-net` network, through the edge.
|
||||
|
||||
### 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.
|
||||
|
||||
---
|
||||
|
||||
@@ -142,17 +187,12 @@ internal compose network.
|
||||
|
||||
```
|
||||
deploy/
|
||||
├─ compose.yml # all services, incl. keys-init / migrate / zitadel-init
|
||||
├─ compose.dev.yml # dev overlay: host ports, pgAdmin
|
||||
├─ compose.prod.yml # prod overlay: nginx edge
|
||||
├─ compose.yml # Musify's own services: wait-for-postgres, keys-init,
|
||||
│ # migrate, seaweedfs-init, zitadel-init, api/worker/gateway/web-player
|
||||
├─ compose.dev.yml # dev overlay: host ports
|
||||
├─ compose.prod.yml # prod overlay: joins infra-net
|
||||
├─ .env.example / .env.prod.example
|
||||
├─ 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)
|
||||
└─ zitadel/
|
||||
└─ .output/ # PAT written by Zitadel on init (not versioned)
|
||||
└─ keys/ # RS256 stream-ticket key pair (generated)
|
||||
```
|
||||
|
||||
The application images are built from `backend/Musify.*.Dockerfile` (build
|
||||
@@ -161,18 +201,12 @@ and `web-player/Dockerfile`.
|
||||
|
||||
## Notes
|
||||
|
||||
- **S3 credentials** are rendered into the SeaweedFS identity file from
|
||||
`S3_ACCESS_KEY` / `S3_SECRET_KEY` when the container starts, so they cannot
|
||||
drift from the ones the apps are given.
|
||||
- **S3 credentials, the Postgres admin user, the RabbitMQ user and the
|
||||
Zitadel masterkey** all live in Infrastructure's own `.env`/`.env.prod` now.
|
||||
This project's env files only duplicate the values that have to match (see
|
||||
the comments in `.env.example`).
|
||||
- **Zitadel** runs `start-from-init`, which is idempotent: the same command
|
||||
works on an empty volume and on an existing one.
|
||||
- **`latest` Zitadel images** require the standalone Login UI v2 container,
|
||||
which this stack does not run. `zitadel-init` turns that requirement off so
|
||||
the built-in `/ui/login` is used.
|
||||
- **`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` or `migrate`** is safe: the former reuses the
|
||||
existing project/apps by name (regenerating the web app's secret only if it
|
||||
is missing from the env file), the latter is a normal `dotnet ef database
|
||||
update`, a no-op once the schema is current.
|
||||
- **Re-running `zitadel-init`, `seaweedfs-init` or `migrate`** is safe: they
|
||||
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.
|
||||
|
||||
@@ -1,67 +1,36 @@
|
||||
name: musify-dev
|
||||
|
||||
# Development overlay: publishes the infrastructure ports to the host so the
|
||||
# .NET apps and the web player can run from the IDE, and adds pgAdmin.
|
||||
# Development overlay: publishes Musify's own ports to the host so the .NET
|
||||
# apps and the web player can run from the IDE.
|
||||
#
|
||||
# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
||||
# docker compose -f deploy/compose.yml -f deploy/compose.dev.yml --profile apps up -d --build
|
||||
#
|
||||
# `host.docker.internal` (see ZITADEL_EXTERNAL_DOMAIN / *_PUBLIC_URL in .env)
|
||||
# resolves to the same address from the browser, from apps on the host and from
|
||||
# inside the containers, so the OIDC issuer and the presigned S3 URLs are valid
|
||||
# in both modes. `extra_hosts` gives Linux containers that same name.
|
||||
# Postgres/Zitadel/SeaweedFS/RabbitMQ/Jaeger ports come from Infrastructure's
|
||||
# own compose.dev.yml, not from here. See ../../../Infrastructure/README.md.
|
||||
# `host.docker.internal` resolves to the same address from the browser, from
|
||||
# apps on the host and from inside these containers, which is what makes the
|
||||
# OIDC issuer and the presigned S3 URLs valid in both run modes. `extra_hosts`
|
||||
# gives Linux containers that same name.
|
||||
|
||||
x-host-alias: &host-alias
|
||||
- "${ZITADEL_EXTERNAL_DOMAIN}:host-gateway"
|
||||
- "host.docker.internal:host-gateway"
|
||||
|
||||
services:
|
||||
postgres:
|
||||
ports:
|
||||
- "${POSTGRES_HOST_PORT}:5432"
|
||||
wait-for-postgres:
|
||||
extra_hosts: *host-alias
|
||||
|
||||
zitadel:
|
||||
ports:
|
||||
- "${ZITADEL_EXTERNAL_PORT}:8080"
|
||||
|
||||
rabbitmq:
|
||||
ports:
|
||||
- "5672:5672" # AMQP -> MassTransit
|
||||
- "15672:15672" # management UI
|
||||
|
||||
seaweedfs:
|
||||
ports:
|
||||
- "9333:9333" # master / admin UI
|
||||
- "8888:8888" # filer -> StreamingGateway
|
||||
- "8333:8333" # S3 API -> API, Worker and presigned uploads
|
||||
|
||||
jaeger:
|
||||
ports:
|
||||
- "16686:16686" # UI
|
||||
- "4317:4317" # OTLP gRPC
|
||||
- "4318:4318" # OTLP HTTP
|
||||
seaweedfs-init:
|
||||
extra_hosts: *host-alias
|
||||
|
||||
# zitadel-init (compose.yml) edits whichever env file is mounted here.
|
||||
zitadel-init:
|
||||
extra_hosts: *host-alias
|
||||
volumes:
|
||||
- ./.env:/workspace/.env
|
||||
|
||||
# Optional Postgres UI: docker compose ... --profile tools up -d
|
||||
pgadmin:
|
||||
profiles: [tools]
|
||||
image: dpage/pgadmin4:latest
|
||||
container_name: musify-pgadmin
|
||||
environment:
|
||||
PGADMIN_DEFAULT_EMAIL: ${PGADMIN_DEFAULT_EMAIL}
|
||||
PGADMIN_DEFAULT_PASSWORD: ${PGADMIN_DEFAULT_PASSWORD}
|
||||
PGADMIN_CONFIG_SERVER_MODE: "False"
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
ports:
|
||||
- "${PGADMIN_HOST_PORT}:80"
|
||||
volumes:
|
||||
- pgadmin_data:/var/lib/pgadmin
|
||||
restart: unless-stopped
|
||||
migrate:
|
||||
extra_hosts: *host-alias
|
||||
|
||||
api:
|
||||
ports:
|
||||
@@ -80,6 +49,3 @@ services:
|
||||
ports:
|
||||
- "3000:3000"
|
||||
extra_hosts: *host-alias
|
||||
|
||||
volumes:
|
||||
pgadmin_data:
|
||||
|
||||
@@ -1,50 +1,21 @@
|
||||
name: musify-prod
|
||||
|
||||
# Production overlay: adds the nginx edge and the host-mounted secrets.
|
||||
# Nothing but nginx publishes a port; every other service is reachable only on
|
||||
# the internal `musify-prod_default` network.
|
||||
# Production overlay: joins Infrastructure's shared `infra-net` network so
|
||||
# this project's containers can reach Postgres, Zitadel, SeaweedFS, RabbitMQ
|
||||
# and Jaeger by container name, and so the shared edge nginx (Infrastructure's
|
||||
# `edge/`, which owns host ports 80/443 and terminates TLS for every domain)
|
||||
# can reach api, gateway and web-player. Nothing here publishes a port.
|
||||
#
|
||||
# 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
|
||||
#
|
||||
# The first command starts the infrastructure, the nginx edge and the
|
||||
# one-shot bootstrap jobs (stream keys, migrations, Zitadel project/apps); the
|
||||
# second builds and starts the applications, picking up the OIDC client ids
|
||||
# the first command just wrote into deploy/.env.prod. See deploy/README.md.
|
||||
|
||||
networks:
|
||||
default:
|
||||
name: infra-net
|
||||
external: true
|
||||
|
||||
services:
|
||||
# zitadel-init (compose.yml) edits whichever env file is mounted here.
|
||||
zitadel-init:
|
||||
volumes:
|
||||
- ./.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
|
||||
depends_on:
|
||||
zitadel:
|
||||
condition: service_started
|
||||
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:
|
||||
|
||||
@@ -1,33 +1,32 @@
|
||||
# Musify — base stack, shared by development and production.
|
||||
# Musify: base stack, shared by development and production.
|
||||
#
|
||||
# This file publishes no host ports and hardcodes no domain: everything that
|
||||
# differs between environments comes from the .env file (see .env.example) or
|
||||
# from the overlay that is merged on top of it.
|
||||
# Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are NOT part of this
|
||||
# file. They're shared infrastructure that lives in ../../../Infrastructure
|
||||
# and must already be running (see Infrastructure/README.md). This file only
|
||||
# has what's specific to Musify: its own database, its own OIDC apps and
|
||||
# storage bucket (created against the shared instances below), and its own
|
||||
# apps.
|
||||
#
|
||||
# dev docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
||||
# prod docker compose --env-file deploy/.env.prod -f deploy/compose.yml -f deploy/compose.prod.yml up -d
|
||||
#
|
||||
# The four application services sit behind the `apps` profile, so a plain `up`
|
||||
# starts the infrastructure plus the one-shot bootstrap jobs (stream-ticket
|
||||
# keys, database migrations, Zitadel project/apps) and stops there — the usual
|
||||
# dev setup, with the apps running from the IDE. Add `--profile apps up -d
|
||||
# --build` as a SECOND command to also build and run them in Docker: by then
|
||||
# zitadel-init has already written the OIDC client ids into the env file, so
|
||||
# the apps start with real values. See deploy/README.md.
|
||||
|
||||
x-app-depends: &app-depends
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
rabbitmq:
|
||||
condition: service_healthy
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
# The four application services sit behind the `apps` profile, so a plain
|
||||
# `up` runs only the one-shot bootstrap jobs (stream-ticket keys, database
|
||||
# migrations, Zitadel project/apps, the storage bucket) and stops there. That's
|
||||
# the usual dev setup, with the apps running from the IDE. Add `--profile apps
|
||||
# up -d --build` as a SECOND command to also build and run them in Docker: by
|
||||
# then zitadel-init has already written the OIDC client ids into the env
|
||||
# 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
|
||||
# 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
|
||||
# (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
|
||||
# already do.
|
||||
x-backend-env: &backend-env
|
||||
Database__ConnectionString: "Host=postgres;Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
||||
MassTransit__Address: "rabbitmq://rabbitmq:5672"
|
||||
Database__ConnectionString: "Host=${POSTGRES_HOST};Port=5432;Database=${POSTGRES_DB};Username=${POSTGRES_USER};Password=${POSTGRES_PASSWORD}"
|
||||
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672"
|
||||
MassTransit__Username: ${RABBITMQ_DEFAULT_USER}
|
||||
MassTransit__Password: ${RABBITMQ_DEFAULT_PASS}
|
||||
# The public S3 URL, not the internal one: the same address is used to sign
|
||||
@@ -38,128 +37,35 @@ x-backend-env: &backend-env
|
||||
InfrastructureStorage__ForcePathStyle: "true"
|
||||
InfrastructureStorage__UseHttp: ${S3_USE_HTTP}
|
||||
ApplicationStorage__Bucket: ${S3_BUCKET}
|
||||
OpenTelemetry__OtlpEndpoint: "http://jaeger:4317"
|
||||
OpenTelemetry__OtlpEndpoint: "http://${JAEGER_HOST}:4317"
|
||||
|
||||
services:
|
||||
# --- Infrastructure -------------------------------------------------------
|
||||
# --- Shared-infrastructure bootstrap ---------------------------------------
|
||||
|
||||
postgres:
|
||||
# Waits for Infrastructure's Postgres to accept connections before `migrate`
|
||||
# runs. This replaces a `depends_on: postgres: condition: service_healthy`:
|
||||
# that only works within the same compose file, and Postgres now lives in a
|
||||
# separate one (../../../Infrastructure).
|
||||
wait-for-postgres:
|
||||
image: postgres:17-alpine
|
||||
container_name: musify-postgres
|
||||
environment:
|
||||
POSTGRES_USER: ${POSTGRES_USER}
|
||||
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
POSTGRES_DB: ${POSTGRES_DB}
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
healthcheck:
|
||||
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# OIDC provider. `start-from-init` runs init + setup (both idempotent) and
|
||||
# then starts, so the same command works on a cold and on a warm volume.
|
||||
# It reuses the Postgres above: the admin user creates the `zitadel` database
|
||||
# and role on first start.
|
||||
zitadel:
|
||||
image: ghcr.io/zitadel/zitadel:latest
|
||||
container_name: musify-zitadel
|
||||
command: start-from-init --masterkeyFromEnv --tlsMode disabled
|
||||
environment:
|
||||
ZITADEL_MASTERKEY: ${ZITADEL_MASTERKEY}
|
||||
# The external domain/port forms the token issuer and must be identical
|
||||
# for the browser, the apps and the containers (Zitadel routes by Host).
|
||||
ZITADEL_EXTERNALSECURE: ${ZITADEL_EXTERNAL_SECURE}
|
||||
ZITADEL_EXTERNALDOMAIN: ${ZITADEL_EXTERNAL_DOMAIN}
|
||||
ZITADEL_EXTERNALPORT: ${ZITADEL_EXTERNAL_PORT}
|
||||
ZITADEL_TLS_ENABLED: "false"
|
||||
ZITADEL_DATABASE_POSTGRES_HOST: postgres
|
||||
ZITADEL_DATABASE_POSTGRES_PORT: 5432
|
||||
ZITADEL_DATABASE_POSTGRES_DATABASE: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_USER_USERNAME: zitadel
|
||||
ZITADEL_DATABASE_POSTGRES_USER_PASSWORD: ${ZITADEL_DATABASE_PASSWORD}
|
||||
ZITADEL_DATABASE_POSTGRES_USER_SSL_MODE: disable
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_USERNAME: ${POSTGRES_USER}
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_PASSWORD: ${POSTGRES_PASSWORD}
|
||||
ZITADEL_DATABASE_POSTGRES_ADMIN_SSL_MODE: disable
|
||||
# Initial console admin. Login: <username>@zitadel.<external domain>
|
||||
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_USERNAME: ${ZITADEL_ADMIN_USERNAME}
|
||||
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORD: ${ZITADEL_ADMIN_PASSWORD}
|
||||
ZITADEL_FIRSTINSTANCE_ORG_HUMAN_PASSWORDCHANGEREQUIRED: "false"
|
||||
# Service account + PAT written on init; the provisioning script uses it
|
||||
# to create the Musify project and its OIDC apps.
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_USERNAME: zitadel-admin-sa
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_MACHINE_NAME: Admin Service Account
|
||||
ZITADEL_FIRSTINSTANCE_ORG_MACHINE_PAT_EXPIRATIONDATE: "2099-01-01T00:00:00Z"
|
||||
ZITADEL_FIRSTINSTANCE_PATPATH: /output/admin-sa.pat
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
volumes:
|
||||
- ./zitadel/.output:/output
|
||||
restart: unless-stopped
|
||||
|
||||
rabbitmq:
|
||||
image: rabbitmq:4-management-alpine
|
||||
container_name: musify-rabbitmq
|
||||
environment:
|
||||
RABBITMQ_DEFAULT_USER: ${RABBITMQ_DEFAULT_USER}
|
||||
RABBITMQ_DEFAULT_PASS: ${RABBITMQ_DEFAULT_PASS}
|
||||
volumes:
|
||||
- rabbitmq_data:/var/lib/rabbitmq
|
||||
healthcheck:
|
||||
test: ["CMD", "rabbitmq-diagnostics", "-q", "ping"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
|
||||
# All-in-one object store: master + volume + filer + S3 gateway.
|
||||
# The S3 identity file is rendered from the environment on start so the
|
||||
# credentials can never drift from the ones handed to the apps.
|
||||
seaweedfs:
|
||||
image: chrislusf/seaweedfs:latest
|
||||
container_name: musify-seaweedfs
|
||||
container_name: musify-wait-for-postgres
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
cat > /tmp/s3.conf <<'JSON'
|
||||
{
|
||||
"identities": [
|
||||
{
|
||||
"name": "musify",
|
||||
"credentials": [{ "accessKey": "${S3_ACCESS_KEY}", "secretKey": "${S3_SECRET_KEY}" }],
|
||||
"actions": ["Admin", "Read", "Write"]
|
||||
}
|
||||
]
|
||||
}
|
||||
JSON
|
||||
exec weed server \
|
||||
-ip.bind=0.0.0.0 \
|
||||
-dir=/data \
|
||||
-master.volumeSizeLimitMB=1024 \
|
||||
-volume.max=0 \
|
||||
-filer \
|
||||
-s3 \
|
||||
-s3.config=/tmp/s3.conf
|
||||
volumes:
|
||||
- seaweedfs_data:/data
|
||||
healthcheck:
|
||||
test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 10
|
||||
restart: unless-stopped
|
||||
set -e
|
||||
deadline=$$(( $$(date +%s) + 60 ))
|
||||
until pg_isready -h ${POSTGRES_HOST} -p 5432 -U ${POSTGRES_USER} >/dev/null 2>&1; do
|
||||
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "postgres did not respond at ${POSTGRES_HOST}:5432. Is Infrastructure running?" >&2; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
restart: "no"
|
||||
|
||||
# One-shot: SeaweedFS does not auto-create buckets.
|
||||
# One-shot: SeaweedFS does not auto-create buckets. `aws s3 ls` doubles as
|
||||
# the readiness probe, since seaweedfs-init no longer has an in-file
|
||||
# `depends_on: seaweedfs: condition: service_healthy` to wait for.
|
||||
seaweedfs-init:
|
||||
image: amazon/aws-cli:latest
|
||||
container_name: musify-seaweedfs-init
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
environment:
|
||||
AWS_ACCESS_KEY_ID: ${S3_ACCESS_KEY}
|
||||
AWS_SECRET_ACCESS_KEY: ${S3_SECRET_KEY}
|
||||
@@ -167,13 +73,19 @@ services:
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
aws --endpoint-url http://seaweedfs:8333 s3 mb s3://${S3_BUCKET} 2>/dev/null || true
|
||||
aws --endpoint-url http://seaweedfs:8333 s3 ls
|
||||
set -e
|
||||
deadline=$$(( $$(date +%s) + 60 ))
|
||||
until aws --endpoint-url ${S3_PUBLIC_URL} s3 ls >/dev/null 2>&1; do
|
||||
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "seaweedfs did not respond at ${S3_PUBLIC_URL}. Is Infrastructure running?" >&2; exit 1; }
|
||||
sleep 2
|
||||
done
|
||||
aws --endpoint-url ${S3_PUBLIC_URL} s3 mb s3://${S3_BUCKET} 2>/dev/null || true
|
||||
aws --endpoint-url ${S3_PUBLIC_URL} s3 ls
|
||||
restart: "no"
|
||||
|
||||
# One-shot: generates the RS256 key pair used to sign (API) and verify
|
||||
# (Gateway) stream tickets, if it is not already in ./keys. No host tools
|
||||
# required — openssl runs inside the container.
|
||||
# required, since openssl runs inside the container.
|
||||
keys-init:
|
||||
image: alpine:3.20
|
||||
container_name: musify-keys-init
|
||||
@@ -203,30 +115,27 @@ services:
|
||||
dockerfile: Musify.Migrator.Dockerfile
|
||||
container_name: musify-migrate
|
||||
depends_on:
|
||||
postgres:
|
||||
condition: service_healthy
|
||||
wait-for-postgres:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
Database__ConnectionString: "Host=postgres;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}"
|
||||
restart: "no"
|
||||
|
||||
# One-shot: creates the Musify project and its two OIDC apps in Zitadel (or
|
||||
# reuses them if they already exist), then writes AUTH_CLIENT_ID,
|
||||
# WEB_CLIENT_ID and WEB_CLIENT_SECRET back into the env file mounted at
|
||||
# /workspace/.env (see compose.dev.yml / compose.prod.yml) — so a later
|
||||
# `--profile apps up` picks up real values instead of empty ones.
|
||||
# One-shot: creates the Musify project and its two OIDC apps in the shared
|
||||
# Zitadel (or reuses them if they already exist), then writes
|
||||
# AUTH_CLIENT_ID, WEB_CLIENT_ID and WEB_CLIENT_SECRET back into the env file
|
||||
# mounted at /workspace/.env (see compose.dev.yml / compose.prod.yml), so a
|
||||
# later `--profile apps up` picks up real values instead of empty ones.
|
||||
zitadel-init:
|
||||
image: alpine:3.20
|
||||
container_name: musify-zitadel-init
|
||||
depends_on:
|
||||
zitadel:
|
||||
condition: service_started
|
||||
environment:
|
||||
PUBLIC_AUTH_URL: ${PUBLIC_AUTH_URL}
|
||||
PUBLIC_API_URL: ${PUBLIC_API_URL}
|
||||
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
|
||||
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
|
||||
volumes:
|
||||
- ./zitadel/.output:/output:ro
|
||||
- ${ZITADEL_ADMIN_PAT_DIR}:/output:ro
|
||||
entrypoint: ["/bin/sh", "-c"]
|
||||
command:
|
||||
- |
|
||||
@@ -240,7 +149,7 @@ services:
|
||||
fi
|
||||
|
||||
# The env file may have CRLF line endings (edited on Windows), so
|
||||
# strip \r on read and drop it on write — otherwise a "blank" value
|
||||
# strip \r on read and drop it on write, otherwise a "blank" value
|
||||
# is actually a lone \r, and `[ -z ... ]` lies.
|
||||
env_get() { grep -E "^$$2=" "$$1" 2>/dev/null | tail -n1 | cut -d= -f2- | tr -d '\r'; }
|
||||
env_set() {
|
||||
@@ -257,7 +166,7 @@ services:
|
||||
echo "==> waiting for the Zitadel service-account PAT"
|
||||
deadline=$$(( $$(date +%s) + 180 ))
|
||||
while [ ! -f /output/admin-sa.pat ]; do
|
||||
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found, check 'docker logs musify-zitadel'" >&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
|
||||
done
|
||||
PAT=$$(tr -d '\r\n' </output/admin-sa.pat)
|
||||
@@ -387,14 +296,6 @@ services:
|
||||
echo "==> wired into $$ENV_FILE"
|
||||
restart: "no"
|
||||
|
||||
# OTLP collector + trace UI (MassTransit / EF Core / HTTP spans).
|
||||
jaeger:
|
||||
image: jaegertracing/all-in-one:latest
|
||||
container_name: musify-jaeger
|
||||
environment:
|
||||
COLLECTOR_OTLP_ENABLED: "true"
|
||||
restart: unless-stopped
|
||||
|
||||
# --- Applications (profile: apps) -----------------------------------------
|
||||
|
||||
api:
|
||||
@@ -404,7 +305,6 @@ services:
|
||||
dockerfile: Musify.Api.Dockerfile
|
||||
container_name: musify-api
|
||||
depends_on:
|
||||
<<: *app-depends
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
keys-init:
|
||||
@@ -439,7 +339,6 @@ services:
|
||||
dockerfile: Musify.Worker.Dockerfile
|
||||
container_name: musify-worker
|
||||
depends_on:
|
||||
<<: *app-depends
|
||||
migrate:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
@@ -458,15 +357,13 @@ services:
|
||||
dockerfile: Musify.StreamingGateway.Dockerfile
|
||||
container_name: musify-gateway
|
||||
depends_on:
|
||||
seaweedfs:
|
||||
condition: service_healthy
|
||||
keys-init:
|
||||
condition: service_completed_successfully
|
||||
environment:
|
||||
ASPNETCORE_ENVIRONMENT: ${DOTNET_ENVIRONMENT}
|
||||
ASPNETCORE_URLS: http://+:8081
|
||||
StreamTicket__PublicKeyPath: /keys/stream_public.pem
|
||||
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://seaweedfs:8888/"
|
||||
ReverseProxy__Clusters__seaweed-filer__Destinations__filer__Address: "http://${SEAWEEDFS_HOST}:8888/"
|
||||
ReverseProxy__Routes__media__Transforms__0__PathPattern: "/buckets/${S3_BUCKET}/{**rest}"
|
||||
Cors__AllowedOrigins__0: ${PUBLIC_WEB_URL}
|
||||
volumes:
|
||||
@@ -484,6 +381,10 @@ services:
|
||||
environment:
|
||||
NODE_ENV: production
|
||||
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}
|
||||
ZITADEL_ISSUER: ${PUBLIC_AUTH_URL}
|
||||
ZITADEL_CLIENT_ID: ${WEB_CLIENT_ID}
|
||||
@@ -495,10 +396,5 @@ services:
|
||||
API_BASE_URL: http://api:5111
|
||||
# Presigned PUTs are signed for the public S3 host; the container sends
|
||||
# them straight to SeaweedFS instead of going back out through the edge.
|
||||
S3_UPLOAD_ENDPOINT: http://seaweedfs:8333
|
||||
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
|
||||
restart: unless-stopped
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
rabbitmq_data:
|
||||
seaweedfs_data:
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# 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;
|
||||
@@ -1,119 +0,0 @@
|
||||
# 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;
|
||||
}
|
||||
}
|
||||
@@ -1,47 +1,58 @@
|
||||
# Musify — Documentación
|
||||
# Musify documentation
|
||||
|
||||
Musify es un backend de streaming de música: subir pistas, procesarlas (transcodificar el audio a `.m4a` y generar miniaturas), organizarlas en playlists y reproducirlas en streaming sin que los bytes pasen por la API.
|
||||
Musify is a music streaming backend: upload tracks, process them (transcode
|
||||
the audio to `.m4a`, generate thumbnails), organize them into playlists, and
|
||||
stream them without the bytes ever passing through the API.
|
||||
|
||||
Esta carpeta explica **qué hace cada pieza, su propósito y por qué está diseñada así**.
|
||||
This folder explains **what each piece does, its purpose, and why it's built
|
||||
this way**.
|
||||
|
||||
## Índice
|
||||
## Index
|
||||
|
||||
- [architecture.md](architecture.md) — Visión global: componentes, cómo encajan y los flujos principales.
|
||||
- [projects.md](projects.md) — Qué es y para qué sirve cada proyecto/solución.
|
||||
- [authentication.md](authentication.md) — Zitadel (OIDC), validación de JWT, provisioning/sync de usuarios.
|
||||
- [storage.md](storage.md) — SeaweedFS (S3), estructura de claves, URLs prefirmadas, qué es público y qué privado.
|
||||
- [streaming.md](streaming.md) — Streaming del audio (`.m4a` por HTTP range) con stream-ticket (RS256) y el reverse proxy (StreamingGateway).
|
||||
- [media-processing.md](media-processing.md) — El worker y los workflows de MassTransit (transcode de audio, miniaturas).
|
||||
- [development.md](development.md) — Cómo levantar todo en local: contenedores, puertos y comandos.
|
||||
- [../deploy/README.md](../deploy/README.md) — El deploy en detalle: compose, scripts y producción.
|
||||
- [architecture.md](architecture.md): overview, components, how they fit together, and the main flows.
|
||||
- [projects.md](projects.md): what each project/solution is and what it's for.
|
||||
- [authentication.md](authentication.md): Zitadel (OIDC), JWT validation, user provisioning/sync.
|
||||
- [storage.md](storage.md): SeaweedFS (S3), key layout, presigned URLs, what's public vs. private.
|
||||
- [streaming.md](streaming.md): audio streaming (`.m4a` over HTTP range) with stream tickets (RS256) and the reverse proxy (StreamingGateway).
|
||||
- [media-processing.md](media-processing.md): the worker and its MassTransit workflows (audio transcoding, thumbnails).
|
||||
- [development.md](development.md): running everything locally, containers, ports, commands.
|
||||
- [../deploy/README.md](../deploy/README.md): deployment in detail, compose, scripts, and production.
|
||||
|
||||
## Mapa de componentes (alto nivel)
|
||||
## Component map (high level)
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────┐
|
||||
Cliente ───► │ Musify.Api (:5111) API REST + emite tickets │
|
||||
Client ───► │ Musify.Api (:5111) REST API + issues tickets│
|
||||
└─────────────────────────────────────────────┘
|
||||
│ EF Core │ presigned (subidas) │ MassTransit (eventos)
|
||||
│ EF Core │ presigned (uploads) │ MassTransit (events)
|
||||
▼ ▼ ▼
|
||||
PostgreSQL SeaweedFS S3 (:8333) RabbitMQ ──► Worker (procesa)
|
||||
(metadatos) (objetos) audio→.m4a, imágenes
|
||||
PostgreSQL SeaweedFS S3 (:8333) RabbitMQ ──► Worker (processes)
|
||||
(metadata) (objects) audio→.m4a, images
|
||||
▲
|
||||
Cliente ───► StreamingGateway (:8081) ┘ sirve audio .m4a (valida ticket, proxy al filer)
|
||||
Client ───► StreamingGateway (:8081) ┘ serves .m4a audio (validates ticket, proxies to the filer)
|
||||
|
||||
Login: Cliente ◄──► Zitadel (:8080, OIDC) → Musify.Api valida el JWT
|
||||
Login: Client ◄──► Zitadel (:8080, OIDC) → Musify.Api validates the JWT
|
||||
```
|
||||
|
||||
PostgreSQL, Zitadel, SeaweedFS and RabbitMQ are not run by this repository.
|
||||
They're shared infrastructure, provided by the separate `Infrastructure`
|
||||
repository, that this stack connects to the same way any other project
|
||||
does.
|
||||
|
||||
## Stack
|
||||
|
||||
- **.NET 10**, C#. Minimal APIs (`Musify.Api`), Worker Service (`Musify.Worker`), ASP.NET + YARP (`Musify.StreamingGateway`).
|
||||
- **CQRS** con [Mediator] (source-generator) y resultados con **ErrorOr**.
|
||||
- **EF Core 10** + **PostgreSQL** (metadatos).
|
||||
- **MassTransit 8** + **RabbitMQ** (procesado asíncrono con routing slips/sagas).
|
||||
- **SeaweedFS** (almacenamiento de objetos compatible con S3, vía AWSSDK.S3).
|
||||
- **ffmpeg** (transcode de audio a AAC/`.m4a`), **SixLabors.ImageSharp** (miniaturas).
|
||||
- **Zitadel** (OIDC/OAuth2) para identidad.
|
||||
- **CQRS** with [Mediator] (source generator) and results via **ErrorOr**.
|
||||
- **EF Core 10** + **PostgreSQL** (metadata).
|
||||
- **MassTransit 8** + **RabbitMQ** (async processing with routing slips/sagas).
|
||||
- **SeaweedFS** (S3-compatible object storage, via AWSSDK.S3).
|
||||
- **ffmpeg** (audio transcoding to AAC/`.m4a`), **SixLabors.ImageSharp** (thumbnails).
|
||||
- **Zitadel** (OIDC/OAuth2) for identity.
|
||||
|
||||
## Estructura del repositorio
|
||||
## Repository layout
|
||||
|
||||
Un solo repo: `backend/` (los seis proyectos .NET), `web-player/` (cliente web
|
||||
SvelteKit), `deploy/` (stacks de Docker Compose y scripts) y `docs/`.
|
||||
A single repo: `backend/` (the six .NET projects), `web-player/` (the
|
||||
SvelteKit web client), `deploy/` (Docker Compose stacks and scripts), and
|
||||
`docs/`. The shared infrastructure (Postgres, Zitadel, SeaweedFS, RabbitMQ,
|
||||
Jaeger) lives in the separate `Infrastructure` repository, a sibling of this
|
||||
one, and is not part of this layout.
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
# Arquitectura
|
||||
# Architecture
|
||||
|
||||
## Principios
|
||||
## Principles
|
||||
|
||||
- **Clean architecture / capas** en `backend/`: el dominio y la aplicación no dependen de infraestructura; la infraestructura implementa las interfaces (`Application/Contracts`).
|
||||
- **CQRS**: cada operación es un `Command` o `Query` con su handler (Mediator). Los handlers devuelven `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), que la API traduce a códigos HTTP.
|
||||
- **Los bytes pesados no pasan por la API**: subidas con URLs prefirmadas (cliente → S3) y reproducción vía un reverse proxy dedicado (gateway → S3). La API solo mueve metadatos y emite permisos.
|
||||
- **Procesado asíncrono**: crear una pista no transcodifica en caliente; publica un evento y un worker hace el trabajo pesado con compensación (sagas/routing slips de MassTransit).
|
||||
- **Clean architecture / layers** in `backend/`: the domain and application layers don't depend on infrastructure; infrastructure implements their interfaces (`Application/Contracts`).
|
||||
- **CQRS**: every operation is a `Command` or `Query` with its own handler (Mediator). Handlers return `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), which the API translates into HTTP status codes.
|
||||
- **Heavy bytes never touch the API**: uploads use presigned URLs (client → S3) and playback goes through a dedicated reverse proxy (gateway → S3). The API only moves metadata and issues permissions.
|
||||
- **Async processing**: creating a track doesn't transcode it on the spot. It publishes an event, and a worker does the heavy lifting, with compensation (MassTransit sagas/routing slips).
|
||||
|
||||
## Componentes
|
||||
## Components
|
||||
|
||||
| Componente | Responsabilidad | Por qué separado |
|
||||
| Component | Responsibility | Why it's separate |
|
||||
|---|---|---|
|
||||
| **Musify.Api** | API REST (usuarios, pistas, playlists), autoriza, emite presigned URLs y stream-tickets | Punto de entrada fino; no procesa medios ni sirve bytes |
|
||||
| **Domain/Application/Infrastructure** | Dominio + casos de uso + infraestructura compartida (EF, S3, MassTransit, ffmpeg) | Lógica reutilizada por la API y el Worker |
|
||||
| **StreamingGateway** | Reverse proxy (YARP) que valida el ticket y sirve el audio (`.m4a`) desde SeaweedFS | Escala con el tráfico de bytes, independiente de la API; SeaweedFS queda privado detrás |
|
||||
| **Worker** | Consume eventos de MassTransit: transcodifica audio a `.m4a` y genera miniaturas | Trabajo CPU/IO intensivo fuera del request HTTP |
|
||||
| **PostgreSQL** | Metadatos (usuarios, pistas, playlists, upload intents) | — |
|
||||
| **RabbitMQ** | Cola de eventos + outbox transaccional | Desacopla creación de procesado |
|
||||
| **SeaweedFS** | Almacenamiento de objetos (S3 + filer) | Guarda originales y derivados (audio `.m4a`, miniaturas) |
|
||||
| **Zitadel** | Identidad (OIDC/OAuth2) | Login y emisión de JWT |
|
||||
| **Musify.Api** | REST API (users, tracks, playlists), authorizes requests, issues presigned URLs and stream tickets | A thin entry point, it doesn't process media or serve bytes |
|
||||
| **Domain/Application/Infrastructure** | Domain + use cases + shared infrastructure (EF, S3, MassTransit, ffmpeg) | Logic shared between the API and the Worker |
|
||||
| **StreamingGateway** | Reverse proxy (YARP) that validates the ticket and serves the audio (`.m4a`) from SeaweedFS | Scales with byte traffic independently of the API, and keeps SeaweedFS private behind it |
|
||||
| **Worker** | Consumes MassTransit events: transcodes audio to `.m4a` and generates thumbnails | Keeps CPU/IO-heavy work off the HTTP request path |
|
||||
| **PostgreSQL** *(shared, runs in the separate `Infrastructure` repo)* | Metadata (users, tracks, playlists, upload intents) | n/a |
|
||||
| **RabbitMQ** *(shared, runs in Infrastructure)* | Event queue + transactional outbox | Decouples creation from processing |
|
||||
| **SeaweedFS** *(shared, runs in Infrastructure)* | Object storage (S3 + filer) | Stores originals and derivatives (`.m4a` audio, thumbnails) |
|
||||
| **Zitadel** *(shared, runs in Infrastructure)* | Identity (OIDC/OAuth2) | Login and JWT issuance |
|
||||
|
||||
## Flujos principales
|
||||
## Main flows
|
||||
|
||||
### 1. Subida de una pista
|
||||
1. Cliente pide `POST /tracks/upload-urls` → la API crea **UploadIntents** (reservas) y devuelve **URLs prefirmadas PUT** (imagen + audio).
|
||||
2. Cliente sube los ficheros **directo a SeaweedFS** con esas URLs (no pasan por la API).
|
||||
3. Cliente llama `POST /tracks` con los `intentId`s → la API valida los intents, crea el `Track` (+ `UserHasTrack`) y publica `CreateTrackResourcesEvent`.
|
||||
4. El **Worker** consume el evento: mueve los originales a su sitio, genera miniaturas y transcodifica el audio a `.m4a`, y actualiza el estado del track.
|
||||
### 1. Uploading a track
|
||||
1. The client requests `POST /tracks/upload-urls`. The API creates **UploadIntents** (reservations) and returns **presigned PUT URLs** (image + audio).
|
||||
2. The client uploads the files **directly to SeaweedFS** using those URLs (never through the API).
|
||||
3. The client calls `POST /tracks` with the `intentId`s. The API validates the intents, creates the `Track` (+ `UserHasTrack`), and publishes `CreateTrackResourcesEvent`.
|
||||
4. The **Worker** consumes the event: moves the originals into place, generates thumbnails, transcodes the audio to `.m4a`, and updates the track's status.
|
||||
|
||||
Ver detalle en [media-processing.md](media-processing.md) y [storage.md](storage.md).
|
||||
See [media-processing.md](media-processing.md) and [storage.md](storage.md) for details.
|
||||
|
||||
### 2. Reproducción (streaming)
|
||||
1. Cliente pide `GET /tracks/{id}/stream` (autenticado) → la API autoriza y devuelve `{ manifestUrl, ticket }` (URL al `.m4a` + ticket RS256 acotado a la carpeta de esa pista).
|
||||
2. El reproductor abre la URL del audio añadiendo `?t=<ticket>` (o cabecera `X-Stream-Ticket`) y descarga por HTTP range.
|
||||
3. El **StreamingGateway** valida el ticket y reenvía al filer de SeaweedFS; los bytes van storage → cliente.
|
||||
### 2. Playback (streaming)
|
||||
1. The client requests `GET /tracks/{id}/stream` (authenticated). The API authorizes it and returns `{ manifestUrl, ticket }` (a URL to the `.m4a` plus an RS256 ticket scoped to that track's folder).
|
||||
2. The player opens the audio URL with `?t=<ticket>` appended (or an `X-Stream-Ticket` header) and downloads it via HTTP range requests.
|
||||
3. The **StreamingGateway** validates the ticket and forwards the request to the SeaweedFS filer. The bytes flow storage → client.
|
||||
|
||||
Ver [streaming.md](streaming.md).
|
||||
See [streaming.md](streaming.md).
|
||||
|
||||
### 3. Autenticación
|
||||
1. Cliente hace login OIDC contra **Zitadel** y obtiene un access token (JWT).
|
||||
2. La Musify.Api valida la firma/iss/aud del JWT.
|
||||
3. En cada token validado, se **sincroniza** el usuario local (crea/actualiza nombre y foto) — provisioning just-in-time.
|
||||
### 3. Authentication
|
||||
1. The client logs in via OIDC against **Zitadel** and gets an access token (JWT).
|
||||
2. Musify.Api validates the JWT's signature, issuer and audience.
|
||||
3. On every validated token, the local user is **synced** (name and picture created or updated). This is just-in-time provisioning.
|
||||
|
||||
Ver [authentication.md](authentication.md).
|
||||
See [authentication.md](authentication.md).
|
||||
|
||||
## Modelo de datos (entidades)
|
||||
## Data model (entities)
|
||||
|
||||
- **User** — `Id` es el `sub` numérico de Zitadel (`long`), más nombre y `ProfilePictureUrl`.
|
||||
- **Track** — pista: nombres de originales/derivados, `AudioFolderName` (carpeta del `.m4a` transcodificado), estados de procesado (`ProcessingStatus`), `LifeCycleStatus`.
|
||||
- **PlayList** — lista del usuario (`UserId`).
|
||||
- **PlayListHasTrack** — pistas dentro de una playlist (con `Position`).
|
||||
- **UserHasTrack** — propiedad: qué usuario posee qué pista.
|
||||
- **UploadIntent** — reserva de subida (clave, bucket, tamaño esperado, estado, expiración) para validar y aplicar cuota.
|
||||
- **Upload** — registro de objetos subidos/transferidos al storage.
|
||||
- **User**: `Id` is Zitadel's numeric `sub` (`long`), plus a name and `ProfilePictureUrl`.
|
||||
- **Track**: original/derivative file names, `AudioFolderName` (the transcoded `.m4a`'s folder), processing state (`ProcessingStatus`), `LifeCycleStatus`.
|
||||
- **PlayList**: a user's playlist (`UserId`).
|
||||
- **PlayListHasTrack**: the tracks inside a playlist (with `Position`).
|
||||
- **UserHasTrack**: ownership, which user owns which track.
|
||||
- **UploadIntent**: an upload reservation (key, bucket, expected size, status, expiration) used for validation and quota.
|
||||
- **Upload**: a record of objects uploaded or transferred to storage.
|
||||
|
||||
@@ -1,40 +1,40 @@
|
||||
# Autenticación e identidad
|
||||
# Authentication and identity
|
||||
|
||||
## Proveedor: Zitadel (OIDC/OAuth2)
|
||||
## Provider: Zitadel (OIDC/OAuth2)
|
||||
|
||||
El login y la emisión de tokens los hace **Zitadel** (no la API). La API es un **resource server**: solo **valida** el access token (JWT) que le llega en `Authorization: Bearer`.
|
||||
Login and token issuance are handled by **Zitadel**, not the API. The API is a **resource server**: it only **validates** the access token (JWT) it receives in `Authorization: Bearer`.
|
||||
|
||||
- Config en la sección `Authentication` (`AuthenticationConfiguration`): metadata address, issuer, audience, client id, endpoints de authorize/token, scopes.
|
||||
- `JwtBearerOptionsSetup` configura la validación (issuer, audience, claves vía metadata OIDC).
|
||||
- En dev, `RequireHttpsMetadata = false`.
|
||||
- Configured under the `Authentication` section (`AuthenticationConfiguration`): metadata address, issuer, audience, client id, authorize/token endpoints, scopes.
|
||||
- `JwtBearerOptionsSetup` sets up validation (issuer, audience, keys via OIDC metadata).
|
||||
- In dev, `RequireHttpsMetadata = false`.
|
||||
|
||||
### Requisito clave en la app de Zitadel
|
||||
- **Auth Token Type = JWT** (si no, Zitadel emite un token **opaco** sin puntos y `JwtBearer` no lo puede validar → 401 en todo lo protegido).
|
||||
- **User Info inside Token** activado si quieres que el access token traiga claims de perfil (`name`, `email`, `picture`). Por defecto el access token es mínimo (`sub`, `aud`, `iss`, `exp`…), sin perfil.
|
||||
### Key requirement in the Zitadel app
|
||||
- **Auth Token Type = JWT** (otherwise Zitadel issues an **opaque** token with no dots, which `JwtBearer` can't validate → 401 on everything protected).
|
||||
- **User Info inside Token** enabled if you want the access token to carry profile claims (`name`, `email`, `picture`). By default the access token is minimal (`sub`, `aud`, `iss`, `exp`…), with no profile info.
|
||||
|
||||
## El `sub` es numérico → `User.Id` es `long`
|
||||
## `sub` is numeric → `User.Id` is `long`
|
||||
|
||||
El `sub` de Zitadel es un entero de 64 bits en string (p. ej. `371953080444977155`), **no un GUID**. Por eso:
|
||||
- `User.Id` y todas las FKs de usuario (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) son **`long`**.
|
||||
- `User.Id` lleva `[DatabaseGenerated(None)]` para que EF **no** lo autogenere: se asigna el `sub`.
|
||||
- `CurrentUser.Id` es `long?` (parsea con `long.TryParse`); `RequiredId` lanza si falta.
|
||||
Zitadel's `sub` is a 64-bit integer encoded as a string (e.g. `371953080444977155`), **not a GUID**. Because of that:
|
||||
- `User.Id` and every user-related FK (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) are **`long`**.
|
||||
- `User.Id` has `[DatabaseGenerated(None)]` so EF doesn't autogenerate it: it's assigned from the `sub`.
|
||||
- `CurrentUser.Id` is `long?` (parsed with `long.TryParse`); `RequiredId` throws if it's missing.
|
||||
|
||||
**Por qué**: el código antiguo hacía `Guid.Parse(sub)` y reventaba (FormatException) → 401 en cada endpoint protegido. Se decidió guardar el `sub` tal cual como número.
|
||||
**Why**: the original code did `Guid.Parse(sub)`, which threw a `FormatException` → 401 on every protected endpoint. The fix was to store the `sub` as-is, as a number.
|
||||
|
||||
## Provisioning / sincronización de usuarios
|
||||
## User provisioning / sync
|
||||
|
||||
En cada token validado, `JwtBearerEventsHandler.TokenValidated`:
|
||||
1. Lee `sub` (id), `name`/`preferred_username`/`email` (con fallback) y el claim OIDC `picture`.
|
||||
2. Envía un `SyncUserCommand`.
|
||||
On every validated token, `JwtBearerEventsHandler.TokenValidated`:
|
||||
1. Reads `sub` (id), `name`/`preferred_username`/`email` (with fallback), and the OIDC `picture` claim.
|
||||
2. Sends a `SyncUserCommand`.
|
||||
|
||||
`SyncUserCommandHandler` hace un **upsert idempotente**:
|
||||
- Si el usuario no existe → lo crea.
|
||||
- Si existe → actualiza nombre/apellidos/`ProfilePictureUrl` **solo si cambiaron** (escribe en DB únicamente cuando hay cambios).
|
||||
`SyncUserCommandHandler` does an **idempotent upsert**:
|
||||
- If the user doesn't exist yet → creates it.
|
||||
- If it exists → updates the name and `ProfilePictureUrl` **only if they changed** (a DB write only happens when something is different).
|
||||
|
||||
**Por qué upsert con "escribe solo si cambió"**: `TokenValidated` se ejecuta en **cada request**; sin ese guardado condicional estaríamos escribiendo en cada petición. Y debe ser resiliente: si el provisioning falla, se loguea pero **no** tumba la autenticación.
|
||||
**Why "write only if changed"**: `TokenValidated` runs on **every request**; without that guard we'd be writing to the DB on every single call. It also needs to be resilient: if provisioning fails, it's logged but authentication **doesn't** fail because of it.
|
||||
|
||||
> Nota: `CreateUserCommand` (endpoint manual `POST /users`) sigue siendo create-only; el sync del login usa `SyncUserCommand`.
|
||||
> Note: `CreateUserCommand` (the manual `POST /users` endpoint) is still create-only; login sync uses `SyncUserCommand`.
|
||||
|
||||
## Foto de perfil
|
||||
## Profile picture
|
||||
|
||||
`User.ProfilePictureUrl` se rellena desde el claim `picture` del token. Si Zitadel no mete la info de perfil en el access token (ver arriba), llega `null` — el código es correcto, pero no hay nada que sincronizar hasta activar esa opción.
|
||||
`User.ProfilePictureUrl` is filled from the token's `picture` claim. If Zitadel doesn't include profile info in the access token (see above), it arrives as `null`. The code is correct, there's just nothing to sync until that option is turned on.
|
||||
|
||||
@@ -1,21 +1,25 @@
|
||||
# Entorno de desarrollo
|
||||
# Development environment
|
||||
|
||||
Cómo levantar todo en local y probarlo. El detalle completo del deploy (y el de
|
||||
producción) está en [deploy/README.md](../deploy/README.md).
|
||||
How to run everything locally and test it. The full deployment details (dev
|
||||
and prod) live in [deploy/README.md](../deploy/README.md).
|
||||
|
||||
## Arranque
|
||||
## Startup
|
||||
|
||||
Bring up the separate `Infrastructure` repository first (Postgres, Zitadel,
|
||||
SeaweedFS, RabbitMQ, Jaeger, see its own README for the exact command), then:
|
||||
|
||||
```sh
|
||||
docker compose -f deploy/compose.yml -f deploy/compose.dev.yml up -d
|
||||
```
|
||||
|
||||
Ese único comando levanta la infraestructura y corre los jobs *one-shot* de
|
||||
bootstrap: genera las claves RS256 de stream-ticket en `deploy/keys/`, aplica
|
||||
las migraciones EF Core y provisiona Zitadel (proyecto + apps OIDC), dejando
|
||||
los client ids en `deploy/.env`. Detalle completo, incluido cómo arrancar las
|
||||
apps en Docker con `--profile apps`, en [deploy/README.md](../deploy/README.md).
|
||||
That runs Musify's own one-shot bootstrap jobs: it generates the RS256
|
||||
stream-ticket keys in `deploy/keys/`, applies the EF Core migrations, creates
|
||||
Musify's storage bucket, and provisions Zitadel (project + OIDC apps),
|
||||
writing the client ids into `deploy/.env`. Full details, including how to run
|
||||
the apps in Docker with `--profile apps`, are in
|
||||
[deploy/README.md](../deploy/README.md).
|
||||
|
||||
## Aplicaciones
|
||||
## Applications
|
||||
|
||||
```powershell
|
||||
dotnet run --project backend/Hosts/Musify.Api # :5111
|
||||
@@ -24,45 +28,45 @@ dotnet run --project backend/Hosts/Musify.Worker # background
|
||||
npm --prefix web-player run dev # :5173
|
||||
```
|
||||
|
||||
## Mapa de puertos
|
||||
## Port map
|
||||
|
||||
| Servicio | Puerto |
|
||||
| Service | Port |
|
||||
|---|---|
|
||||
| Musify.Api | 5111 (`/scalar/v1`, `/openapi/v1.json`) |
|
||||
| Musify.StreamingGateway | 8081 (`/health`, `/media/...`) |
|
||||
| web-player | 5173 (`npm run dev`) o 3000 (contenedor) |
|
||||
| SeaweedFS S3 / filer / master | 8333 / 8888 / 9333 |
|
||||
| Zitadel | 8080 |
|
||||
| PostgreSQL | 59000 (→ 5432 en el contenedor) |
|
||||
| RabbitMQ AMQP / management | 5672 / 15672 |
|
||||
| Jaeger UI / OTLP | 16686 / 4317 |
|
||||
| pgAdmin (`-Tools`) | 5050 |
|
||||
| web-player | 5173 (`npm run dev`) or 3000 (container) |
|
||||
|
||||
DB: `musify_db`, usuario `postgres`/`postgres`. Bucket S3: `webapi-storage`
|
||||
(credenciales `admin_access_key`/`admin_secret_key`). Todos los valores salen de
|
||||
`deploy/.env`.
|
||||
Postgres, Zitadel, SeaweedFS, RabbitMQ and Jaeger are not run by this repo.
|
||||
Their ports are documented in the separate `Infrastructure` repository's own
|
||||
README.
|
||||
|
||||
## Migraciones
|
||||
DB: `musify_db`, user `postgres`/`postgres` on Infrastructure's shared
|
||||
Postgres. S3 bucket: `webapi-storage` (credentials
|
||||
`admin_access_key`/`admin_secret_key`, also Infrastructure's). All of these
|
||||
come from `deploy/.env`.
|
||||
|
||||
El servicio `migrate` de `deploy/compose.yml` las aplica en cada `docker
|
||||
compose up -d` (no-op si el esquema ya está al día). A mano, tras cambios de
|
||||
esquema, contra la base local:
|
||||
## Migrations
|
||||
|
||||
The `migrate` service in `deploy/compose.yml` applies them on every `docker
|
||||
compose up -d` (a no-op if the schema is already current). To run them by
|
||||
hand after a schema change, against the local database:
|
||||
|
||||
```powershell
|
||||
dotnet ef database update --project backend/Core/Musify.Infrastructure --startup-project backend/Core/Musify.Infrastructure --context Database
|
||||
```
|
||||
|
||||
`Musify.Infrastructure` es a la vez el proyecto de migraciones y el de arranque:
|
||||
tiene el `IDesignTimeDbContextFactory` y el paquete `EFCore.Design`. La cadena
|
||||
de conexión se lee de sus user-secrets, o de la variable de entorno
|
||||
`Database__ConnectionString` si no hay user-secrets (así es como la usa el
|
||||
contenedor `migrate`, ver `backend/Musify.Migrator.Dockerfile`).
|
||||
`Musify.Infrastructure` is both the migrations project and the startup
|
||||
project: it has the `IDesignTimeDbContextFactory` and the `EFCore.Design`
|
||||
package. The connection string is read from its user-secrets, or from the
|
||||
`Database__ConnectionString` environment variable if there are no
|
||||
user-secrets (which is how the `migrate` container uses it, see
|
||||
`backend/Musify.Migrator.Dockerfile`).
|
||||
|
||||
## Stream-tickets (claves RS256)
|
||||
## Stream tickets (RS256 keys)
|
||||
|
||||
La API firma los stream-tickets con la clave privada y el gateway los valida con
|
||||
la pública. El servicio `keys-init` de `deploy/compose.yml` las genera en
|
||||
`deploy/keys/` si no existen; a mano:
|
||||
The API signs stream tickets with the private key, and the gateway validates
|
||||
them with the public one. The `keys-init` service in `deploy/compose.yml`
|
||||
generates them in `deploy/keys/` if they don't exist yet; to do it by hand:
|
||||
|
||||
```powershell
|
||||
openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out deploy/keys/stream_private.pem
|
||||
@@ -78,74 +82,74 @@ openssl rsa -in deploy/keys/stream_private.pem -pubout -out deploy/keys/stream_p
|
||||
dotnet test backend/Musify.slnx
|
||||
```
|
||||
|
||||
`backend/Tests/` (carpeta `/Tests/` en el `.slnx`) tiene cinco proyectos xUnit:
|
||||
`backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects:
|
||||
|
||||
| Proyecto | Cubre | Docker |
|
||||
| Project | Covers | Docker |
|
||||
|---|---|---|
|
||||
| `Musify.Domain.Tests` | Lógica de los value objects (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Las entidades son en su mayoría anémicas — sin comportamiento propio, nada más que testear ahí. | No |
|
||||
| `Musify.Application.Tests` | Los ~45 handlers y servicios de `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No |
|
||||
| `Musify.Infrastructure.Tests` | `Database`/migraciones, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Sí** |
|
||||
| `Musify.Api.Tests` | Endpoints HTTP reales (routing, auth, `ValidationFilter`, mapeo `ErrorOr`→HTTP) vía `WebApplicationFactory`, más `ErrorOrHttpExtensions`/`CurrentUser`/validators FluentValidation en aislado. | **Sí** |
|
||||
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) y `TicketValidationMiddleware` (traversal, límites de prefijo, extracción de ticket). | No |
|
||||
| `Musify.Domain.Tests` | Value object logic (`TrackAudio`/`TrackPictures`: `IsProcessed`, `IsFailed`, `IsInProgress`). Entities are mostly anemic, with no behavior of their own beyond that. | No |
|
||||
| `Musify.Application.Tests` | The ~45 handlers and services in `Musify.Application` (Albums, Tracks, PlayLists, Users, Mixes). | No |
|
||||
| `Musify.Infrastructure.Tests` | `Database`/migrations, `StorageService`, `StreamTicketService`, `AuditableEntityInterceptor`, `PictureService`, `SingleFlightCache`, `PlayListPresetSeeder`. | **Yes** |
|
||||
| `Musify.Api.Tests` | Real HTTP endpoints (routing, auth, `ValidationFilter`, `ErrorOr`→HTTP mapping) via `WebApplicationFactory`, plus `ErrorOrHttpExtensions`/`CurrentUser`/FluentValidation validators in isolation. | **Yes** |
|
||||
| `Musify.StreamingGateway.Tests` | `TicketValidator` (RS256) and `TicketValidationMiddleware` (traversal, prefix limits, ticket extraction). | No |
|
||||
|
||||
`Musify.Worker` no tiene proyecto de test: su `Program.cs` es puro *wiring* de
|
||||
DI/host (Hangfire, MassTransit, jobs) — la lógica real que ejecuta vive en
|
||||
`Musify.Infrastructure`, ya cubierta ahí.
|
||||
`Musify.Worker` has no test project: its `Program.cs` is pure DI/host wiring
|
||||
(Hangfire, MassTransit, jobs). The actual logic it runs lives in
|
||||
`Musify.Infrastructure`, already covered there.
|
||||
|
||||
### Application.Tests: por qué SQLite en memoria y no mocks
|
||||
### Application.Tests: why in-memory SQLite instead of mocks
|
||||
|
||||
`IDatabase` expone `DbSet<T>` directamente, así que los handlers arman LINQ
|
||||
real (`Where`, `Include`, `Select`...) contra él — no se puede mockear eso con
|
||||
NSubstitute, no hay `IQueryable` detrás de un mock. En su lugar,
|
||||
`TestSupport/TestDatabase.cs` es un `DbContext` real sobre SQLite en memoria
|
||||
(una conexión `:memory:` nueva por test, con foreign keys reales — a diferencia
|
||||
del proveedor InMemory de EF Core, SQLite además soporta transacciones, que dos
|
||||
handlers usan). `TestSupport/TestEntities.cs` y `TestConfigurations.cs`
|
||||
construyen entidades y configuración con valores por defecto sensatos.
|
||||
`IStorageService`, `IEventBus`, etc. sí se mockean con NSubstitute, al ser
|
||||
interfaces normales.
|
||||
`IDatabase` exposes `DbSet<T>` directly, so handlers build real LINQ
|
||||
(`Where`, `Include`, `Select`...) against it. That can't be mocked with
|
||||
NSubstitute, since there's no `IQueryable` behind a mock. Instead,
|
||||
`TestSupport/TestDatabase.cs` is a real `DbContext` on top of in-memory
|
||||
SQLite (a fresh `:memory:` connection per test, with real foreign keys,
|
||||
unlike EF Core's InMemory provider, SQLite also supports transactions,
|
||||
which two handlers rely on). `TestSupport/TestEntities.cs` and
|
||||
`TestConfigurations.cs` build entities and configuration with sensible
|
||||
defaults. `IStorageService`, `IEventBus`, etc. are mocked with NSubstitute,
|
||||
since those are plain interfaces.
|
||||
|
||||
### Infrastructure.Tests y Api.Tests: Testcontainers
|
||||
### Infrastructure.Tests and Api.Tests: Testcontainers
|
||||
|
||||
Estos dos sí necesitan Docker corriendo. Levantan un Postgres real
|
||||
(`Testcontainers.PostgreSql`) y, en `Infrastructure.Tests`, también un
|
||||
SeaweedFS real (mismo `chrislusf/seaweedfs:latest` e imagen/comando que
|
||||
`deploy/compose.yml`, no MinIO) — así `StorageService` se prueba contra el
|
||||
mismo backend S3-compatible que usa producción, no contra AWS S3. Los
|
||||
contenedores se levantan una vez por corrida (`ICollectionFixture`), con
|
||||
migraciones EF Core reales aplicadas contra el Postgres efímero.
|
||||
These two need Docker running. They spin up a real Postgres
|
||||
(`Testcontainers.PostgreSql`) and, in `Infrastructure.Tests`, a real
|
||||
SeaweedFS too (the same `chrislusf/seaweedfs:latest` image/command as
|
||||
`deploy/compose.yml`, not MinIO), so `StorageService` is tested against the
|
||||
same S3-compatible backend production uses, not against AWS S3. Containers
|
||||
are started once per run (`ICollectionFixture`), with real EF Core
|
||||
migrations applied against the ephemeral Postgres.
|
||||
|
||||
`Api.Tests` usa `WebApplicationFactory<Program>` contra ese mismo Postgres —
|
||||
routing, auth y validación reales — pero sustituye por fakes los servicios que
|
||||
hablan por red (`IStorageService`, `IEventBus`, `IStreamTicketService`) y el
|
||||
esquema de autenticación JWT real por un handler
|
||||
de prueba (ver `TestSupport/FakeAuthenticationHandler.cs`) que autentica según
|
||||
un header, sin necesitar un Zitadel real.
|
||||
`Api.Tests` uses `WebApplicationFactory<Program>` against that same
|
||||
Postgres, with real routing, auth, and validation, but replaces the services
|
||||
that talk over the network with fakes (`IStorageService`, `IEventBus`,
|
||||
`IStreamTicketService`), and swaps the real JWT auth scheme for a test
|
||||
handler (see `TestSupport/FakeAuthenticationHandler.cs`) that authenticates
|
||||
based on a header, with no real Zitadel needed.
|
||||
|
||||
Escribir estos tests contra el backend real encontró y arregló dos bugs reales
|
||||
(no simulados): `StorageService.RemoveFolderAsync` reventaba con
|
||||
`NullReferenceException` contra SeaweedFS (su respuesta `DeleteObjects` omite
|
||||
la lista `<Error>` cuando no hay errores, a diferencia de AWS S3), y
|
||||
`AuditableEntityInterceptor` nunca actualizaba `UpdatedAt` en una actualización
|
||||
normal (carga → modifica → guarda) porque corría antes de que EF detectara los
|
||||
cambios pendientes.
|
||||
Writing these tests against the real backend found and fixed two actual
|
||||
bugs (not staged ones): `StorageService.RemoveFolderAsync` threw a
|
||||
`NullReferenceException` against SeaweedFS (its `DeleteObjects` response
|
||||
omits the `<Error>` list when there are no errors, unlike AWS S3), and
|
||||
`AuditableEntityInterceptor` never updated `UpdatedAt` on a normal update
|
||||
(load → modify → save) because it ran before EF had detected the pending
|
||||
changes.
|
||||
|
||||
## Config (AppSettings)
|
||||
## Config (appsettings)
|
||||
|
||||
Convención: `AppSettings.json` es la plantilla con valores vacíos o neutros;
|
||||
`AppSettings.Development.json` tiene los valores reales de dev; en Docker todo
|
||||
se sobreescribe con variables de entorno (`Seccion__Clave`) desde
|
||||
`deploy/compose.yml`. Para correr `Musify.Api` desde el IDE, los valores de
|
||||
`Authentication` van en user-secrets (no en el repo) — cópialos de
|
||||
`deploy/.env` una vez que `zitadel-init` los haya generado (ver
|
||||
Convention: `appsettings.json` is the template, with empty or neutral
|
||||
values; `appsettings.Development.json` has the real dev values; in Docker
|
||||
everything is overridden with environment variables (`Section__Key`) from
|
||||
`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`
|
||||
once `zitadel-init` has generated them (see
|
||||
[deploy/README.md](../deploy/README.md)).
|
||||
|
||||
Secciones clave: `Authentication` (Zitadel), `Database`, `MassTransit`,
|
||||
Key sections: `Authentication` (Zitadel), `Database`, `MassTransit`,
|
||||
`InfrastructureStorage` (S3), `ApplicationStorage` (bucket), `Track`, `PlayList`,
|
||||
`UploadIntent`, `AudioTranscoder`, `Workers`, `StreamGateway` / `StreamTicket`.
|
||||
|
||||
## Requisitos del host
|
||||
## Host requirements
|
||||
|
||||
- .NET 10 SDK y Node 22.
|
||||
- **ffmpeg** en el PATH (transcode de audio del Worker).
|
||||
- .NET 10 SDK and Node 22.
|
||||
- **ffmpeg** on the PATH (for the Worker's audio transcoding).
|
||||
- Docker Desktop.
|
||||
|
||||
@@ -1,46 +1,46 @@
|
||||
# Procesado de medios (Worker + MassTransit)
|
||||
# Media processing (Worker + MassTransit)
|
||||
|
||||
El procesado pesado (transcode de audio, miniaturas, transferencias en el bucket) ocurre **fuera del request HTTP**, en el **Worker**, orquestado con **MassTransit** sobre RabbitMQ.
|
||||
Heavy processing (audio transcoding, thumbnails, bucket transfers) happens **outside the HTTP request**, in the **Worker**, orchestrated with **MassTransit** on top of RabbitMQ.
|
||||
|
||||
## Por qué asíncrono y con routing slips
|
||||
## Why async, and why routing slips
|
||||
|
||||
- Transcodificar audio y redimensionar imágenes es CPU/IO intensivo: no debe bloquear una llamada a la API.
|
||||
- Es un proceso **multi-paso con efectos secundarios** (descargar de S3 → transcodificar → subir resultados → actualizar DB). Si un paso falla, hay que **deshacer** los anteriores. Para eso se usan **routing slips** (patrón Saga de MassTransit): cada paso es una *activity* con su **compensación**.
|
||||
- **Outbox transaccional** (EF + MassTransit): el evento se publica de forma consistente con el cambio en la DB (no se pierde ni se duplica).
|
||||
- Transcoding audio and resizing images is CPU/IO-intensive: it shouldn't block an API call.
|
||||
- It's a **multi-step process with side effects** (download from S3 → transcode → upload results → update the DB). If a step fails, the previous ones need to be **undone**. That's what **routing slips** are for (MassTransit's Saga pattern): each step is an *activity* with its own **compensation**.
|
||||
- **Transactional outbox** (EF + MassTransit): the event is published consistently with the DB change (never lost, never duplicated).
|
||||
|
||||
## Disparo
|
||||
## Trigger
|
||||
|
||||
Al crear/actualizar una pista o playlist, el handler publica un evento (p. ej. `CreateTrackResourcesEvent`). Un **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, …) construye un **routing slip** con la secuencia de activities y lo lanza.
|
||||
When a track or playlist is created or updated, the handler publishes an event (e.g. `CreateTrackResourcesEvent`). A **consumer** (`CreateTrackConsumer`, `UpdateTrackPictureConsumer`, …) builds a **routing slip** with the sequence of activities and executes it.
|
||||
|
||||
## Builders de routing slip
|
||||
## Routing slip builders
|
||||
|
||||
- `CreateTrackRoutingSlipBuilder` — alta de pista (consume intents, mueve ficheros, dispara procesado).
|
||||
- `PictureWorkflowRoutingSlipBuilder` — pipeline de imágenes (descargar → redimensionar → subir → actualizar).
|
||||
- `AudioWorkflowRoutingSlipBuilder` — pipeline de audio (descargar → transcodificar a `.m4a` → subir carpeta → actualizar).
|
||||
- `CreateTrackRoutingSlipBuilder`: creating a track (consumes intents, moves files, kicks off processing).
|
||||
- `PictureWorkflowRoutingSlipBuilder`: image pipeline (download → resize → upload → update).
|
||||
- `AudioWorkflowRoutingSlipBuilder`: audio pipeline (download → transcode to `.m4a` → upload folder → update).
|
||||
- `DeleteTrackRoutingSlipBuilder`, `DeletePlayListRoutingSlipBuilder`, `PlayListPictureSourceRoutingSlipBuilder`.
|
||||
|
||||
## Activities (pasos)
|
||||
## Activities (steps)
|
||||
|
||||
Agrupadas por área:
|
||||
Grouped by area:
|
||||
- **Files**: `DownloadFileFromBucketActivity`, `UploadFileToBucketActivity`, `TransferFilesToBucketActivity`, `CopyFileInBucketActivity`, `RemoveFileFromBucketActivity`.
|
||||
- **Audio**: `GenerateAudioWorkflowPathsActivity`, `TranscodeAudioActivity` (ffmpeg → `.m4a`), `UpdateTrackAudioActivity` (guarda `AudioFolderName` y marca `Completed`).
|
||||
- **Audio**: `GenerateAudioWorkflowPathsActivity`, `TranscodeAudioActivity` (ffmpeg → `.m4a`), `UpdateTrackAudioActivity` (saves `AudioFolderName` and marks it `Completed`).
|
||||
- **Pictures**: `GeneratePictureWorkflowPathsActivity`, `ResizePictureActivity` (ImageSharp), `UpdateTrackPictureActivity`, `UpdatePlayListPictureActivity`.
|
||||
- **Tracks/PlayLists**: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`, `PublishTrackProcessingEventsActivity`, y equivalentes de playlist.
|
||||
- **Tracks/Playlists**: `MarkTrackAsRemovingActivity`, `DeleteTrackFromDbActivity`, `PublishTrackProcessingEventsActivity`, and their playlist equivalents.
|
||||
- **UploadIntents**: `ConsumeUploadIntentsActivity`.
|
||||
|
||||
`RoutingSlipCleanUpConsumer` reacciona a `Completed`/`Faulted` para limpiar (p. ej. borrar el directorio temporal de trabajo).
|
||||
`RoutingSlipCleanUpConsumer` reacts to `Completed`/`Faulted` to clean up (e.g. deleting the temporary working directory).
|
||||
|
||||
## Transcode de audio
|
||||
## Audio transcoding
|
||||
|
||||
`AudioTranscoderService` invoca **ffmpeg** leyendo el original por stdin y produciendo en un directorio de trabajo un único `audio.m4a`: AAC con codec/bitrate/sample-rate/profile configurables y `-movflags +faststart` (moov al inicio → seek por range). Luego `TransferFilesToBucketActivity` sube esa carpeta a `Tracks/ProcessedAudios/{folder}/` y `UpdateTrackAudioActivity` guarda el `AudioFolderName` (la carpeta **de destino en S3**, no la local) y pone el estado en `Completed`.
|
||||
`AudioTranscoderService` calls **ffmpeg**, reading the original from stdin and producing a single `audio.m4a` in a working directory: AAC with a configurable codec/bitrate/sample-rate/profile, and `-movflags +faststart` (moov at the start, so range-based seeking works). `TransferFilesToBucketActivity` then uploads that folder to `Tracks/ProcessedAudios/{folder}/`, and `UpdateTrackAudioActivity` saves the `AudioFolderName` (the **destination folder in S3**, not the local one) and sets the status to `Completed`.
|
||||
|
||||
## Configuración que necesita el Worker
|
||||
## What the Worker needs configured
|
||||
|
||||
El Worker registra: storage (S3), transcoder (ffmpeg), pictures, DB, los consumers de MassTransit y los jobs de upload intents. Su `AppSettings` debe tener las secciones correctas: `Bucket` (= `webapi-storage`, el mismo que la API), `PlayList`, `Track`, `MassTransit`, `AudioTranscoder`, `UploadIntent`, `Workers` (directorio temporal), `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`.
|
||||
|
||||
## Jobs de fondo
|
||||
## Background jobs
|
||||
|
||||
- `UploadIntentExpirationJob` — marca intents caducados como expirados (libera cuota) cada `ExpirationJobIntervalSeconds`.
|
||||
- `TemporalUploadsCleanUpJob` — limpia subidas temporales antiguas.
|
||||
- `UploadIntentExpirationJob`: marks expired intents as such (freeing quota) every `ExpirationJobIntervalSeconds`.
|
||||
- `TemporalUploadsCleanUpJob`: cleans up old temporary uploads.
|
||||
|
||||
Corren en el host del Worker (`AddUploadIntentJobs`). Si no se registran en ningún proceso, los intents colgados no se limpian solos.
|
||||
Both run on the Worker host (`AddUploadIntentJobs`). If they aren't registered anywhere, stale intents never get cleaned up on their own.
|
||||
|
||||
@@ -1,74 +1,76 @@
|
||||
# Proyectos
|
||||
# Projects
|
||||
|
||||
Los proyectos .NET viven en `backend/` (solución `Musify.slnx`, carpetas
|
||||
`Core/`, `Hosts/` y `Tests/`); el cliente web en `web-player/`.
|
||||
The .NET projects live under `backend/` (solution `Musify.slnx`, folders
|
||||
`Core/`, `Hosts/` and `Tests/`); the web client lives under `web-player/`.
|
||||
|
||||
## Musify.Api (`:5111`)
|
||||
|
||||
API REST pública (Minimal APIs, .NET 10). Es la fachada fina del sistema.
|
||||
The public REST API (Minimal APIs, .NET 10). The thin public face of the system.
|
||||
|
||||
- **Endpoints**: `UserEndpoints`, `TrackEndpoints`, `PlayListEndpoints`.
|
||||
- **Autenticación**: JWT Bearer contra Zitadel (`Authentication/`), con `JwtBearerEventsHandler` que sincroniza el usuario en cada token validado.
|
||||
- **`CurrentUser`**: abstracción que se enlaza como parámetro de endpoint (`IBindableFromHttpContext`) y expone los claims básicos (`Id` como `long?`, `RequiredId`, nombre, etc.) sin tener que leer `ClaimsPrincipal` a mano.
|
||||
- **Emite**: presigned URLs de subida (vía `IStorageService`) y **stream-tickets** (vía `IStreamTicketService`, RS256).
|
||||
- **OpenAPI + Scalar** para documentación/prueba; OAuth2 (PKCE) configurado para login desde Scalar.
|
||||
- **CORS** totalmente abierto **solo en Development** (para el front local).
|
||||
- Traduce `ErrorOr` → HTTP en `ResultHttpExtensions`.
|
||||
- **Authentication**: JWT Bearer against Zitadel (`Authentication/`), with a `JwtBearerEventsHandler` that syncs the user on every validated token.
|
||||
- **`CurrentUser`**: an abstraction bound as an endpoint parameter (`IBindableFromHttpContext`) that exposes the basic claims (`Id` as `long?`, `RequiredId`, name, etc.) without reading `ClaimsPrincipal` by hand.
|
||||
- **Issues**: presigned upload URLs (via `IStorageService`) and **stream tickets** (via `IStreamTicketService`, RS256).
|
||||
- **OpenAPI + Scalar** for docs/testing; OAuth2 (PKCE) set up for logging in from Scalar.
|
||||
- **CORS** wide open, but **only in Development** (for the local frontend).
|
||||
- Translates `ErrorOr` into HTTP in `ResultHttpExtensions`.
|
||||
|
||||
**Propósito/porqué**: separar la cara pública (autorización + metadatos) del procesado y del servido de bytes.
|
||||
**Purpose**: keep the public surface (authorization + metadata) separate from processing and byte-serving.
|
||||
|
||||
## Musify.Domain + Musify.Application + Musify.Infrastructure
|
||||
|
||||
El núcleo, en tres proyectos (clean architecture):
|
||||
The core, split into three projects (clean architecture):
|
||||
|
||||
### Domain
|
||||
Entidades (`Entities/`) y value objects/enums (`ValueObjects/`: `ProcessingStatus`, `LifeCycleStatus`, `UploadIntentStatus`, …). Sin dependencias de infraestructura.
|
||||
Entities (`Entities/`) and value objects/enums (`ValueObjects/`: `ProcessingStatus`, `LifeCycleStatus`, `UploadIntentStatus`, …). No infrastructure dependencies.
|
||||
|
||||
### Application
|
||||
Casos de uso (CQRS con Mediator):
|
||||
Use cases (CQRS with Mediator):
|
||||
- `Tracks/`, `PlayLists/`, `Users/` → `Commands`, `Queries`, `Handlers`, `Responses`.
|
||||
- `Contracts/` → interfaces que implementa la infraestructura (`IDatabase`, `IStorageService`, `IAudioTranscoderService`, `IPictureService`, `IEventBus`, `IStreamTicketService`).
|
||||
- `Configuration/` → opciones tipadas (Track, PlayList, ApplicationStorage, UploadIntent…) bindeadas y validadas al arranque.
|
||||
- `Shared/` → helpers transversales (`StorageKey.Combine`, `ImageSize`).
|
||||
- `Contracts/` → interfaces that infrastructure implements (`IDatabase`, `IStorageService`, `IAudioTranscoderService`, `IPictureService`, `IEventBus`, `IStreamTicketService`).
|
||||
- `Configuration/` → typed options (Track, PlayList, ApplicationStorage, UploadIntent…) bound and validated at startup.
|
||||
- `Shared/` → cross-cutting helpers (`StorageKey.Combine`, `ImageSize`).
|
||||
- `Pagination/` → `PaginatedResponse<T>`.
|
||||
|
||||
**Porqué**: la lógica de negocio no conoce EF, S3 ni RabbitMQ; solo interfaces. Reutilizable por la API y el Worker.
|
||||
**Why**: business logic doesn't know about EF, S3, or RabbitMQ, only interfaces. Shared by both the API and the Worker.
|
||||
|
||||
### Infrastructure
|
||||
Implementaciones concretas:
|
||||
- `Persistence/` → `Database` (EF Core/Npgsql), migraciones.
|
||||
- `Services/` → `StorageService` (AWSSDK.S3 contra SeaweedFS), `AudioTranscoderService` (ffmpeg → `.m4a`), `PictureService` (ImageSharp), `StreamTicketService` (firma RS256).
|
||||
- `MassTransit/` → consumers, activities y routing slips (ver [media-processing.md](media-processing.md)).
|
||||
Concrete implementations:
|
||||
- `Persistence/` → `Database` (EF Core/Npgsql), migrations.
|
||||
- `Services/` → `StorageService` (AWSSDK.S3 against SeaweedFS), `AudioTranscoderService` (ffmpeg → `.m4a`), `PictureService` (ImageSharp), `StreamTicketService` (RS256 signing).
|
||||
- `MassTransit/` → consumers, activities, and routing slips (see [media-processing.md](media-processing.md)).
|
||||
- `Jobs/` → `UploadIntentExpirationJob`, `TemporalUploadsCleanUpJob`.
|
||||
|
||||
## Musify.StreamingGateway (`:8081`)
|
||||
|
||||
Reverse proxy (ASP.NET + **YARP**) que sirve el audio (`.m4a`) desde SeaweedFS. Valida el stream-ticket (RS256, clave pública) y, si el objeto pedido cae bajo el prefijo autorizado, reenvía al filer.
|
||||
A reverse proxy (ASP.NET + **YARP**) that serves audio (`.m4a`) from SeaweedFS. Validates the stream ticket (RS256, public key) and, if the requested object falls under the authorized prefix, forwards it to the filer.
|
||||
|
||||
**Porqué un proyecto aparte**: escala con el tráfico de bytes independientemente de la API, tiene superficie mínima (solo sabe validar un JWT y proxiar) y permite que SeaweedFS quede en red privada. Ver [streaming.md](streaming.md).
|
||||
**Why a separate project**: it scales with byte traffic independently of the API, has a minimal surface (it only knows how to validate a JWT and proxy), and lets SeaweedFS stay on a private network. See [streaming.md](streaming.md).
|
||||
|
||||
## Musify.Worker
|
||||
|
||||
Worker Service (host de fondo) que consume los eventos de MassTransit y ejecuta el procesado pesado: transcode de audio a `.m4a` (ffmpeg), generación de miniaturas (ImageSharp), transferencias de ficheros en el bucket, y los jobs de expiración/limpieza de upload intents.
|
||||
A background Worker Service that consumes MassTransit events and runs the heavy processing: audio transcoding to `.m4a` (ffmpeg), thumbnail generation (ImageSharp), file transfers within the bucket, and the upload-intent expiration/cleanup jobs.
|
||||
|
||||
**Porqué aparte**: el trabajo CPU/IO no debe ocurrir dentro de un request HTTP; se escala y reinicia por separado. (Antes se llamaba `Musify.PictureWorker`; se renombró porque hace más que imágenes.)
|
||||
**Why separate**: CPU/IO-heavy work shouldn't happen inside an HTTP request; it scales and restarts independently. (Previously named `Musify.PictureWorker`; renamed once it started doing more than just images.)
|
||||
|
||||
## web-player (`:5173` en dev, `:3000` en contenedor)
|
||||
## web-player (`:5173` in dev, `:3000` in a container)
|
||||
|
||||
Cliente web (SvelteKit 2 + Svelte 5 + Tailwind 4). La sesión OIDC vive en el
|
||||
servidor (cookie cifrada); las llamadas al backend salen del servidor con el
|
||||
access token, y las subidas se hacen contra las URLs prefirmadas de S3.
|
||||
The web client (SvelteKit 2 + Svelte 5 + Tailwind 4). The OIDC session lives
|
||||
on the server (an encrypted cookie); backend calls go out from the server
|
||||
with the access token, and uploads go straight to the presigned S3 URLs.
|
||||
|
||||
## Tests
|
||||
|
||||
`backend/Tests/`, carpeta `/Tests/` en el `.slnx`: cinco proyectos xUnit —
|
||||
`Musify.Domain.Tests`, `Musify.Application.Tests` (SQLite en memoria, sin
|
||||
Docker), `Musify.Infrastructure.Tests` y `Musify.Api.Tests` (Testcontainers:
|
||||
Postgres + SeaweedFS reales) y `Musify.StreamingGateway.Tests`. Detalle en
|
||||
`backend/Tests/` (the `/Tests/` folder in the `.slnx`) has five xUnit projects:
|
||||
`Musify.Domain.Tests`, `Musify.Application.Tests` (in-memory SQLite, no
|
||||
Docker), `Musify.Infrastructure.Tests` and `Musify.Api.Tests` (Testcontainers:
|
||||
real Postgres + SeaweedFS), and `Musify.StreamingGateway.Tests`. Details in
|
||||
[development.md](development.md#tests).
|
||||
|
||||
## Otras carpetas
|
||||
## Other folders
|
||||
|
||||
- **`deploy/`** — stacks de Docker Compose (dev y prod), autocontenidos. Ver
|
||||
- **`deploy/`**: Musify's own Docker Compose stacks (dev and prod). The
|
||||
shared infrastructure (Postgres, Zitadel, SeaweedFS, RabbitMQ, Jaeger)
|
||||
lives in the separate `Infrastructure` repo instead. See
|
||||
[../deploy/README.md](../deploy/README.md).
|
||||
- **`docs/`** — esta documentación.
|
||||
- **`docs/`**: this documentation.
|
||||
|
||||
@@ -1,48 +1,48 @@
|
||||
# Almacenamiento (SeaweedFS / S3)
|
||||
# Storage (SeaweedFS / S3)
|
||||
|
||||
## Qué es
|
||||
## What it is
|
||||
|
||||
**SeaweedFS** es el almacén de objetos, compatible con la API S3. Guarda los originales subidos y los derivados (audio `.m4a`, miniaturas). Se accede vía `AWSSDK.S3` desde `StorageService` (`IStorageService`).
|
||||
**SeaweedFS** is the object store, S3-API compatible. It holds the uploaded originals and their derivatives (`.m4a` audio, thumbnails). Accessed via `AWSSDK.S3` from `StorageService` (`IStorageService`).
|
||||
|
||||
Expone:
|
||||
- **S3 API** (`:8333`) — operaciones S3 (presigned URLs, put/get/list…).
|
||||
- **filer HTTP** (`:8888`) — sirve los ficheros por ruta; lo usa el **StreamingGateway**.
|
||||
- master (`:9333`) y volume server — internos.
|
||||
It exposes:
|
||||
- **S3 API** (`:8333`): S3 operations (presigned URLs, put/get/list…).
|
||||
- **filer HTTP** (`:8888`): serves files by path, used by the **StreamingGateway**.
|
||||
- master (`:9333`) and volume server, internal only.
|
||||
|
||||
Bucket de la app: **`webapi-storage`** (`ApplicationStorage:Bucket`). Importante: la API y el Worker usan **el mismo bucket** (el worker procesa lo que sube la API).
|
||||
App bucket: **`webapi-storage`** (`ApplicationStorage:Bucket`). Important: the API and the Worker use **the same bucket** (the worker processes whatever the API uploads).
|
||||
|
||||
## Estructura de claves (layout)
|
||||
## Key layout
|
||||
|
||||
- `temp/{userId}/Tracks/{guid}.{ext}` — subidas temporales (objetivo de las presigned PUT, antes de consumir el intent).
|
||||
- `uploads/{userId}/Tracks/OriginalPictures|OriginalAudios/...` — originales ya consumidos.
|
||||
- `Tracks/SmallPictures|MediumPictures|LargePictures/...` — miniaturas generadas.
|
||||
- `Tracks/ProcessedAudios/{AudioFolderName}/` — audio transcodificado (`audio.m4a`, AAC con faststart).
|
||||
- `temp/{userId}/Tracks/{guid}.{ext}`: temporary uploads, the target of presigned PUTs, before the intent is consumed.
|
||||
- `uploads/{userId}/Tracks/OriginalPictures|OriginalAudios/...`: originals that have already been consumed.
|
||||
- `Tracks/SmallPictures|MediumPictures|LargePictures/...`: generated thumbnails.
|
||||
- `Tracks/ProcessedAudios/{AudioFolderName}/`: transcoded audio (`audio.m4a`, AAC with faststart).
|
||||
|
||||
Las claves se construyen **siempre** con el helper `StorageKey.Combine(...)` (en `Application/Shared`), que une segmentos con `/` limpiando espacios y barras. Está centralizado para estandarizar y no repetir lógica.
|
||||
Keys are always built with the `StorageKey.Combine(...)` helper (in `Application/Shared`), which joins segments with `/` and trims stray spaces and slashes. It's centralized to keep the logic consistent and avoid repeating it.
|
||||
|
||||
> En el filer, los objetos S3 viven bajo `/buckets/{bucket}/{key}` — de ahí que el gateway reescriba `/media/{key}` → `/buckets/webapi-storage/{key}`.
|
||||
> In the filer, S3 objects live under `/buckets/{bucket}/{key}`, which is why the gateway rewrites `/media/{key}` → `/buckets/webapi-storage/{key}`.
|
||||
|
||||
## URLs prefirmadas (presigned)
|
||||
## Presigned URLs
|
||||
|
||||
Para **no** hacer pasar ficheros grandes por la API, el cliente habla directo con S3 mediante URLs firmadas de corta vida:
|
||||
To **avoid** routing large files through the API, the client talks to S3 directly using short-lived signed URLs:
|
||||
|
||||
- **Subidas (PUT)**: `RequestTrackUploadUrls` / `RequestPlayListPictureUpload` generan presigned PUT (con `Content-Type` y `If-None-Match: *` firmados, expiración ~120s). El cliente sube directo a S3.
|
||||
- **Imágenes (GET)**: las miniaturas se sirven con presigned GET (un objeto = una URL).
|
||||
- **Uploads (PUT)**: `RequestTrackUploadUrls` / `RequestPlayListPictureUpload` generate presigned PUTs (with signed `Content-Type` and `If-None-Match: *`, ~120s expiry). The client uploads straight to S3.
|
||||
- **Images (GET)**: thumbnails are served via presigned GETs (one object = one URL).
|
||||
|
||||
**Por qué presigned y no proxiar**: subir un audio de ~100 MB a través de la API consumiría memoria/ancho de banda de los servidores de aplicación y escalaría mal. La presigned PUT directa a S3 es el patrón estándar y correcto.
|
||||
**Why presigned instead of proxying**: routing a ~100 MB audio file through the API would burn memory and bandwidth on the application servers and wouldn't scale well. A direct presigned PUT to S3 is the standard, correct pattern here.
|
||||
|
||||
### Upload intents y cuota
|
||||
Cada `upload-urls` crea **UploadIntents** (reservas en estado `Issued`) y devuelve las URLs. Al crear la pista/playlist se **consumen**. Hay cuota por usuario (`MaxActiveUploadIntentsPerUser`, bytes…) para evitar abuso. El `UploadIntentExpirationJob` (en el Worker) marca como expirados los intents caducados y libera la cuota; si ese job no corre, los intents colgados se acumulan y bloquean nuevas subidas.
|
||||
### Upload intents and quota
|
||||
Every `upload-urls` call creates **UploadIntents** (reservations in the `Issued` state) and returns the URLs. They're **consumed** when the track/playlist is created. There's a per-user quota (`MaxActiveUploadIntentsPerUser`, byte limits…) to prevent abuse. The `UploadIntentExpirationJob` (in the Worker) marks expired intents and frees up quota. If that job isn't running, stale intents pile up and block new uploads.
|
||||
|
||||
## Qué es público y qué privado (despliegue)
|
||||
## What's public and what's private (deployment)
|
||||
|
||||
| Exponer a internet | Mantener privado |
|
||||
| Expose to the internet | Keep private |
|
||||
|---|---|
|
||||
| S3 API `:8333` (tras TLS) — necesario para presigned PUT/GET del cliente | filer `:8888` (solo el gateway, en red interna) |
|
||||
| StreamingGateway `:8081` | master `:9333`, volume server, UI de admin |
|
||||
| S3 API `:8333` (behind TLS), needed for the client's presigned PUT/GET | filer `:8888` (internal network, gateway only) |
|
||||
| StreamingGateway `:8081` | master `:9333`, volume server, admin UI |
|
||||
|
||||
- **El audio** se sirve por el gateway → SeaweedFS puede quedar privado para ese flujo.
|
||||
- **Subidas e imágenes** usan presigned → el cliente toca el endpoint S3 directamente, así que `:8333` debe ser alcanzable (pero inútil sin firma válida; es el modelo de cualquier bucket S3/MinIO).
|
||||
- En **producción**: `InfrastructureStorage:Address` debe ser el **endpoint S3 público** (la firma se calcula para ese host); si los servidores no lo alcanzan internamente, se usa config dividida (host público para firmar / interno para operaciones server-side).
|
||||
- **Audio** is served through the gateway, so SeaweedFS can stay private for that flow.
|
||||
- **Uploads and images** use presigned URLs, so the client talks to the S3 endpoint directly and `:8333` has to be reachable (though useless without a valid signature, this is the standard model for any S3/MinIO bucket).
|
||||
- In **production**: `InfrastructureStorage:Address` must be the **public S3 endpoint** (the signature is computed for that host). If the servers can't reach it internally, use split config: public host for signing, internal host for server-side operations.
|
||||
|
||||
**Regla**: el gateway y, como mucho, el `:8333` firmado son la única superficie pública; master/volume/filer/admin **nunca** se exponen.
|
||||
**Rule of thumb**: the gateway, and at most the signed `:8333`, are the only public surface. Master, volume, filer and admin are **never** exposed.
|
||||
|
||||
@@ -1,55 +1,55 @@
|
||||
# Streaming de audio
|
||||
# Audio streaming
|
||||
|
||||
## Objetivo
|
||||
## Goal
|
||||
|
||||
Reproducir el audio **sin que los bytes pasen por la API**, pero con control de acceso. La API solo emite un permiso corto; el reverse proxy sirve los bytes desde SeaweedFS.
|
||||
Play audio **without the bytes passing through the API**, while still controlling access. The API only issues a short-lived permission; the reverse proxy serves the bytes from SeaweedFS.
|
||||
|
||||
## Formato: fichero único AAC (.m4a) por HTTP range
|
||||
## Format: a single AAC file (.m4a), served via HTTP range
|
||||
|
||||
El audio se transcodifica a **un único `audio.m4a`** (AAC, contenedor MP4 con `+faststart` para que el átomo `moov` quede al inicio y el seek por range funcione), dentro de la carpeta `Tracks/ProcessedAudios/{AudioFolderName}/`. No hay manifest ni segmentos: el reproductor descarga el fichero por **HTTP range requests** (modelo tipo Spotify para VOD musical).
|
||||
Audio is transcoded into **one `audio.m4a`** (AAC, MP4 container with `+faststart` so the `moov` atom sits at the start and range-based seeking works), stored under `Tracks/ProcessedAudios/{AudioFolderName}/`. There's no manifest or segments: the player downloads the file via **HTTP range requests** (the same model Spotify uses for on-demand music).
|
||||
|
||||
Multi-bitrate no está implementado hoy; el camino para añadirlo es transcodificar varias calidades (`audio_96.m4a`, …) y que el cliente elija una al iniciar (sin ABR fluido a mitad de pista, igual que Spotify).
|
||||
Multi-bitrate isn't implemented yet; the path to add it is transcoding several qualities (`audio_96.m4a`, …) and letting the client pick one at playback start (no seamless mid-track ABR, same as Spotify).
|
||||
|
||||
## El acceso: stream-ticket de prefijo
|
||||
## Access control: a prefix-scoped stream ticket
|
||||
|
||||
Se autoriza **el prefijo de la carpeta** de la pista con un *stream-ticket* de corta vida, y el proxy lo valida en cada petición. Aunque hoy sea un único objeto, el ticket sigue acotado al prefijo `Tracks/ProcessedAudios/<folder>/` (cubre el fichero actual y cualquier calidad futura).
|
||||
Access is authorized for **the track's folder prefix** via a short-lived *stream ticket*, validated by the proxy on every request. Even though there's only one object today, the ticket is still scoped to the prefix `Tracks/ProcessedAudios/<folder>/` (covering the current file and any future quality variants).
|
||||
|
||||
## Flujo
|
||||
## Flow
|
||||
|
||||
1. `GET /tracks/{id}/stream` (autenticado) → la API autoriza y devuelve:
|
||||
1. `GET /tracks/{id}/stream` (authenticated) → the API authorizes the request and returns:
|
||||
```json
|
||||
{ "manifestUrl": "http://<gateway>/media/Tracks/ProcessedAudios/<folder>/audio.m4a",
|
||||
"ticket": "<JWT RS256>", "expiresInSeconds": 3600 }
|
||||
```
|
||||
El ticket lleva el claim `prefix = "Tracks/ProcessedAudios/<folder>/"`.
|
||||
2. El reproductor abre la URL del audio añadiéndole `?t=<ticket>` (o la cabecera `X-Stream-Ticket`).
|
||||
3. El **StreamingGateway** (YARP) valida el ticket y reenvía al filer; los bytes van SeaweedFS → cliente (con range/seek).
|
||||
The ticket carries the claim `prefix = "Tracks/ProcessedAudios/<folder>/"`.
|
||||
2. The player opens the audio URL with `?t=<ticket>` appended (or the `X-Stream-Ticket` header).
|
||||
3. The **StreamingGateway** (YARP) validates the ticket and forwards the request to the filer; the bytes flow SeaweedFS → client (with range/seek support).
|
||||
|
||||
## El ticket (RS256)
|
||||
## The ticket (RS256)
|
||||
|
||||
- Lo firma la API con clave **privada** (`StreamTicketService`); el gateway valida con la **pública** (`TicketValidator`). Así el gateway **no puede emitir** tickets, solo verificarlos.
|
||||
- Claims: `sub`, `prefix`, `aud = media-gateway`, `iss = musify-webapi`, `exp` (TTL ~1h).
|
||||
- Signed by the API with a **private** key (`StreamTicketService`); validated by the gateway with the **public** one (`TicketValidator`). That way the gateway **can't issue** tickets, only verify them.
|
||||
- Claims: `sub`, `prefix`, `aud = media-gateway`, `iss = musify-webapi`, `exp` (~1h TTL).
|
||||
|
||||
**Por qué RS256 (asimétrica)**: separa responsabilidades — emisor (la API) y verificador (gateway) no comparten secreto; el gateway, aunque se comprometa, no puede crear permisos.
|
||||
**Why RS256 (asymmetric)**: it separates responsibilities. The issuer (API) and the verifier (gateway) don't share a secret, so even if the gateway is compromised, it can't mint permissions.
|
||||
|
||||
## El gateway (validación + proxy)
|
||||
## The gateway (validation + proxy)
|
||||
|
||||
`TicketValidationMiddleware` en cada petición a `/media/**`:
|
||||
1. Lee `?t=`, valida firma/aud/iss/exp.
|
||||
2. Comprueba que la clave pedida **empieza por** el `prefix` del ticket (`StartsWith`). Si no → 403.
|
||||
3. Quita el `?t=` y deja pasar a YARP, que reescribe `/media/{key}` → `/buckets/webapi-storage/{key}` del filer.
|
||||
`TicketValidationMiddleware`, on every request to `/media/**`:
|
||||
1. Reads `?t=`, validates its signature/aud/iss/exp.
|
||||
2. Checks that the requested key **starts with** the ticket's `prefix`. If not → 403.
|
||||
3. Strips the `?t=` and passes the request on to YARP, which rewrites `/media/{key}` → `/buckets/webapi-storage/{key}` for the filer.
|
||||
|
||||
- Sin ticket → **401**; fuera de prefijo → **403**; ok → **200** (y **206** en range requests, así que el seek funciona).
|
||||
- Pasa `Range`/`Accept-Ranges` y expone esos headers por CORS.
|
||||
- No ticket → **401**; outside the prefix → **403**; ok → **200** (and **206** for range requests, so seeking works).
|
||||
- Passes through `Range`/`Accept-Ranges` and exposes those headers via CORS.
|
||||
|
||||
## ¿Un ticket por canción?
|
||||
## One ticket per track?
|
||||
|
||||
Sí. Cada ticket está acotado a **una** carpeta (una pista). Para reproducir otra, se pide otro ticket. Es barato (la API solo firma un JWT) y es lo más seguro: cada permiso abre solo lo que vas a reproducir.
|
||||
Yes. Each ticket is scoped to **one** folder (one track). Playing a different one means requesting a new ticket. It's cheap (the API just signs a JWT) and it's the safest option: every permission only unlocks what you're about to play.
|
||||
|
||||
- Si algún día molesta (muchísimos cambios de pista), el mismo mecanismo del claim `prefix` permite ampliar el alcance: a nivel de playlist o de toda la biblioteca (`Tracks/ProcessedAudios/`). Trade-off seguridad ↔ comodidad. Por defecto, **por canción**.
|
||||
- If that ever becomes a problem (very frequent track switching), the same `prefix` claim mechanism can widen the scope to a whole playlist or the whole library (`Tracks/ProcessedAudios/`). It's a security/convenience trade-off. The default is **per track**.
|
||||
|
||||
## Pendiente / notas de seguridad
|
||||
## Open items / security notes
|
||||
|
||||
- La política de quién puede pedir el ticket es hoy "cualquier autenticado" (TODO: restringir con `UserHasTrack`/visibilidad).
|
||||
- El check de prefijo es `StartsWith` literal; conviene endurecer rechazando claves con `..` (path traversal), aunque navegadores y ASP.NET normalizan `..`.
|
||||
- El ticket viaja en la query (`?t=`); el gateway lo elimina antes de reenviar (no llega a los logs del filer).
|
||||
- Today, the policy for who can request a ticket is "any authenticated user" (TODO: restrict it with `UserHasTrack`/visibility).
|
||||
- The prefix check is a literal `StartsWith`; it would be worth hardening it against `..` path traversal, even though browsers and ASP.NET normalize `..`.
|
||||
- The ticket travels in the query string (`?t=`); the gateway strips it before forwarding, so it never reaches the filer's logs.
|
||||
|
||||
@@ -9,3 +9,5 @@ package
|
||||
package-lock.json
|
||||
src/lib/api/schema.d.ts
|
||||
static
|
||||
.agents
|
||||
skills-lock.json
|
||||
|
||||
@@ -3,6 +3,7 @@
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"printWidth": 100,
|
||||
"endOfLine": "auto",
|
||||
"plugins": ["prettier-plugin-svelte", "prettier-plugin-tailwindcss"],
|
||||
"tailwindStylesheet": "./src/routes/layout.css",
|
||||
"overrides": [
|
||||
|
||||
@@ -1,56 +1,79 @@
|
||||
# Musify — web-player
|
||||
# Musify: web-player
|
||||
|
||||
Web pública de Musify (carpeta `web-player/`). Para diseño y recetas de UI usa el skill **`musify-web`**.
|
||||
Musify's public web app (`web-player/` folder). For design and UI recipes, use the **`musify-web`** skill.
|
||||
|
||||
## Stack
|
||||
|
||||
SvelteKit 2 (`adapter-node`) · Svelte 5 **runes** · Tailwind 4 · TypeScript strict · auth OIDC Zitadel (`openid-client` + `jose`).
|
||||
SvelteKit 2 (`adapter-node`), Svelte 5 **runes**, Tailwind 4, TypeScript strict, Zitadel OIDC auth (`openid-client` + `jose`).
|
||||
|
||||
No hay `svelte.config.js` (config inline en `vite.config.ts`) ni `tailwind.config.js` (config en `src/routes/layout.css`).
|
||||
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`).
|
||||
|
||||
## Comandos
|
||||
## Commands
|
||||
|
||||
```sh
|
||||
npm run dev # servidor de desarrollo (puerto 5173)
|
||||
npm run dev # dev server (port 5173)
|
||||
npm run build
|
||||
npm run check # svelte-check — debe salir 0 errores antes de cerrar un cambio
|
||||
npm run format # prettier --write .
|
||||
npm run lint # prettier --check . && eslint . && npm run lint:tokens
|
||||
npm run lint:tokens # scripts/check-tokens.mjs — guardrail del sistema de diseño (ver abajo)
|
||||
npm run check # svelte-check, must exit 0 errors before closing a change
|
||||
npm run format # prettier --write .
|
||||
npm run lint # prettier --check . && eslint . && npm run lint:tokens
|
||||
npm run lint:tokens # scripts/check-tokens.mjs, the design system guardrail (see below)
|
||||
```
|
||||
|
||||
## Reglas
|
||||
## Rules
|
||||
|
||||
- **Sin comentarios** en el código salvo que sean imprescindibles.
|
||||
- `npm run lint` y `npm run check` deben salir limpios antes de cerrar un cambio.
|
||||
- **Svelte 5 runes siempre:** `$props()` con `interface Props`, `$state`, `$derived`, `$effect`. Nada de `export let` ni `$:`. `{#each}` siempre con clave.
|
||||
- **Server-only bajo `$lib/server/`**; nunca importado desde cliente.
|
||||
- Datos SSR vía `load` en `+page.server.ts`/`+layout.server.ts` → prop `data`.
|
||||
- Enlaces a endpoints `+server.ts` (login/logout) llevan `data-sveltekit-reload`.
|
||||
- Reutiliza los tokens/recetas del sistema de diseño; extrae a `$lib/components/ui/` lo que se repita.
|
||||
- **No comments** in code unless essential.
|
||||
- `npm run lint` and `npm run check` must exit clean before closing a change.
|
||||
- **Always Svelte 5 runes:** `$props()` with `interface Props`, `$state`, `$derived`, `$effect`. No `export let` or `$:`. `{#each}` always keyed.
|
||||
- **Server only code goes under `$lib/server/`**, and it's never imported from client code.
|
||||
- SSR data comes through `load` in `+page.server.ts`/`+layout.server.ts` and lands in the `data` prop.
|
||||
- Links to `+server.ts` endpoints (`/auth/login`, `/auth/logout`) carry `data-sveltekit-reload`.
|
||||
- Reuse design system tokens and recipes before writing new CSS. If something repeats, extract it to `$lib/components/ui/`.
|
||||
|
||||
## Estilos
|
||||
## Folder structure
|
||||
|
||||
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/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/`.
|
||||
- `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]/`.
|
||||
|
||||
- **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`).
|
||||
- **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`).
|
||||
- **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`).
|
||||
- **`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.
|
||||
- Componentes base a reutilizar antes de crear uno nuevo: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||
- 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).
|
||||
## Styles
|
||||
|
||||
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.
|
||||
|
||||
- **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`.
|
||||
- **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`).
|
||||
- **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-[…]`.
|
||||
- **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`).
|
||||
- **Colors:** always use `--mf-*`/`text-*`/`bg-*`/`border-*` from `theme.css`/`tokens.ts`. Never a literal hex or `rgba()` inside a `.svelte` file; those literals only belong in `theme.css`/`tokens.ts` as the source of truth.
|
||||
- **Inline `style="…"` and arbitrary `-[…]` values:** only the ones whitelisted in `scripts/check-tokens.mjs` (the stagger variable `--i`, tile hue, menu position, progress width, `text-[clamp(…)]`, `leading-[…]` on login's display type, `grid-cols-[auto_1fr]`, `max-h-[85dvh]`, `my-`/`py-[Ndvh]` for vertical centering, `transition-[filter]` on genre tiles, and `bg-[image:var(--mf-*)]`). Everything else should become a token.
|
||||
- Reach for these base components before creating a new one: `Artwork`, `Avatar`, `ListRow`, `MediaIdentity`, `TrackList`, `MediaCard`, `PageHeader`, `ContextMenu`, `ConfirmDialog`, `PlayButton`, `Slider`, `Logo`, `Chip`, `SegmentedControl`, `CoverForm`.
|
||||
- Known and accepted gap (documented in `scripts/check-tokens.mjs`): icon `strokeWidth={n}` isn't unified yet. There are 13 different values spread across almost every component, and migrating them to `stroke-thin/regular/bold` recipes safely would require visually confirming that `lucide-svelte` actually respects `stroke-width` through CSS, so that was left out of the last pass.
|
||||
|
||||
## 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 and the player dock padding.
|
||||
|
||||
Entrances and movement always use `--mf-ease` (`ease-snappy` in Tailwind, `expoOut` in Svelte transitions), which feels immediate because most of the motion happens at the start. Color transitions keep the Tailwind default curve. Overlays must feel instant: the modal backdrop uses `transition:fade={{ duration: 200 }}` and the panel uses a local `pop` transition (opacity + scale + translateY, `expoOut`, 200ms) mirroring `animate-pop`'s keyframes — both directives so the modal fades and pops on close too, not just on open, and the whole thing settles in 200ms. Staggered lists use `--i` with 40ms steps capped at ten items.
|
||||
|
||||
The only exceptions are looping indicators (equalizer bars, pulse, spin, sheen) and the accent hue crossfade in `+layout.svelte` (600ms), which is ambient and meant to be slow. Do not add new durations or curves; if something needs a different feel, pick the closest step of the scale. `prefers-reduced-motion` disables everything globally in `layout.css`.
|
||||
|
||||
## Auth
|
||||
|
||||
`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`.
|
||||
`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`.
|
||||
|
||||
Variables de entorno en `.env` (ver `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
|
||||
Environment variables live in `.env` (see `.env.example`): `ZITADEL_*`, `AUTH_REDIRECT_URI`, `AUTH_POST_LOGOUT_URI`, `SESSION_SECRET`, `API_BASE_URL`.
|
||||
|
||||
## Backend API
|
||||
|
||||
Cliente tipado con `openapi-fetch` sobre el OpenAPI de `Musify.Api`. En un `load`/action server:
|
||||
Typed client via `openapi-fetch` over the `Musify.Api` OpenAPI spec. Inside a server `load` or action:
|
||||
|
||||
```ts
|
||||
import { createApiClient } from '$lib/server/api';
|
||||
@@ -58,4 +81,4 @@ const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefine
|
||||
const { data, error: err } = await api.GET('/tracks');
|
||||
```
|
||||
|
||||
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).
|
||||
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).
|
||||
|
||||
@@ -1,183 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<script src="./support.js"></script>
|
||||
</head>
|
||||
<body>
|
||||
<x-dc>
|
||||
<helmet>
|
||||
<link href="https://fonts.googleapis.com/css2?family=Sora:wght@400;500;600;700&family=Plus+Jakarta+Sans:wght@400;500;600;700&display=swap" rel="stylesheet">
|
||||
<style>
|
||||
*{box-sizing:border-box}
|
||||
html,body{margin:0;padding:0;background:#050507}
|
||||
a{color:#b4a4ea;text-decoration:none}
|
||||
a:hover{color:#cdc0f5}
|
||||
img{display:block}
|
||||
input,textarea{font-family:inherit}
|
||||
input::placeholder,textarea::placeholder{color:#5d5d63}
|
||||
@keyframes modalIn{from{opacity:0;transform:translateY(14px) scale(.985)}to{opacity:1;transform:none}}
|
||||
@keyframes fadeIn{from{opacity:0}to{opacity:1}}
|
||||
</style>
|
||||
</helmet>
|
||||
|
||||
<div style="position:relative;min-height:100vh;display:grid;place-items:center;padding:40px 20px;background:radial-gradient(120% 90% at 50% 0%,#15131c,#08080b 58%,#050507);font-family:'Plus Jakarta Sans',system-ui,sans-serif;color:#c9c8cc;-webkit-font-smoothing:antialiased">
|
||||
|
||||
<div style="position:absolute;inset:0;background:rgba(4,4,6,.55);backdrop-filter:blur(3px)"></div>
|
||||
|
||||
<div data-screen-label="Crear playlist" style="position:relative;width:100%;max-width:560px;border-radius:24px;overflow:hidden;border:1px solid rgba(255,255,255,.075);background:linear-gradient(180deg,#141419,#0d0d11 62%,#0b0b0e);box-shadow:0 32px 90px rgba(0,0,0,.66),0 2px 0 rgba(255,255,255,.035) inset;animation:modalIn .28s cubic-bezier(.2,.8,.3,1)">
|
||||
|
||||
<div style="position:absolute;inset:0;pointer-events:none;background:{{ auraTop }};transition:background .5s ease"></div>
|
||||
|
||||
<!-- HEADER -->
|
||||
<div style="position:relative;display:flex;align-items:flex-start;justify-content:space-between;gap:16px;padding:24px 26px 0">
|
||||
<div>
|
||||
<div style="font-size:10.5px;letter-spacing:.16em;text-transform:uppercase;color:#8f8f93;font-weight:600;margin-bottom:9px">Tu biblioteca</div>
|
||||
<div style="font-family:'Sora',sans-serif;font-size:21px;font-weight:600;letter-spacing:-.03em;color:#d8d7db">Crear playlist</div>
|
||||
</div>
|
||||
<div onClick="{{ close }}" title="Cerrar" style="flex:0 0 auto;width:32px;height:32px;border-radius:10px;display:grid;place-items:center;cursor:pointer;color:#87878c;border:1px solid rgba(255,255,255,.06);background:rgba(255,255,255,.025);transition:background .18s ease,color .18s ease" style-hover="background:rgba(255,255,255,.07);color:#d0cfd4">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.9" stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18"></path></svg>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- BODY -->
|
||||
<div style="position:relative;padding:22px 26px 0;display:flex;gap:20px;align-items:flex-start">
|
||||
|
||||
<!-- COVER -->
|
||||
<div style="flex:0 0 auto;width:124px">
|
||||
<div style="position:relative;width:124px;height:124px;border-radius:16px;overflow:hidden;border:1px solid rgba(255,255,255,.07);background:{{ coverGrad }};box-shadow:{{ coverGlow }};transition:background .45s ease,box-shadow .45s ease;display:grid;place-items:center">
|
||||
<svg viewBox="0 0 24 24" width="26" height="26" fill="none" stroke="rgba(255,255,255,.62)" stroke-width="1.5" stroke-linecap="round"><path d="M9 18V6l10-2v12"></path><circle cx="6.5" cy="18" r="2.5"></circle><circle cx="16.5" cy="16" r="2.5"></circle></svg>
|
||||
</div>
|
||||
<div style="display:flex;gap:6px;margin-top:11px">
|
||||
<sc-for list="{{ swatches }}" as="s" hint-placeholder-count="5">
|
||||
<div onClick="{{ s.pick }}" title="{{ s.name }}" style="flex:1;height:20px;border-radius:7px;cursor:pointer;background:{{ s.dot }};box-shadow:{{ s.ring }};transition:box-shadow .18s ease"></div>
|
||||
</sc-for>
|
||||
</div>
|
||||
<div onClick="{{ upload }}" style="margin-top:9px;text-align:center;font-size:11px;font-weight:500;color:#8a8a8f;padding:7px 0;border-radius:9px;border:1px dashed rgba(255,255,255,.1);cursor:pointer;transition:color .18s ease,border-color .18s ease" style-hover="color:#cfccd9;border-color:rgba(255,255,255,.2)">Subir imagen</div>
|
||||
</div>
|
||||
|
||||
<!-- FIELDS -->
|
||||
<div style="flex:1;min-width:0">
|
||||
<label style="display:block;font-size:11px;font-weight:600;letter-spacing:.03em;color:#87878c;margin-bottom:8px">Nombre</label>
|
||||
<input value="{{ name }}" onChange="{{ onName }}" maxLength="60" placeholder="Playlist sin título" style="width:100%;padding:12px 14px;border-radius:12px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.075);outline:none;color:#dcdbe0;font-family:'Sora',sans-serif;font-size:16px;font-weight:600;letter-spacing:-.02em" style-focus="border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.055)">
|
||||
<div style="display:flex;justify-content:flex-end;font-size:10.5px;color:#65656a;margin-top:6px">{{ counter }}</div>
|
||||
|
||||
<label style="display:block;font-size:11px;font-weight:600;letter-spacing:.03em;color:#87878c;margin:10px 0 8px">Descripción <span style="font-weight:400;color:#5f5f65">· opcional</span></label>
|
||||
<textarea value="{{ desc }}" onChange="{{ onDesc }}" rows="3" placeholder="Para qué sirve esta playlist, cuándo la escuchas…" style="width:100%;resize:none;padding:11px 14px;border-radius:12px;background:rgba(255,255,255,.035);border:1px solid rgba(255,255,255,.075);outline:none;color:#c4c3c9;font-size:12.5px;line-height:1.6" style-focus="border-color:rgba(255,255,255,.2);background:rgba(255,255,255,.055)"></textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- OPTIONS -->
|
||||
<div style="position:relative;padding:20px 26px 0">
|
||||
<div style="display:flex;align-items:center;justify-content:space-between;gap:14px;padding:13px 15px;border-radius:13px;background:rgba(255,255,255,.026);border:1px solid rgba(255,255,255,.055)">
|
||||
<div style="min-width:0">
|
||||
<div style="font-size:12.5px;font-weight:600;color:#cfccd9">Visibilidad</div>
|
||||
<div style="font-size:11px;color:#7b7b81;margin-top:3px">{{ visHint }}</div>
|
||||
</div>
|
||||
<div style="flex:0 0 auto;display:flex;gap:3px;padding:3px;border-radius:10px;background:rgba(0,0,0,.34);border:1px solid rgba(255,255,255,.05)">
|
||||
<sc-for list="{{ visOptions }}" as="v" hint-placeholder-count="2">
|
||||
<div onClick="{{ v.pick }}" style="padding:6px 14px;border-radius:8px;font-size:11.5px;font-weight:600;cursor:pointer;background:{{ v.bg }};color:{{ v.fg }};transition:background .18s ease,color .18s ease">{{ v.label }}</div>
|
||||
</sc-for>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div onClick="{{ toggleCollab }}" style="display:flex;align-items:center;justify-content:space-between;gap:14px;margin-top:9px;padding:13px 15px;border-radius:13px;cursor:pointer;background:rgba(255,255,255,.026);border:1px solid rgba(255,255,255,.055);transition:background .18s ease" style-hover="background:rgba(255,255,255,.045)">
|
||||
<div style="min-width:0">
|
||||
<div style="font-size:12.5px;font-weight:600;color:#cfccd9">Colaborativa</div>
|
||||
<div style="font-size:11px;color:#7b7b81;margin-top:3px">Quien tenga el enlace puede añadir canciones</div>
|
||||
</div>
|
||||
<div style="flex:0 0 auto;width:40px;height:23px;border-radius:20px;padding:3px;background:{{ collabTrack }};transition:background .2s ease">
|
||||
<div style="width:17px;height:17px;border-radius:50%;background:#0c0c10;transform:{{ collabKnob }};transition:transform .2s cubic-bezier(.3,.8,.3,1)"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<div style="position:relative;display:flex;align-items:center;justify-content:space-between;gap:14px;flex-wrap:wrap;margin-top:22px;padding:17px 26px 20px;border-top:1px solid rgba(255,255,255,.055);background:rgba(0,0,0,.2)">
|
||||
<div style="font-size:11px;color:#6e6e74;max-width:230px;line-height:1.5">Podrás añadir canciones desde cualquier pantalla.</div>
|
||||
<div style="display:flex;gap:9px;margin-left:auto">
|
||||
<div onClick="{{ close }}" style="padding:11px 19px;border-radius:11px;border:1px solid rgba(255,255,255,.1);font-size:12.5px;font-weight:500;cursor:pointer;color:#a9a7b2;transition:background .18s ease,color .18s ease" style-hover="background:rgba(255,255,255,.05);color:#d3d2d8">Cancelar</div>
|
||||
<div onClick="{{ create }}" style="display:flex;align-items:center;gap:8px;padding:11px 21px;border-radius:11px;font-size:12.5px;font-weight:600;background:{{ ctaBg }};color:{{ ctaFg }};cursor:{{ ctaCursor }};opacity:{{ ctaOpacity }};transition:background .18s ease,opacity .18s ease">
|
||||
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2" stroke-linecap="round"><path d="M12 5v14M5 12h14"></path></svg>
|
||||
Crear playlist
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<sc-if value="{{ created }}" hint-placeholder-val="{{ false }}">
|
||||
<div style="position:fixed;left:50%;bottom:34px;transform:translateX(-50%);display:flex;align-items:center;gap:11px;padding:12px 18px;border-radius:13px;background:#16161c;border:1px solid rgba(255,255,255,.09);box-shadow:0 18px 46px rgba(0,0,0,.6);animation:fadeIn .22s ease">
|
||||
<div style="width:26px;height:26px;border-radius:8px;background:{{ coverGrad }};flex:0 0 auto"></div>
|
||||
<div style="font-size:12.5px;color:#cfccd9;font-weight:500">Playlist «{{ createdName }}» creada</div>
|
||||
</div>
|
||||
</sc-if>
|
||||
</div>
|
||||
|
||||
</x-dc>
|
||||
<script type="text/x-dc" data-dc-script data-props="{"$preview":{"width":900,"height":760}}">
|
||||
const HUES = [
|
||||
{ name: 'Ámbar', h: 68 },
|
||||
{ name: 'Violeta', h: 288 },
|
||||
{ name: 'Azul', h: 244 },
|
||||
{ name: 'Verde', h: 158 },
|
||||
{ name: 'Rojo', h: 22 },
|
||||
];
|
||||
|
||||
class Component extends DCLogic {
|
||||
state = { name: '', desc: '', hue: 1, vis: 'Privada', collab: false, created: false, createdName: '' };
|
||||
|
||||
renderVals() {
|
||||
const h = HUES[this.state.hue].h;
|
||||
const name = this.state.name;
|
||||
const ready = name.trim().length > 0;
|
||||
return {
|
||||
name, desc: this.state.desc,
|
||||
counter: name.length + '/60',
|
||||
onName: e => this.setState({ name: e.target.value }),
|
||||
onDesc: e => this.setState({ desc: e.target.value }),
|
||||
coverGrad: `linear-gradient(150deg,oklch(.66 .16 ${h}),oklch(.34 .11 ${h}) 58%,#0e0e13)`,
|
||||
coverGlow: `0 14px 34px oklch(.5 .13 ${h} / .22)`,
|
||||
auraTop: `radial-gradient(110% 60% at 18% -10%,oklch(.55 .13 ${h} / .16),transparent 62%)`,
|
||||
swatches: HUES.map((s, i) => ({
|
||||
name: s.name,
|
||||
dot: `linear-gradient(145deg,oklch(.66 .16 ${s.h}),oklch(.38 .11 ${s.h}))`,
|
||||
ring: i === this.state.hue
|
||||
? '0 0 0 1.5px rgba(255,255,255,.85), 0 0 0 4px rgba(0,0,0,.5)'
|
||||
: '0 0 0 1px rgba(255,255,255,.07)',
|
||||
pick: () => this.setState({ hue: i }),
|
||||
})),
|
||||
visOptions: ['Privada', 'Pública'].map(label => ({
|
||||
label,
|
||||
bg: this.state.vis === label ? '#cfccd9' : 'transparent',
|
||||
fg: this.state.vis === label ? '#0a0a0d' : '#8e8e94',
|
||||
pick: () => this.setState({ vis: label }),
|
||||
})),
|
||||
visHint: this.state.vis === 'Privada'
|
||||
? 'Solo tú la ves en tu biblioteca'
|
||||
: 'Aparece en tu perfil y se puede compartir',
|
||||
collabTrack: this.state.collab ? `oklch(.68 .15 ${h})` : 'rgba(255,255,255,.12)',
|
||||
collabKnob: this.state.collab ? 'translateX(17px)' : 'translateX(0)',
|
||||
toggleCollab: () => this.setState(s => ({ collab: !s.collab })),
|
||||
ctaBg: ready ? '#c6c4cb' : 'rgba(255,255,255,.08)',
|
||||
ctaFg: ready ? '#0a0a0d' : '#6c6c72',
|
||||
ctaCursor: ready ? 'pointer' : 'not-allowed',
|
||||
ctaOpacity: ready ? 1 : 0.85,
|
||||
create: () => {
|
||||
if (!ready) return;
|
||||
this.setState({ created: true, createdName: name.trim() });
|
||||
clearTimeout(this._t);
|
||||
this._t = setTimeout(() => this.setState({ created: false }), 2600);
|
||||
},
|
||||
close: () => this.setState({ name: '', desc: '', collab: false, created: false }),
|
||||
upload: () => {},
|
||||
created: this.state.created,
|
||||
createdName: this.state.createdName,
|
||||
};
|
||||
}
|
||||
componentWillUnmount() { clearTimeout(this._t); }
|
||||
}
|
||||
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -1,399 +0,0 @@
|
||||
# Handoff: Musify — Web Music Player
|
||||
|
||||
## Overview
|
||||
|
||||
Musify es un reproductor de música web: un player permanente, navegación lateral, y vistas de Home / Descubrir / Playlists / Me gusta / Reciente, más estados de carga, error, vacío y sin resultados. Su rasgo de identidad es que **toda la paleta de acento y los fondos derivan de la canción en reproducción**: al cambiar de pista, el tinte del hero, el glow ambiental, el sidebar, la barra del player, el logo, la barra de progreso y los acentos viran en bloque con una transición de 0.6s.
|
||||
|
||||
## About the Design Files
|
||||
|
||||
Los archivos de este paquete son **referencias de diseño hechas en HTML** — prototipos que muestran aspecto y comportamiento previstos, **no código de producción para copiar tal cual**. La tarea es **recrear estos diseños en el entorno del codebase destino** (React, Vue, SwiftUI, nativo…) siguiendo sus patrones y librerías ya establecidos. Si todavía no hay entorno, elige el framework más apropiado para el proyecto e implementa allí.
|
||||
|
||||
El prototipo está escrito como un componente único con estado local y estilos inline. En producción conviene separarlo en componentes (Sidebar, TopBar, Player, Queue, TrackRow, MediaCard, sections de Home) y mover los tokens a la solución de estilos del proyecto.
|
||||
|
||||
## Fidelity
|
||||
|
||||
**Alta fidelidad (hifi).** Colores, tipografía, espaciado, radios, estados y microinteracciones son definitivos. Se espera una recreación fiel usando las librerías del codebase. Las carátulas son fotografías genéricas de marcador de posición; el contenido musical (artistas, álbumes, canciones, playlists, duraciones, reproducciones) es **mock data** y debe sustituirse por datos reales.
|
||||
|
||||
**Regla de producto:** la UI nunca menciona la procedencia de la música. Sin etiquetas de fuente, proveedor ni "powered by". Para el usuario esto es solo Musify.
|
||||
|
||||
---
|
||||
|
||||
## Layout global
|
||||
|
||||
Altura fija de viewport, sin scroll de página; solo el área de contenido y la cola scrollean.
|
||||
|
||||
```
|
||||
┌──────────┬──────────────────────────────┬──────────┐
|
||||
│ Sidebar │ TopBar │ Queue │
|
||||
│ 254px ├──────────────────────────────┤ 304px │
|
||||
│ │ Main (scroll) │ (258px │
|
||||
│ │ │ <1180) │
|
||||
└──────────┴──────────────────────────────┴──────────┘
|
||||
Player flotante (absolute, left/right 0, bottom 0, padding 0 16px 16px)
|
||||
```
|
||||
|
||||
- Raíz: `position:relative; height:100vh; overflow:hidden; background: pageBg; transition: background .6s ease`.
|
||||
- Dos capas decorativas absolutas y no interactivas sobre el fondo: el **ambient glow** y un scrim inferior `radial-gradient(130% 85% at 50% 118%, rgba(0,0,0,.8), transparent 62%)`.
|
||||
- Fila principal `display:flex; height:100%`.
|
||||
- El main tiene `padding: 6px 26px` y `padding-bottom` dinámico: `126px` en desktop, `206px` cuando hay barra de pestañas móvil.
|
||||
|
||||
### Breakpoints
|
||||
|
||||
| Ancho | Comportamiento |
|
||||
|---|---|
|
||||
| `>= 1180px` | Sidebar visible (254px), cola 304px, player completo |
|
||||
| `< 1180px` (tablet) | Sidebar oculto → barra de pestañas bajo el player; cola 258px; padding inferior 206px |
|
||||
| `< 760px` (móvil) | Cola oculta; se ocultan shuffle/repeat/volumen/letra/cola del player (queda carátula + título + transporte + progreso); el bloque de info del player pasa a `width:auto` |
|
||||
|
||||
---
|
||||
|
||||
## Sistema de color dinámico
|
||||
|
||||
Cada pista lleva un `hue` (0–360) asignado. Todo el color de la app se deriva de ese hue en oklch. Los valores están repartidos por la rueda para que el cambio entre pistas consecutivas sea evidente (paso mínimo ~50°).
|
||||
|
||||
```js
|
||||
// hue = hue de la pista en reproducción
|
||||
accent = oklch(0.74 0.145 H) // acción, barra de progreso, corazón activo, EQ
|
||||
accentTitle = oklch(0.87 0.07 H) // títulos sobre superficie tintada
|
||||
accentMuted = oklch(0.79 0.035 H) // título de la pista activa en tarjetas
|
||||
accentSoft = oklch(0.74 0.145 H / .08) // fondo de fila/tarjeta activa
|
||||
accentLine = oklch(0.74 0.145 H / .2) // borde de superficie acentuada
|
||||
accentHair = oklch(0.74 0.145 H / .09) // borde de tarjeta activa
|
||||
accentBtnBg = oklch(0.74 0.145 H / .15)
|
||||
accentBtnBgHover = oklch(0.74 0.145 H / .24)
|
||||
|
||||
// superficies
|
||||
pageBg = oklch(0.068 0.008 H)
|
||||
sidebarBg = linear-gradient(180deg, oklch(0.095 0.012 H), oklch(0.068 0.007 H))
|
||||
panelBg = oklch(0.09 0.010 H / .28) // panel de cola
|
||||
barBg = oklch(0.105 0.012 H / .9) // player y tabs (con backdrop-filter: blur(22px))
|
||||
hairline = oklch(0.62 0.030 H / .08) // todos los separadores
|
||||
|
||||
// gradientes: tc(a, l) = oklch(0.34 - l/380 , 0.11 - l/1600 , H / a)
|
||||
ambient = radial-gradient(52% 38% at 14% 0%, tc(.26,30), transparent 54%),
|
||||
radial-gradient(46% 34% at 78% 0%, tc(.20,40), transparent 56%)
|
||||
heroBg = linear-gradient(105deg, tc(.34,42) 0%, tc(.11,70) 28%, tc(.03,88) 50%, rgba(6,6,9,0) 70%)
|
||||
spotlightBg = linear-gradient(100deg, tc(.28,46) 0%, tc(.10,74) 30%, tc(.03,88) 52%, rgba(6,6,9,0) 72%)
|
||||
logoGrad = linear-gradient(140deg, oklch(0.78 0.15 H), oklch(0.62 0.13 H+40) 92%)
|
||||
logoGlow = 0 0 20px oklch(0.74 0.145 H / .3)
|
||||
```
|
||||
|
||||
**Importante:** hero y spotlight terminan en `alpha 0` antes del borde derecho (70–72%) y **no llevan borde**, para fundirse con el fondo sin corte visible.
|
||||
|
||||
Los elementos que cambian de color reciben `transition: background .6s ease` (y `box-shadow .6s ease` en el logo): raíz, sidebar, panel de cola, barra del player, tabs.
|
||||
|
||||
### Grises fijos (no dependen del hue)
|
||||
|
||||
| Rol | Hex |
|
||||
|---|---|
|
||||
| Texto principal (títulos de sección, h1/h2) | `#d3d2d6` – `#d9d8dc` |
|
||||
| Texto de tarjeta / fila | `#cbcacf` / `#cdccd1` |
|
||||
| Texto base del documento | `#c9c8cc` |
|
||||
| Secundario | `#8a8a8e`, `#8f8f93`, `#919195` |
|
||||
| Terciario | `#7f7f83`, `#7d7d81`, `#848488` |
|
||||
| Cuaternario / contadores | `#6c6c70`, `#696a6d`, `#646468`, `#616165`, `#5c5c60` |
|
||||
| Iconos inactivos player | `#747478` |
|
||||
| Botón primario (fondo) | `#c6c4cb`, hover `#d8d7dc`; texto `#0a0a0d` |
|
||||
| Superficies neutras | `rgba(255,255,255,.022)` base, `.045` hover; bordes `rgba(255,255,255,.04)` / `.075` |
|
||||
|
||||
Los grises son **neutros a propósito** (sin tinte), para que solo el acento lleve color.
|
||||
|
||||
---
|
||||
|
||||
## Tipografía
|
||||
|
||||
- Display / títulos: **Sora** 400/500/600/700.
|
||||
- UI / texto: **Plus Jakarta Sans** 400/500/600/700.
|
||||
- `-webkit-font-smoothing: antialiased`.
|
||||
|
||||
| Uso | Fuente | Tamaño | Peso | Tracking |
|
||||
|---|---|---|---|---|
|
||||
| H1 hero | Sora | 36px / 1.1 | 600 | -0.035em |
|
||||
| H1 vista (Descubrir, Playlists, Tracklist) | Sora | 28–33px | 600 | -0.03 / -0.035em |
|
||||
| H2 sección | Sora | 17.5px | 600 | -0.025em |
|
||||
| Título spotlight | Sora | 23px | 600 | -0.03em |
|
||||
| Wordmark | Sora | 17.5px | 600 | -0.02em |
|
||||
| Subtítulo de sección | Jakarta | 12px | 400 | — |
|
||||
| Eyebrow / cabecera de columna | Jakarta | 10.5px uppercase | 600 | 0.12–0.17em |
|
||||
| Título de tarjeta | Jakarta | 13–13.5px | 600 | -0.01em |
|
||||
| Fila de canción (título) | Jakarta | 12.5–13px | 500 | — |
|
||||
| Meta / artista | Jakarta | 11–11.5px | 400 | — |
|
||||
| Números (duración, índice, reproducciones) | Jakarta | 11–12px | 400 | `font-variant-numeric: tabular-nums` |
|
||||
|
||||
Todo texto de una línea en tarjetas y filas usa `white-space:nowrap; overflow:hidden; text-overflow:ellipsis` con `min-width:0` en el contenedor flex. Los titulares llevan `text-wrap: pretty`.
|
||||
|
||||
---
|
||||
|
||||
## Escalas
|
||||
|
||||
**Radios:** 8px (miniaturas pequeñas, chips) · 9–11px (carátulas 36–48px, botones) · 12–13px (filas activas, carátulas de tarjeta) · 14–16px (tarjetas, portadas grandes) · 18px (player, tabs, portada de cabecera) · 22–24px (hero, spotlight) · 50% (avatares, botón de play, artistas) · 999px (chips de sugerencia).
|
||||
|
||||
**Sombras:** carátula de tarjeta `0 14px 32px rgba(0,0,0,.45)` · artista `0 12px 28px rgba(0,0,0,.4)` · portada de cabecera `0 24px 56px rgba(0,0,0,.55)` · player `0 20px 56px rgba(0,0,0,.6)` · botón primario hero `0 10px 28px rgba(0,0,0,.45)`.
|
||||
|
||||
**Gaps de grid:** tarjetas 20px · artistas 18px · playlists 14–22px · filas compactas 6–14px.
|
||||
|
||||
**Anchos mínimos de columna (auto-fill):** mixes/álbumes/descubrir 164px · artistas 132px · playlists (tarjeta ancha) 258px · playlists (índice) 196px · continuar escuchando 268px · populares 330px · géneros 228px.
|
||||
|
||||
---
|
||||
|
||||
## Pantallas
|
||||
|
||||
### 1. Sidebar (`>= 1180px`)
|
||||
|
||||
`width:254px; padding:22px 12px 0 20px; border-right:1px solid hairline; background: sidebarBg`.
|
||||
|
||||
- **Wordmark**: cuadrado 24px con `logoGrad` + `logoGlow`, gap 11px, texto "Musify". Padding inferior 26px.
|
||||
- **Nav** (columna, gap 2px). Ítem: `padding:9px 12px; border-radius:10px; font-size:13.5px; weight:500`, icono SVG 17px stroke 1.6, etiqueta flexible, contador a la derecha (11px, `#5c5c60`, tabular).
|
||||
- Activo: color `#d5d4d8`, fondo `rgba(255,255,255,.06)`.
|
||||
- Inactivo: color `#8b8b8f`, fondo transparente. Hover: fondo `rgba(255,255,255,.04)`, color `#d5d4d8`. Transición `.16s`.
|
||||
- Ítems: Inicio · Descubrir · Playlists (7) · Me gusta (312) · Reciente.
|
||||
- **Separador**: `margin:24px 12px 14px; height:1px; background:rgba(255,255,255,.05)`.
|
||||
- **Cabecera "TUS PLAYLISTS"** (10.5px uppercase, 0.13em, `#616165`) con un "+" a la derecha que navega al índice de playlists.
|
||||
- **Lista de playlists** (scroll, gap 1px). Fila: miniatura 34px radio 8px (imagen `opacity:.88`), nombre 12.5px, meta 11px `#6c6c70`. Si esa playlist está sonando, un ecualizador de 3 barras de 2px a la derecha.
|
||||
|
||||
### 2. TopBar
|
||||
|
||||
`padding:16px 26px 12px; gap:14px`.
|
||||
|
||||
- Atrás / adelante: círculos de 30px, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.05)`, icono 15px. Atrás activo (`#8b8b8f`, hover `#dbdadf`), adelante deshabilitado (`#555559`).
|
||||
- Buscador: `flex:1; max-width:480px; height:38px; radius:11px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.055)`, lupa 15px `#737377`, input 13px transparente. Placeholder: "Canciones, artistas, álbumes o playlists".
|
||||
- Spacer.
|
||||
- Botón "Cola": alto 33px, radio 10px, 12.5px/500, icono 15px. Activo: color `#d6d5da`, fondo `rgba(255,255,255,.06)`. Inactivo: `#8b8b8f`, fondo `rgba(255,255,255,.028)`.
|
||||
- Avatar 33px circular con borde `rgba(255,255,255,.1)`.
|
||||
|
||||
### 3. Home
|
||||
|
||||
Ritmo editorial deliberado — no filas iguales encadenadas:
|
||||
|
||||
**a) Hero** — `border-radius:24px; margin-bottom:42px; background: heroBg`, sin borde, `padding:38px 36px 34px; max-width:620px`.
|
||||
- Saludo H1 dinámico por hora y día (ver *Copy dinámico*).
|
||||
- Blurb 13.5px `#8a8a8e`, `line-height:1.65`, `max-width:440px`.
|
||||
- Botones: primario "Reproducir" (`#c6c4cb` sobre `#0a0a0d`, 12px 21px, radio 12px, icono play 14px) + secundario "Guardar" (borde `rgba(255,255,255,.11)`), y un meta a la derecha en 12px.
|
||||
- Tres métricas: valor Sora 26px/600 y etiqueta 11.5px — "Canciones esta semana", "Tiempo de escucha", "Artistas nuevos".
|
||||
|
||||
**b) Continuar escuchando** — grid `auto-fill minmax(268px,1fr)`, gap 10px. Fila-tarjeta: `padding:9px 14px 9px 9px; radius:13px`, carátula 46px radio 9px, título 13px/600, sub 11.5px, y un icono play 15px en `accent` que aparece en hover. La pista en curso usa `accentSoft` de fondo, `accentHair` de borde y `accentMuted` en el título.
|
||||
|
||||
**c) Playlist destacada (spotlight)** — grid de dos columnas `auto-fit minmax(290px,1fr)`, `radius:22px`, `background: spotlightBg`, sin borde ni divisor central.
|
||||
- Izquierda: portada 88px radio 14px, eyebrow "PLAYLIST DESTACADA", nombre Sora 23px, meta 12px, blurb 13px/1.7, botón "Reproducir" (`accentBtnBg` + `accentLine` + `accentTitle`, hover `accentBtnBgHover`) y "Mezclar" secundario.
|
||||
- Derecha: 5 canciones con índice 20px, título 12.5px, artista 11px, duración tabular; fondo `rgba(0,0,0,.26)` que se desvanece a transparente hacia la derecha.
|
||||
|
||||
**d) Mixes** — H2 "Mixes" + sub "Generados a partir de lo que más repites". Grid `minmax(164px,1fr)` gap 20px. Tarjeta cuadrada radio 13px con scrim `linear-gradient(180deg,transparent 45%,rgba(0,0,0,.42))` y botón de play circular 38px abajo a la derecha (`rgba(10,10,13,.76)`, blur 8px, borde `rgba(255,255,255,.12)`) que aparece en hover.
|
||||
|
||||
**e) Artistas que sigues** — grid `minmax(132px,1fr)` gap 18px, retratos circulares, texto centrado, nombre 12.5px/600, oyentes 11px.
|
||||
|
||||
**f) Populares esta semana** — grid de dos columnas `auto-fit minmax(330px,1fr)`, `gap:6px 34px`. Fila: índice/EQ 18px, carátula 38px, título + artista, reproducciones 11px y duración 11.5px.
|
||||
|
||||
**g) Tus playlists** — tarjetas anchas `minmax(258px,1fr)` gap 14px: portada 62px radio 11px, nombre 13.5px/600, nº de canciones 11.5px, artistas principales 11px `#696a6d`, play en hover. Enlace "VER TODAS" en la cabecera.
|
||||
|
||||
### 4. Descubrir
|
||||
|
||||
H1 28px + sub. Mosaico de géneros `minmax(228px,1fr)` gap 14px: tarjetas de 112px, radio 16px, fondo `linear-gradient(140deg, oklch(0.24 0.07 Hgénero), rgba(9,9,12,.95))`, borde `rgba(255,255,255,.05)`, nombre Sora 15.5px, meta 11.5px, y una miniatura de 80px rotada 20° asomando en la esquina inferior derecha (`opacity:.82`). Debajo, "Fuera de tu burbuja" con la misma tarjeta cuadrada de Mixes.
|
||||
|
||||
### 5. Tracklist (Me gusta / Reciente / Playlist)
|
||||
|
||||
Cabecera: portada 164px radio 18px + eyebrow (`Colección` / `Historial` / `Playlist`), H1 33px, meta 12.5px, botón "Reproducir" y un botón cuadrado 40px de "aleatorio".
|
||||
|
||||
Tabla: `grid-template-columns: 32px 1fr 1fr 92px; gap:16px`. Cabecera 10.5px uppercase `#616165` con borde inferior `rgba(255,255,255,.05)`. Fila `padding:10px 14px; radius:12px`:
|
||||
- Col 1: número o, si es la pista activa, EQ de 3 barras de 2.5px en `accent`.
|
||||
- Col 2: carátula 40px radio 9px + título 13px/500 + artista 11.5px.
|
||||
- Col 3: álbum (o `—` si falta).
|
||||
- Col 4: corazón (14px; visible siempre si está marcado, si no solo en hover) + duración tabular.
|
||||
- Activa: fondo `accentSoft`, título `accentTitle`. Hover: `rgba(255,255,255,.032)`.
|
||||
|
||||
### 5b. Resultados de búsqueda
|
||||
|
||||
**Vista propia, no una tracklist.** La búsqueda devuelve canciones, álbumes, artistas y playlists, así que no lleva portada-hero ni columnas de tabla: es una lista agrupada donde cada tipo se reconoce a simple vista.
|
||||
|
||||
**Cabecera** (`margin:12px 0 18px`): eyebrow `RESULTADOS DE BÚSQUEDA` (10.5px uppercase, tracking .16em, `#8a8a8e`, 600) · H1 Sora 26px/600 tracking -0.03em `#d9d8dc` con el texto `Resultados para «{query}»` · meta 12.5px `#8a8a8e`: `{n} coincidencias en canciones, álbumes, artistas y playlists` (singular "coincidencia" con 1).
|
||||
|
||||
**Chips de filtro** (`display:flex; gap:7px; flex-wrap:wrap; margin-bottom:26px`): `Todo`, `Canciones`, `Álbumes`, `Artistas`, `Playlists`. Cada chip `padding:7px 14px; radius:999px; font-size:12px/500` y lleva a su derecha el contador en 10.5px tabular-nums.
|
||||
- Activo: fondo `#cfccd9`, texto `#0a0a0d`, contador `rgba(10,10,13,.55)`, sin borde.
|
||||
- Inactivo: fondo `rgba(255,255,255,.03)`, texto `#9b9b9f`, contador `#65656a`, borde `1px solid rgba(255,255,255,.07)`.
|
||||
- Transición `background .16s, color .16s`.
|
||||
|
||||
**Mejor resultado** (solo con el filtro `Todo`): fila-tarjeta `display:flex; gap:20px; padding:18px; radius:18px; border:1px solid rgba(255,255,255,.06)`, fondo `rgba(255,255,255,.025)` → `.05` en hover, `margin-bottom:34px`. Miniatura 88px con el radio del tipo y `box-shadow:0 14px 34px rgba(0,0,0,.5)`. Eyebrow `Mejor resultado · {Tipo}` 10.5px uppercase tracking .14em en el **tinte del tipo**; título Sora 22px/600 tracking -0.03em `#dcdbe0`; subtítulo 12.5px `#84848a`. A la derecha, botón circular 46px `#c6c4cb` / icono `#0a0a0d`, `opacity .55` → `1` en hover.
|
||||
Selección: el primer elemento cuyo nombre **empieza** por la consulta, recorriendo Artista → Canción → Álbum → Playlist; si ninguno empieza por ella, el primer resultado del primer grupo no vacío.
|
||||
|
||||
**Grupos** (`margin-bottom:30px` cada uno). Cabecera de grupo: título Sora 14.5px/600 `#cfccd9` + regla `1px rgba(255,255,255,.06)` que ocupa el espacio libre + contador 11px `#6e6e74` (`{n} resultados`). Con filtro `Todo` cada grupo muestra **máximo 4** elementos; al filtrar por un tipo se muestran todos los de ese tipo.
|
||||
|
||||
**Fila de resultado:** `grid-template-columns: 52px 1fr auto; gap:15px; padding:9px 12px; radius:12px; border-bottom:1px solid rgba(255,255,255,.035)`; fondo transparente → `rgba(255,255,255,.035)` en hover (`.15s ease`), sin elevación.
|
||||
- Miniatura 52px con degradado de respaldo del hue del elemento y overlay `rgba(6,6,9,.55)` + play 15px `#e2e1e6` que aparece en hover (`opacity 0 → 1`).
|
||||
- Título 13.5px/500 `#cbcacf` → `#e0dfe4` en hover, seguido del **badge de tipo**: 9.5px uppercase, tracking .1em, 600, `padding:3px 7px; radius:5px`, color del tinte del tipo sobre el mismo tinte al 11%.
|
||||
- Subtítulo 11.5px `#7f7f83` (una línea, ellipsis).
|
||||
- Meta a la derecha 11.5px `#76767c` tabular-nums.
|
||||
|
||||
**Diferenciación por tipo** — la forma de la miniatura es la señal primaria; el badge la confirma. El tinte se deriva del hue de la canción en reproducción con un desplazamiento fijo, así que la vista sigue siendo cromáticamente coherente con el resto de la app:
|
||||
|
||||
| Tipo | Radio de miniatura | Tinte | Subtítulo | Meta |
|
||||
|---|---|---|---|---|
|
||||
| Canción | `9px` (cuadrado suave) | `oklch(0.80 0.10 H)` | `artista · álbum` | duración |
|
||||
| Álbum | `5px` (casi recto) | `oklch(0.80 0.10 H+46)` | `artista · año` | `Álbum` |
|
||||
| Artista | `50%` (círculo) | `oklch(0.80 0.10 H-52)` | oyentes | `Artista` |
|
||||
| Playlist | `13px` (muy redondeado) | `oklch(0.80 0.10 H+92)` | artistas principales | `{n} canciones` |
|
||||
|
||||
Orden de los grupos: Canciones, Álbumes, Artistas, Playlists. Los grupos vacíos no se renderizan.
|
||||
|
||||
**Coincidencias:** canciones por título + artista + álbum; álbumes por título + `artista · año`; artistas por nombre; playlists por nombre + artistas principales. Todo en minúsculas, `includes`.
|
||||
|
||||
**Acciones:** canción → fija la pista y reproduce; álbum y artista → reproducen; playlist → abre la vista de playlist con su contexto y limpia la búsqueda.
|
||||
|
||||
### 6. Índice de playlists
|
||||
|
||||
H1 "Tus playlists" + meta agregada, botón "Nueva playlist" secundario, grid `minmax(196px,1fr)` gap 22px de tarjetas cuadradas con nombre, nº de canciones y artistas principales.
|
||||
|
||||
### 7. Cola (aside derecho)
|
||||
|
||||
`border-left:1px solid hairline; background: panelBg; padding:20px 16px` + padding inferior dinámico.
|
||||
- Cabecera "En cola" (Sora 14.5px) + cerrar "×".
|
||||
- "REPRODUCIENDO": tarjeta con fondo `accentSoft`, borde `accentLine`, carátula 40px, título en `accentTitle`.
|
||||
- Cabecera "A CONTINUACIÓN · {playlist}" con `gap:12px`; la etiqueta trunca con elipsis (`flex:1 1 auto; min-width:0; nowrap`) y "Vaciar" es `flex:0 0 auto`.
|
||||
- Lista scrollable: miniatura 36px, título 12px, artista 11px, duración.
|
||||
|
||||
### 8. Player (siempre visible)
|
||||
|
||||
Barra flotante: `radius:18px; padding:11px 16px; background: barBg; backdrop-filter: blur(22px); border:1px solid hairline; box-shadow:0 20px 56px rgba(0,0,0,.6)`; `pointer-events:none` en el contenedor y `auto` en la barra.
|
||||
|
||||
- **Izquierda (256px):** carátula 48px radio 11px, título 13px/600, artista · álbum 11.5px, corazón 16px (activo en `accent`).
|
||||
- **Centro (flex:1):** shuffle · anterior · play/pausa · siguiente · repetir, gap 14px. Play: círculo 40px `#c6c4cb` con icono `#0a0a0d`, hover `#d6d5da` (**sin scale**). Shuffle/repeat activos en `accent`, inactivos `#747478`.
|
||||
Progreso: tiempo transcurrido (10.5px tabular, ancho mín. 32px) · pista `rgba(255,255,255,.09)` de 3.5px con relleno en `accent` · duración. Clic en la pista = seek proporcional.
|
||||
- **Derecha (256px):** letra, volumen (icono + pista de 84px con relleno `#8e8c98`), y botón de cola. Oculto por completo en móvil.
|
||||
|
||||
### 9. Barra de pestañas (`< 1180px`)
|
||||
|
||||
Bajo el player, `margin-top:10px; padding:7px; radius:18px`, mismo `barBg` y blur. Icono 17px + etiqueta 10px, distribuidos con `space-around`. Muestra los 5 ítems de navegación.
|
||||
|
||||
---
|
||||
|
||||
## Estados
|
||||
|
||||
- **Loading (skeleton):** bloque de hero de 260px + 2 secciones de 6 tarjetas. Gradiente `linear-gradient(90deg, rgba(255,255,255,.025), .055, .025)` con `background-size:420px 100%` y `animation: shim 1.25s linear infinite` (`-420px 0` → `420px 0`). Se muestra 900ms al arrancar.
|
||||
- **Error:** cuadro 52px con "!" (`rgba(226,130,130,.08)` / borde `.18` / texto `#e09292`), título 20px, cuerpo 13px, botones "Reintentar" (primario) y "Ver descargas". Copy: "No se pudo cargar tu música" / "La conexión se interrumpió. Lo que tengas descargado sigue disponible sin conexión." Reintentar vuelve al skeleton 800ms.
|
||||
- **Vacío:** icono 70px radio 19px con degradado tenue, título 20px, cuerpo 13px, CTA primario + "Volver al inicio". Dos variantes de copy: playlists sin crear y "me gusta" sin canciones.
|
||||
- **Sin resultados:** se muestra cuando **ningún** tipo tiene coincidencias (canciones + álbumes + artistas + playlists = 0). Título `Sin resultados para "{query}"`, cuerpo de ayuda y 4 chips de sugerencia (radio 999px, borde `rgba(255,255,255,.08)`, 12px).
|
||||
- **Datos incompletos:** si falta el álbum, la columna muestra `—` y el player omite el `· álbum`; si falta el subtítulo de una tarjeta, "Sin información". Toda carátula tiene un degradado de respaldo detrás de la imagen.
|
||||
|
||||
---
|
||||
|
||||
## Interacciones
|
||||
|
||||
| Elemento | Comportamiento |
|
||||
|---|---|
|
||||
| Nav / tabs | Cambia de vista y limpia la búsqueda |
|
||||
| Playlist (sidebar, tarjeta, índice, spotlight) | Abre la vista de playlist con su contexto |
|
||||
| Tarjeta / fila de canción | Fija la pista, `pos = 0`, `playing = true` |
|
||||
| Play/pausa | Alterna reproducción (el reloj avanza 1s por segundo) |
|
||||
| Siguiente / anterior | Cíclicos sobre el array de pistas, resetean la posición |
|
||||
| Pista de progreso | Clic → `pos = (x / ancho) * duración` |
|
||||
| Pista de volumen | Clic → `vol` entre 0 y 1 |
|
||||
| Corazón (fila o player) | Alterna en el mapa `liked` |
|
||||
| Shuffle / repeat | Alternan, se tiñen de `accent` |
|
||||
| Botón "Cola" | Abre/cierra el aside |
|
||||
| Buscar | Filtra canciones, álbumes, artistas y playlists → vista de resultados agrupada; vacío → vuelve a la vista normal; 0 coincidencias en total → estado "sin resultados" |
|
||||
| Chip de filtro de resultados | Fija `sfilter`; `Todo` recorta cada grupo a 4, un tipo concreto muestra todos |
|
||||
| Nueva playlist (sidebar / índice) | Abre el modal de crear playlist |
|
||||
|
||||
**Animación — reglas:**
|
||||
- **Nada de elevación en hover.** Se eliminó a propósito: el feedback es solo un cambio de fondo y la aparición del botón de play.
|
||||
- Hover de fondo/color: `.15–.18s ease`. Play del player: `.14s` solo de color, sin `scale`.
|
||||
- Cambio de tinte por canción: `.6s ease`.
|
||||
- Entrada de vista: `rise` (`opacity 0 → 1`, `translateY(12px) → 0`) en `.3s ease`. Cola: `.22s`.
|
||||
- EQ de pista activa: `eq` `.9s ease-in-out infinite` alternando `scaleY(.25)` y `scaleY(1)`, con desfases de 0.25/0.5s (0.3/0.6s en el sidebar).
|
||||
|
||||
---
|
||||
|
||||
## Estado necesario
|
||||
|
||||
```
|
||||
view 'home' | 'explore' | 'playlists' | 'playlist' | 'liked' | 'recent'
|
||||
listCtx nombre de la playlist abierta
|
||||
idx índice de la pista en reproducción
|
||||
playing boolean
|
||||
pos segundos transcurridos (intervalo de 1s, vuelve a 0 al llegar a la duración)
|
||||
vol 0..1
|
||||
queueOpen boolean (forzado a false por debajo de 760px)
|
||||
query texto de búsqueda
|
||||
sfilter 'Todo' | 'Canciones' | 'Álbumes' | 'Artistas' | 'Playlists' (filtro de resultados)
|
||||
liked { [trackId]: true }
|
||||
shuffle / repeat boolean
|
||||
hover clave del elemento con hover (o null)
|
||||
w ancho de ventana (listener de resize)
|
||||
demoState 'normal' | 'loading' | 'error' | 'empty' (solo para demostrar estados)
|
||||
```
|
||||
|
||||
### Datos esperados
|
||||
|
||||
`Track { id, title, artist, album?, duration, cover?, hue, plays? }` ·
|
||||
`Album { title, artist, year, cover? }` ·
|
||||
`Artist { name, listeners, photo? }` ·
|
||||
`Playlist { name, trackCount, topArtists, cover?, isPlaying? }`
|
||||
|
||||
Todos los campos opcionales deben tener degradado o texto de respaldo. El `hue` debería calcularse en backend desde la carátula (color dominante, con saturación mínima garantizada) y venir en el payload; en el prototipo está precalculado por pista.
|
||||
|
||||
---
|
||||
|
||||
## Modal: crear playlist
|
||||
|
||||
Diseño aparte en `Crear playlist.dc.html`. Se invoca desde "Nueva playlist" (sidebar e índice de playlists).
|
||||
|
||||
**Scrim:** `rgba(4,4,6,.55)` + `backdrop-filter: blur(3px)` sobre el fondo `radial-gradient(120% 90% at 50% 0%, #15131c, #08080b 58%, #050507)`. Modal centrado con `display:grid; place-items:center; padding:40px 20px`.
|
||||
|
||||
**Contenedor:** `max-width:560px; radius:24px; border:1px solid rgba(255,255,255,.075)`, fondo `linear-gradient(180deg,#141419,#0d0d11 62%,#0b0b0e)`, `box-shadow:0 32px 90px rgba(0,0,0,.66)` + `inset 0 2px 0 rgba(255,255,255,.035)`. Entrada `modalIn .28s cubic-bezier(.2,.8,.3,1)` (`opacity 0 → 1`, `translateY(14px) scale(.985)` → sin transformar).
|
||||
|
||||
**Aura:** capa absoluta no interactiva `radial-gradient(110% 60% at 18% -10%, oklch(.55 .13 H / .16), transparent 62%)`, con `transition: background .5s` — vira con el acento elegido.
|
||||
|
||||
**Header** (`padding:24px 26px 0`): eyebrow `TU BIBLIOTECA` 10.5px uppercase tracking .16em `#8f8f93`; título Sora 21px/600 tracking -0.03em `#d8d7db` "Crear playlist". Cerrar: 32px, radio 10px, borde `rgba(255,255,255,.06)`, fondo `rgba(255,255,255,.025)` → `.07` y color `#87878c` → `#d0cfd4` en hover.
|
||||
|
||||
**Portada** (columna izquierda, 124px):
|
||||
- Cuadrado 124px radio 16px, `linear-gradient(150deg, oklch(.66 .16 H), oklch(.34 .11 H) 58%, #0e0e13)`, borde `rgba(255,255,255,.07)`, `box-shadow:0 14px 34px oklch(.5 .13 H / .22)`, con el icono de nota 26px `rgba(255,255,255,.62)` centrado. Transición `.45s` al cambiar de acento.
|
||||
- **Selector de acento:** 5 franjas `flex:1; height:20px; radius:7px` (gap 6px, `margin-top:11px`), cada una `linear-gradient(145deg, oklch(.66 .16 h), oklch(.38 .11 h))`. Hues: Ámbar 68, Violeta 288 (por defecto), Azul 244, Verde 158, Rojo 22. Seleccionada: `box-shadow:0 0 0 1.5px rgba(255,255,255,.85), 0 0 0 4px rgba(0,0,0,.5)`; el resto `0 0 0 1px rgba(255,255,255,.07)`.
|
||||
- **Subir imagen:** botón de texto 11px `#8a8a8f`, `padding:7px 0; radius:9px`, borde `1px dashed rgba(255,255,255,.1)`; hover texto `#cfccd9` y borde `rgba(255,255,255,.2)`.
|
||||
|
||||
**Campos** (columna derecha, `gap:20px` respecto a la portada):
|
||||
- Etiquetas 11px/600 `#87878c`.
|
||||
- Nombre: `padding:12px 14px; radius:12px`, fondo `rgba(255,255,255,.035)`, borde `rgba(255,255,255,.075)`, texto **Sora 16px/600** tracking -0.02em `#dcdbe0`, placeholder "Playlist sin título" `#5d5d63`, `maxlength 60`. Foco: borde `rgba(255,255,255,.2)`, fondo `.055`. Contador `{n}/60` 10.5px `#65656a` alineado a la derecha.
|
||||
- Descripción (opcional): `textarea` 3 filas, `resize:none`, 12.5px/1.6 `#c4c3c9`, mismo tratamiento de superficie y foco. Placeholder "Para qué sirve esta playlist, cuándo la escuchas…".
|
||||
|
||||
**Opciones** (`padding:20px 26px 0`, dos bloques `radius:13px; padding:13px 15px`, fondo `rgba(255,255,255,.026)`, borde `rgba(255,255,255,.055)`, gap 9px):
|
||||
- **Visibilidad** — segmentado de 2 (`Privada` por defecto / `Pública`) en un riel `radius:10px` `rgba(0,0,0,.34)`; opción activa fondo `#cfccd9` texto `#0a0a0d`, inactiva `#8e8e94`. El texto de ayuda cambia: "Solo tú la ves en tu biblioteca" / "Aparece en tu perfil y se puede compartir".
|
||||
- **Colaborativa** — toggle 40×23px, riel `rgba(255,255,255,.12)` → `oklch(.68 .15 H)` al activar, perilla 17px `#0c0c10` con `translateX(17px)`, `.2s cubic-bezier(.3,.8,.3,1)`. Ayuda: "Quien tenga el enlace puede añadir canciones". Toda la fila es clicable.
|
||||
|
||||
**Footer** (`border-top:1px solid rgba(255,255,255,.055)`, fondo `rgba(0,0,0,.2)`, `padding:17px 26px 20px`): nota 11px `#6e6e74` "Podrás añadir canciones desde cualquier pantalla."; a la derecha "Cancelar" (secundario, borde `rgba(255,255,255,.1)`, `#a9a7b2`) y "Crear playlist" (primario con icono +).
|
||||
|
||||
**Validación:** el CTA solo se habilita con nombre no vacío (`trim`). Deshabilitado: fondo `rgba(255,255,255,.08)`, texto `#6c6c72`, `cursor:not-allowed`. Habilitado: `#c6c4cb` sobre `#0a0a0d`.
|
||||
|
||||
**Confirmación:** al crear, toast fijo abajo-centro (`bottom:34px`, `radius:13px`, fondo `#16161c`, borde `rgba(255,255,255,.09)`, `box-shadow:0 18px 46px rgba(0,0,0,.6)`, `fadeIn .22s`) con un cuadrado 26px del degradado elegido y el texto «{nombre}» creada. Se oculta a los 2.6s.
|
||||
|
||||
**Estado:** `{ name, desc, hue: 0..4, vis: 'Privada'|'Pública', collab: boolean, created: boolean, createdName }`.
|
||||
**Salida esperada:** `POST /playlists { name, description, coverHue, visibility, collaborative }` → redirigir a la playlist recién creada.
|
||||
|
||||
---
|
||||
|
||||
## Copy dinámico
|
||||
|
||||
Saludo del hero según hora y día de la semana:
|
||||
|
||||
- `< 5h` → "¿Sesión de madrugada, {nombre}?"
|
||||
- `< 13h` → "Buenos días, {nombre}"
|
||||
- `< 20h` → "Buenas tardes, {nombre}" (miércoles → "Feliz miércoles"; viernes → "Feliz viernes")
|
||||
- `>= 20h` → "Buenas noches, {nombre}"
|
||||
- Sábado/domingo → "Que tengas buen finde, {nombre}" (o "¿Madrugada de finde, {nombre}?" antes de las 5h)
|
||||
|
||||
---
|
||||
|
||||
## Assets
|
||||
|
||||
- **Carátulas y retratos:** marcadores de posición fotográficos servidos por `picsum.photos` con una semilla por elemento, pintados como `background-image` (no `<img>`) para evitar peticiones fallidas mientras se resuelven los datos. Sustituir por las imágenes reales del catálogo.
|
||||
- **Iconos:** SVG inline, `stroke-width` 1.6–1.8, `stroke-linecap:round`. Reemplazables por el icon set del codebase.
|
||||
- **Fuentes:** Sora y Plus Jakarta Sans (Google Fonts).
|
||||
- Sin logotipos de terceros. El wordmark de Musify es tipográfico + el cuadrado con degradado.
|
||||
|
||||
## Files
|
||||
|
||||
- `Crear playlist.dc.html` — modal de creación de playlist (diseño aparte).
|
||||
- `Musify.dc.html` — prototipo completo (plantilla + lógica + datos de ejemplo). Contiene todas las vistas y estados; el selector `demoState` permite ver loading / error / empty.
|
||||
- `support.js` — runtime del prototipo. **No portar**: solo hace funcionar el HTML de referencia.
|
||||
- `screenshots/` — capturas de referencia (viewport ~924px, es decir en modo tablet: sidebar plegado a barra de pestañas inferior):
|
||||
- `01-home-hero.png` … `04-home-populares-playlists.png` — recorrido vertical de Home
|
||||
- `05-descubrir.png`, `06-playlists.png`, `07-tracklist-me-gusta.png`, `08-sin-resultados.png`
|
||||
Nota: las capturas son anteriores al rediseño de la vista de resultados de búsqueda (5b) y al modal de crear playlist; para esos dos, la referencia son los archivos HTML y este documento.
|
||||
Todas con "Yankee" sonando, de ahí el tinte ámbar: con otra pista el mismo layout se ve en otro color.
|
||||
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 33 KiB |
|
Before Width: | Height: | Size: 36 KiB |
|
Before Width: | Height: | Size: 32 KiB |
|
Before Width: | Height: | Size: 28 KiB |
@@ -1,931 +0,0 @@
|
||||
# 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 |
|
||||
@@ -1,159 +1,128 @@
|
||||
# Musify — Guía de páginas
|
||||
# Musify, page guide
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
Leyenda de estado: ✅ hecho · 🟡 parcial / maqueta · ⬜ pendiente.
|
||||
Status legend: ✅ done · 🟡 partial / mockup · ⬜ pending.
|
||||
|
||||
> **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).
|
||||
> **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).
|
||||
|
||||
## Inicio (app) `/` 🟡
|
||||
## Home (app) `/` 🟡
|
||||
|
||||
- **Hero**: saludo + "Tu música. Sin límites." con color-wash del hue de la pista actual.
|
||||
- **Acceso rápido = escuchado recientemente** (`player.recentlyPlayed`, hoy en cliente).
|
||||
Estado vacío con icono + CTA «Explorar».
|
||||
- **Tus playlists**: tarjetas mosaico de las playlists (hoy datos de muestra). Estado vacío.
|
||||
- **Hero**: a greeting plus "Your music. No limits." with a color wash taken from the current track's hue.
|
||||
- **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.
|
||||
- **Your playlists**: mosaic cards for the user's playlists (sample data for now). Has an empty state.
|
||||
- **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).
|
||||
|
||||
### Backend pendiente (ASP.NET) para Inicio
|
||||
### Pending backend work (ASP.NET) for Home
|
||||
|
||||
- **Escuchado recientemente**: registrar reproducciones por usuario (tabla de historial:
|
||||
`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.
|
||||
- **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.
|
||||
- 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.
|
||||
|
||||
## Estructura de rutas
|
||||
## Route structure
|
||||
|
||||
Rutas agrupadas para separar lo público de lo autenticado (los grupos no cambian la URL):
|
||||
Routes are grouped to separate the public experience from the authenticated one (the groups don't change the URL):
|
||||
|
||||
```
|
||||
src/routes/
|
||||
(marketing)/ Público, sin sesión obligatoria
|
||||
(marketing)/ Public, no session required
|
||||
+page.svelte / Landing ✅
|
||||
(app)/ Experiencia de la app
|
||||
explore/ /explore Explorar / buscar ✅
|
||||
upload/ /upload Subir música 🟡 maqueta
|
||||
library/ /library Tu biblioteca ⬜
|
||||
playlists/ /playlists Tus playlists ⬜
|
||||
[id]/ /playlists/:id Detalle playlist ⬜
|
||||
login logout auth/callback Auth (endpoints) ✅
|
||||
api/tracks/[id]/stream Proxy de stream ✅
|
||||
(app)/ The app experience
|
||||
explore/ /explore Explore / search ✅
|
||||
upload/ /upload Upload music 🟡 mockup
|
||||
library/ /library Your library ⬜
|
||||
playlists/ /playlists Your playlists ⬜
|
||||
[id]/ /playlists/:id Playlist detail ⬜
|
||||
auth auth/login auth/logout auth/callback Auth (page + endpoints) ✅
|
||||
api/tracks/[id]/stream Stream proxy ✅
|
||||
```
|
||||
|
||||
Componentes globales (en todas las páginas): **Navbar**, **Footer** y **PlayerBar**
|
||||
(barra de reproducción fija, aparece al reproducir).
|
||||
Global components (present on every page): **Navbar**, **Footer**, and **PlayerBar** (the fixed playback bar, shown while something is playing).
|
||||
|
||||
---
|
||||
|
||||
## 1. Landing `/` ✅ (marketing)
|
||||
|
||||
Página de entrada para no autenticados. Vender el producto y llevar a registro/explorar.
|
||||
The entry page for logged out visitors. Its job is to sell the product and drive people to sign up or explore.
|
||||
|
||||
- **Hero**: badge de estado (beta), título, subtítulo, CTAs primario (Empezar gratis →
|
||||
registro) y secundario (Explorar canciones).
|
||||
- **Features**: 3 cards (escuchar, playlists, subir).
|
||||
- **Pendiente sugerido**: sección de capturas/demo, prueba social, FAQ corta.
|
||||
- **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).
|
||||
- **Features**: three cards covering listening, playlists, and uploading.
|
||||
- **Suggested next steps**: a screenshots or demo section, social proof, a short FAQ.
|
||||
|
||||
## 2. Login / Auth ✅ (endpoints)
|
||||
|
||||
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.
|
||||
`/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.
|
||||
|
||||
## 3. Explorar / Buscar `/explore` ✅ (app)
|
||||
## 3. Explore / Search `/explore` ✅ (app)
|
||||
|
||||
Descubrimiento y búsqueda de canciones del catálogo.
|
||||
Discovery and search over the catalog.
|
||||
|
||||
- **Buscador**: input de texto (`?q=`) que filtra por nombre en el backend.
|
||||
- **Contador de resultados** y **grid de tracks** (2→4 columnas): portada, título, fecha,
|
||||
botón play/pausa en hover que encola y reproduce vía el player global.
|
||||
- **Paginación** anterior/siguiente (`?page=`, 24 por página).
|
||||
- **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).
|
||||
- **Search box**: a text input (`?q=`) that filters by name on the 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.
|
||||
- **Pagination**: previous/next (`?page=`, 24 per page).
|
||||
- **Empty state**: no results, or an empty catalog.
|
||||
- **Suggested next steps**: filters (most recent, by user), loading skeletons, a real track cover (the listing API doesn't currently return the image key).
|
||||
|
||||
## 4. Subir música `/upload` 🟡 maqueta (app)
|
||||
## 4. Upload music `/upload` 🟡 mockup (app)
|
||||
|
||||
Alta de una canción nueva. Flujo real de backend (para cuando se cablee):
|
||||
Adding a new song. The real backend flow, for when it gets wired up:
|
||||
|
||||
1. `POST /tracks/upload-urls` → URLs presignadas para portada y audio + `intentId`s.
|
||||
2. `PUT` de cada archivo a su URL presignada (subida directa al almacenamiento).
|
||||
3. `POST /tracks` con `title`, `pictureIntentId`, `audioIntentId`.
|
||||
4. El backend procesa el audio (transcodifica a DASH) de forma asíncrona.
|
||||
1. `POST /tracks/upload-urls` returns presigned URLs for cover and audio, plus `intentId`s.
|
||||
2. `PUT` each file to its presigned URL (a direct upload to storage).
|
||||
3. `POST /tracks` with `title`, `pictureIntentId`, `audioIntentId`.
|
||||
4. The backend processes the audio asynchronously (transcoding it to DASH).
|
||||
|
||||
Contenido de la página:
|
||||
What the page needs:
|
||||
|
||||
- **Zona de audio**: drag & drop o selector; muestra nombre y tamaño del archivo, validación
|
||||
de tipo (audio) y tamaño.
|
||||
- **Portada**: selector de imagen con vista previa cuadrada.
|
||||
- **Metadatos**: título (obligatorio). Espacio para futuros campos (descripción, etc.).
|
||||
- **Pipeline visible**: pasos Subir → Procesar → Publicar con su estado.
|
||||
- **Barra de progreso** de subida y estado de procesado.
|
||||
- **Estados**: vacío, archivo seleccionado, subiendo, procesando, publicado, error.
|
||||
- **Requiere sesión** (al cablear: redirigir a `/login?returnTo=/upload`).
|
||||
- **Audio zone**: drag and drop or a file picker, showing the file's name and size, with type (audio) and size validation.
|
||||
- **Cover**: an image picker with a square preview.
|
||||
- **Metadata**: title (required), with room for future fields like a description.
|
||||
- **Visible pipeline**: the Upload, Process, Publish steps and their status.
|
||||
- **Progress bar** for the upload and the processing state.
|
||||
- **States**: empty, file selected, uploading, processing, published, error.
|
||||
- **Requires a session** (once wired up: redirect to `/auth?returnTo=/upload`).
|
||||
|
||||
## 5. Tu biblioteca `/library` ⬜ (app)
|
||||
## 5. Your library `/library` ⬜ (app)
|
||||
|
||||
Panel personal del usuario autenticado: lo suyo en un sitio.
|
||||
The authenticated user's personal panel, everything of theirs in one place.
|
||||
|
||||
- **Tabs / secciones**: «Tus canciones» (`GET /tracks/users/{userId}`) y «Tus playlists»
|
||||
(`GET /playlists/users/{userId}`).
|
||||
- **Tus canciones**: lista con play, y acciones de gestión (borrar `DELETE /tracks/{id}`,
|
||||
añadir a playlist).
|
||||
- **Tus playlists**: grid de portadas + botón «Crear playlist».
|
||||
- **Estado vacío** con CTA a subir / crear.
|
||||
- **Requiere sesión**.
|
||||
- **Tabs / sections**: "Your songs" (`GET /tracks/users/{userId}`) and "Your playlists" (`GET /playlists/users/{userId}`).
|
||||
- **Your songs**: a list with play, plus management actions (delete via `DELETE /tracks/{id}`, add to a playlist).
|
||||
- **Your playlists**: a grid of covers with a "Create playlist" button.
|
||||
- **Empty state** with a CTA to upload or create.
|
||||
- **Requires a session.**
|
||||
|
||||
## 6. Playlists `/playlists` ⬜ (app)
|
||||
|
||||
Listado de las playlists del usuario.
|
||||
A listing of the user's playlists.
|
||||
|
||||
- **Grid** de playlists (portada, nombre, nº de tracks) → enlaza al detalle.
|
||||
- **Crear playlist**: modal/página con nombre, descripción y portada
|
||||
(`POST /playlists`, portada vía `POST /playlists/upload-picture`).
|
||||
- **Estado vacío** con CTA.
|
||||
- **Requiere sesión**.
|
||||
- **Grid** of playlists (cover, name, track count), linking to the detail page.
|
||||
- **Create playlist**: a modal or page with a name, description, and cover (`POST /playlists`, cover via `POST /playlists/upload-picture`).
|
||||
- **Empty state** with a CTA.
|
||||
- **Requires a session.**
|
||||
|
||||
## 7. Detalle de playlist `/playlists/[id]` ⬜ (app)
|
||||
## 7. Playlist detail `/playlists/[id]` ⬜ (app)
|
||||
|
||||
- **Cabecera**: portada grande, nombre, descripción, nº de tracks, botón «Reproducir todo».
|
||||
- **Lista de tracks** (`GET /playlists/{id}/tracks`, paginada): índice, título, fecha,
|
||||
play, quitar de la playlist (`DELETE /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}`).
|
||||
- **Header**: a large cover, name, description, track count, and a "Play all" button.
|
||||
- **Track list** (`GET /playlists/{id}/tracks`, paginated): index, title, date, play, and remove from the playlist (`DELETE /playlists/{id}/tracks/{trackId}`).
|
||||
- **Owner actions**: edit (`PUT`), delete (`DELETE`) the playlist, reorder (a future addition).
|
||||
- **Add tracks**: from explore or the library (`POST /playlists/{id}/tracks/{trackId}`).
|
||||
|
||||
## 8. Perfil de usuario `/users/[id]` ⬜ (futuro)
|
||||
## 8. User profile `/user/[id]` ⬜ (future)
|
||||
|
||||
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}`).
|
||||
A public view of a user: avatar, name, their public songs and playlists (`GET /users/{id}`, `GET /tracks/users/{id}`, `GET /playlists/users/{id}`).
|
||||
|
||||
---
|
||||
|
||||
## Player global (PlayerBar) ✅
|
||||
## Global player (PlayerBar) ✅
|
||||
|
||||
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.
|
||||
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.
|
||||
|
||||
## Backend disponible (resumen)
|
||||
## Available backend (summary)
|
||||
|
||||
| Recurso | Endpoints |
|
||||
| --------- | ----------------------------------------------------------------------- |
|
||||
| Tracks | listar/buscar, detalle, por usuario, stream, crear, borrar, upload-urls |
|
||||
| Playlists | CRUD, tracks de la playlist, add/remove track, upload-picture |
|
||||
| Users | listar/buscar, detalle, crear |
|
||||
| Resource | Endpoints |
|
||||
| --------- | ----------------------------------------------------------------- |
|
||||
| Tracks | list/search, detail, by user, stream, create, delete, upload-urls |
|
||||
| Playlists | CRUD, playlist tracks, add/remove track, upload-picture |
|
||||
| Users | list/search, detail, create |
|
||||
|
||||
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`.
|
||||
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`.
|
||||
|
||||
@@ -36,12 +36,6 @@ export default ts.config(
|
||||
}
|
||||
},
|
||||
{
|
||||
ignores: [
|
||||
'build/',
|
||||
'.svelte-kit/',
|
||||
'static/',
|
||||
'src/lib/api/schema.d.ts',
|
||||
'design_handoff_musify_player/'
|
||||
]
|
||||
ignores: ['build/', '.svelte-kit/', 'static/', 'src/lib/api/schema.d.ts']
|
||||
}
|
||||
);
|
||||
|
||||
@@ -39,6 +39,40 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.2.tgz",
|
||||
@@ -1118,7 +1152,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.69.1.tgz",
|
||||
"integrity": "sha512-+nz8Fx/cElzb2ZPHC+6Ll3y3NEVIe4Na75PeplLlyTmd1dBXAjz2KR14y1ZgNjb2ThfAYzulu+PFy1UE3RCUzA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@sveltejs/acorn-typescript": "^1.0.9",
|
||||
@@ -1170,7 +1203,6 @@
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/vite-plugin-svelte/-/vite-plugin-svelte-7.1.2.tgz",
|
||||
"integrity": "sha512-DrUBA2UXRfDmUX/ZTiEopd3X40yavsJF1FX2RygcuIScHL7o5YX1fMvoYnDhjeJQC4weCOklirpNWlcb2NiSeA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"deepmerge": "^4.3.1",
|
||||
"magic-string": "^0.30.21",
|
||||
@@ -1408,6 +1440,27 @@
|
||||
"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": {
|
||||
"version": "1.2.2",
|
||||
"dev": true,
|
||||
@@ -1544,7 +1597,6 @@
|
||||
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"undici-types": "~6.21.0"
|
||||
}
|
||||
@@ -1606,7 +1658,6 @@
|
||||
"integrity": "sha512-KA0OshtlcCCXmbfqyZkM5pV3/WNraJf7DkJRLpyrmwPtud57H5BDX7C3k0LPSPxpprfRL+cJDGabF10mvNCoCw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@typescript-eslint/scope-manager": "8.64.0",
|
||||
"@typescript-eslint/types": "8.64.0",
|
||||
@@ -1714,7 +1765,6 @@
|
||||
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
|
||||
"devOptional": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
|
||||
},
|
||||
@@ -1798,7 +1848,6 @@
|
||||
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
|
||||
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"acorn": "bin/acorn"
|
||||
},
|
||||
@@ -2033,7 +2082,6 @@
|
||||
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"workspaces": [
|
||||
"packages/*"
|
||||
],
|
||||
@@ -3112,7 +3160,6 @@
|
||||
}
|
||||
],
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"picocolors": "^1.1.1",
|
||||
@@ -3246,7 +3293,6 @@
|
||||
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"prettier": "bin/prettier.cjs"
|
||||
},
|
||||
@@ -3263,7 +3309,6 @@
|
||||
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=20"
|
||||
},
|
||||
@@ -3434,7 +3479,6 @@
|
||||
"resolved": "https://registry.npmjs.org/rollup/-/rollup-4.62.2.tgz",
|
||||
"integrity": "sha512-RFnrW4lhXA3s3eqHDZvN654g8OTjzRfqpIRJYczCGB6HzphckVAi/Qh4tbPUbRuDi7s1Llv8g/NspLkttY3gTA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@types/estree": "1.0.9"
|
||||
},
|
||||
@@ -3569,7 +3613,6 @@
|
||||
"resolved": "https://registry.npmjs.org/svelte/-/svelte-5.56.4.tgz",
|
||||
"integrity": "sha512-/d0QHehmRuJW8gVz395MTkPcPozxzdjBMBE8oEYGz8O3b9KTMzzQ9ZHJQLuFKOHOPQbU6kx/X4iid/EBBzH7iw==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"@jridgewell/remapping": "^2.3.4",
|
||||
"@jridgewell/sourcemap-codec": "^1.5.0",
|
||||
@@ -3781,7 +3824,6 @@
|
||||
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
|
||||
"devOptional": true,
|
||||
"license": "Apache-2.0",
|
||||
"peer": true,
|
||||
"bin": {
|
||||
"tsc": "bin/tsc",
|
||||
"tsserver": "bin/tsserver"
|
||||
@@ -3843,7 +3885,6 @@
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-8.1.3.tgz",
|
||||
"integrity": "sha512-Ds+gBRbj0lwRO2Y5hwnUBdxSwlAve9LeRyU4sNnAr0ewW0gWF0n5bgXgUzbgZ49MV9BVUAQUFYVcDUcilUExMA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"dependencies": {
|
||||
"lightningcss": "^1.32.0",
|
||||
"picomatch": "^4.0.4",
|
||||
|
||||
@@ -71,7 +71,7 @@ for (const file of files) {
|
||||
if (violations.length > 0) {
|
||||
console.error(`\n${violations.length} violación(es) del sistema de diseño:\n`);
|
||||
for (const v of violations) console.error(` ${v}`);
|
||||
console.error('\nVer docs/frontend-refactor-plan.md, sección B.4.\n');
|
||||
console.error('\nVer la lista blanca (ARBITRARY_WHITELIST) en este script, o CLAUDE.md.\n');
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,15 +1,16 @@
|
||||
import type { Handle } from '@sveltejs/kit';
|
||||
import {
|
||||
clearSessionCookie,
|
||||
decodeSession,
|
||||
encodeSession,
|
||||
readSessionCookie,
|
||||
refreshSession,
|
||||
sessionCookieOptions,
|
||||
SESSION_COOKIE
|
||||
writeSessionCookie
|
||||
} from '$lib/server/auth';
|
||||
import { authConfig } from '$lib/server/config';
|
||||
|
||||
export const handle: Handle = async ({ event, resolve }) => {
|
||||
let session = await decodeSession(event.cookies.get(SESSION_COOKIE));
|
||||
let session = await decodeSession(readSessionCookie(event.cookies));
|
||||
|
||||
if (
|
||||
session &&
|
||||
@@ -17,13 +18,9 @@ export const handle: Handle = async ({ event, resolve }) => {
|
||||
) {
|
||||
session = await refreshSession(session);
|
||||
if (session) {
|
||||
event.cookies.set(
|
||||
SESSION_COOKIE,
|
||||
await encodeSession(session),
|
||||
sessionCookieOptions(event.url)
|
||||
);
|
||||
writeSessionCookie(event.cookies, await encodeSession(session), event.url);
|
||||
} else {
|
||||
event.cookies.delete(SESSION_COOKIE, { path: '/' });
|
||||
clearSessionCookie(event.cookies);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -3,11 +3,11 @@
|
||||
import Upload from '@lucide/svelte/icons/upload';
|
||||
import Folder from '@lucide/svelte/icons/folder';
|
||||
import LogIn from '@lucide/svelte/icons/log-in';
|
||||
import MenuItem from './ui/MenuItem.svelte';
|
||||
import AccountMenu from './ui/AccountMenu.svelte';
|
||||
import Avatar from './ui/Avatar.svelte';
|
||||
import IconButton from './ui/IconButton.svelte';
|
||||
import Logo from './ui/Logo.svelte';
|
||||
import MenuItem from '../ui/overlay/MenuItem.svelte';
|
||||
import AccountMenu from '../ui/overlay/AccountMenu.svelte';
|
||||
import Avatar from '../ui/media/Avatar.svelte';
|
||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
||||
import Logo from '../ui/primitives/Logo.svelte';
|
||||
|
||||
interface Props {
|
||||
user: SessionUser | null;
|
||||
@@ -18,7 +18,7 @@
|
||||
</script>
|
||||
|
||||
<header
|
||||
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"
|
||||
class="sticky top-0 z-(--z-sticky) flex h-14 items-center justify-between bg-transparent page-x backdrop-blur-md sm:hidden"
|
||||
>
|
||||
<a href="/">
|
||||
<Logo size="sm" />
|
||||
@@ -1,13 +1,13 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
|
||||
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
|
||||
import { liked } from '$lib/player/liked.svelte';
|
||||
import { createPlaylistModal } from '$lib/playlists.svelte';
|
||||
import { createPlaylistModal } from '$lib/state/playlists.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
import ListRow from './ui/ListRow.svelte';
|
||||
import IconButton from './ui/IconButton.svelte';
|
||||
import Logo from './ui/Logo.svelte';
|
||||
import ListRow from '../ui/media/ListRow.svelte';
|
||||
import IconButton from '../ui/primitives/IconButton.svelte';
|
||||
import Plus from '@lucide/svelte/icons/plus';
|
||||
import Logo from '../ui/primitives/Logo.svelte';
|
||||
|
||||
interface SidebarPlaylist {
|
||||
id: string;
|
||||
@@ -31,10 +31,10 @@
|
||||
</script>
|
||||
|
||||
<aside
|
||||
class="hidden shrink-0 flex-col border-r border-hairline bg-bg p-5 theme-transition lg:flex"
|
||||
class="hidden shrink-0 flex-col border-r border-hairline p-5 lg:flex"
|
||||
style="width:var(--mf-sidebar-w)"
|
||||
>
|
||||
<a href="/" class="px-3 pb-3">
|
||||
<a href="/" class="mb-4">
|
||||
<Logo size="sm" />
|
||||
</a>
|
||||
|
||||
@@ -44,14 +44,14 @@
|
||||
<a
|
||||
href={link.href}
|
||||
aria-current={active ? 'page' : undefined}
|
||||
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors duration-150 {active
|
||||
class="flex items-center gap-3 rounded-control px-3 py-2 text-sm transition-colors {active
|
||||
? 'bg-surface-2 text-fg'
|
||||
: 'text-fg-2 hover:bg-hover hover:text-fg'}"
|
||||
>
|
||||
<link.icon class="size-4.5 shrink-0" strokeWidth={1.5} />
|
||||
<link.icon class="size-icon-md shrink-0" strokeWidth={1.5} />
|
||||
<span class="flex-1 truncate">{link.label}</span>
|
||||
{#if link.count}
|
||||
<span class="text-xs text-muted tabular-nums">{link.count}</span>
|
||||
<span class="text-xs text-fg-2 tabular-nums">{link.count}</span>
|
||||
{/if}
|
||||
</a>
|
||||
{/each}
|
||||
@@ -60,7 +60,7 @@
|
||||
{#if user}
|
||||
<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">
|
||||
<span class="text-eyebrow text-muted">Tus playlists</span>
|
||||
<span class="text-eyebrow text-fg-2">Tus playlists</span>
|
||||
{#if playlists.length > 0}
|
||||
<IconButton
|
||||
label="Crear playlist"
|
||||
@@ -68,12 +68,12 @@
|
||||
size="xs"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
>
|
||||
<Plus class="size-icon-sm" strokeWidth={1.25} />
|
||||
<Plus class="size-icon-sm text-fg-2" strokeWidth={1.25} />
|
||||
</IconButton>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-4.5">
|
||||
<div class="flex flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
|
||||
{#each playlists as playlist (playlist.id)}
|
||||
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
|
||||
<ListRow
|
||||
@@ -91,7 +91,7 @@
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => createPlaylistModal.show()}
|
||||
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"
|
||||
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover hover:text-fg-2"
|
||||
>
|
||||
<Plus class="size-3.5" strokeWidth={1.8} />
|
||||
Crear tu primera lista
|
||||
@@ -1,6 +1,6 @@
|
||||
<script lang="ts">
|
||||
import { page } from '$app/state';
|
||||
import { isSectionActive, appNavLinks } from '$lib/navigation.svelte';
|
||||
import { isSectionActive, appNavLinks } from '$lib/state/navigation.svelte';
|
||||
import type { SessionUser } from '$lib/types';
|
||||
|
||||
interface Props {
|
||||