Author SHA1 Message Date
Admin 35d6314ce8 Point the IDE dev settings at the shared Infrastructure stack
CI/CD / BackEnd (push) Successful in 1m18s
CI/CD / WebPlayer (push) Successful in 41s
CI/CD / Deploy (push) Skipped
appsettings.Development.json still targeted Musify's old own Postgres
(port 59000) with the superuser, the default RabbitMQ vhost as guest and
the admin S3 keys. They now use Infrastructure's dev ports and Musify's
own dev credentials (role, vhost and S3 identity), the same defaults as
deploy/.env.example.

The API's non-secret Zitadel settings (issuer, metadata, endpoints) move
there too, so user-secrets only need the per-machine client id.
2026-09-24 17:11:50 +02:00
Admin f99f74f4a1 Use Musify's own credentials on the shared services
Infrastructure now gives each project its own Postgres role, RabbitMQ
user and vhost, S3 identity limited to its bucket and a Zitadel service
account that owns only its project, instead of handing out the admin
accounts.

- Postgres, RabbitMQ and S3 settings default to Musify's own (musify).
- MassTransit connects to the `musify` vhost (RABBITMQ_VHOST, taken from
  the address path); RABBITMQ_DEFAULT_USER/PASS become RABBITMQ_USER and
  RABBITMQ_PASSWORD.
- zitadel-init mounts ZITADEL_PAT_DIR (Infrastructure's
  zitadel/.output/musify) and uses that PAT. It no longer creates the
  Zitadel project, which that account can't do: Infrastructure does.
2026-09-24 17:05:23 +02:00
Admin f2765e2e7b Call the API by container name from the web player
In prod every project shares infra-net, where the `api` service name
can resolve to another project's container as well.
2026-09-24 15:06:25 +02:00
Admin 7b8f3f1222 Use a reverse-domain redirect scheme for the native app
- Android/iOS now return to cat.ikerdemo.musify:/callback instead of
  musify://callback: a private-use scheme based on a domain we own
  (RFC 8252 section 7.1), so another app is unlikely to claim it.
- The Musify Native app no longer needs devMode: loopback and custom
  scheme redirects are both valid for a native app.
- zitadel-init also updates an existing app's config, so the change
  reaches instances created before it (the client id is kept).
- iOS takes the scheme from everything before the first ':', since the
  new URI has a single slash.
2026-09-24 15:06:25 +02:00
Admin c33b0e8bfd Stop disabling Zitadel Login V2 from zitadel-init
The shared Infrastructure stack now runs the Login V2 container, and
turning the feature off here would switch every project on the shared
instance back to the old login.
2026-09-24 15:06:25 +02:00
119 changed files with 1467 additions and 2122 deletions
@@ -21,8 +21,6 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -38,7 +36,7 @@ namespace Musify.Application.PlayLists
&& entry.PlayList.UserId != request.RequestingUserId)
return Error.NotFound();
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds);
return PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds);
}
}
}
@@ -25,8 +25,6 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -36,7 +34,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -46,8 +46,6 @@ namespace Musify.Application.PlayLists
.Select(p => new
{
PlayList = p,
TrackCount = p.PlayListTracks.Count,
DurationSeconds = p.PlayListTracks.Sum(plt => (double?)plt.Track.DurationSeconds) ?? 0,
CoverTrackIds = p.PlayListTracks
.OrderBy(plt => plt.Position)
.Take(PlayListApplicationResponse.CoverTrackCount)
@@ -57,7 +55,7 @@ namespace Musify.Application.PlayLists
.ToPagedListAsync(request.PageNumber, request.PageSize, totalCount, cancellationToken);
var pagedPlayLists = new StaticPagedList<PlayListApplicationResponse>(
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.TrackCount, entry.DurationSeconds, entry.CoverTrackIds)),
pagedEntities.Select(entry => PlayListApplicationResponse.FromEntity(entry.PlayList, entry.CoverTrackIds)),
pagedEntities.PageNumber, pagedEntities.PageSize, pagedEntities.TotalItemCount);
return PaginatedResponse<PlayListApplicationResponse>.FromPagedList(pagedPlayLists);
@@ -1,5 +1,3 @@
using System.Text.Json.Serialization;
using Musify.Application.Serialization;
using Musify.Domain.Entities;
using Musify.Domain.ValueObjects;
@@ -12,10 +10,7 @@ namespace Musify.Application.PlayLists.Responses
string? SmallImageKeyName,
string? MediumImageKeyName,
string? LargeImageKeyName,
[property: JsonConverter(typeof(LongAsStringConverter))] long OwnerUserId,
PlaylistVisibility Visibility,
int TrackCount,
double DurationSeconds,
DateTime CreatedAt,
DateTime UpdatedAt,
IReadOnlyList<Guid> CoverTrackIds)
@@ -24,8 +19,6 @@ namespace Musify.Application.PlayLists.Responses
public static PlayListApplicationResponse FromEntity(
PlayList playList,
int trackCount = 0,
double durationSeconds = 0,
IReadOnlyList<Guid>? coverTrackIds = null)
{
return new PlayListApplicationResponse(
@@ -35,10 +28,7 @@ namespace Musify.Application.PlayLists.Responses
playList.Pictures?.SmallName,
playList.Pictures?.MediumName,
playList.Pictures?.LargeName,
playList.UserId,
playList.Visibility,
trackCount,
durationSeconds,
playList.CreatedAt,
playList.UpdatedAt,
coverTrackIds ?? []);
@@ -112,22 +112,7 @@ namespace Musify.Application.PlayLists
.ToListAsync(cancellationToken);
logger.LogInformation("Updated playlist {PlayListId}", playListEntity.Id);
var totals = await database.PlayListHasTracks
.AsNoTracking()
.Where(plt => plt.PlayListId == playListEntity.Id)
.GroupBy(plt => plt.PlayListId)
.Select(group => new
{
TrackCount = group.Count(),
DurationSeconds = group.Sum(plt => plt.Track.DurationSeconds)
})
.SingleOrDefaultAsync(cancellationToken);
return PlayListApplicationResponse.FromEntity(
playListEntity,
totals?.TrackCount ?? 0,
totals?.DurationSeconds ?? 0,
coverTrackIds);
return PlayListApplicationResponse.FromEntity(playListEntity, coverTrackIds);
}
private async Task<ErrorOr<Success>> PublishUpdatePlayListPictureSourceEventAsync(
@@ -6,21 +6,26 @@
}
},
"Authentication": {
"MetadataAddress": "http://host.docker.internal:8080/.well-known/openid-configuration",
"IssuerAddress": "http://host.docker.internal:8080",
"AuthorizationEndpoint": "http://host.docker.internal:8080/oauth/v2/authorize?prompt=login",
"TokenEndpoint": "http://host.docker.internal:8080/oauth/v2/token",
"ScalarRedirectUri": "http://localhost:5111/scalar/",
"Scopes": [ "openid", "profile", "email" ],
"RequireHttpsMetadata": false
},
"Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
"ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify"
},
"MassTransit": {
"Address": "rabbitmq://localhost:5672",
"Username": "guest",
"Password": "guest"
"Address": "rabbitmq://localhost:5672/musify",
"Username": "musify",
"Password": "musify"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
"SecretAccessKey": "admin_secret_key",
"AccessKey": "musify_access_key",
"SecretAccessKey": "musify_secret_key",
"ForcePathStyle": true,
"UseHttp": true
},
@@ -6,20 +6,20 @@
}
},
"Database": {
"ConnectionString": "Host=localhost;Port=59000;Database=musify_db;Username=postgres;Password=postgres"
"ConnectionString": "Host=localhost;Port=5432;Database=musify_db;Username=musify;Password=musify"
},
"MassTransit": {
"Address": "rabbitmq://localhost:5672",
"Username": "guest",
"Password": "guest"
"Address": "rabbitmq://localhost:5672/musify",
"Username": "musify",
"Password": "musify"
},
"OpenTelemetry": {
"OtlpEndpoint": "http://localhost:4317"
},
"InfrastructureStorage": {
"Address": "http://localhost:8333",
"AccessKey": "admin_access_key",
"SecretAccessKey": "admin_secret_key",
"AccessKey": "musify_access_key",
"SecretAccessKey": "musify_secret_key",
"ForcePathStyle": true,
"UseHttp": true
},
+21 -15
View File
@@ -5,8 +5,10 @@
#
# 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.
# Musify has its own credentials on every shared service (role, vhost, S3
# identity, Zitadel service account), provisioned by Infrastructure. Every
# value marked "= Infrastructure's MUSIFY_*" below has to be the same as that
# setting in Infrastructure's .env.
DOTNET_ENVIRONMENT=Development
@@ -21,17 +23,18 @@ PUBLIC_STREAM_URL=http://localhost:8081
PUBLIC_AUTH_URL=http://host.docker.internal:8080
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.
# Musify's own role, owner of musify_db only.
POSTGRES_HOST=host.docker.internal
POSTGRES_USER=postgres
POSTGRES_PASSWORD=postgres
POSTGRES_USER=musify
# = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=musify
POSTGRES_DB=musify_db
# --- 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
# Folder with Musify's own Zitadel PAT (a service account that owns the
# Musify project only), written by Infrastructure's `provision`. Adjust if
# your Infrastructure checkout lives somewhere else relative to this one.
ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
# Allows the OIDC apps this project creates to use plain-http redirect URIs
# (dev only).
ZITADEL_DEV_MODE=true
@@ -43,16 +46,19 @@ WEB_CLIENT_ID=
WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) --------------------------------
# Must match Infrastructure's .env.
# Musify's own user, only on its own vhost.
RABBITMQ_HOST=host.docker.internal
RABBITMQ_DEFAULT_USER=guest
RABBITMQ_DEFAULT_PASS=guest
RABBITMQ_VHOST=musify
RABBITMQ_USER=musify
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=musify
# --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
# S3_ACCESS_KEY / S3_SECRET_KEY must match Infrastructure's .env.
# Musify's own identity, limited to S3_BUCKET.
SEAWEEDFS_HOST=host.docker.internal
S3_ACCESS_KEY=admin_access_key
S3_SECRET_KEY=admin_secret_key
# = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_ACCESS_KEY=musify_access_key
S3_SECRET_KEY=musify_secret_key
S3_BUCKET=webapi-storage
S3_PUBLIC_URL=http://host.docker.internal:8333
S3_INTERNAL_URL=http://host.docker.internal:8333
+10 -5
View File
@@ -28,14 +28,16 @@ PUBLIC_AUTH_URL=https://auth.example.com
S3_PUBLIC_URL=https://s3.example.com
# --- PostgreSQL (Infrastructure's shared instance, Musify's own database) ---
# POSTGRES_USER / POSTGRES_PASSWORD must match Infrastructure's .env.prod.
# Musify's own role, owner of musify_db only.
POSTGRES_HOST=postgres
POSTGRES_USER=postgres
POSTGRES_USER=musify
# = Infrastructure's MUSIFY_DB_PASSWORD
POSTGRES_PASSWORD=CHANGE_ME
POSTGRES_DB=musify_db
# --- Zitadel (OIDC, shared instance) -----------------------------------------
ZITADEL_ADMIN_PAT_DIR=../../../Infrastructure/zitadel/.output
# Folder with Musify's own Zitadel PAT, written by Infrastructure's `provision`.
ZITADEL_PAT_DIR=../../../Infrastructure/zitadel/.output/musify
ZITADEL_DEV_MODE=false
# OIDC clients: the zitadel-init one-shot service (see compose.yml) fills
@@ -46,11 +48,14 @@ WEB_CLIENT_SECRET=
# --- RabbitMQ (shared instance, MassTransit) --------------------------------
RABBITMQ_HOST=rabbitmq
RABBITMQ_DEFAULT_USER=musify
RABBITMQ_DEFAULT_PASS=CHANGE_ME
RABBITMQ_VHOST=musify
RABBITMQ_USER=musify
# = Infrastructure's MUSIFY_RABBITMQ_PASSWORD
RABBITMQ_PASSWORD=CHANGE_ME
# --- SeaweedFS (S3, shared instance, Musify's own bucket) --------------------
SEAWEEDFS_HOST=seaweedfs
# = Infrastructure's MUSIFY_S3_ACCESS_KEY / MUSIFY_S3_SECRET_KEY
S3_ACCESS_KEY=CHANGE_ME
S3_SECRET_KEY=CHANGE_ME
S3_BUCKET=webapi-storage
+11 -7
View File
@@ -68,13 +68,17 @@ dotnet run --project backend/Hosts/Musify.Worker # background
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 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.
When running the apps from the IDE, neither the .NET hosts nor `web-player`
look at `deploy/.env`. The hosts' `appsettings.Development.json` already
point at Infrastructure's dev stack with Musify's own dev credentials (the
defaults of its `MUSIFY_*` settings) and at the shared Zitadel. Only the
OIDC client ids are per-machine: copy the values `zitadel-init` wrote once,
`AUTH_CLIENT_ID` into both `Authentication:ClientId` and
`Authentication:AudienceAddress` (`dotnet user-secrets set <key> <value>
--project backend/Hosts/Musify.Api`), `WEB_CLIENT_ID` and
`WEB_CLIENT_SECRET` into `web-player/.env`, and `NATIVE_CLIENT_ID` into
`mobile/gradle.properties`. They stay valid until Infrastructure's Zitadel
volume is wiped.
### Ports
+46 -37
View File
@@ -26,9 +26,10 @@
# already do.
x-backend-env: &backend-env
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}
# Musify's own vhost and user on the shared broker (the path is the vhost).
MassTransit__Address: "rabbitmq://${RABBITMQ_HOST}:5672/${RABBITMQ_VHOST}"
MassTransit__Username: ${RABBITMQ_USER}
MassTransit__Password: ${RABBITMQ_PASSWORD}
# The public S3 URL, not the internal one: the same address is used to sign
# the presigned upload/download URLs the browser has to be able to reach.
InfrastructureStorage__Address: ${S3_PUBLIC_URL}
@@ -135,7 +136,7 @@ services:
PUBLIC_WEB_URL: ${PUBLIC_WEB_URL}
ZITADEL_DEV_MODE: ${ZITADEL_DEV_MODE:-false}
volumes:
- ${ZITADEL_ADMIN_PAT_DIR}:/output:ro
- ${ZITADEL_PAT_DIR}:/output:ro
entrypoint: ["/bin/sh", "-c"]
command:
- |
@@ -163,13 +164,15 @@ services:
fi
}
echo "==> waiting for the Zitadel service-account PAT"
# Musify's own service account, owner of the Musify project only
# (created by Infrastructure's `provision`, see its README).
echo "==> waiting for Musify's Zitadel PAT"
deadline=$$(( $$(date +%s) + 180 ))
while [ ! -f /output/admin-sa.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "PAT not found under /output. Is Infrastructure's Zitadel running? Check 'docker logs infra-zitadel'." >&2; exit 1; }
while [ ! -s /output/zitadel.pat ]; do
[ "$$(date +%s)" -lt "$$deadline" ] || { echo "no zitadel.pat in ZITADEL_PAT_DIR. Is Infrastructure up, and does ZITADEL_PAT_DIR point at its zitadel/.output/musify? Check 'docker logs infra-provision'." >&2; exit 1; }
sleep 3
done
PAT=$$(tr -d '\r\n' </output/admin-sa.pat)
PAT=$$(tr -d '\r\n' </output/zitadel.pat)
MGMT="$$PUBLIC_AUTH_URL/management/v1"
echo "==> waiting for Zitadel to answer at $$PUBLIC_AUTH_URL"
@@ -188,21 +191,13 @@ services:
}
name_query() { printf '{"queries":[{"nameQuery":{"name":"%s","method":"TEXT_QUERY_METHOD_EQUALS"}}]}' "$$1"; }
# `latest` images require the standalone Login UI v2 container, which
# this stack does not run; opting out keeps the built-in /ui/login.
curl -fsS -X PUT "$$PUBLIC_AUTH_URL/v2/features/instance" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" \
-d '{"loginV2":{"required":false}}' >/dev/null 2>&1 || echo "warning: could not disable Login UI v2"
echo "==> project"
PROJECT_NAME=Musify
# Created by Infrastructure's `provision`: this account can't create
# projects, only manage this one.
PROJECT_ID=$$(api_call POST /projects/_search "$$(name_query "$$PROJECT_NAME")" | jq -r --arg n "$$PROJECT_NAME" '.result // [] | map(select(.name==$$n)) | first | .id // empty')
if [ -z "$$PROJECT_ID" ]; then
PROJECT_ID=$$(api_call POST /projects "$$(printf '{"name":"%s"}' "$$PROJECT_NAME")" | jq -r '.id')
echo " created (id $$PROJECT_ID)"
else
echo " reused (id $$PROJECT_ID)"
fi
[ -n "$$PROJECT_ID" ] || { echo "Zitadel project $$PROJECT_NAME not found. Is musify in Infrastructure's x-projects?" >&2; exit 1; }
echo " found (id $$PROJECT_ID)"
find_app() { api_call POST "/projects/$$PROJECT_ID/apps/_search" "$$(name_query "$$1")" | jq -r --arg n "$$1" '.result // [] | map(select(.name==$$n)) | first | .id // empty'; }
@@ -265,27 +260,38 @@ services:
fi
echo "==> native app (KMP client: desktop loopback + mobile custom scheme)"
# Loopback (desktop) and a reverse-domain custom scheme (Android/iOS,
# RFC 8252 §7.1) are both valid for a NATIVE app without devMode, so
# dev and prod share the same config. Unlike the other apps, an
# existing one is also updated, so a change here reaches instances
# created before it.
NATIVE_APP_NAME="Musify Native"
NATIVE_CONFIG='{
"redirectUris": ["http://127.0.0.1:17453/callback", "cat.ikerdemo.musify:/callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "cat.ikerdemo.musify:/logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"devMode": false,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true
}'
NATIVE_APP_ID=$$(find_app "$$NATIVE_APP_NAME")
if [ -n "$$NATIVE_APP_ID" ]; then
NATIVE_CLIENT_ID=$$(api_call GET "/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID" | jq -r '.app.oidcConfig.clientId')
echo " reused (clientId $$NATIVE_CLIENT_ID)"
# Zitadel answers 400 "No changes" when the config already matches.
RESULT=$$(curl -sS -X PUT "$$MGMT/projects/$$PROJECT_ID/apps/$$NATIVE_APP_ID/oidc_config" \
-H "Authorization: Bearer $$PAT" -H "Content-Type: application/json" -d "$$NATIVE_CONFIG")
case "$$RESULT" in
*'"details"'*'"changeDate"'*) echo " reused (clientId $$NATIVE_CLIENT_ID, config updated)" ;;
*COMMAND-1m88i*) echo " reused (clientId $$NATIVE_CLIENT_ID)" ;;
*) echo "could not update the native app config: $$RESULT" >&2; exit 1 ;;
esac
else
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" '{
"name": "Musify Native",
"redirectUris": ["http://127.0.0.1:17453/callback", "musify://callback"],
"postLogoutRedirectUris": ["http://127.0.0.1:17453/logout", "musify://logout"],
"responseTypes": ["OIDC_RESPONSE_TYPE_CODE"],
"grantTypes": ["OIDC_GRANT_TYPE_AUTHORIZATION_CODE", "OIDC_GRANT_TYPE_REFRESH_TOKEN"],
"appType": "OIDC_APP_TYPE_NATIVE",
"authMethodType": "OIDC_AUTH_METHOD_TYPE_NONE",
"version": "OIDC_VERSION_1_0",
"devMode": true,
"accessTokenType": "OIDC_TOKEN_TYPE_JWT",
"accessTokenRoleAssertion": true,
"idTokenRoleAssertion": true,
"idTokenUserinfoAssertion": true
}' | jq -r '.clientId')
NATIVE_CLIENT_ID=$$(api_call POST "/projects/$$PROJECT_ID/apps/oidc" "$$(echo "$$NATIVE_CONFIG" | jq --arg name "$$NATIVE_APP_NAME" '. + {name: $$name, version: "OIDC_VERSION_1_0"}')" | jq -r '.clientId')
echo " created (clientId $$NATIVE_CLIENT_ID)"
fi
@@ -393,7 +399,10 @@ services:
AUTH_POST_LOGOUT_URI: "${PUBLIC_WEB_URL}/"
AUTH_ALLOW_INSECURE_HTTP: ${AUTH_ALLOW_INSECURE_HTTP}
SESSION_SECRET: ${SESSION_SECRET}
API_BASE_URL: http://api:5111
# The container name, not the `api` service name: in prod every project
# shares infra-net, where a generic name can resolve to another
# project's container.
API_BASE_URL: http://musify-api:5111
# Presigned PUTs are signed for the public S3 host; the container sends
# them straight to SeaweedFS instead of going back out through the edge.
S3_UPLOAD_ENDPOINT: ${S3_INTERNAL_URL}
+3 -3
View File
@@ -54,7 +54,7 @@ al patrón que ya usa Scalar para probar la API — ver `deploy/compose.yml` (`z
Redirect URIs registrados:
- Desktop: `http://127.0.0.1:17453/callback` (RFC 8252, loopback con puerto fijo).
- Android / iOS: `musify://callback` (custom scheme).
- Android / iOS: `cat.ikerdemo.musify:/callback` (custom scheme con dominio invertido, RFC 8252 §7.1).
`mobile/gradle.properties` controla a qué backend apunta Android/Desktop:
@@ -125,13 +125,13 @@ Kotlin/Native (`iosMain/`) sigue las APIs estándar de interop (`AVFoundation`,
1. Genera un proyecto Xcode con el asistente "Kotlin Multiplatform" de Android Studio, o crea uno vacío
e importa `composeApp` como framework (`./gradlew :composeApp:embedAndSignAppleFrameworkForXcode`).
2. Registra el scheme `musify` en `Info.plist`:
2. Registra el scheme `cat.ikerdemo.musify` en `Info.plist`:
```xml
<key>CFBundleURLTypes</key>
<array>
<dict>
<key>CFBundleURLSchemes</key>
<array><string>musify</string></array>
<array><string>cat.ikerdemo.musify</string></array>
</dict>
</array>
```
@@ -18,7 +18,9 @@
</intent-filter>
</activity>
<!-- Catches the `musify://callback` redirect Zitadel sends after login/register.
<!-- Catches the `cat.ikerdemo.musify:/callback` redirect Zitadel sends after
login/register. The URI has no host, so the filter matches on the scheme
alone.
See BrowserAuthLauncher.android.kt and AppConfig.nativeRedirectUri. -->
<activity
android:name=".auth.AuthCallbackActivity"
@@ -29,7 +31,7 @@
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="musify" android:host="callback" />
<data android:scheme="cat.ikerdemo.musify" />
</intent-filter>
</activity>
</application>
@@ -9,5 +9,5 @@ actual object AppConfig {
// Matches the AuthCallbackActivity intent-filter in AndroidManifest.xml and
// the redirect URI registered for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback"
actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
}
@@ -5,7 +5,7 @@ import android.content.Intent
import android.os.Bundle
/**
* Declared in AndroidManifest.xml with an intent-filter for `musify://callback`.
* Declared in AndroidManifest.xml with an intent-filter for `cat.ikerdemo.musify:/callback`.
* Chrome Custom Tabs redirects here once Zitadel finishes the login/register
* flow; this activity just forwards the URL to [AndroidAuthBridge] and closes.
*/
@@ -9,7 +9,8 @@ package com.musify.app
* `nativeRedirectUri` is the OAuth redirect registered for the "Musify
* Native" Zitadel app (see deploy/compose.yml, zitadel-init): a fixed
* loopback port on Desktop (RFC 8252 native-app loopback flow), and the
* `musify://callback` custom scheme on Android/iOS.
* `cat.ikerdemo.musify:/callback` reverse-domain custom scheme on Android/iOS
* (RFC 8252 §7.1: based on a domain we own, so no other app plausibly claims it).
*/
expect object AppConfig {
val apiBaseUrl: String
@@ -12,7 +12,7 @@ package com.musify.app.auth
* - Desktop: a one-shot loopback HTTP server on the fixed port registered in
* Zitadel (RFC 8252 §7.3), opened via the OS default browser.
* - Android: Chrome Custom Tabs; the redirect is caught by
* [AuthCallbackActivity] via the `musify://callback` intent filter.
* [AuthCallbackActivity] via the `cat.ikerdemo.musify:/callback` intent filter.
* - iOS: `ASWebAuthenticationSession`, which owns the whole round trip.
*/
expect class BrowserAuthLauncher() {
@@ -12,5 +12,5 @@ actual object AppConfig {
// Matches CFBundleURLTypes in Info.plist and the redirect URI registered
// for the "Musify Native" app in Zitadel.
actual val nativeRedirectUri: String = "musify://callback"
actual val nativeRedirectUri: String = "cat.ikerdemo.musify:/callback"
}
@@ -23,14 +23,15 @@ private class PresentationContextProvider : NSObject(), ASWebAuthenticationPrese
/**
* `ASWebAuthenticationSession` owns the whole round trip: it opens a system
* browser sheet at [authorizeUrl] (Zitadel's hosted login, with its
* "Regístrate" link) and intercepts the `musify://` redirect itself, no app
* "Regístrate" link) and intercepts the `cat.ikerdemo.musify:` redirect itself, no app
* delegate / `onOpenURL` wiring required.
*/
@OptIn(ExperimentalForeignApi::class)
actual class BrowserAuthLauncher actual constructor() {
actual suspend fun launch(authorizeUrl: String, redirectUri: String): String =
suspendCancellableCoroutine { continuation ->
val scheme = redirectUri.substringBefore("://")
// `scheme:/path` (RFC 8252 private-use scheme): everything before the first ':'.
val scheme = redirectUri.substringBefore(":")
val contextProvider = PresentationContextProvider()
val session = ASWebAuthenticationSession(
+2 -3
View File
@@ -11,9 +11,8 @@ ZITADEL_CLIENT_SECRET=your-web-app-client-secret
AUTH_REDIRECT_URI=http://localhost:5173/auth/callback
AUTH_POST_LOGOUT_URI=http://localhost:5173/
# Secret used to derive the session cookie encryption key. Required, at least
# 32 characters (e.g. `openssl rand -base64 48`).
SESSION_SECRET=change-me-to-a-long-random-string-of-32-chars-or-more
# Secret used to derive the session cookie encryption key (any long random string)
SESSION_SECRET=change-me-to-a-long-random-string
# Backend API base URL
API_BASE_URL=http://localhost:5111
+1 -1
View File
@@ -50,7 +50,7 @@ The design system lives in `src/routes/layout.css` (`@theme` tokens plus `@utili
- **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`, with tracking through `tracking-display`/`tracking-eyebrow` rather than a loose `tracking-[…]`.
- **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.
+65 -289
View File
@@ -36,8 +36,41 @@
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"vite": "^8.0.16",
"vitest": "^5.0.1"
"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": {
@@ -310,9 +343,9 @@
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"version": "1.5.5",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz",
"integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==",
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
@@ -1119,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",
@@ -1171,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",
@@ -1409,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,
@@ -1513,30 +1565,12 @@
"tslib": "^2.4.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/cookie": {
"version": "0.6.0",
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
"license": "MIT"
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/esrecurse": {
"version": "4.3.1",
"resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz",
@@ -1563,7 +1597,6 @@
"integrity": "sha512-EANqOCF9QFyra+4pfxUcX9STKJpCLjMbObVzljIJomAWSnuSIEAvyzEU53GaajbXJEgdh0iEcPL+DGvpUd4k1Q==",
"devOptional": true,
"license": "MIT",
"peer": true,
"dependencies": {
"undici-types": "~6.21.0"
}
@@ -1625,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",
@@ -1733,7 +1765,6 @@
"integrity": "sha512-qjhfuTfLXjA4IOzXvz0rTjT01BqEiIgPoUeMwiEjnaHKJMTNo8rH5pYW1a2L/0Dnux2fPC85AeyJoWaGa8WxTA==",
"devOptional": true,
"license": "MIT",
"peer": true,
"engines": {
"node": "^18.18.0 || ^20.9.0 || >=21.1.0"
},
@@ -1812,70 +1843,11 @@
"url": "https://opencollective.com/typescript-eslint"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.1.tgz",
"integrity": "sha512-6K1DoBNAPGvuOcSsGA4D6x+5zEEff/KmOOP3uetT2TrGpVfI+HRHRnJJfKi5ib/g1vx8IYHQD8s0pbJz8WQI7Q==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.1",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/mocker/node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/@vitest/mocker/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/@vitest/spy": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.1.tgz",
"integrity": "sha512-rbto/mF/SGERxEgYOek7Xm6B9b+y+mVoo+f4b2LymYO8zM1b7uB5nHuhVMTP2hxdzgxvGiZYGxGIaMvL5y180Q==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/acorn": {
"version": "8.17.0",
"resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz",
"integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==",
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1919,16 +1891,6 @@
"node": ">= 0.4"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/axobject-query": {
"version": "4.1.0",
"resolved": "https://registry.npmjs.org/axobject-query/-/axobject-query-4.1.0.tgz",
@@ -1961,16 +1923,6 @@
"node": "18 || 20 || >=22"
}
},
"node_modules/chai": {
"version": "6.2.2",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/chokidar": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-4.0.3.tgz",
@@ -2111,13 +2063,6 @@
"node": ">= 0.4"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/escape-string-regexp": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz",
@@ -2137,7 +2082,6 @@
"integrity": "sha512-GVTD7s1vdIl6UYvAfriOPeY1Df8LIZjfofLvHwde+erDHGGuHyuM6xoxRxmHiebhYuD2p1vN4wWh0XzPARSGDQ==",
"dev": true,
"license": "MIT",
"peer": true,
"workspaces": [
"packages/*"
],
@@ -2379,16 +2323,6 @@
"node": ">=0.10.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fast-deep-equal": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@@ -3073,9 +3007,9 @@
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"version": "2.1.3",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz",
"integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==",
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
@@ -3196,9 +3130,9 @@
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"version": "4.0.5",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.5.tgz",
"integrity": "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A==",
"license": "MIT",
"engines": {
"node": ">=12"
@@ -3226,7 +3160,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.12",
"picocolors": "^1.1.1",
@@ -3360,7 +3293,6 @@
"integrity": "sha512-/FVl766LpUfB5vXgCYOYa0MeV/441Ia99AeICQIQFTY/Nw0roZwULcXpku5i1/m5kt/baz+s4Zogspd839HSMg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"prettier": "bin/prettier.cjs"
},
@@ -3377,7 +3309,6 @@
"integrity": "sha512-wXvbXMjSvb4C9ENWTHXyd+ihakKCsJ6rJhLP6/8HFNj4GkZr48jqL9PoKsl2sk7SyCZRTnJ7O2TTowUpOxP/KA==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=20"
},
@@ -3548,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"
},
@@ -3643,13 +3573,6 @@
"node": ">=8"
}
},
"node_modules/siginfo": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz",
"integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==",
"dev": true,
"license": "ISC"
},
"node_modules/sirv": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/sirv/-/sirv-3.0.2.tgz",
@@ -3673,20 +3596,6 @@
"node": ">=0.10.0"
}
},
"node_modules/stackback": {
"version": "0.0.2",
"resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz",
"integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==",
"dev": true,
"license": "MIT"
},
"node_modules/std-env": {
"version": "4.2.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.2.0.tgz",
"integrity": "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw==",
"dev": true,
"license": "MIT"
},
"node_modules/supports-preserve-symlinks-flag": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/supports-preserve-symlinks-flag/-/supports-preserve-symlinks-flag-1.0.0.tgz",
@@ -3704,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",
@@ -3852,26 +3760,6 @@
"url": "https://opencollective.com/webpack"
}
},
"node_modules/tinybench": {
"version": "6.1.4",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.1.4.tgz",
"integrity": "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.0",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.0.tgz",
"integrity": "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
@@ -3936,7 +3824,6 @@
"integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==",
"devOptional": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -3998,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",
@@ -4090,99 +3976,6 @@
}
}
},
"node_modules/vitest": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.1.tgz",
"integrity": "sha512-iA95lQbKEkvrtTkdAgnWbXfbipWiiWe/hDl2P5tMi6WFwD76G0NxXAGp/M9EOcYupeGJRr6wppMc7CoA41TQjg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.1",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "6.1.4",
"tinyexec": "1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "^2.3.0"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.1",
"@vitest/browser-preview": "5.0.1",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.1",
"@vitest/coverage-v8": "5.0.1",
"@vitest/ui": "5.0.1",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/vitest/node_modules/magic-string": {
"version": "1.4.2",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.2.tgz",
"integrity": "sha512-vG+rjFRj1PqdIBozIxAGMjPlOhaVe+GXpbttY/iSK7rGcJRMlwNJO7dcUwmUqkymsFLJiNGI06t4D7Fr7yRC9g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",
@@ -4199,23 +3992,6 @@
"node": ">= 8"
}
},
"node_modules/why-is-node-running": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz",
"integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==",
"dev": true,
"license": "MIT",
"dependencies": {
"siginfo": "^2.0.0",
"stackback": "0.0.2"
},
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=8"
}
},
"node_modules/word-wrap": {
"version": "1.2.5",
"resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz",
+2 -5
View File
@@ -11,11 +11,9 @@
"check": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json",
"check:watch": "svelte-kit sync && svelte-check --tsconfig ./tsconfig.json --watch",
"gen:api": "npx -y openapi-typescript@7 http://localhost:5111/openapi/v1.json -o src/lib/api/schema.d.ts",
"test": "vitest run",
"test:watch": "vitest",
"format": "prettier --write .",
"lint:tokens": "node scripts/check-tokens.mjs",
"lint": "prettier --check . && eslint . && npm run lint:tokens && npm run test"
"lint": "prettier --check . && eslint . && npm run lint:tokens"
},
"devDependencies": {
"@eslint/js": "^10.0.1",
@@ -35,8 +33,7 @@
"tailwindcss": "^4.3.0",
"typescript": "^6.0.3",
"typescript-eslint": "^8.64.0",
"vite": "^8.0.16",
"vitest": "^5.0.1"
"vite": "^8.0.16"
},
"dependencies": {
"@fontsource-variable/bricolage-grotesque": "^5.3.0",
-1
View File
@@ -940,7 +940,6 @@ export interface components {
smallImageKeyName: null | string;
mediumImageKeyName: null | string;
largeImageKeyName: null | string;
ownerUserId: string;
visibility: components["schemas"]["PlaylistVisibility"];
/** Format: date-time */
createdAt: string;
@@ -9,8 +9,6 @@
import Plus from '@lucide/svelte/icons/plus';
import Logo from '../ui/primitives/Logo.svelte';
const SIDEBAR_PLAYLISTS_LIMIT = 8;
interface SidebarPlaylist {
id: string;
name: string;
@@ -21,14 +19,11 @@
interface Props {
user: SessionUser | null;
playlists: SidebarPlaylist[];
playlistCount: number;
}
let { user, playlists, playlistCount }: Props = $props();
let { user, playlists }: Props = $props();
const visiblePlaylists = $derived(playlists.slice(0, SIDEBAR_PLAYLISTS_LIMIT));
const navLinks = $derived(appNavLinks(user, { playlists: playlistCount, liked: liked.count }));
const navLinks = $derived(appNavLinks(user, { playlists: playlists.length, liked: liked.count }));
function isActive(href: string) {
return isSectionActive(href, page.url.pathname, page.data.section);
@@ -66,10 +61,10 @@
<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-fg-2">Tus playlists</span>
{#if visiblePlaylists.length > 0}
{#if playlists.length > 0}
<IconButton
label="Crear playlist"
plain
tone="plain"
size="xs"
onclick={() => createPlaylistModal.show()}
>
@@ -79,7 +74,7 @@
</div>
<div class="flex min-h-0 flex-1 flex-col gap-px overflow-y-auto pb-icon-md">
{#each visiblePlaylists as playlist (playlist.id)}
{#each playlists as playlist (playlist.id)}
{@const active = page.url.pathname === `/playlists/${playlist.id}`}
<ListRow
href="/playlists/{playlist.id}"
@@ -96,7 +91,7 @@
<button
type="button"
onclick={() => createPlaylistModal.show()}
class="flex items-center gap-2 rounded-control px-3 py-2 text-xs text-fg-2 transition-colors hover:bg-hover"
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" />
Crear tu primera lista
@@ -54,7 +54,14 @@
</script>
<header class="hidden items-center gap-3.5 px-6.5 pt-4 pb-3 sm:flex">
<IconButton label="Atrás" shape="round" surface size="xs" onclick={() => history.back()}>
<IconButton
label="Atrás"
shape="round"
tone="subtle"
surface
size="xs"
onclick={() => history.back()}
>
<ChevronLeft class="size-icon-sm" />
</IconButton>
@@ -84,7 +91,9 @@
</label>
</form>
<div class="ml-auto shrink-0">
<div class="flex-1"></div>
<div class="shrink-0">
{#if user}
<AccountMenu {user} {accountUrl}>
{#snippet trigger({ toggle, open })}
@@ -12,7 +12,7 @@
import ListMusic from '@lucide/svelte/icons/list-music';
function seek(value: number) {
if (player.duration > 0) player.seekFraction(value / player.duration);
if (player.current.duration > 0) player.seekFraction(value / player.current.duration);
}
</script>
@@ -39,20 +39,20 @@
</span>
<Slider
value={player.progress}
max={player.duration}
max={player.current.duration}
label="Barra de progreso"
oninput={seek}
variant="seek"
/>
<span class="min-w-8 text-xs text-fg-2 tabular-nums">
{fmtTime(player.duration)}
{fmtTime(player.current.duration)}
</span>
</div>
</div>
<div class="flex w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5">
<div class="hidden w-(--mf-player-side-w) shrink-0 items-center justify-end gap-3.5 sm:flex">
<IconButton
label={player.muted ? 'Activar sonido' : 'Silenciar'}
plain
tone="plain"
size="xs"
onclick={() => player.toggleMute()}
>
@@ -73,7 +73,7 @@
/>
<IconButton
label="Cola de reproducción"
plain
tone="plain"
size="xs"
pressed={queuePanel.open}
onclick={() => queuePanel.toggle()}
@@ -48,12 +48,12 @@
>
<div class="mb-5 flex items-center justify-between">
<h2 class="font-display text-base font-medium tracking-tight text-fg">En cola</h2>
<IconButton label="Cerrar cola" plain size="xs" onclick={() => queuePanel.close()}>
<IconButton label="Cerrar cola" tone="plain" size="xs" onclick={() => queuePanel.close()}>
<X class="size-icon-md" />
</IconButton>
</div>
{#if player.current}
{#if player.current.id}
<p class="mb-3 text-eyebrow text-fg-2">Reproduciendo</p>
<div class="mb-6 surface-active rounded-control p-2.5">
<MediaIdentity
@@ -13,20 +13,27 @@
let { compact = false }: Props = $props();
const current = $derived(player.current);
const isLiked = $derived(current !== null && liked.isLiked(current.id));
const isLiked = $derived(player.current.id !== '' && liked.isLiked(player.current.id));
function toggleLike() {
if (current) liked.toggle(current);
if (!player.current.id) return;
liked.toggle({
id: player.current.id,
title: player.current.title,
artist: player.current.artist,
explicit: player.current.explicit,
ownerUserId: player.current.ownerUserId,
duration: player.current.duration
});
}
</script>
<div class="flex min-w-0 items-center gap-2.5 md:gap-3">
{#if current}
{#if player.current.id}
<Artwork
trackIds={[current.id]}
trackIds={[player.current.id]}
size="sm"
alt={current.title}
alt={player.current.title}
class="shrink-0 md:size-cover-md"
/>
{:else}
@@ -34,21 +41,21 @@
{/if}
<div class="min-w-0">
<div class="flex min-w-0 items-center gap-1.5">
{#if current?.explicit}
{#if player.current.explicit}
<ExplicitBadge />
{/if}
<span class="truncate text-sm text-fg">{current?.title ?? ''}</span>
<span class="truncate text-sm text-fg">{player.current.title}</span>
</div>
<ArtistLink
name={current?.artist || '—'}
ownerUserId={current?.ownerUserId}
name={player.current.artist || '—'}
ownerUserId={player.current.ownerUserId}
class="mt-0.5 text-xs text-fg-2"
/>
</div>
{#if !compact}
<IconButton
label={isLiked ? 'Quitar de Me gusta' : 'Añadir a Me gusta'}
plain
tone="plain"
size="md"
pressed={isLiked}
onclick={toggleLike}
@@ -5,7 +5,7 @@
import Field from '../primitives/Field.svelte';
import Input from '../primitives/Input.svelte';
import Textarea from '../primitives/Textarea.svelte';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface Props {
action: string;
@@ -58,13 +58,10 @@
>
{#snippet fields()}
<Field label="Título" for="album-title">
{#snippet hint()}
<span class="ml-auto tabular-nums">{title.length}/{LIMITS.albumTitle}</span>
{/snippet}
<Input
id="album-title"
name="title"
maxlength={LIMITS.albumTitle}
maxlength={200}
required
bind:value={title}
placeholder={titlePlaceholder}
@@ -88,7 +85,7 @@
<Textarea
id="album-description"
name="description"
maxlength={LIMITS.albumDescription}
maxlength={256}
bind:value={description}
placeholder="¿De qué va este álbum?"
/>
@@ -81,8 +81,10 @@
<div class="flex items-center justify-between gap-4">
{#if helperText}
<p class="text-sm text-fg-2">{helperText}</p>
{:else}
<span></span>
{/if}
<div class="ml-auto flex shrink-0 items-center gap-3">
<div class="flex shrink-0 items-center gap-3">
<Button type="button" variant="secondary" onclick={onCancel}>Cancelar</Button>
<Button type="submit" disabled={submitting || !canSubmit}>
{submitting ? submittingLabel : submitLabel}
@@ -37,16 +37,9 @@
function onInput(event: Event) {
const file = (event.currentTarget as HTMLInputElement).files?.[0];
if (!file) return;
releasePreview();
preview = URL.createObjectURL(file);
onselect?.(file);
}
function releasePreview() {
if (preview !== '') URL.revokeObjectURL(preview);
}
$effect(() => releasePreview);
</script>
<label
@@ -7,7 +7,8 @@
import Textarea from '../primitives/Textarea.svelte';
import SegmentedControl from '../primitives/SegmentedControl.svelte';
import { LIMITS } from '$lib/validation';
const NAME_MAX_LENGTH = 60;
const DESCRIPTION_MAX_LENGTH = 300;
interface Props {
action: string;
@@ -60,12 +61,12 @@
<input type="hidden" name="visibility" value={visibility} />
<Field label="Nombre" for="playlist-name">
{#snippet hint()}
<span class="ml-auto tabular-nums">{name.length}/{LIMITS.playlistName}</span>
<span class="ml-auto tabular-nums">{name.length}/{NAME_MAX_LENGTH}</span>
{/snippet}
<Input
id="playlist-name"
name="name"
maxlength={LIMITS.playlistName}
maxlength={NAME_MAX_LENGTH}
required
bind:value={name}
placeholder={namePlaceholder}
@@ -76,7 +77,7 @@
<Textarea
id="playlist-description"
name="description"
maxlength={LIMITS.playlistDescription}
maxlength={DESCRIPTION_MAX_LENGTH}
bind:value={description}
placeholder="Para qué sirve esta playlist, cuándo la escuchas…"
class="flex-1"
@@ -13,7 +13,7 @@
<a
href="/user/{ownerUserId}"
onclick={(e) => e.stopPropagation()}
class="block max-w-full truncate hover:underline {klass}"
class="block max-w-full truncate hover:text-fg-2 hover:underline {klass}"
>
{name}
</a>
@@ -9,6 +9,7 @@
size: Size;
shape?: 'auto' | 'round';
imageSize?: 'small' | 'medium' | 'large';
fallback?: 'music' | 'gradient' | 'none';
alt?: string;
class?: string;
children?: Snippet;
@@ -20,6 +21,7 @@
size,
shape = 'auto',
imageSize,
fallback = 'music',
alt = '',
class: klass = '',
children
@@ -114,7 +116,7 @@
onerror={() => (singleFailed = true)}
class="h-full w-full object-cover"
/>
{:else}
{:else if fallback === 'music'}
<div class="grid h-full w-full place-items-center bg-surface">
<svg viewBox="0 0 24 24" fill="currentColor" class="h-2/5 w-2/5 text-fg-2">
<path
@@ -122,6 +124,10 @@
/>
</svg>
</div>
{:else if fallback === 'gradient'}
<div class="h-full w-full bg-(image:--mf-cover-grad)"></div>
{:else}
<div class="h-full w-full bg-surface"></div>
{/if}
{@render children?.()}
</div>
@@ -1,11 +1,19 @@
<script lang="ts">
interface Props {
size?: number;
barWidth?: number;
paused?: boolean;
overlay?: boolean;
class?: string;
}
let { paused = false, overlay = false, class: klass = '' }: Props = $props();
let {
size = 13,
barWidth = 2,
paused = false,
overlay = false,
class: klass = ''
}: Props = $props();
const delays = [0, 0.3, 0.6];
</script>
@@ -22,11 +30,11 @@
{/each}
</div>
{:else}
<div class="flex h-3.5 items-end justify-center gap-0.5 {klass}">
<div class="flex items-end justify-center gap-0.5 {klass}" style="height:{size}px">
{#each delays as delay (delay)}
<span
class="h-full w-0.5 rounded-full bg-accent"
style="animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
class="rounded-full bg-accent"
style="width:{barWidth}px; height:100%; animation:equalize .9s ease-in-out {delay}s infinite; animation-play-state:{paused
? 'paused'
: 'running'}"
></span>
@@ -5,11 +5,12 @@
interface Props {
following: boolean;
userId?: string;
invalidateAll?: boolean;
class?: string;
}
let { following: initial, invalidateAll = false, class: klass = '' }: Props = $props();
let { following: initial, userId, invalidateAll = false, class: klass = '' }: Props = $props();
let following = $state(untrack(() => initial));
let submitting = $state(false);
@@ -29,6 +30,9 @@
};
}}
>
{#if userId}
<input type="hidden" name="userId" value={userId} />
{/if}
<Button
type="submit"
size="sm"
@@ -18,6 +18,7 @@
class?: string;
art?: Snippet;
overlay?: Snippet;
badge?: Snippet;
}
let {
@@ -33,7 +34,8 @@
titleClass = '',
class: klass = '',
art,
overlay
overlay,
badge
}: Props = $props();
</script>
@@ -59,6 +61,7 @@
<span class="min-w-0 truncate text-sm {active ? 'text-accent-soft' : 'text-fg'} {titleClass}"
>{title}</span
>
{@render badge?.()}
</div>
<ArtistLink name={artist} {ownerUserId} class="mt-0.5 text-xs text-fg-2" />
</div>
@@ -1,9 +1,18 @@
<script lang="ts" module>
import type { Track } from '$lib/types';
export type TrackListColumn = 'added' | 'uploaded' | 'plays';
export type TrackListTrack = Track & { date?: string | number };
export interface TrackListTrack {
id: string | number;
title: string;
artist?: string | null;
ownerUserId?: string | number | null;
isExplicit?: boolean;
explicit?: boolean;
duration?: number | string;
listensCount?: number | string;
addedAt?: string | number;
uploadedAt?: string | number;
}
const COLUMN_PRESET: Record<TrackListColumn, { label: string; width: string }> = {
added: { label: 'Añadida', width: '124px' },
@@ -99,7 +108,7 @@
</div>
<div class="mt-1 flex flex-col gap-0.5" role="list">
{#each tracks as track, i (track.id)}
{@const active = player.currentId === track.id}
{@const active = player.current.id === track.id}
<div
role="listitem"
@@ -129,6 +138,8 @@
role="button"
tabindex="0"
use:pressable={() => onPlay(i)}
onclick={(event) => event.stopPropagation()}
onkeydown={(event) => event.stopPropagation()}
class="flex min-w-0 flex-1 text-left"
>
<MediaIdentity
@@ -136,7 +147,7 @@
title={track.title}
artist={track.artist}
ownerUserId={track.ownerUserId}
explicit={track.explicit}
explicit={track.isExplicit ?? track.explicit ?? false}
{active}
>
{#snippet overlay()}
@@ -149,18 +160,26 @@
{#each columns as column (column)}
<span class="hidden truncate text-center text-xs text-fg-2 tabular-nums sm:block">
{#if column === 'plays'}
{track.listensCount}
{:else if track.date}
{fmtDate(track.date)}
{Number(track.listensCount ?? 0)}
{:else if column === 'added' && track.addedAt}
{fmtDate(track.addedAt)}
{:else if column === 'uploaded' && track.uploadedAt}
{fmtDate(track.uploadedAt)}
{/if}
</span>
{/each}
<span class="truncate text-center text-xs text-fg-2 tabular-nums">
{fmtTime(track.duration)}
{fmtTime(Number(track.duration))}
</span>
{#if rowAction}
{#if rowAction.action}
<form method="POST" action={rowAction.action} use:enhance>
<form
method="POST"
action={rowAction.action}
use:enhance={() =>
async ({ update }) =>
update()}
>
<input type="hidden" name="trackId" value={track.id} />
<IconButton type="submit" label={rowAction.label} size="xs" revealOnHover>
<rowAction.icon class="size-icon-sm" />
@@ -61,7 +61,7 @@
{/if}
<MenuItem
icon={themeMode.current === 'dark' ? Sun : Moon}
label={themeMode.current === 'dark' ? 'Modo claro' : 'Modo oscuro'}
label={themeMode.current === 'dark' ? 'Modo blanco' : 'Modo oscuro'}
onclick={() => themeMode.toggle()}
/>
<form method="POST" action="/auth/logout" data-sveltekit-reload>
@@ -9,19 +9,15 @@
description: string;
confirmLabel: string;
action: string;
fields?: Record<string, string>;
}
let { open, onClose, title, description, confirmLabel, action, fields = {} }: Props = $props();
let { open, onClose, title, description, confirmLabel, action }: Props = $props();
</script>
<Dialog {open} {onClose} {title} {description}>
{#snippet actions()}
<Button variant="secondary" onclick={onClose}>Cancelar</Button>
<form method="POST" {action}>
{#each Object.entries(fields) as [name, value] (name)}
<input type="hidden" {name} {value} />
{/each}
<Button type="submit" variant="danger">{confirmLabel}</Button>
</form>
{/snippet}
@@ -6,7 +6,7 @@
type?: 'button' | 'submit';
size?: 'xs' | 'sm' | 'md' | 'lg';
shape?: 'square' | 'round';
plain?: boolean;
tone?: 'muted' | 'subtle' | 'plain';
pressed?: boolean;
surface?: boolean;
revealOnHover?: boolean;
@@ -22,7 +22,7 @@
type = 'button',
size = 'sm',
shape = 'square',
plain = false,
tone = 'muted',
pressed,
surface = false,
revealOnHover = false,
@@ -40,12 +40,18 @@
lg: 'size-10'
};
const TEXT: Record<'muted' | 'subtle' | 'plain', string> = {
muted: 'text-fg-2 hover:text-fg',
subtle: 'text-fg-2 hover:text-fg',
plain: 'text-fg-2 hover:text-fg'
};
const background = $derived(
surface ? 'bg-surface-2 hover:bg-surface-hover' : plain ? '' : 'hover:bg-hover'
surface ? 'bg-surface-2 hover:bg-surface-hover' : tone === 'plain' ? '' : 'hover:bg-hover'
);
const classes = $derived(
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : 'text-fg-2 hover:text-fg'} ${background} ${
`grid place-items-center transition focus-visible:ring-2 focus-visible:ring-accent focus-visible:outline-none ${SIZE[size]} ${shape === 'round' ? 'rounded-full' : 'rounded-control'} ${pressed ? 'text-accent' : TEXT[tone]} ${background} ${
revealOnHover ? 'sm:opacity-0 sm:group-hover:opacity-100 sm:focus-visible:opacity-100' : ''
} ${klass}`
);
@@ -26,7 +26,7 @@
class="relative z-(--z-raised) flex-1 px-4 py-1.5 text-center text-xs font-medium transition-colors {value ===
option.value
? 'text-ink'
: 'text-fg-2'}"
: 'text-fg-2 hover:text-fg-2'}"
>
{option.label}
</button>
+3 -7
View File
@@ -1,23 +1,19 @@
export const EXPLORE_PAGE_SIZE = 27;
export const MAX_PAGE_SIZE = 100;
export const SEARCH_DEBOUNCE_MS = 450;
export const SEARCH_MIN_LENGTH = 2;
export const INFINITE_SCROLL_ROOT_MARGIN = '300px';
export const ALBUMS_PAGE_SIZE = 50;
export const ALBUM_TRACKS_PAGE_SIZE = 200;
export const PLAYLIST_TRACKS_PAGE_SIZE = 200;
export const ALBUM_EARLIEST_YEAR = 1877;
export const LIBRARY_PICKER_PAGE_SIZE = 50;
export const LIBRARY_PAGE_SIZE = 50;
export const PLAYLISTS_PAGE_SIZE = 50;
export const PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE = 500;
export const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const COUNT_ONLY_PAGE_SIZE = 1;
export const EXPLORE_ALBUMS_PAGE_SIZE = 10;
export const EXPLORE_ALBUMS_LIMIT = 18;
export const HOME_LATEST_PAGE_SIZE = 10;
export const HOME_MIXES_LIMIT = 6;
export const HOME_SHELF_LIMIT = 6;
export const HOME_CONTINUE_LIMIT = 10;
export const HOME_SPOTLIGHT_TRACKS_LIMIT = 5;
export const HOME_POPULAR_MAX = 8;
export const PLAYLIST_PICKER_PAGE_SIZE = 50;
export const LIKED_TRACKS_PAGE_SIZE = 500;
export const EXPLORE_USERS_LIMIT = 12;
+5 -9
View File
@@ -1,12 +1,8 @@
import type { Paged, Track } from '$lib/types';
import { toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
async function fetchTracks(url: string): Promise<Track[]> {
const res = await fetch(url);
export async function fetchAlbumQueueItems(albumId: string): Promise<QueueItem[]> {
const res = await fetch(`/api/albums/${albumId}/tracks`);
if (!res.ok) return [];
return ((await res.json()) as Paged<Track>).items;
const data = (await res.json()) as { items: ApiTrackLike[] };
return toQueueItems(data.items ?? []);
}
export const fetchAlbumTracks = (albumId: string) => fetchTracks(`/api/albums/${albumId}/tracks`);
export const fetchPlaylistTracks = (playlistId: string) =>
fetchTracks(`/api/playlists/${playlistId}/tracks`);
@@ -1,23 +0,0 @@
import { describe, expect, it } from 'vitest';
import { appendUnique, shuffle } from './collections';
describe('appendUnique', () => {
const keyOf = (item: { id: number }) => item.id;
it('appends only unseen keys', () => {
const result = appendUnique([{ id: 1 }], [{ id: 1 }, { id: 2 }, { id: 2 }, { id: 3 }], keyOf);
expect(result).toEqual([{ id: 1 }, { id: 2 }, { id: 3 }]);
});
it('returns the same array when nothing is new', () => {
const current = [{ id: 1 }];
expect(appendUnique(current, [{ id: 1 }], keyOf)).toBe(current);
});
});
describe('shuffle', () => {
it('keeps the same elements and does not mutate the input', () => {
const input = [1, 2, 3, 4, 5, 6];
const result = shuffle(input);
expect([...result].sort()).toEqual(input);
expect(input).toEqual([1, 2, 3, 4, 5, 6]);
});
});
-28
View File
@@ -1,28 +0,0 @@
import { describe, expect, it } from 'vitest';
import { findConflict, genreHue } from './genres';
describe('genreHue', () => {
it('is stable and within 0-359', () => {
for (const genre of ['rock', 'something-unknown', 'ñandú']) {
const hue = genreHue(genre);
expect(hue).toBe(genreHue(genre));
expect(hue).toBeGreaterThanOrEqual(0);
expect(hue).toBeLessThan(360);
}
});
});
describe('findConflict', () => {
const options = [
{ genre: 'a', incompatibleWith: ['b'] },
{ genre: 'b', incompatibleWith: ['a'] },
{ genre: 'c', incompatibleWith: [] }
];
it('returns the first incompatible pair', () => {
expect(findConflict(['c', 'a', 'b'], options)).toEqual(['a', 'b']);
});
it('returns undefined when compatible or unknown', () => {
expect(findConflict(['a', 'c'], options)).toBeUndefined();
expect(findConflict(['x', 'y'], options)).toBeUndefined();
});
});
+19
View File
@@ -0,0 +1,19 @@
import type { components } from '$lib/api/schema';
import type { ApiTrackLike } from '$lib/player/player.svelte';
export type Mix = components['schemas']['MixApplicationResponse'];
export type MixItem = components['schemas']['MixItemApplicationResponse'];
export function mixItemKey(item: MixItem): string {
return `mf:${item.trackId}`;
}
export function mixItemTrack(item: MixItem): ApiTrackLike {
return {
id: item.trackId,
title: item.title,
artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount
};
}
+15 -8
View File
@@ -1,14 +1,21 @@
import { player } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
import { player, toQueueItems, type ApiTrackLike, type QueueItem } from '$lib/player/player.svelte';
export function mergeRecentlyPlayed(history: Track[], limit: number): Track[] {
export function mergeRecentlyPlayed(history: ApiTrackLike[], limit: number): QueueItem[] {
const seen = new Set<string>();
const merged: Track[] = [];
const merged: QueueItem[] = [];
for (const track of [...player.recentlyPlayed, ...history]) {
if (seen.has(track.id)) continue;
seen.add(track.id);
merged.push(track);
for (const t of player.recentlyPlayed) {
const id = String(t.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({ ...t, id });
}
for (const item of toQueueItems(history)) {
const id = String(item.id);
if (seen.has(id)) continue;
seen.add(id);
merged.push({ ...item, id });
}
return merged.slice(0, limit);
-28
View File
@@ -1,28 +0,0 @@
import { describe, expect, it } from 'vitest';
import { findTopResult } from './search';
const empty = { tracks: [], albums: [], playlists: [], users: [] };
describe('findTopResult', () => {
it('prefers prefix matches by kind: user, track, album, playlist', () => {
const result = findTopResult({
query: 'Ab',
users: [{ name: 'Zed' }, { name: 'abel' }],
tracks: [{ title: 'Abc' }],
albums: [{ title: 'Abd' }],
playlists: [{ name: 'Abe' }]
});
expect(result).toEqual({ kind: 'user', user: { name: 'abel' } });
});
it('matches case-insensitively and ignores surrounding spaces', () => {
const result = findTopResult({ ...empty, query: ' SO ', tracks: [{ title: 'song' }] });
expect(result).toEqual({ kind: 'track', track: { title: 'song' } });
});
it('falls back to the first available result', () => {
const result = findTopResult({ ...empty, query: 'zzz', albums: [{ title: 'Other' }] });
expect(result).toEqual({ kind: 'album', album: { title: 'Other' } });
});
it('returns null without results', () => {
expect(findTopResult({ ...empty, query: 'x' })).toBeNull();
});
});
+41 -12
View File
@@ -1,4 +1,24 @@
import type { LikedTrack, Track } from '$lib/types';
import { toQueueItems, type ApiTrackLike } from './player.svelte';
export interface LikedTrack {
id: string;
title: string;
artist?: string;
explicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
duration?: number | string;
likedAt: number;
}
type LikeToggleInput = {
id: string | number;
title: string;
artist?: string;
explicit?: boolean;
ownerUserId?: string | number | null;
duration?: number | string;
};
class LikedStore {
entries = $state<Record<string, LikedTrack>>({});
@@ -7,23 +27,35 @@ class LikedStore {
count = $derived(Object.keys(this.entries).length);
list = $derived(Object.values(this.entries).sort((a, b) => b.likedAt - a.likedAt));
hydrate(tracks: LikedTrack[]) {
hydrate(tracks: (ApiTrackLike & { createdAt: string; duration: number | string })[]) {
if (this.hydrated) return;
this.hydrated = true;
this.entries = Object.fromEntries(tracks.map((track) => [track.id, track]));
const items = toQueueItems(tracks);
const next: Record<string, LikedTrack> = {};
items.forEach((item, i) => {
const id = String(item.id);
next[id] = {
...item,
id,
duration: tracks[i].duration,
likedAt: new Date(tracks[i].createdAt).getTime()
};
});
this.entries = next;
}
isLiked(id: string): boolean {
return id in this.entries;
isLiked(id: string | number): boolean {
return String(id) in this.entries;
}
async toggle(track: Track) {
const id = track.id;
async toggle(track: LikeToggleInput) {
const id = String(track.id);
const before = this.entries;
const wasLiked = id in before;
const next = { ...before };
if (wasLiked) delete next[id];
else next[id] = { ...track, likedAt: Date.now() };
else next[id] = { ...track, id, likedAt: Date.now() };
this.entries = next;
try {
@@ -34,10 +66,7 @@ class LikedStore {
});
if (!res.ok) throw new Error('Failed to toggle like');
} catch {
const reverted = { ...this.entries };
if (wasLiked) reverted[id] = before[id];
else delete reverted[id];
this.entries = reverted;
this.entries = before;
}
}
}
+192 -74
View File
@@ -1,24 +1,85 @@
import { browser } from '$app/environment';
import { DEFAULT_ACCENT } from '$lib/theme/color';
import { extractAccent } from '$lib/theme/palette';
import { extractAccent, type Accent } from '$lib/theme/palette';
import { shuffle } from '$lib/data/collections';
import { dialog } from '$lib/state/dialog.svelte';
import { ListenTracker } from './listenTracker';
import { readStorage, writeStorage } from '$lib/utils/storage';
import type { Track } from '$lib/types';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
export function isQueueCurrent(items: { id: string }[]): boolean {
return items.some((item) => item.id === player.currentId);
export interface PlayerTrack {
id: string | number;
title: string;
artist: string;
duration: number;
explicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
}
export function playAllOrToggle(items: Track[]) {
export interface Playlist {
id: string | number;
name: string;
trackIds: (string | number)[];
}
export interface QueueItem {
id: string | number;
title: string;
artist?: string;
duration?: number | string;
explicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
}
export interface ApiTrackLike {
id: string | number;
title: string;
artist?: string | null;
duration?: number | string;
isExplicit?: boolean;
ownerUserId?: string | number | null;
listensCount?: number | string;
}
export function toQueueItems(tracks: ApiTrackLike[]): QueueItem[] {
return tracks.map((track) => ({
id: track.id,
title: track.title,
artist: track.artist ?? undefined,
duration: track.duration,
explicit: track.isExplicit,
ownerUserId: track.ownerUserId,
listensCount: track.listensCount
}));
}
export function toQueueItem(track: ApiTrackLike): QueueItem {
return toQueueItems([track])[0];
}
export function trackFromQueueItem(item: QueueItem): ApiTrackLike {
return {
id: item.id,
title: item.title,
artist: item.artist,
duration: item.duration,
isExplicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
};
}
export function isQueueCurrent(items: { id: string | number }[]): boolean {
return items.some((item) => item.id === player.current.id);
}
export function playAllOrToggle(items: QueueItem[]) {
if (items.length === 0) return;
if (isQueueCurrent(items)) player.toggle();
else player.playQueue(items, 0);
}
export function playShuffled(items: Track[]) {
export function playShuffled(items: QueueItem[]) {
if (items.length === 0) return;
player.playQueue(shuffle(items), 0);
}
@@ -33,30 +94,36 @@ async function readErrorMessage(res: Response): Promise<string> {
}
const RECENT_LIMIT = 15;
const DEFAULT_VOLUME = 100;
const VOLUME_KEY = 'musify.player.volume';
const MUTED_KEY = 'musify.player.muted';
function clampVolume(value: number): number {
return Math.min(100, Math.max(0, Math.round(value)));
}
const EMPTY: PlayerTrack = {
id: '',
title: '',
artist: '',
duration: 0
};
function loadVolume(): number {
const stored = readStorage(VOLUME_KEY);
if (stored === null || stored.trim() === '') return DEFAULT_VOLUME;
const parsed = Number(stored);
return Number.isFinite(parsed) ? clampVolume(parsed) : DEFAULT_VOLUME;
function toTrack(item: QueueItem): PlayerTrack {
return {
id: item.id,
title: item.title,
artist: item.artist ?? '',
duration: Number(item.duration) || 0,
explicit: item.explicit,
ownerUserId: item.ownerUserId,
listensCount: item.listensCount
};
}
class PlayerState {
tracks = $state<Track[]>([]);
currentId = $state<string | null>(null);
tracks = $state<PlayerTrack[]>([]);
currentId = $state<string | number | null>(null);
playing = $state(false);
progress = $state(0);
volume = $state(browser ? loadVolume() : DEFAULT_VOLUME);
muted = $state(browser ? readStorage(MUTED_KEY) === 'true' : false);
volume = $state(browser ? Number(localStorage.getItem('player.volume') ?? 100) : 100);
muted = $state(browser ? localStorage.getItem('player.muted') === 'true' : false);
loading = $state(false);
recentlyPlayed = $state<Track[]>([]);
recentlyPlayed = $state<PlayerTrack[]>([]);
playlists = $state<Playlist[]>([]);
shuffle = $state(false);
repeat = $state(false);
@@ -64,17 +131,20 @@ class PlayerState {
#audio: HTMLAudioElement | null = null;
#loadToken = 0;
#accentCache = new Map<string, string>();
#accentCache = new Map<string, Accent>();
#rafId: number | null = null;
#listen = new ListenTracker();
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? null);
current = $derived(this.tracks.find((t) => t.id === this.currentId) ?? EMPTY);
accent = $derived(this.accentColor ?? DEFAULT_ACCENT);
duration = $derived(this.current?.duration ?? 0);
progressPercent = $derived(
this.duration > 0 ? Math.min(100, (this.progress / this.duration) * 100) : 0
this.current.duration > 0 ? Math.min(100, (this.progress / this.current.duration) * 100) : 0
);
get isPlaying() {
return this.playing;
}
#audioEl(): HTMLAudioElement | null {
if (!browser) return null;
if (this.#audio) return this.#audio;
@@ -112,10 +182,6 @@ class PlayerState {
audio.play().catch(() => {});
return;
}
if (!this.shuffle && isLastIndex(this.tracks.length, this.#index())) {
this.#stopAtEnd(audio);
return;
}
this.next();
});
audio.addEventListener('error', () => {
@@ -133,13 +199,6 @@ class PlayerState {
return audio;
}
#stopAtEnd(audio: HTMLAudioElement) {
audio.currentTime = 0;
this.progress = 0;
this.playing = false;
this.#syncMediaSessionPlaybackState();
}
#setupMediaSession(audio: HTMLAudioElement) {
if (!('mediaSession' in navigator)) return;
navigator.mediaSession.setActionHandler('play', () => audio.play().catch(() => {}));
@@ -153,11 +212,15 @@ class PlayerState {
navigator.mediaSession.playbackState = this.playing ? 'playing' : 'paused';
}
#syncMediaSessionMetadata(track: Track) {
#syncMediaSessionMetadata(track: PlayerTrack) {
if (!browser || !('mediaSession' in navigator)) return;
if (!track.id) {
navigator.mediaSession.metadata = null;
return;
}
navigator.mediaSession.metadata = new MediaMetadata({
title: track.title,
artist: track.artist ?? '',
artist: track.artist,
artwork: [{ src: `/api/tracks/${track.id}/cover?size=small`, sizes: '256x256' }]
});
}
@@ -199,24 +262,26 @@ class PlayerState {
return this.tracks.findIndex((t) => t.id === this.currentId);
}
#pushRecent(track: Track) {
#pushRecent(track: PlayerTrack) {
if (!track.id) return;
this.recentlyPlayed = [track, ...this.recentlyPlayed.filter((t) => t.id !== track.id)].slice(
0,
RECENT_LIMIT
);
}
async #applyAccent(id: string) {
const cached = this.#accentCache.get(id);
async #applyAccent(id: string | number) {
const key = String(id);
const cached = this.#accentCache.get(key);
if (cached) {
this.accentColor = cached;
this.accentColor = cached.accent;
return;
}
const result = await extractAccent(`/api/tracks/${id}/cover?size=small`);
if (this.currentId !== id) return;
if (result) {
this.#accentCache.set(id, result);
this.accentColor = result;
this.#accentCache.set(key, result);
this.accentColor = result.accent;
} else {
this.accentColor = null;
}
@@ -241,14 +306,18 @@ class PlayerState {
this.#listen.begin(listenId);
await audio.play();
this.loading = false;
if (track) this.#pushRecent(track);
} catch (err) {
console.error('playback failed', err);
this.#pushRecent(this.current);
} catch (exception) {
console.error('playback failed', exception);
if (token !== this.#loadToken) return;
this.loading = false;
this.playing = false;
if (err instanceof DOMException && err.name === 'AbortError') return;
this.#fail(err instanceof Error && !(err instanceof DOMException) ? err.message : undefined);
if (exception instanceof DOMException && exception.name === 'AbortError') return;
this.#fail(
exception instanceof Error && !(exception instanceof DOMException)
? exception.message
: undefined
);
}
}
@@ -290,44 +359,79 @@ class PlayerState {
return `${url}${sep}t=${encodeURIComponent(ticket)}`;
}
hydrate(track: Track) {
hydrate(item: ApiTrackLike) {
if (this.currentId !== null || this.tracks.length > 0) return;
const track = toTrack(toQueueItem(item));
this.tracks = [track];
this.currentId = track.id;
this.#applyAccent(track.id);
this.#syncMediaSessionMetadata(track);
}
playQueue(list: Track[], startIndex = 0) {
playlistTracks(ids: (string | number)[]): PlayerTrack[] {
return ids
.map((id) => this.tracks.find((t) => t.id === id))
.filter((t): t is PlayerTrack => Boolean(t));
}
playQueue(list: QueueItem[], startIndex = 0) {
if (list.length === 0) return;
this.tracks = [...list];
this.tracks = list.map(toTrack);
const start = this.tracks[startIndex] ?? this.tracks[0];
this.currentId = start.id;
this.#loadCurrent();
}
playOrToggle(list: Track[], index: number) {
playOrToggle(list: QueueItem[], index: number) {
if (index < 0 || index >= list.length) return;
if (this.currentId === list[index].id) this.toggle();
else this.playQueue(list, index);
}
playNext(items: Track[]) {
if (items.length === 0) return;
if (this.currentId === null) {
this.playQueue(items, 0);
return;
}
this.tracks = insertNext(this.tracks, this.#index(), items);
playNextItem(item: QueueItem) {
this.playNext([item]);
}
appendToQueue(items: Track[]) {
playNext(items: QueueItem[]) {
if (items.length === 0) return;
if (this.currentId === null) {
this.playQueue(items, 0);
return;
}
this.tracks = append(this.tracks, items);
const tracks = [...this.tracks];
tracks.splice(this.#index() + 1, 0, ...items.map(toTrack));
this.tracks = tracks;
}
appendToQueue(items: QueueItem[]) {
if (items.length === 0) return;
if (this.currentId === null) {
this.playQueue(items, 0);
return;
}
this.tracks = [...this.tracks, ...items.map(toTrack)];
}
playTrack(id: string | number) {
if (this.currentId === id) {
this.toggle();
return;
}
const known =
this.tracks.find((t) => t.id === id) ?? this.recentlyPlayed.find((t) => t.id === id);
if (known && !this.tracks.some((t) => t.id === id)) {
this.tracks = [known];
}
if (!this.tracks.some((t) => t.id === id)) return;
this.currentId = id;
this.#loadCurrent();
}
playPlaylist(id: string | number) {
const playlist = this.playlists.find((p) => p.id === id);
if (!playlist) return;
const items = this.playlistTracks(playlist.trackIds);
if (items.length > 0) this.playQueue(items, 0);
}
toggle() {
@@ -350,13 +454,19 @@ class PlayerState {
this.muted = value;
const audio = this.#audioEl();
if (audio) audio.muted = value;
if (browser) writeStorage(MUTED_KEY, String(value));
if (browser) localStorage.setItem('player.muted', String(value));
}
next() {
if (this.tracks.length === 0) return;
const idx = this.#index();
this.currentId = this.tracks[nextIndex(this.tracks.length, idx, this.shuffle)].id;
let nextIdx = (idx + 1) % this.tracks.length;
if (this.shuffle && this.tracks.length > 1) {
do {
nextIdx = Math.floor(Math.random() * this.tracks.length);
} while (nextIdx === idx);
}
this.currentId = this.tracks[nextIdx].id;
this.#loadCurrent();
}
@@ -380,11 +490,17 @@ class PlayerState {
}
moveQueueItem(from: number, insertAt: number) {
this.tracks = move(this.tracks, from, insertAt);
if (from < 0 || from >= this.tracks.length) return;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return;
const tracks = [...this.tracks];
const [item] = tracks.splice(from, 1);
tracks.splice(target, 0, item);
this.tracks = tracks;
}
clearUpcoming() {
this.tracks = this.current ? [this.current] : [];
this.tracks = this.currentId === null ? [] : [this.current];
}
previous() {
@@ -395,24 +511,26 @@ class PlayerState {
return;
}
const idx = this.#index();
this.currentId = this.tracks[previousIndex(this.tracks.length, idx)].id;
this.currentId = this.tracks[(idx - 1 + this.tracks.length) % this.tracks.length].id;
this.#loadCurrent();
}
seekFraction(fraction: number) {
const audio = this.#audioEl();
const clamped = Math.min(1, Math.max(0, fraction));
const target = clamped * this.duration;
const target = clamped * this.current.duration;
this.progress = target;
if (audio && Number.isFinite(audio.duration)) audio.currentTime = target;
}
setVolume(value: number) {
this.volume = Number.isFinite(value) ? clampVolume(value) : this.volume;
this.volume = Math.min(100, Math.max(0, Math.round(value)));
if (this.muted && this.volume > 0) this.#applyMuted(false);
const audio = this.#audioEl();
if (audio) audio.volume = this.volume / 100;
if (browser) writeStorage(VOLUME_KEY, String(this.volume));
if (audio) {
audio.volume = this.volume / 100;
localStorage.setItem('player.volume', String(this.volume));
}
}
}
-85
View File
@@ -1,85 +0,0 @@
import { describe, expect, it } from 'vitest';
import { append, insertNext, isLastIndex, move, nextIndex, previousIndex } from './queue';
const t = (id: string) => ({ id });
const ids = (queue: { id: string | number }[]) => queue.map((item) => item.id);
describe('insertNext', () => {
it('inserts right after the current track without mutating', () => {
const queue = ['a', 'b', 'c'].map(t);
expect(ids(insertNext(queue, 0, ['x', 'y'].map(t)))).toEqual(['a', 'x', 'y', 'b', 'c']);
expect(ids(queue)).toEqual(['a', 'b', 'c']);
});
it('inserts at the start when there is no current track', () => {
expect(ids(insertNext([t('a')], -1, [t('x')]))).toEqual(['x', 'a']);
});
it('moves tracks already queued instead of duplicating them', () => {
const queue = ['a', 'b', 'c', 'd'].map(t);
expect(ids(insertNext(queue, 1, [t('d'), t('a')]))).toEqual(['b', 'd', 'a', 'c']);
});
it('ignores the current track', () => {
const queue = ['a', 'b'].map(t);
expect(ids(insertNext(queue, 0, [t('a')]))).toEqual(['a', 'b']);
});
});
describe('append', () => {
it('adds new items at the end', () => {
expect(ids(append([t('a')], ['b', 'c'].map(t)))).toEqual(['a', 'b', 'c']);
});
it('skips tracks already in the queue and repeated ones', () => {
expect(ids(append(['a', 'b'].map(t), ['b', 'c', 'c'].map(t)))).toEqual(['a', 'b', 'c']);
});
it('returns the same queue when nothing is added', () => {
const queue = [t('a')];
expect(append(queue, [t('a')])).toBe(queue);
});
});
describe('move', () => {
const queue = ['a', 'b', 'c', 'd'];
it('moves an item forward using insert position semantics', () => {
expect(move(queue, 0, 3)).toEqual(['b', 'c', 'a', 'd']);
});
it('moves an item backward', () => {
expect(move(queue, 3, 1)).toEqual(['a', 'd', 'b', 'c']);
});
it('is a no-op when the target equals the origin', () => {
expect(move(queue, 1, 1)).toBe(queue);
expect(move(queue, 1, 2)).toBe(queue);
});
it('ignores out-of-range origins', () => {
expect(move(queue, -1, 0)).toBe(queue);
expect(move(queue, 4, 0)).toBe(queue);
});
});
describe('nextIndex', () => {
it('advances and wraps around', () => {
expect(nextIndex(3, 0, false)).toBe(1);
expect(nextIndex(3, 2, false)).toBe(0);
});
it('never repeats the current index when shuffling', () => {
const values = [0.4, 0.5, 0.9];
let call = 0;
expect(nextIndex(3, 1, true, () => values[call++])).toBe(2);
expect(call).toBe(3);
});
it('ignores shuffle with a single track', () => {
expect(nextIndex(1, 0, true)).toBe(0);
});
});
describe('previousIndex', () => {
it('steps back and wraps around', () => {
expect(previousIndex(3, 2)).toBe(1);
expect(previousIndex(3, 0)).toBe(2);
});
});
describe('isLastIndex', () => {
it('detects the end of the queue', () => {
expect(isLastIndex(3, 2)).toBe(true);
expect(isLastIndex(3, 1)).toBe(false);
});
});
-60
View File
@@ -1,60 +0,0 @@
interface Identified {
id: string | number;
}
export function insertNext<T extends Identified>(
queue: T[],
currentIndex: number,
items: T[]
): T[] {
const currentId = queue[currentIndex]?.id;
const incoming = items.filter((item) => item.id !== currentId);
const moving = new Set(incoming.map((item) => item.id));
const rest = queue.filter((item) => !moving.has(item.id));
const anchor = currentId === undefined ? -1 : rest.findIndex((item) => item.id === currentId);
return [...rest.slice(0, anchor + 1), ...incoming, ...rest.slice(anchor + 1)];
}
export function append<T extends Identified>(queue: T[], items: T[]): T[] {
const present = new Set(queue.map((item) => item.id));
const added = items.filter((item) => {
if (present.has(item.id)) return false;
present.add(item.id);
return true;
});
return added.length > 0 ? [...queue, ...added] : queue;
}
export function move<T>(queue: T[], from: number, insertAt: number): T[] {
if (from < 0 || from >= queue.length) return queue;
const target = insertAt > from ? insertAt - 1 : insertAt;
if (target === from) return queue;
const result = [...queue];
const [item] = result.splice(from, 1);
result.splice(target, 0, item);
return result;
}
export function isLastIndex(length: number, index: number): boolean {
return index >= length - 1;
}
export function nextIndex(
length: number,
index: number,
shuffle: boolean,
random: () => number = Math.random
): number {
if (shuffle && length > 1) {
let next: number;
do {
next = Math.floor(random() * length);
} while (next === index);
return next;
}
return (index + 1) % length;
}
export function previousIndex(length: number, index: number): number {
return (index - 1 + length) % length;
}
@@ -1,5 +1,4 @@
import { formString } from '$lib/server/api';
import { ALBUM_EARLIEST_YEAR, LIMITS } from '$lib/validation';
import { ALBUM_EARLIEST_YEAR } from '$lib/config';
interface AlbumFormBody {
title: string;
@@ -8,19 +7,17 @@ interface AlbumFormBody {
}
export function parseAlbumForm(form: FormData): { body: AlbumFormBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > LIMITS.albumTitle) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.albumTitle} caracteres).` };
const title = String(form.get('title') ?? '').trim();
if (title === '' || title.length > 200) {
return { failMessage: 'El título es obligatorio (máx. 200 caracteres).' };
}
const description = formString(form, 'description').trim();
if (description.length > LIMITS.albumDescription) {
return {
failMessage: `La descripción no puede pasar de ${LIMITS.albumDescription} caracteres.`
};
const description = String(form.get('description') ?? '').trim();
if (description.length > 256) {
return { failMessage: 'La descripción no puede pasar de 256 caracteres.' };
}
const rawYear = formString(form, 'releaseYear').trim();
const rawYear = String(form.get('releaseYear') ?? '').trim();
const latestYear = new Date().getFullYear() + 1;
let releaseYear: number | null = null;
+14 -58
View File
@@ -1,5 +1,5 @@
import createClient from 'openapi-fetch';
import { error, fail, redirect, type ActionFailure, type RequestEvent } from '@sveltejs/kit';
import { error, fail, redirect, type ActionFailure } from '@sveltejs/kit';
import type { paths } from '$lib/api/schema';
import type { SessionUser } from '$lib/types';
import { apiConfig } from '$lib/server/config';
@@ -17,18 +17,9 @@ export function createApiClient({ fetch, accessToken }: ApiClientOptions) {
});
}
export type ApiClient = ReturnType<typeof createApiClient>;
export function apiFor({ fetch, locals }: Pick<RequestEvent, 'fetch' | 'locals'>): ApiClient {
return createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
}
export function loginRedirect(url: URL): never {
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
}
export function requireUser(locals: App.Locals, url: URL): SessionUser {
if (!locals.user) loginRedirect(url);
if (!locals.user)
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return locals.user;
}
@@ -38,42 +29,16 @@ export function optionalUser(
allowAnonymousListening: boolean
): SessionUser | null {
if (locals.user) return locals.user;
if (!allowAnonymousListening) loginRedirect(url);
if (!allowAnonymousListening)
redirect(302, `/auth?returnTo=${encodeURIComponent(url.pathname + url.search)}`);
return null;
}
export function formString(form: FormData, key: string): string {
return String(form.get(key) ?? '');
}
export function formFile(form: FormData, key: string): File | null {
const value = form.get(key);
return value instanceof File && value.size > 0 ? value : null;
}
interface ActionContext {
api: ApiClient;
form: FormData;
params: Record<string, string>;
locals: App.Locals;
url: URL;
}
export function authedAction<R>(
handler: (context: ActionContext) => R | Promise<R>,
unauthorizedMessage = 'Inicia sesión.'
) {
return async (event: RequestEvent) => {
if (!event.locals.accessToken) return fail(401, { message: unauthorizedMessage });
const form = await event.request.formData();
return handler({
api: apiFor(event),
form,
params: event.params as Record<string, string>,
locals: event.locals,
url: event.url
});
};
export function requireAccessTokenAction(
locals: App.Locals,
message = 'Inicia sesión.'
): string | ActionFailure<{ message: string }> {
return locals.accessToken ?? fail(401, { message });
}
export function unwrapOrError<T>(
@@ -105,18 +70,9 @@ export function apiErrorDetail(error: unknown): string | undefined {
return undefined;
}
type Failure = ActionFailure<{ message: string; detail?: string }>;
export function failOnError(result: { error?: unknown }, message: string): Failure | undefined {
export function unwrapOrFail(
result: { error?: unknown },
message: string
): ActionFailure<{ message: string; detail?: string }> | undefined {
return result.error ? fail(502, { message, detail: apiErrorDetail(result.error) }) : undefined;
}
export function requireData<T>(
result: { data?: T; error?: unknown },
message: string
): { data: T; failure?: undefined } | { data?: undefined; failure: Failure } {
if (result.error || result.data === undefined) {
return { failure: fail(502, { message, detail: apiErrorDetail(result.error) }) };
}
return { data: result.data };
}
-21
View File
@@ -1,21 +0,0 @@
import { describe, expect, it } from 'vitest';
import { safeReturnTo } from './auth';
describe('safeReturnTo', () => {
it('keeps local paths', () => {
expect(safeReturnTo('/')).toBe('/');
expect(safeReturnTo('/playlists/1?tab=x#top')).toBe('/playlists/1?tab=x#top');
});
it('falls back to / for missing values', () => {
expect(safeReturnTo(null)).toBe('/');
expect(safeReturnTo(undefined)).toBe('/');
expect(safeReturnTo('')).toBe('/');
});
it('rejects absolute and protocol-relative URLs', () => {
expect(safeReturnTo('https://example.com')).toBe('/');
expect(safeReturnTo('//example.com')).toBe('/');
expect(safeReturnTo('/\\example.com')).toBe('/');
expect(safeReturnTo('javascript:alert(1)')).toBe('/');
expect(safeReturnTo('relative/path')).toBe('/');
});
});
+5 -12
View File
@@ -18,13 +18,6 @@ export interface Session extends SessionUser {
expiresAt: number;
}
export function safeReturnTo(value: string | null | undefined): string {
if (!value || !value.startsWith('/') || value.startsWith('//') || value.startsWith('/\\')) {
return '/';
}
return value;
}
let configPromise: Promise<client.Configuration> | undefined;
export function getOidcConfig(): Promise<client.Configuration> {
configPromise ??= (async () => {
@@ -36,9 +29,9 @@ export function getOidcConfig(): Promise<client.Configuration> {
return secret
? client.discovery(issuer, authConfig.clientId, secret, undefined, options)
: client.discovery(issuer, authConfig.clientId, undefined, client.None(), options);
})().catch((err) => {
})().catch((exception) => {
configPromise = undefined;
throw err;
throw exception;
});
return configPromise;
}
@@ -51,7 +44,7 @@ function sessionKey(): Promise<Uint8Array> {
return keyPromise;
}
function sessionCookieOptions(url: URL) {
export function sessionCookieOptions(url: URL) {
return {
httpOnly: true,
sameSite: 'lax' as const,
@@ -151,8 +144,8 @@ export async function refreshSession(session: Session): Promise<Session | null>
idToken: tokens.id_token ?? session.idToken,
expiresAt: Math.floor(Date.now() / 1000) + (tokens.expires_in ?? 3600)
};
} catch (err) {
console.error('Token refresh failed', err);
} catch (exception) {
console.error('Token refresh failed', exception);
return null;
}
}
+1 -8
View File
@@ -1,7 +1,6 @@
import { env } from '$env/dynamic/private';
const DAY_SECONDS = 60 * 60 * 24;
const MIN_SESSION_SECRET_LENGTH = 32;
const DEFAULT_OIDC_SCOPE = 'openid profile email offline_access';
function positiveInt(value: string | undefined, fallback: number): number {
@@ -36,13 +35,7 @@ export const authConfig = {
return required('AUTH_POST_LOGOUT_URI', env.AUTH_POST_LOGOUT_URI);
},
get sessionSecret() {
const secret = required('SESSION_SECRET', env.SESSION_SECRET);
if (secret.length < MIN_SESSION_SECRET_LENGTH) {
throw new Error(
`SESSION_SECRET must be at least ${MIN_SESSION_SECRET_LENGTH} characters long.`
);
}
return secret;
return env.SESSION_SECRET;
},
get sessionTtlSeconds() {
return positiveInt(env.SESSION_TTL_SECONDS, 7 * DAY_SECONDS);
+23 -19
View File
@@ -1,24 +1,28 @@
import { fail } from '@sveltejs/kit';
import { authedAction } from '$lib/server/api';
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
const setFollowing = (follow: boolean) =>
authedAction(async ({ api, params }) => {
const id = params.id ?? '';
if (!id) return fail(400, { message: 'Falta el usuario.' });
async function setFollowing({ request, params, locals, fetch }: RequestEvent, follow: boolean) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
const form = await request.formData();
const id = String(form.get('userId') ?? '') || String(params.id ?? '');
if (!id) return fail(400, { message: 'Falta el usuario.' });
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
const api = createApiClient({ fetch, accessToken });
const options = { params: { path: { id } } };
const { error } = follow
? await api.POST('/users/{id}/follow', options)
: await api.DELETE('/users/{id}/follow', options);
return { following: follow, userId: id };
});
if (error) {
return fail(502, {
message: follow ? 'No se pudo seguir al usuario.' : 'No se pudo dejar de seguir al usuario.'
});
}
export const followUserAction = setFollowing(true);
export const unfollowUserAction = setFollowing(false);
return { following: follow, userId: id };
}
export const followUserAction = (event: RequestEvent) => setFollowing(event, true);
export const unfollowUserAction = (event: RequestEvent) => setFollowing(event, false);
+3 -1
View File
@@ -1,6 +1,8 @@
import type { ApiClient } from '$lib/server/api';
import type { createApiClient } from '$lib/server/api';
import type { FollowList } from '$lib/types';
type ApiClient = ReturnType<typeof createApiClient>;
export function fetchFollowList(
api: ApiClient,
list: FollowList,
@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest';
import { ALBUM_EARLIEST_YEAR } from '$lib/validation';
import { parseAlbumForm } from './albumForm';
function form(fields: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) data.set(key, value);
return data;
}
describe('parseAlbumForm', () => {
it('parses a valid form and trims values', () => {
expect(
parseAlbumForm(form({ title: ' Title ', description: ' d ', releaseYear: '2020' }))
).toEqual({ body: { title: 'Title', description: 'd', releaseYear: 2020 } });
});
it('turns empty optional fields into null', () => {
expect(parseAlbumForm(form({ title: 'T' }))).toEqual({
body: { title: 'T', description: null, releaseYear: null }
});
});
it('rejects empty or too long titles', () => {
expect(parseAlbumForm(form({ title: ' ' }))).toHaveProperty('failMessage');
expect(parseAlbumForm(form({ title: 'x'.repeat(201) }))).toHaveProperty('failMessage');
});
it('rejects too long descriptions', () => {
expect(parseAlbumForm(form({ title: 'T', description: 'x'.repeat(257) }))).toHaveProperty(
'failMessage'
);
});
it('rejects invalid years', () => {
for (const releaseYear of ['abc', '2020.5', String(ALBUM_EARLIEST_YEAR - 1), '9999']) {
expect(parseAlbumForm(form({ title: 'T', releaseYear }))).toHaveProperty('failMessage');
}
});
});
@@ -1,34 +0,0 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parsePlaylistForm } from './playlistForm';
function form(fields: Record<string, string>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) data.set(key, value);
return data;
}
describe('parsePlaylistForm', () => {
it('parses a valid form', () => {
expect(
parsePlaylistForm(form({ name: ' Mix ', description: ' d ', visibility: 'public' }))
).toEqual({ body: { name: 'Mix', description: 'd', visibility: 'Public', cover: null } });
});
it('defaults to private', () => {
const parsed = parsePlaylistForm(form({ name: 'Mix' }));
expect(parsed).toMatchObject({ body: { visibility: 'Private', description: '' } });
});
it('rejects empty or too long names keeping the typed values', () => {
expect(parsePlaylistForm(form({ name: ' ', description: 'd' }))).toMatchObject({
failMessage: expect.any(String),
description: 'd'
});
expect(parsePlaylistForm(form({ name: 'x'.repeat(LIMITS.playlistName + 1) }))).toHaveProperty(
'failMessage'
);
});
it('rejects too long descriptions', () => {
const description = 'x'.repeat(LIMITS.playlistDescription + 1);
expect(parsePlaylistForm(form({ name: 'Mix', description }))).toHaveProperty('failMessage');
});
});
@@ -1,42 +0,0 @@
import { formFile, formString } from '$lib/server/api';
import { LIMITS } from '$lib/validation';
interface PlaylistFormBody {
name: string;
description: string;
visibility: 'Public' | 'Private';
cover: File | null;
}
export type PlaylistFormFailure = { failMessage: string; name: string; description: string };
export function parsePlaylistForm(
form: FormData
): { body: PlaylistFormBody } | PlaylistFormFailure {
const name = formString(form, 'name').trim();
const description = formString(form, 'description').trim();
if (name === '' || name.length > LIMITS.playlistName) {
return {
failMessage: `El nombre es obligatorio (máx. ${LIMITS.playlistName} caracteres).`,
name,
description
};
}
if (description.length > LIMITS.playlistDescription) {
return {
failMessage: `La descripción no puede pasar de ${LIMITS.playlistDescription} caracteres.`,
name,
description
};
}
return {
body: {
name,
description,
visibility: formString(form, 'visibility') === 'public' ? 'Public' : 'Private',
cover: formFile(form, 'cover')
}
};
}
@@ -1,36 +0,0 @@
import { describe, expect, it } from 'vitest';
import { LIMITS } from '$lib/validation';
import { parseTrackUploadForm } from './trackUploadForm';
function form(fields: Record<string, string | string[] | File>): FormData {
const data = new FormData();
for (const [key, value] of Object.entries(fields)) {
if (Array.isArray(value)) value.forEach((item) => data.append(key, item));
else data.set(key, value);
}
return data;
}
const audio = new File(['a'], 'a.mp3');
const cover = new File(['c'], 'c.png');
describe('parseTrackUploadForm', () => {
it('parses a valid form and deduplicates tags', () => {
const parsed = parseTrackUploadForm(
form({ title: ' Song ', audio, cover, tags: ['rock', 'rock', 'pop'], isExplicit: 'on' })
);
expect(parsed).toMatchObject({
body: { title: 'Song', tags: ['rock', 'pop'], isExplicit: true }
});
});
it('validates each field', () => {
const ok = { title: 'Song', audio, cover, tags: ['rock'] };
expect(parseTrackUploadForm(form({ ...ok, title: '' }))).toHaveProperty('failMessage');
expect(
parseTrackUploadForm(form({ ...ok, title: 'x'.repeat(LIMITS.trackTitle + 1) }))
).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, audio: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, cover: '' }))).toHaveProperty('failMessage');
expect(parseTrackUploadForm(form({ ...ok, tags: [] }))).toHaveProperty('failMessage');
});
});
@@ -1,30 +0,0 @@
import { formFile, formString } from '$lib/server/api';
import { LIMITS } from '$lib/validation';
interface TrackUploadBody {
title: string;
audio: File;
cover: File;
tags: string[];
isExplicit: boolean;
}
export function parseTrackUploadForm(
form: FormData
): { body: TrackUploadBody } | { failMessage: string } {
const title = formString(form, 'title').trim();
if (title === '' || title.length > LIMITS.trackTitle) {
return { failMessage: `El título es obligatorio (máx. ${LIMITS.trackTitle} caracteres).` };
}
const audio = formFile(form, 'audio');
if (!audio) return { failMessage: 'Selecciona un archivo de audio.' };
const cover = formFile(form, 'cover');
if (!cover) return { failMessage: 'Selecciona una portada.' };
const tags = [...new Set(form.getAll('tags').map(String))];
if (tags.length === 0) return { failMessage: 'Elige al menos un género.' };
return { body: { title, audio, cover, tags, isExplicit: form.get('isExplicit') === 'on' } };
}
@@ -1,22 +0,0 @@
import { describe, expect, it } from 'vitest';
import { pickGreeting } from './greeting';
const BIRTHDAY = 'Hoy toca celebrar. Tú eliges la música.';
describe('pickGreeting', () => {
it('uses the birthday message over special dates', () => {
const date = new Date(2026, 11, 25, 10);
expect(pickGreeting(date, { month: 12, day: 25 })).toBe(BIRTHDAY);
});
it('uses the special date message', () => {
expect(pickGreeting(new Date(2026, 11, 25, 10))).toBe('Feliz Navidad. Pon tu banda sonora.');
});
it('returns a non-empty schedule message at every hour', () => {
for (let hour = 0; hour < 24; hour++) {
expect(pickGreeting(new Date(2026, 2, 10, hour)).length).toBeGreaterThan(0);
}
});
it('ignores a birthday on another day', () => {
expect(pickGreeting(new Date(2026, 2, 10, 10), { month: 1, day: 2 })).not.toBe(BIRTHDAY);
});
});
+1 -3
View File
@@ -10,9 +10,7 @@ export async function proxyCoverImage(
): Promise<Response> {
const safeSize = size && ALLOWED_SIZES.has(size) ? size : 'medium';
const upstream = await fetch(
`${apiConfig.baseUrl}/${kind}/${encodeURIComponent(id)}/cover?size=${safeSize}`
);
const upstream = await fetch(`${apiConfig.baseUrl}/${kind}/${id}/cover?size=${safeSize}`);
if (!upstream.ok) {
return new Response(null, { status: upstream.status });
-139
View File
@@ -1,139 +0,0 @@
import { describe, expect, it } from 'vitest';
import type { components } from '$lib/api/schema';
import {
toAlbum,
toDatedTrack,
toLikedTrack,
toMix,
toPage,
toPlaylist,
toPlaylistSummary,
toTrack
} from './mappers';
const trackDto: components['schemas']['TrackApplicationResponse'] = {
id: 't1',
title: 'Song',
artist: null,
audioStatus: 'Completed',
duration: '183.5',
listensCount: '12',
createdAt: '2026-01-02T00:00:00Z',
updatedAt: '2026-01-03T00:00:00Z',
ownerUserId: '42',
tags: [],
isExplicit: true
};
describe('toTrack', () => {
it('normalizes numeric strings and renames isExplicit', () => {
expect(toTrack(trackDto)).toEqual({
id: 't1',
title: 'Song',
artist: null,
ownerUserId: '42',
explicit: true,
duration: 183.5,
listensCount: 12
});
});
it('falls back to 0 for invalid numbers', () => {
const track = toTrack({ ...trackDto, duration: 'x', listensCount: 'y' });
expect(track.duration).toBe(0);
expect(track.listensCount).toBe(0);
});
});
describe('toDatedTrack and toLikedTrack', () => {
it('keep the creation date', () => {
expect(toDatedTrack(trackDto).date).toBe('2026-01-02T00:00:00Z');
expect(toLikedTrack(trackDto).likedAt).toBe(Date.parse('2026-01-02T00:00:00Z'));
});
});
describe('toMix', () => {
it('maps items into tracks', () => {
const mix = toMix({
id: 'm1',
title: 'Mix',
subtitle: null,
itemCount: 1,
items: [{ trackId: 't1', title: 'Song', artist: 'A', durationSeconds: '60', listensCount: 3 }]
});
expect(mix.tracks).toEqual([
{
id: 't1',
title: 'Song',
artist: 'A',
ownerUserId: null,
explicit: false,
duration: 60,
listensCount: 3
}
]);
});
});
describe('toAlbum', () => {
const dto: components['schemas']['AlbumApplicationResponse'] = {
id: 'a1',
title: 'Album',
description: null,
releaseYear: '2020',
ownerUserId: '42',
trackCount: '5',
smallImageKeyName: null,
mediumImageKeyName: null,
largeImageKeyName: null,
createdAt: '',
updatedAt: 'u',
coverTrackIds: ['t1']
};
it('normalizes year and count', () => {
expect(toAlbum(dto)).toMatchObject({ releaseYear: 2020, trackCount: 5, updatedAt: 'u' });
});
it('keeps a missing year as null', () => {
expect(toAlbum({ ...dto, releaseYear: null }).releaseYear).toBeNull();
});
});
describe('toPlaylist', () => {
const dto: components['schemas']['PlayListApplicationResponse'] = {
id: 'p1',
name: 'List',
description: 'd',
smallImageKeyName: null,
mediumImageKeyName: null,
largeImageKeyName: null,
ownerUserId: '42',
visibility: 'Public',
createdAt: '',
updatedAt: 'u',
coverTrackIds: []
};
it('drops storage keys', () => {
expect(toPlaylist(dto)).toEqual({
id: 'p1',
name: 'List',
description: 'd',
visibility: 'Public',
ownerUserId: '42',
coverTrackIds: [],
updatedAt: 'u'
});
});
it('adds the track count in summaries', () => {
expect(toPlaylistSummary(dto, 7).trackCount).toBe(7);
});
});
describe('toPage', () => {
it('maps items and normalizes numbers', () => {
expect(
toPage(
{ items: [1, 2], pageNumber: '2', hasNextPage: true, totalItemCount: '10' },
(n) => n * 2
)
).toEqual({ items: [2, 4], pageNumber: 2, hasNextPage: true, totalItemCount: 10 });
});
});
-94
View File
@@ -1,94 +0,0 @@
import type { components } from '$lib/api/schema';
import type { Album, LikedTrack, Mix, Paged, Playlist, PlaylistSummary, Track } from '$lib/types';
type TrackDto = components['schemas']['TrackApplicationResponse'];
type AlbumDto = components['schemas']['AlbumApplicationResponse'];
type PlaylistDto = components['schemas']['PlayListApplicationResponse'];
type MixDto = components['schemas']['MixApplicationResponse'];
type MixItemDto = components['schemas']['MixItemApplicationResponse'];
interface PageDto<T> {
items: T[];
pageNumber: number | string;
hasNextPage: boolean;
totalItemCount: number | string;
}
export function toTrack(dto: TrackDto): Track {
return {
id: dto.id,
title: dto.title,
artist: dto.artist,
ownerUserId: dto.ownerUserId,
explicit: dto.isExplicit,
duration: Number(dto.duration) || 0,
listensCount: Number(dto.listensCount) || 0
};
}
export function toDatedTrack(dto: TrackDto): Track & { date: string } {
return { ...toTrack(dto), date: dto.createdAt };
}
export function toLikedTrack(dto: TrackDto): LikedTrack {
return { ...toTrack(dto), likedAt: new Date(dto.createdAt).getTime() };
}
export function toMixTrack(item: MixItemDto): Track {
return {
id: item.trackId,
title: item.title,
artist: item.artist,
ownerUserId: null,
explicit: false,
duration: Number(item.durationSeconds) || 0,
listensCount: Number(item.listensCount) || 0
};
}
export function toMix(dto: MixDto): Mix {
return {
id: dto.id,
title: dto.title,
subtitle: dto.subtitle,
tracks: dto.items.map(toMixTrack)
};
}
export function toAlbum(dto: AlbumDto): Album {
return {
id: dto.id,
title: dto.title,
description: dto.description,
releaseYear: dto.releaseYear === null ? null : Number(dto.releaseYear),
ownerUserId: dto.ownerUserId,
trackCount: Number(dto.trackCount) || 0,
coverTrackIds: dto.coverTrackIds,
updatedAt: dto.updatedAt
};
}
export function toPlaylist(dto: PlaylistDto): Playlist {
return {
id: dto.id,
name: dto.name,
description: dto.description,
visibility: dto.visibility,
ownerUserId: dto.ownerUserId,
coverTrackIds: dto.coverTrackIds,
updatedAt: dto.updatedAt
};
}
export function toPlaylistSummary(dto: PlaylistDto, trackCount: number): PlaylistSummary {
return { ...toPlaylist(dto), trackCount };
}
export function toPage<D, T>(dto: PageDto<D>, map: (item: D) => T): Paged<T> {
return {
items: dto.items.map(map),
pageNumber: Number(dto.pageNumber) || 1,
hasNextPage: dto.hasNextPage,
totalItemCount: Number(dto.totalItemCount) || 0
};
}
+1 -2
View File
@@ -9,8 +9,7 @@ export async function getAllowAnonymousListening(fetchFn: typeof fetch): Promise
try {
const api = createApiClient({ fetch: fetchFn });
const { data, error } = await api.GET('/config/playback');
if (error !== undefined) return cached?.allowAnonymousListening ?? false;
const { data } = await api.GET('/config/playback');
const allowAnonymousListening = data?.allowAnonymousListening ?? false;
cached = { allowAnonymousListening, expiresAt: Date.now() + CACHE_TTL_MS };
+27 -17
View File
@@ -1,21 +1,26 @@
import { fail } from '@sveltejs/kit';
import { authedAction, formString } from '$lib/server/api';
import { fail, type RequestEvent } from '@sveltejs/kit';
import { createApiClient, requireAccessTokenAction } from '$lib/server/api';
import { ALBUM_TRACKS_PAGE_SIZE } from '$lib/config';
export const addTrackAction = authedAction(async ({ api, form, params }) => {
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
const trackId = formString(form, 'trackId');
export async function addTrackAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const trackId = String(form.get('trackId') ?? '');
if (!playlistId || !trackId) {
return fail(400, { message: 'Faltan datos de la canción.' });
}
const { error, response } = await api.POST('/playlists/{playlistId}/tracks', {
const api = createApiClient({ fetch, accessToken });
const { error: err, response } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } },
body: { trackId }
});
if (error) {
if (err) {
if (response?.status === 409) {
return fail(409, { message: 'La canción ya está en esa playlist.', trackId });
}
@@ -23,32 +28,37 @@ export const addTrackAction = authedAction(async ({ api, form, params }) => {
}
return { added: true, trackId, playlistId };
});
}
export const addAlbumToPlaylistAction = authedAction(async ({ api, form, params }) => {
const playlistId = formString(form, 'playlistId') || (params.id ?? '');
const albumId = formString(form, 'albumId');
export async function addAlbumToPlaylistAction({ request, params, locals, fetch }: RequestEvent) {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const playlistId = String(form.get('playlistId') ?? '') || String(params.id ?? '');
const albumId = String(form.get('albumId') ?? '');
if (!playlistId || !albumId) {
return fail(400, { message: 'Faltan datos del álbum.' });
}
const { data: tracks, error: tracksError } = await api.GET('/albums/{albumId}/tracks', {
const api = createApiClient({ fetch, accessToken });
const { data: tracks, error: tracksErr } = await api.GET('/albums/{albumId}/tracks', {
params: { path: { albumId }, query: { pageNumber: 1, pageSize: ALBUM_TRACKS_PAGE_SIZE } }
});
if (tracksError || !tracks) {
if (tracksErr || !tracks) {
return fail(502, { message: 'No se pudo cargar el álbum.', albumId });
}
let added = 0;
for (const track of tracks.items) {
const { error } = await api.POST('/playlists/{playlistId}/tracks', {
const { error: err } = await api.POST('/playlists/{playlistId}/tracks', {
params: { path: { playlistId } },
body: { trackId: track.id }
body: { trackId: String(track.id) }
});
if (!error) added++;
if (!err) added++;
}
return { addedAlbum: true, albumId, playlistId, added };
});
}
-17
View File
@@ -78,8 +78,6 @@ export async function putPresigned(
});
}
import type { ApiClient } from '$lib/server/api';
export type UploadImageResult = { intentId: string } | { failMessage: string; detail?: string };
export async function uploadPresignedImage(
@@ -111,18 +109,3 @@ export async function uploadPresignedImage(
return { intentId: reserved.intentId };
}
export async function uploadOptionalCover(
api: ApiClient,
endpoint: '/albums/upload-picture' | '/playlists/upload-picture',
file: File | null
): Promise<{ intentId: string | null } | { failMessage: string; detail?: string }> {
if (!file) return { intentId: null };
return uploadPresignedImage(
(args) =>
endpoint === '/albums/upload-picture'
? api.POST('/albums/upload-picture', { body: args })
: api.POST('/playlists/upload-picture', { body: args }),
file
);
}
+4 -5
View File
@@ -2,10 +2,9 @@ import {
contextMenuPosition,
type MenuPosition
} from '$lib/components/ui/overlay/ContextMenu.svelte';
import { player } from '$lib/player/player.svelte';
import type { Track } from '$lib/types';
import { player, toQueueItem, type ApiTrackLike } from '../player/player.svelte';
export type TrackMenuState = { track: Track } & MenuPosition;
export type TrackMenuState = { track: ApiTrackLike } & MenuPosition;
export function createTrackMenu() {
let state = $state<TrackMenuState | null>(null);
@@ -14,7 +13,7 @@ export function createTrackMenu() {
get state() {
return state;
},
open(event: MouseEvent, track: Track) {
open(event: MouseEvent, track: ApiTrackLike) {
state = { track, ...contextMenuPosition(event) };
},
close() {
@@ -22,7 +21,7 @@ export function createTrackMenu() {
},
playNext() {
if (!state) return;
player.playNext([state.track]);
player.playNextItem(toQueueItem(state.track));
state = null;
}
};
+10 -3
View File
@@ -1,5 +1,4 @@
import { browser } from '$app/environment';
import { readStorage, writeStorage } from '$lib/utils/storage';
export type ThemeName = 'dark' | 'light';
@@ -7,7 +6,11 @@ const STORAGE_KEY = 'musify.theme';
function loadInitial(): ThemeName {
if (!browser) return 'dark';
return readStorage(STORAGE_KEY) === 'light' ? 'light' : 'dark';
try {
return localStorage.getItem(STORAGE_KEY) === 'light' ? 'light' : 'dark';
} catch {
return 'dark';
}
}
function applyTheme(theme: ThemeName) {
@@ -26,7 +29,11 @@ class ThemeModeStore {
this.current = this.current === 'dark' ? 'light' : 'dark';
applyTheme(this.current);
if (!browser) return;
writeStorage(STORAGE_KEY, this.current);
try {
localStorage.setItem(STORAGE_KEY, this.current);
} catch {
/* localStorage unavailable (private mode, quota) — preference just won't persist */
}
}
}
+12 -2
View File
@@ -1,6 +1,11 @@
import { browser } from '$app/environment';
import { ACCENT_LIGHTNESS } from './color';
export interface Accent {
accent: string;
gradient: string;
}
function srgbToLinear(channel: number): number {
const c = channel / 255;
return c <= 0.04045 ? c / 12.92 : Math.pow((c + 0.055) / 1.055, 2.4);
@@ -40,7 +45,7 @@ function loadImage(url: string): Promise<HTMLImageElement> {
const BUCKETS = 36;
export async function extractAccent(url: string): Promise<string | null> {
export async function extractAccent(url: string): Promise<Accent | null> {
if (!browser) return null;
let img: HTMLImageElement;
@@ -99,6 +104,11 @@ export async function extractAccent(url: string): Promise<string | null> {
const h = hue.toFixed(1);
const c = chroma.toFixed(3);
const c2 = (chroma * 0.85).toFixed(3);
const h2 = ((hue + 25) % 360).toFixed(1);
return `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`;
return {
accent: `oklch(${ACCENT_LIGHTNESS}% ${c} ${h})`,
gradient: `linear-gradient(135deg, oklch(74% ${c} ${h}), oklch(56% ${c2} ${h2}))`
};
}
-53
View File
@@ -6,56 +6,3 @@ export interface SessionUser {
}
export type FollowList = 'followers' | 'following';
export interface Track {
id: string;
title: string;
artist: string | null;
ownerUserId: string | null;
explicit: boolean;
duration: number;
listensCount: number;
}
export interface LikedTrack extends Track {
likedAt: number;
}
export interface Album {
id: string;
title: string;
description: string | null;
releaseYear: number | null;
ownerUserId: string;
trackCount: number;
coverTrackIds: string[];
updatedAt: string;
}
export interface Playlist {
id: string;
name: string;
description: string | null;
visibility: 'Public' | 'Private';
ownerUserId: string;
coverTrackIds: string[];
updatedAt: string;
}
export interface PlaylistSummary extends Playlist {
trackCount: number;
}
export interface Mix {
id: string;
title: string;
subtitle: string | null;
tracks: Track[];
}
export interface Paged<T> {
items: T[];
pageNumber: number;
hasNextPage: boolean;
totalItemCount: number;
}
-46
View File
@@ -1,46 +0,0 @@
import { describe, expect, it } from 'vitest';
import { albumMeta, fmtDurationLong, fmtTime, plural } from './format';
describe('fmtTime', () => {
it('formats minutes and padded seconds', () => {
expect(fmtTime(0)).toBe('0:00');
expect(fmtTime(65)).toBe('1:05');
expect(fmtTime(600)).toBe('10:00');
});
it('floors fractions and clamps invalid input', () => {
expect(fmtTime(59.9)).toBe('0:59');
expect(fmtTime(-5)).toBe('0:00');
expect(fmtTime(NaN)).toBe('0:00');
});
});
describe('fmtDurationLong', () => {
it('shows minutes only under an hour', () => {
expect(fmtDurationLong(59)).toBe('0 Min');
expect(fmtDurationLong(125)).toBe('2 Min');
});
it('shows hours and minutes', () => {
expect(fmtDurationLong(3600)).toBe('1 H 0 Min');
expect(fmtDurationLong(3600 * 2 + 60 * 5)).toBe('2 H 5 Min');
});
});
describe('plural', () => {
it('uses the singular only for 1', () => {
expect(plural(1, 'canción', 'canciones')).toBe('1 canción');
expect(plural(0, 'canción', 'canciones')).toBe('0 canciones');
expect(plural(2, 'canción', 'canciones')).toBe('2 canciones');
});
});
describe('albumMeta', () => {
it('joins year and track count', () => {
expect(albumMeta(2020, 1)).toBe('2020 · 1 canción');
expect(albumMeta(2020, 12)).toBe('2020 · 12 canciones');
});
it('omits missing parts', () => {
expect(albumMeta(undefined, 3)).toBe('3 canciones');
expect(albumMeta(2020)).toBe('2020');
expect(albumMeta()).toBe('');
});
});
+3 -9
View File
@@ -22,12 +22,12 @@ export function plural(n: number, one: string, many: string): string {
return `${n} ${n === 1 ? one : many}`;
}
export function fmtPlays(count: number): string {
const value = Math.max(0, Math.floor(count));
export function fmtPlays(count: number | string | undefined): string {
const value = Math.max(0, Math.floor(Number(count ?? 0)));
return plural(value, 'reproducción', 'reproducciones');
}
export function albumMeta(releaseYear?: number | null, trackCount?: number): string {
export function albumMeta(releaseYear?: number, trackCount?: number): string {
return [
releaseYear ? String(releaseYear) : undefined,
trackCount === undefined ? undefined : plural(trackCount, 'canción', 'canciones')
@@ -36,12 +36,6 @@ export function albumMeta(releaseYear?: number | null, trackCount?: number): str
.join(' · ');
}
export function formatSize(bytes: number): string {
if (bytes === 0) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
export function playlistMeta(trackCount: number): string {
return plural(trackCount, 'canción', 'canciones');
}
-15
View File
@@ -1,15 +0,0 @@
export function readStorage(key: string, fallback: string | null = null): string | null {
try {
return localStorage.getItem(key) ?? fallback;
} catch {
return fallback;
}
}
export function writeStorage(key: string, value: string): void {
try {
localStorage.setItem(key, value);
} catch {
/* localStorage unavailable (private mode, quota) — the value just won't persist */
}
}
-9
View File
@@ -1,9 +0,0 @@
export const ALBUM_EARLIEST_YEAR = 1877;
export const LIMITS = {
playlistName: 50,
playlistDescription: 256,
albumTitle: 200,
albumDescription: 256,
trackTitle: 200
} as const;
@@ -1,31 +1,43 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { apiFor, authedAction, formFile, requireUser, unwrapOrError } from '$lib/server/api';
import { toAlbum, toPage } from '$lib/server/mappers';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { ALBUMS_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/albums/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: ALBUMS_PAGE_SIZE } }
});
const albums = toPage(unwrapOrError(result, 'No se pudieron cargar tus álbumes.'), toAlbum);
const albums = unwrapOrError(result, 'No se pudieron cargar tus álbumes.');
return { albums };
};
export const actions: Actions = {
create: authedAction(async ({ api, form }) => {
create: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const cover = formFile(form, 'cover');
if (!cover) return fail(400, { message: 'La portada es obligatoria.' });
const cover = form.get('cover');
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'La portada es obligatoria.' });
}
const api = createApiClient({ fetch, accessToken });
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
@@ -35,12 +47,12 @@ export const actions: Actions = {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
const { data, error } = await api.POST('/albums', {
const { data, error: err } = await api.POST('/albums', {
body: { ...parsed.body, pictureIntentId: uploaded.intentId }
});
if (error || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
if (err || !data) return fail(502, { message: 'No se pudo crear el álbum.' });
redirect(303, `/albums/${data.id}`);
})
}
};
@@ -40,7 +40,10 @@
<MediaCard
href="/albums/{album.id}"
title={album.title}
subtitle={albumMeta(album.releaseYear, album.trackCount)}
subtitle={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
Number(album.trackCount)
)}
trackIds={album.coverTrackIds}
src="/api/albums/{album.id}/cover?size=large"
index={i}
@@ -1,23 +1,20 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
apiFor,
authedAction,
failOnError,
formFile,
formString,
createApiClient,
optionalUser,
unwrapOrError
requireAccessTokenAction,
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { toAlbum, toTrack } from '$lib/server/mappers';
import { ALBUM_TRACKS_PAGE_SIZE, LIBRARY_PICKER_PAGE_SIZE } from '$lib/config';
import { parseAlbumForm } from '$lib/server/forms/albumForm';
import { uploadOptionalCover } from '$lib/server/upload';
import { parseAlbumForm } from '$lib/server/albumForm';
import { uploadPresignedImage } from '$lib/server/upload';
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening);
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [albumRes, tracksRes, libraryRes] = await Promise.all([
api.GET('/albums/{id}', { params: { path: { id: params.id } } }),
@@ -37,13 +34,11 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
: Promise.resolve(null)
]);
const album = toAlbum(unwrapOrError(albumRes, 'Álbum no encontrado.', 404));
const album = unwrapOrError(albumRes, 'Álbum no encontrado.', 404);
const tracks = (tracksRes.data?.items ?? []).map(toTrack);
const tracks = tracksRes.data?.items ?? [];
const inAlbum = new Set(tracks.map((track) => track.id));
const library = (libraryRes?.data?.items ?? [])
.filter((track) => !inAlbum.has(track.id))
.map(toTrack);
const library = (libraryRes?.data?.items ?? []).filter((track) => !inAlbum.has(track.id));
const isOwner = user !== null && String(album.ownerUserId) === user.sub;
@@ -51,33 +46,59 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
};
export const actions: Actions = {
addTrack: authedAction(async ({ api, form, params }) => {
const trackId = formString(form, 'trackId');
addTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.POST('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } }
});
return failOnError(result, 'No se pudo añadir la canción.') ?? { added: true };
}),
const failure = unwrapOrFail(result, 'No se pudo añadir la canción.');
if (failure) return failure;
return { added: true };
},
removeTrack: authedAction(async ({ api, form, params }) => {
const trackId = formString(form, 'trackId');
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}/tracks/{trackId}', {
params: { path: { albumId: params.id, trackId } }
});
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
}),
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
edit: authedAction(async ({ api, form, params }) => {
edit: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const parsed = parseAlbumForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const cover = await uploadOptionalCover(api, '/albums/upload-picture', formFile(form, 'cover'));
if ('failMessage' in cover) {
return fail(502, { message: cover.failMessage, detail: cover.detail });
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
const cover = form.get('cover');
if (cover instanceof File && cover.size > 0) {
const uploaded = await uploadPresignedImage(
(args) => api.POST('/albums/upload-picture', { body: args }),
cover
);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
}
newPictureIntentId = uploaded.intentId;
}
const result = await api.PUT('/albums/{albumId}', {
@@ -86,18 +107,24 @@ export const actions: Actions = {
newTitle: parsed.body.title,
newDescription: parsed.body.description,
newReleaseYear: parsed.body.releaseYear,
newPictureIntentId: cover.intentId
newPictureIntentId
}
});
return failOnError(result, 'No se pudo actualizar el álbum.') ?? { edited: true };
}),
const failure = unwrapOrFail(result, 'No se pudo actualizar el álbum.');
if (failure) return failure;
return { edited: true };
},
delete: authedAction(async ({ api, params }) => {
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/albums/{albumId}', {
params: { path: { albumId: params.id } }
});
const failure = failOnError(result, 'No se pudo eliminar el álbum.');
const failure = unwrapOrFail(result, 'No se pudo eliminar el álbum.');
if (failure) return failure;
redirect(303, '/albums');
})
}
};
@@ -1,5 +1,5 @@
<script lang="ts">
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import X from '@lucide/svelte/icons/x';
import Plus from '@lucide/svelte/icons/plus';
import Page from '$lib/components/ui/layout/Page.svelte';
@@ -28,15 +28,15 @@
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
playAllOrToggle(tracks);
playAllOrToggle(toQueueItems(tracks));
}
function playTrackFrom(index: number) {
player.playOrToggle(tracks, index);
player.playOrToggle(toQueueItems(tracks), index);
}
function playLibraryFrom(index: number) {
player.playOrToggle(library, index);
player.playOrToggle(toQueueItems(library), index);
}
</script>
@@ -55,7 +55,10 @@
eyebrow="Álbum"
title={album.title}
description={album.description ?? undefined}
meta={albumMeta(album.releaseYear, tracks.length)}
meta={albumMeta(
album.releaseYear === null ? undefined : Number(album.releaseYear),
tracks.length
)}
>
{#snippet cover()}
<Artwork
@@ -115,7 +118,7 @@
action="?/edit"
initialTitle={album.title}
initialDescription={album.description ?? ''}
initialReleaseYear={album.releaseYear ?? undefined}
initialReleaseYear={album.releaseYear === null ? undefined : Number(album.releaseYear)}
coverFallbackUrl="/api/albums/{album.id}/cover?size=medium"
formMessage={form?.message}
submitLabel="Guardar"
@@ -1,8 +1,12 @@
import type { PageServerLoad, Actions } from './$types';
import { apiFor, unwrapOrError } from '$lib/server/api';
import { toAlbum, toPage, toTrack } from '$lib/server/mappers';
import { createApiClient, unwrapOrError } from '$lib/server/api';
import { addAlbumToPlaylistAction, addTrackAction } from '$lib/server/playlistActions';
import { EXPLORE_ALBUMS_PAGE_SIZE, EXPLORE_PAGE_SIZE, EXPLORE_USERS_LIMIT } from '$lib/config';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
import {
EXPLORE_ALBUMS_PAGE_SIZE,
EXPLORE_PAGE_SIZE as PAGE_SIZE,
EXPLORE_USERS_LIMIT
} from '$lib/config';
export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const query = url.searchParams.get('q')?.trim() ?? '';
@@ -10,7 +14,7 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
const page = Math.max(1, Number(url.searchParams.get('page')) || 1);
const user = locals.user;
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const genresRes = await api.GET('/genres');
const genres = genresRes.data ?? [];
@@ -22,11 +26,17 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
name: query || undefined,
genre: genre ?? undefined,
pageNumber: page,
pageSize: EXPLORE_PAGE_SIZE
pageSize: PAGE_SIZE
}
}
});
const playlistsPromise = user
? api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
})
: Promise.resolve(null);
const albumsPromise = query
? api.GET('/albums', {
params: { query: { title: query, pageNumber: 1, pageSize: EXPLORE_ALBUMS_PAGE_SIZE } }
@@ -39,30 +49,30 @@ export const load: PageServerLoad = async ({ url, locals, fetch }) => {
})
: Promise.resolve(null);
const [tracksRes, albumsRes, usersRes] = await Promise.all([
const [tracksRes, playlistsRes, albumsRes, usersRes] = await Promise.all([
tracksPromise,
playlistsPromise,
albumsPromise,
usersPromise
]);
const tracks = toPage(unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.'), (item) =>
toTrack(item.track)
);
const tracks = unwrapOrError(tracksRes, 'No se pudieron cargar las canciones.');
return {
query,
genre,
genres,
tracks,
albums: (albumsRes?.data?.items ?? []).map((item) => toAlbum(item.album)),
albumsTotal: Number(albumsRes?.data?.totalItemCount ?? 0),
albums: (albumsRes?.data?.items ?? []).map((item) => item.album),
users: usersRes?.data?.items ?? [],
usersTotal: Number(usersRes?.data?.totalItemCount ?? 0),
viewerId: user?.sub ?? null
viewerId: user?.sub ?? null,
playlists: playlistsRes?.data?.items ?? []
};
};
export const actions: Actions = {
addTrack: addTrackAction,
addAlbumToPlaylist: addAlbumToPlaylistAction
addAlbumToPlaylist: addAlbumToPlaylistAction,
follow: followUserAction,
unfollow: unfollowUserAction
};
@@ -1,8 +1,7 @@
<script lang="ts">
import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte';
import { fetchAlbumTracks } from '$lib/data/albums';
import type { Track } from '$lib/types';
import { player, toQueueItem } from '$lib/player/player.svelte';
import { fetchAlbumQueueItems } from '$lib/data/albums';
import { fmtTime, fmtPlays, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
@@ -50,7 +49,9 @@
let albumMenu = $state<AlbumMenuState | null>(null);
let sfilter = $state<SearchFilter>('Todo');
let localItems = $state<Track[]>([]);
type LocalTrack = (typeof data.tracks.items)[number]['track'];
let localItems = $state<LocalTrack[]>([]);
let localPage = $state(1);
let hasMore = $state(false);
let loadingMore = $state(false);
@@ -59,12 +60,19 @@
trackMenu.close();
albumMenu = null;
sfilter = data.genre ? 'Canciones' : 'Todo';
localItems = data.tracks.items;
localPage = data.tracks.pageNumber;
localItems = data.tracks.items.map((item) => item.track);
localPage = Number(data.tracks.pageNumber);
hasMore = data.tracks.hasNextPage;
});
const items = $derived(localItems);
const songRows = $derived(
localItems.map((track) => ({
track,
seconds: Number(track.duration)
}))
);
const items = $derived(songRows.map((row) => row.track));
const tiles = $derived(genreTiles(data.genres));
const activeGenre = $derived(data.genre ? genreInfo(data.genre) : null);
@@ -75,7 +83,7 @@
const hasAlbums = $derived(albums.length > 0);
const hasUsers = $derived(users.length > 0);
const playlistMatches = $derived(matchPlaylists(data.userPlaylists, data.query));
const playlistMatches = $derived(matchPlaylists(data.playlists, data.query));
const hasPlaylists = $derived(playlistMatches.length > 0);
const nothingFound = $derived(
@@ -84,10 +92,10 @@
const counts = $derived(
searchCounts({
tracks: data.tracks.totalItemCount,
albums: data.albumsTotal,
tracks: songRows.length,
albums: albums.length,
playlists: playlistMatches.length,
users: data.usersTotal
users: users.length
})
);
const chips = $derived(searchChips(counts));
@@ -98,7 +106,7 @@
type TopResult = NonNullable<
ReturnType<
typeof findTopResult<
Track,
LocalTrack,
(typeof albums)[number],
(typeof playlistMatches)[number],
(typeof users)[number]
@@ -120,7 +128,7 @@
function playTopResult() {
if (topResult?.kind !== 'track') return;
player.playOrToggle([topResult.track], 0);
player.playOrToggle([toQueueItem(topResult.track)], 0);
}
const topResultHref = $derived.by(() => {
@@ -131,7 +139,11 @@
});
function togglePlay(index: number) {
player.playOrToggle(items, index);
player.playOrToggle(items.map(toQueueItem), index);
}
function openContextMenu(event: MouseEvent, track: LocalTrack) {
trackMenu.open(event, track);
}
function openAlbumMenu(event: MouseEvent, albumId: string) {
@@ -142,14 +154,14 @@
if (!albumMenu) return;
const { albumId } = albumMenu;
albumMenu = null;
player.playNext(await fetchAlbumTracks(albumId));
player.playNext(await fetchAlbumQueueItems(albumId));
}
async function albumAddToQueue() {
if (!albumMenu) return;
const { albumId } = albumMenu;
albumMenu = null;
player.appendToQueue(await fetchAlbumTracks(albumId));
player.appendToQueue(await fetchAlbumQueueItems(albumId));
}
async function loadMore() {
@@ -165,8 +177,12 @@
const res = await fetch(`/api/tracks?${params}`);
if (!res.ok) throw new Error(String(res.status));
const next = (await res.json()) as typeof data.tracks;
localItems = appendUnique(localItems, next.items, (track) => track.id);
localPage = next.pageNumber;
localItems = appendUnique(
localItems,
next.items.map((item) => item.track),
(track) => track.id
);
localPage = Number(next.pageNumber);
hasMore = next.hasNextPage;
} catch {
hasMore = false;
@@ -324,30 +340,30 @@
<div class="mt-9 flex flex-col gap-9">
{#if showGroup(sfilter, 'Canciones')}
<div>
{#if items.length > 0}
{#if songRows.length > 0}
<SectionHeading title="Canciones" count={counts.Canciones} />
<ul class="flex flex-col gap-1">
{#each capped(sfilter, items) as track, i (track.id)}
{#each capped(sfilter, songRows) as { track, seconds }, i (track.id)}
<li>
<ListRow
title={track.title}
subtitle={track.artist}
subtitleHref={track.ownerUserId}
explicit={track.explicit}
active={player.currentId === track.id}
explicit={track.isExplicit}
active={player.current.id === track.id}
size="lg"
trackId={track.id}
onclick={() => togglePlay(i)}
oncontextmenu={(e) => trackMenu.open(e, track)}
oncontextmenu={(e) => openContextMenu(e, track)}
>
{#snippet overlay()}
{#if player.currentId === track.id}
{#if player.current.id === track.id}
<EqBars overlay paused={!player.playing} />
{/if}
{/snippet}
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{fmtTime(track.duration)} · {fmtPlays(track.listensCount)}
{fmtTime(seconds)} · {fmtPlays(track.listensCount)}
</span>
{/snippet}
</ListRow>
@@ -384,7 +400,7 @@
>
{#snippet trailing()}
<span class="hidden shrink-0 text-xs text-fg-2 tabular-nums sm:block">
{plural(album.trackCount, 'canción', 'canciones')}
{Number(album.trackCount)} canciones
</span>
{/snippet}
</ListRow>
@@ -443,10 +459,10 @@
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: trackMenu.state.track.id },
fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
playlists={data.playlists}
/>
{/if}
@@ -465,6 +481,6 @@
fields: { albumId: albumMenu.albumId },
label: 'Añadir álbum a una playlist'
}}
playlists={data.userPlaylists}
playlists={data.playlists}
/>
{/if}
@@ -1,36 +1,38 @@
import type { PageServerLoad, Actions } from './$types';
import { fail } from '@sveltejs/kit';
import { toDatedTrack, toPage } from '$lib/server/mappers';
import {
apiFor,
authedAction,
failOnError,
formString,
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { LIBRARY_PAGE_SIZE } from '$lib/config';
const PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/tracks/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: LIBRARY_PAGE_SIZE } }
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PAGE_SIZE } }
});
return {
tracks: toPage(unwrapOrError(result, 'No se pudo cargar tu biblioteca.'), toDatedTrack)
};
return { tracks: unwrapOrError(result, 'No se pudo cargar tu biblioteca.') };
};
export const actions: Actions = {
deleteTrack: authedAction(async ({ api, form }) => {
const trackId = formString(form, 'trackId');
deleteTrack: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/tracks/{trackId}', {
params: { path: { trackId } }
});
return failOnError(result, 'No se pudo borrar la canción.') ?? { deleted: true };
})
const failure = unwrapOrFail(result, 'No se pudo borrar la canción.');
if (failure) return failure;
return { deleted: true };
}
};
@@ -2,24 +2,22 @@
import Upload from '@lucide/svelte/icons/upload';
import X from '@lucide/svelte/icons/x';
import Music from '@lucide/svelte/icons/music';
import { player } from '$lib/player/player.svelte';
import { player, toQueueItems } from '$lib/player/player.svelte';
import { plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import ConfirmDialog from '$lib/components/ui/overlay/ConfirmDialog.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
let { data } = $props();
const items = $derived(data.tracks.items);
const total = $derived(data.tracks.totalItemCount);
let deleting = $state<(typeof items)[number] | null>(null);
const total = $derived(Number(data.tracks.totalItemCount));
const listTracks = $derived(items.map((track) => ({ ...track, uploadedAt: track.createdAt })));
function togglePlay(index: number) {
player.playOrToggle(items, index);
player.playOrToggle(toQueueItems(items), index);
}
</script>
@@ -45,14 +43,10 @@
{#if items.length > 0}
<TrackList
tracks={items}
tracks={listTracks}
columns={['uploaded', 'plays']}
onPlay={togglePlay}
rowAction={{
onclick: (track) => (deleting = items.find((item) => item.id === track.id) ?? null),
icon: X,
label: 'Borrar canción'
}}
rowAction={{ action: '?/deleteTrack', icon: X, label: 'Borrar canción' }}
/>
{:else}
<EmptyState
@@ -69,13 +63,3 @@
</EmptyState>
{/if}
</Page>
<ConfirmDialog
open={deleting !== null}
onClose={() => (deleting = null)}
title="¿Borrar «{deleting?.title ?? ''}»?"
description="Esta acción no se puede deshacer, la canción se eliminará de tu biblioteca, de tus álbumes y de las playlists donde esté."
confirmLabel="Borrar"
action="?/deleteTrack"
fields={{ trackId: deleting?.id ?? '' }}
/>
@@ -11,7 +11,7 @@
import TrackList from '$lib/components/ui/media/TrackList.svelte';
const tracks = $derived(liked.list);
const listTracks = $derived(tracks.map((track) => ({ ...track, date: track.likedAt })));
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.likedAt })));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playFrom(index: number) {
@@ -55,7 +55,7 @@
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={{
onclick: (track) => liked.toggle(track),
onclick: (track) => liked.toggle({ ...track, artist: track.artist ?? undefined }),
icon: X,
label: 'Quitar de Me gusta'
}}
@@ -1,15 +1,25 @@
import type { PageServerLoad, Actions } from './$types';
import { apiFor, requireUser, unwrapOrError } from '$lib/server/api';
import { toMix } from '$lib/server/mappers';
import { createApiClient, requireUser, unwrapOrError } from '$lib/server/api';
import { addTrackAction } from '$lib/server/playlistActions';
import { PLAYLIST_PICKER_PAGE_SIZE } from '$lib/config';
export const load: PageServerLoad = async ({ params, locals, url, fetch }) => {
requireUser(locals, url);
const api = apiFor({ fetch, locals });
const user = requireUser(locals, url);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const mixRes = await api.GET('/mixes/{id}', { params: { path: { id: params.id } } });
const [mixRes, playlistsRes] = await Promise.all([
api.GET('/mixes/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/users/{userId}', {
params: {
path: { userId: user.sub },
query: { pageNumber: 1, pageSize: PLAYLIST_PICKER_PAGE_SIZE }
}
})
]);
return { mix: toMix(unwrapOrError(mixRes, 'Mezcla no encontrada.', 404)) };
const mix = unwrapOrError(mixRes, 'Mezcla no encontrada.', 404);
return { mix, playlists: playlistsRes.data?.items ?? [] };
};
export const actions: Actions = {
@@ -1,6 +1,12 @@
<script lang="ts">
import Shuffle from '@lucide/svelte/icons/shuffle';
import { player, isQueueCurrent, playAllOrToggle, playShuffled } from '$lib/player/player.svelte';
import {
player,
isQueueCurrent,
playAllOrToggle,
playShuffled,
toQueueItem
} from '$lib/player/player.svelte';
import { fmtTime, plural } from '$lib/utils/format';
import Page from '$lib/components/ui/layout/Page.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
@@ -12,19 +18,33 @@
import ContextMenu from '$lib/components/ui/overlay/ContextMenu.svelte';
import ListPlus from '@lucide/svelte/icons/list-plus';
import { createTrackMenu } from '$lib/state/menus.svelte';
import { mixItemTrack } from '$lib/data/mixes';
let { data, form } = $props();
const mix = $derived(data.mix);
const tracks = $derived(mix.tracks);
const items = $derived(mix.items);
const tracks = $derived(items.map(mixItemTrack));
const queue = $derived(tracks.map(toQueueItem));
const listTracks = $derived(
items.map((item) => ({
id: item.trackId,
title: item.title,
artist: item.artist,
duration: item.durationSeconds,
listensCount: item.listensCount
}))
);
const totalSeconds = $derived(tracks.reduce((total, track) => total + track.duration, 0));
const isCurrentQueue = $derived(isQueueCurrent(tracks));
const totalSeconds = $derived(
items.reduce((total, item) => total + Number(item.durationSeconds), 0)
);
const isCurrentQueue = $derived(isQueueCurrent(queue));
const trackMenu = createTrackMenu();
function playFrom(index: number) {
player.playOrToggle(tracks, index);
player.playOrToggle(queue, index);
}
</script>
@@ -40,18 +60,18 @@
eyebrow="Mezcla"
title={mix.title}
description={mix.subtitle ?? undefined}
meta="{plural(tracks.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
meta="{plural(items.length, 'canción', 'canciones')} · {fmtTime(totalSeconds)}"
>
{#snippet cover()}
<Artwork
trackIds={tracks.map((track) => track.id)}
trackIds={items.map((item) => item.trackId)}
size="hero"
alt={mix.title}
class="shrink-0"
/>
{/snippet}
{#snippet actions()}
<Button size="sm" onclick={() => playAllOrToggle(tracks)} disabled={tracks.length === 0}>
<Button size="sm" onclick={() => playAllOrToggle(queue)} disabled={queue.length === 0}>
{#if isCurrentQueue && player.playing}
Pausar
{:else}
@@ -61,8 +81,8 @@
<Button
variant="secondary"
size="sm"
onclick={() => playShuffled(tracks)}
disabled={tracks.length === 0}
onclick={() => playShuffled(queue)}
disabled={queue.length === 0}
>
<Shuffle class="size-4" />
Aleatorio
@@ -75,7 +95,7 @@
{/if}
<TrackList
{tracks}
tracks={listTracks}
columns={['plays']}
onPlay={playFrom}
oncontextmenu={(e, _track, i) => trackMenu.open(e, tracks[i])}
@@ -93,9 +113,9 @@
]}
playlistAction={{
action: '?/addTrack',
fields: { trackId: trackMenu.state.track.id },
fields: { trackId: String(trackMenu.state.track.id) },
label: 'Añadir a una playlist'
}}
playlists={data.userPlaylists}
playlists={data.playlists}
/>
{/if}
@@ -1,16 +1,20 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import { apiFor, authedAction, requireUser, unwrapOrError } from '$lib/server/api';
import { PLAYLISTS_PAGE_SIZE, PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE } from '$lib/config';
import { toPlaylistSummary, toTrack } from '$lib/server/mappers';
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
import { uploadOptionalCover } from '$lib/server/upload';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrError
} from '$lib/server/api';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
const user = requireUser(locals, url);
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const result = await api.GET('/playlists/users/{userId}', {
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: PLAYLISTS_PAGE_SIZE } }
params: { path: { userId: user.sub }, query: { pageNumber: 1, pageSize: 50 } }
});
const data = unwrapOrError(result, 'No se pudieron cargar tus playlists.');
@@ -19,54 +23,68 @@ export const load: PageServerLoad = async ({ locals, url, fetch }) => {
data.items.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: PLAYLIST_SUMMARY_TRACKS_PAGE_SIZE }
}
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 500 } }
})
.then((res) => (res.data?.items ?? []).map(toTrack))
.then((res) => res.data?.items ?? [])
)
);
const items = data.items.map((playlist, i) =>
toPlaylistSummary(playlist, tracksByPlaylist[i].length)
);
const items = data.items.map((playlist, i) => {
const tracks = tracksByPlaylist[i];
const trackCount = tracks.length;
const durationSeconds = tracks.reduce((sum, t) => sum + Number(t.duration || 0), 0);
return { ...playlist, trackCount, durationSeconds };
});
const totals = {
playlistCount: items.length,
trackCount: items.reduce((sum, playlist) => sum + playlist.trackCount, 0),
durationSeconds: tracksByPlaylist.flat().reduce((sum, track) => sum + track.duration, 0)
trackCount: items.reduce((sum, p) => sum + p.trackCount, 0),
durationSeconds: items.reduce((sum, p) => sum + p.durationSeconds, 0)
};
return { playlists: items, totals };
return { playlists: { ...data, items }, totals };
};
export const actions: Actions = {
create: authedAction(async ({ api, form }) => {
const parsed = parsePlaylistForm(form);
if ('failMessage' in parsed) {
const { failMessage: message, name, description } = parsed;
return fail(400, { message, name, description });
}
const { name, description, visibility, cover } = parsed.body;
create: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
if ('failMessage' in uploaded) {
return fail(502, {
message: uploaded.failMessage,
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const visibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, {
message: 'El nombre es obligatorio (máx. 100 caracteres).',
name,
description,
detail: uploaded.detail
description
});
}
const { data, error } = await api.POST('/playlists', {
body: { name, description, pictureIntentId: uploaded.intentId, visibility }
const api = createApiClient({ fetch, accessToken });
let pictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, name, description, detail: result.detail });
}
pictureIntentId = result.intentId;
}
const { data, error: err } = await api.POST('/playlists', {
body: { name, description, pictureIntentId, visibility }
});
if (error || !data)
if (err || !data)
return fail(502, { message: 'No se pudo crear la playlist.', name, description });
redirect(303, `/playlists/${data.id}`);
})
}
};
@@ -10,7 +10,7 @@
let { data } = $props();
const items = $derived(data.playlists);
const items = $derived(data.playlists.items);
const totals = $derived(data.totals);
const summary = $derived(
`${plural(totals.playlistCount, 'playlist', 'playlists')} · ${plural(totals.trackCount, 'canción', 'canciones')} · ${fmtDurationLong(totals.durationSeconds)}`
@@ -40,7 +40,7 @@
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(playlist.trackCount)}
subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt
@@ -1,80 +1,95 @@
import type { PageServerLoad, Actions } from './$types';
import { fail, redirect } from '@sveltejs/kit';
import {
apiFor,
authedAction,
failOnError,
formString,
createApiClient,
optionalUser,
unwrapOrError
requireAccessTokenAction,
unwrapOrError,
unwrapOrFail
} from '$lib/server/api';
import { PLAYLIST_TRACKS_PAGE_SIZE } from '$lib/config';
import { toDatedTrack, toPlaylist } from '$lib/server/mappers';
import { parsePlaylistForm } from '$lib/server/forms/playlistForm';
import { uploadOptionalCover } from '$lib/server/upload';
import { uploadPresignedImage } from '$lib/server/upload';
const MAX_NAME = 100;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const user = optionalUser(locals, url, allowAnonymousListening);
const api = apiFor({ fetch, locals });
optionalUser(locals, url, allowAnonymousListening);
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [playlistRes, tracksRes] = await Promise.all([
api.GET('/playlists/{id}', { params: { path: { id: params.id } } }),
api.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: params.id },
query: { pageNumber: 1, pageSize: PLAYLIST_TRACKS_PAGE_SIZE }
}
params: { path: { playlistId: params.id }, query: { pageNumber: 1, pageSize: 200 } }
})
]);
const playlist = toPlaylist(unwrapOrError(playlistRes, 'Playlist no encontrada.', 404));
const tracks = (tracksRes.data?.items ?? []).map(toDatedTrack);
const playlist = unwrapOrError(playlistRes, 'Playlist no encontrada.', 404);
const tracks = tracksRes.data?.items ?? [];
const isOwner = user !== null && String(playlist.ownerUserId) === user.sub;
return { playlist, tracks, isOwner, section: isOwner ? '/playlists' : null };
return { playlist, tracks };
};
export const actions: Actions = {
removeTrack: authedAction(async ({ api, form, params }) => {
const trackId = formString(form, 'trackId');
removeTrack: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const trackId = String((await request.formData()).get('trackId') ?? '');
if (!trackId) return fail(400, { message: 'Falta la canción.' });
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}/tracks/{trackId}', {
params: { path: { playlistId: params.id, trackId } }
});
return failOnError(result, 'No se pudo quitar la canción.') ?? { removed: true };
}),
const failure = unwrapOrFail(result, 'No se pudo quitar la canción.');
if (failure) return failure;
return { removed: true };
},
edit: authedAction(async ({ api, form, params }) => {
const parsed = parsePlaylistForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const { name, description, visibility, cover } = parsed.body;
rename: async ({ request, params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const name = String(form.get('name') ?? '').trim();
const description = String(form.get('description') ?? '').trim();
const newVisibility = form.get('visibility') === 'public' ? 'Public' : 'Private';
const cover = form.get('cover');
if (name === '' || name.length > MAX_NAME) {
return fail(400, { message: 'El nombre es obligatorio (máx. 100 caracteres).' });
}
const uploaded = await uploadOptionalCover(api, '/playlists/upload-picture', cover);
if ('failMessage' in uploaded) {
return fail(502, { message: uploaded.failMessage, detail: uploaded.detail });
const api = createApiClient({ fetch, accessToken });
let newPictureIntentId: string | null = null;
if (cover instanceof File && cover.size > 0) {
const result = await uploadPresignedImage(
(args) => api.POST('/playlists/upload-picture', { body: args }),
cover
);
if ('failMessage' in result) {
return fail(502, { message: result.failMessage, detail: result.detail });
}
newPictureIntentId = result.intentId;
}
const result = await api.PUT('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } },
body: {
newName: name,
newDescription: description,
newPictureIntentId: uploaded.intentId,
newVisibility: visibility
}
body: { newName: name, newDescription: description, newPictureIntentId, newVisibility }
});
return failOnError(result, 'No se pudo actualizar la playlist.') ?? { edited: true };
}),
const failure = unwrapOrFail(result, 'No se pudo actualizar la playlist.');
if (failure) return failure;
return { renamed: true };
},
delete: authedAction(async ({ api, params }) => {
delete: async ({ params, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals);
if (typeof accessToken !== 'string') return accessToken;
const api = createApiClient({ fetch, accessToken });
const result = await api.DELETE('/playlists/{playlistId}', {
params: { path: { playlistId: params.id } }
});
const failure = failOnError(result, 'No se pudo eliminar la playlist.');
const failure = unwrapOrFail(result, 'No se pudo eliminar la playlist.');
if (failure) return failure;
redirect(303, '/playlists');
})
}
};
@@ -1,7 +1,7 @@
<script lang="ts">
import ListMusic from '@lucide/svelte/icons/list-music';
import X from '@lucide/svelte/icons/x';
import { player, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import { player, toQueueItems, isQueueCurrent, playAllOrToggle } from '$lib/player/player.svelte';
import Page from '$lib/components/ui/layout/Page.svelte';
import PageHeader from '$lib/components/ui/layout/PageHeader.svelte';
import Modal from '$lib/components/ui/overlay/Modal.svelte';
@@ -9,7 +9,6 @@
import Artwork from '$lib/components/ui/media/Artwork.svelte';
import PlaylistForm from '$lib/components/ui/forms/PlaylistForm.svelte';
import Button from '$lib/components/ui/primitives/Button.svelte';
import BackLink from '$lib/components/ui/primitives/BackLink.svelte';
import EmptyState from '$lib/components/ui/primitives/EmptyState.svelte';
import TrackList from '$lib/components/ui/media/TrackList.svelte';
import { playlistMeta } from '$lib/utils/format';
@@ -18,7 +17,7 @@
const playlist = $derived(data.playlist);
const tracks = $derived(data.tracks);
const isOwner = $derived(data.isOwner);
const listTracks = $derived(tracks.map((track) => ({ ...track, addedAt: track.createdAt })));
let editing = $state(false);
let confirmingDelete = $state(false);
@@ -26,11 +25,11 @@
const isCurrentQueue = $derived(isQueueCurrent(tracks));
function playAll() {
playAllOrToggle(tracks);
playAllOrToggle(toQueueItems(tracks));
}
function playFrom(index: number) {
player.playOrToggle(tracks, index);
player.playOrToggle(toQueueItems(tracks), index);
}
</script>
@@ -40,13 +39,8 @@
</svelte:head>
<Page>
<BackLink
href={isOwner ? '/playlists' : '/'}
label={isOwner ? 'Volver a tus playlists' : 'Volver al inicio'}
/>
<PageHeader
eyebrow="Playlist"
eyebrow="Lista"
title={playlist.name}
description={playlist.description ?? undefined}
meta="{playlistMeta(tracks.length)} · {playlist.visibility === 'Public'
@@ -73,23 +67,19 @@
Reproducir
{/if}
</Button>
{#if isOwner}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
Eliminar
</Button>
{/if}
<Button size="sm" variant="secondary" onclick={() => (editing = true)}>Editar</Button>
<Button size="sm" variant="secondary" onclick={() => (confirmingDelete = true)}>
Eliminar
</Button>
{/snippet}
</PageHeader>
{#if tracks.length > 0}
<TrackList
{tracks}
tracks={listTracks}
columns={['added', 'plays']}
onPlay={playFrom}
rowAction={isOwner
? { action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }
: undefined}
rowAction={{ action: '?/removeTrack', icon: X, label: 'Quitar de la playlist' }}
/>
{:else}
<EmptyState
@@ -106,7 +96,7 @@
<Modal open={editing} onClose={() => (editing = false)} title="Editar playlist">
<PlaylistForm
action="?/edit"
action="?/rename"
initialName={playlist.name}
initialDescription={playlist.description ?? ''}
initialVisibility={playlist.visibility === 'Public' ? 'public' : 'private'}
@@ -1,33 +1,59 @@
import type { Actions, PageServerLoad } from './$types';
import { fail } from '@sveltejs/kit';
import { apiFor, authedAction, requireData, requireUser } from '$lib/server/api';
import { parseTrackUploadForm } from '$lib/server/forms/trackUploadForm';
import {
createApiClient,
requireAccessTokenAction,
requireUser,
unwrapOrFail
} from '$lib/server/api';
import { putPresigned, extOf, contentTypeOf, AUDIO_TYPES, IMAGE_TYPES } from '$lib/server/upload';
import { findConflict, genreInfo } from '$lib/data/genres';
import type { components } from '$lib/api/schema';
type Genre = components['schemas']['Genre'];
const MAX_TITLE = 100;
export const load: PageServerLoad = async ({ locals, url, fetch }) => {
requireUser(locals, url);
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const { data } = await api.GET('/genres/available');
return { genres: data ?? [] };
};
export const actions: Actions = {
default: authedAction(async ({ api, form }) => {
const parsed = parseTrackUploadForm(form);
if ('failMessage' in parsed) return fail(400, { message: parsed.failMessage });
const { title, audio, cover, tags: rawTags, isExplicit } = parsed.body;
default: async ({ request, locals, fetch }) => {
const accessToken = requireAccessTokenAction(locals, 'Inicia sesión para subir música.');
if (typeof accessToken !== 'string') return accessToken;
const form = await request.formData();
const title = String(form.get('title') ?? '').trim();
const audio = form.get('audio');
const cover = form.get('cover');
const rawTags = form.getAll('tags').map(String);
const isExplicit = form.get('isExplicit') === 'on';
if (title === '' || title.length > MAX_TITLE) {
return fail(400, { message: 'El título es obligatorio (máx. 100 caracteres).' });
}
if (!(audio instanceof File) || audio.size === 0) {
return fail(400, { message: 'Selecciona un archivo de audio.' });
}
if (!(cover instanceof File) || cover.size === 0) {
return fail(400, { message: 'Selecciona una portada.' });
}
if (rawTags.length === 0) {
return fail(400, { message: 'Elige al menos un género.' });
}
const api = createApiClient({ fetch, accessToken });
const { data: available } = await api.GET('/genres/available');
const options = available ?? [];
const known = new Set<string>(options.map((option) => option.genre));
if (!rawTags.every((tag) => known.has(tag))) {
return fail(400, { message: 'Alguno de los géneros no es válido.' });
}
const tags = rawTags as Genre[];
const tags = [...new Set(rawTags)] as Genre[];
const conflict = findConflict(tags, options);
if (conflict) {
return fail(400, {
@@ -49,11 +75,9 @@ export const actions: Actions = {
}
});
const { data: urls, failure: urlsFailure } = requireData(
urlsResult,
'No se pudieron reservar las URLs de subida.'
);
const urlsFailure = unwrapOrFail(urlsResult, 'No se pudieron reservar las URLs de subida.');
if (urlsFailure) return urlsFailure;
const urls = urlsResult.data!;
try {
const [audioBuffer, coverBuffer] = await Promise.all([
@@ -81,12 +105,10 @@ export const actions: Actions = {
}
});
const { data: track, failure: trackFailure } = requireData(
trackResult,
'No se pudo crear la pista tras la subida.'
);
const trackFailure = unwrapOrFail(trackResult, 'No se pudo crear la pista tras la subida.');
if (trackFailure) return trackFailure;
const track = trackResult.data!;
return { success: true, trackId: track.id, title: track.title };
}, 'Inicia sesión para subir música.')
}
};
@@ -15,13 +15,13 @@
import Button from '$lib/components/ui/primitives/Button.svelte';
import ExplicitBadge from '$lib/components/ui/media/ExplicitBadge.svelte';
import Chip from '$lib/components/ui/primitives/Chip.svelte';
import { LIMITS } from '$lib/validation';
import { formatSize } from '$lib/utils/format';
import { genreOptions } from '$lib/data/genres';
type Status = 'idle' | 'uploading' | 'done' | 'error';
type StepState = 'idle' | 'active' | 'done';
const MAX_TITLE = 100;
let { data } = $props();
let title = $state('');
@@ -70,6 +70,12 @@
return s === 'idle' ? '' : 'bg-accent-tint';
}
function formatSize(bytes: number) {
if (bytes === 0) return '';
const mb = bytes / (1024 * 1024);
return mb >= 1 ? `${mb.toFixed(1)} MB` : `${Math.round(bytes / 1024)} KB`;
}
function pickAudio(file: File) {
audioName = file.name;
audioSize = file.size;
@@ -250,12 +256,12 @@
id="title"
name="title"
bind:value={title}
maxlength={LIMITS.trackTitle}
maxlength={MAX_TITLE}
placeholder="Nombre de la canción"
/>
{#snippet hint()}
<p>Se usa como nombre público de la pista en el catálogo.</p>
<span class="shrink-0 tabular-nums">{title.length}/{LIMITS.trackTitle}</span>
<span class="shrink-0 tabular-nums">{title.length}/{MAX_TITLE}</span>
{/snippet}
</Field>
@@ -293,9 +299,14 @@
</Surface>
<Checkbox bind:checked={acceptedTerms}>
Acepto los términos y condiciones al subir {title.trim() !== ''
? `«${title}»`
: 'esta canción'}.
Acepto los
<a
href="/terms"
class="text-accent-soft underline underline-offset-2 transition hover:brightness-110"
>
términos y condiciones
</a>
al subir {title.trim() !== '' ? `«${title}»` : 'esta canción'}.
</Checkbox>
{#if status === 'uploading'}
@@ -1,13 +1,13 @@
import type { PageServerLoad, Actions } from './$types';
import { apiFor, optionalUser, unwrapOrError } from '$lib/server/api';
import { toPlaylistSummary } from '$lib/server/mappers';
import { COUNT_ONLY_PAGE_SIZE, PROFILE_PLAYLISTS_PAGE_SIZE } from '$lib/config';
import { createApiClient, optionalUser, unwrapOrError } from '$lib/server/api';
import { followUserAction, unfollowUserAction } from '$lib/server/followActions';
const PROFILE_PLAYLISTS_PAGE_SIZE = 50;
export const load: PageServerLoad = async ({ params, locals, url, fetch, parent }) => {
const { allowAnonymousListening } = await parent();
const viewer = optionalUser(locals, url, allowAnonymousListening);
const api = apiFor({ fetch, locals });
const api = createApiClient({ fetch, accessToken: locals.accessToken ?? undefined });
const [profileRes, playlistsRes] = await Promise.all([
api.GET('/users/{id}/profile', { params: { path: { id: params.id } } }),
@@ -26,28 +26,20 @@ export const load: PageServerLoad = async ({ params, locals, url, fetch, parent
playlistItems.map((playlist) =>
api
.GET('/playlists/{playlistId}/tracks', {
params: {
path: { playlistId: playlist.id },
query: { pageNumber: 1, pageSize: COUNT_ONLY_PAGE_SIZE }
}
params: { path: { playlistId: playlist.id }, query: { pageNumber: 1, pageSize: 1 } }
})
.then((res) => Number(res.data?.totalItemCount ?? 0))
)
);
const playlists = playlistItems.map((playlist, i) =>
toPlaylistSummary(playlist, tracksByPlaylist[i])
);
const playlists = playlistItems.map((playlist, i) => ({
...playlist,
trackCount: tracksByPlaylist[i]
}));
const isOwnProfile = viewer !== null && viewer.sub === params.id;
return {
profile,
followersCount: Number(profile.followersCount),
playlists,
isOwnProfile,
isAnonymous: viewer === null
};
return { profile, playlists, isOwnProfile, isAnonymous: viewer === null };
};
export const actions: Actions = {
@@ -26,7 +26,7 @@
{/snippet}
{#snippet followStats()}
{@const followers = plural(data.followersCount, 'seguidor', 'seguidores')}
{@const followers = plural(Number(profile.followersCount), 'seguidor', 'seguidores')}
{#if data.isOwnProfile || profile.canViewFollowers}
<a href="/user/{profile.id}/followers" class="transition-colors hover:text-fg">{followers}</a>
{:else}
@@ -60,7 +60,7 @@
<MediaCard
href="/playlists/{playlist.id}"
title={playlist.name}
subtitle={playlistMeta(playlist.trackCount)}
subtitle={playlistMeta(Number(playlist.trackCount))}
trackIds={playlist.coverTrackIds}
src="/api/playlists/{playlist.id}/cover?size=large&v={encodeURIComponent(
playlist.updatedAt

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