diff --git a/deploy/seaweedfs/docker-compose.yml b/deploy/seaweedfs/docker-compose.yml new file mode 100644 index 0000000..1d48e3e --- /dev/null +++ b/deploy/seaweedfs/docker-compose.yml @@ -0,0 +1,32 @@ +services: + seaweedfs: + image: chrislusf/seaweedfs:latest + container_name: seaweedfs + # All-in-one dev server: master + volume + filer + S3 gateway. + command: > + server + -ip.bind=0.0.0.0 + -dir=/data + -master.volumeSizeLimitMB=1024 + -volume.max=0 + -filer + -s3 + -s3.config=/etc/seaweedfs/s3.conf + ports: + - "9333:9333" # master (admin/UI) + - "8888:8888" # filer HTTP -> used by the Media Gateway + - "8333:8333" # S3 API -> used by the WebApi (InfrastructureStorage:Address) + # Volume server (8080) is left unpublished on purpose: it only needs to be + # reachable internally, and 8080 clashes with Zitadel on the host. + volumes: + - seaweedfs_data:/data + - ./s3.conf:/etc/seaweedfs/s3.conf:ro + restart: unless-stopped + healthcheck: + test: ["CMD", "wget", "-q", "-O", "-", "http://localhost:9333/cluster/healthz"] + interval: 10s + timeout: 5s + retries: 10 + +volumes: + seaweedfs_data: diff --git a/deploy/seaweedfs/s3.conf b/deploy/seaweedfs/s3.conf new file mode 100644 index 0000000..dc0ed2a --- /dev/null +++ b/deploy/seaweedfs/s3.conf @@ -0,0 +1,24 @@ +{ + "identities": [ + { + "name": "admin_user", + "credentials": [ + { + "accessKey": "admin_access_key", + "secretKey": "admin_secret_key" + } + ], + "actions": ["Admin", "Read", "Write"] + }, + { + "name": "read_only_user", + "credentials": [ + { + "accessKey": "readonly_access_key", + "secretKey": "readonly_secret_key" + } + ], + "actions": ["Read"] + } + ] +} diff --git a/docs/README.md b/docs/README.md new file mode 100644 index 0000000..963c0d0 --- /dev/null +++ b/docs/README.md @@ -0,0 +1,45 @@ +# Musify — Documentación + +Musify es un backend de streaming de música: subir pistas, procesarlas (transcodificar el audio a `.m4a` y generar miniaturas), organizarlas en playlists y reproducirlas en streaming sin que los bytes pasen por la API. + +Esta carpeta explica **qué hace cada pieza, su propósito y por qué está diseñada así**. + +## Índice + +- [architecture.md](architecture.md) — Visión global: componentes, cómo encajan y los flujos principales. +- [projects.md](projects.md) — Qué es y para qué sirve cada proyecto/solución. +- [authentication.md](authentication.md) — Zitadel (OIDC), validación de JWT, provisioning/sync de usuarios. +- [storage.md](storage.md) — SeaweedFS (S3), estructura de claves, URLs prefirmadas, qué es público y qué privado. +- [streaming.md](streaming.md) — Streaming del audio (`.m4a` por HTTP range) con stream-ticket (RS256) y el reverse proxy (StreamingGateway). +- [media-processing.md](media-processing.md) — El worker y los workflows de MassTransit (transcode de audio, miniaturas). +- [development.md](development.md) — Cómo levantar todo en local: contenedores, puertos y comandos. + +## Mapa de componentes (alto nivel) + +``` + ┌─────────────────────────────────────────────┐ + Cliente ───► │ WebApi (:5111) API REST + emite tickets │ + └─────────────────────────────────────────────┘ + │ EF Core │ presigned (subidas) │ MassTransit (eventos) + ▼ ▼ ▼ + PostgreSQL SeaweedFS S3 (:8333) RabbitMQ ──► Worker (procesa) + (metadatos) (objetos) audio→.m4a, imágenes + ▲ + Cliente ───► StreamingGateway (:8081) ┘ sirve audio .m4a (valida ticket, proxy al filer) + + Login: Cliente ◄──► Zitadel (:8080, OIDC) → WebApi valida el JWT +``` + +## Stack + +- **.NET 10**, C#. Minimal APIs (WebApi), Worker Service (Worker), ASP.NET + YARP (StreamingGateway). +- **CQRS** con [Mediator] (source-generator) y resultados con **Ardalis.Result**. +- **EF Core 10** + **PostgreSQL** (metadatos). +- **MassTransit 8** + **RabbitMQ** (procesado asíncrono con routing slips/sagas). +- **SeaweedFS** (almacenamiento de objetos compatible con S3, vía AWSSDK.S3). +- **ffmpeg** (transcode de audio a AAC/`.m4a`), **SixLabors.ImageSharp** (miniaturas). +- **Zitadel** (OIDC/OAuth2) para identidad. + +## Repositorios git + +Cada proyecto de nivel superior es su propio repo git: `MusifyBackend`, `Musify.WebApi`, `Musify.StreamingGateway`, `Musify.Worker`. La raíz del workspace (`docs/`, `deploy/`, `player.html`) no está versionada. diff --git a/docs/architecture.md b/docs/architecture.md new file mode 100644 index 0000000..9226e1c --- /dev/null +++ b/docs/architecture.md @@ -0,0 +1,55 @@ +# Arquitectura + +## Principios + +- **Clean architecture / capas** en `MusifyBackend`: el dominio y la aplicación no dependen de infraestructura; la infraestructura implementa las interfaces (`Application/Contracts`). +- **CQRS**: cada operación es un `Command` o `Query` con su handler (Mediator). Los handlers devuelven `Ardalis.Result` (Ok/NotFound/Unauthorized/Conflict/Invalid…), que la WebApi traduce a códigos HTTP. +- **Los bytes pesados no pasan por la API**: subidas con URLs prefirmadas (cliente → S3) y reproducción vía un reverse proxy dedicado (gateway → S3). La WebApi solo mueve metadatos y emite permisos. +- **Procesado asíncrono**: crear una pista no transcodifica en caliente; publica un evento y un worker hace el trabajo pesado con compensación (sagas/routing slips de MassTransit). + +## Componentes + +| Componente | Responsabilidad | Por qué separado | +|---|---|---| +| **WebApi** | API REST (usuarios, pistas, playlists), autoriza, emite presigned URLs y stream-tickets | Punto de entrada fino; no procesa medios ni sirve bytes | +| **MusifyBackend** | Dominio + casos de uso + infraestructura compartida (EF, S3, MassTransit, ffmpeg) | Lógica reutilizada por WebApi y Worker | +| **StreamingGateway** | Reverse proxy (YARP) que valida el ticket y sirve el audio (`.m4a`) desde SeaweedFS | Escala con el tráfico de bytes, independiente de la API; SeaweedFS queda privado detrás | +| **Worker** | Consume eventos de MassTransit: transcodifica audio a `.m4a` y genera miniaturas | Trabajo CPU/IO intensivo fuera del request HTTP | +| **PostgreSQL** | Metadatos (usuarios, pistas, playlists, upload intents) | — | +| **RabbitMQ** | Cola de eventos + outbox transaccional | Desacopla creación de procesado | +| **SeaweedFS** | Almacenamiento de objetos (S3 + filer) | Guarda originales y derivados (audio `.m4a`, miniaturas) | +| **Zitadel** | Identidad (OIDC/OAuth2) | Login y emisión de JWT | + +## Flujos principales + +### 1. Subida de una pista +1. Cliente pide `POST /tracks/upload-urls` → la WebApi crea **UploadIntents** (reservas) y devuelve **URLs prefirmadas PUT** (imagen + audio). +2. Cliente sube los ficheros **directo a SeaweedFS** con esas URLs (no pasan por la API). +3. Cliente llama `POST /tracks` con los `intentId`s → la WebApi valida los intents, crea el `Track` (+ `UserHasTrack`) y publica `CreateTrackResourcesEvent`. +4. El **Worker** consume el evento: mueve los originales a su sitio, genera miniaturas y transcodifica el audio a `.m4a`, y actualiza el estado del track. + +Ver detalle en [media-processing.md](media-processing.md) y [storage.md](storage.md). + +### 2. Reproducción (streaming) +1. Cliente pide `GET /tracks/{id}/stream` (autenticado) → la WebApi autoriza y devuelve `{ manifestUrl, ticket }` (URL al `.m4a` + ticket RS256 acotado a la carpeta de esa pista). +2. El reproductor abre la URL del audio añadiendo `?t=` (o cabecera `X-Stream-Ticket`) y descarga por HTTP range. +3. El **StreamingGateway** valida el ticket y reenvía al filer de SeaweedFS; los bytes van storage → cliente. + +Ver [streaming.md](streaming.md). + +### 3. Autenticación +1. Cliente hace login OIDC contra **Zitadel** y obtiene un access token (JWT). +2. La WebApi valida la firma/iss/aud del JWT. +3. En cada token validado, se **sincroniza** el usuario local (crea/actualiza nombre y foto) — provisioning just-in-time. + +Ver [authentication.md](authentication.md). + +## Modelo de datos (entidades) + +- **User** — `Id` es el `sub` numérico de Zitadel (`long`), más nombre y `ProfilePictureUrl`. +- **Track** — pista: nombres de originales/derivados, `AudioFolderName` (carpeta del `.m4a` transcodificado), estados de procesado (`ProcessingStatus`), `LifeCycleStatus`. +- **PlayList** — lista del usuario (`UserId`). +- **PlayListHasTrack** — pistas dentro de una playlist (con `Position`). +- **UserHasTrack** — propiedad: qué usuario posee qué pista. +- **UploadIntent** — reserva de subida (clave, bucket, tamaño esperado, estado, expiración) para validar y aplicar cuota. +- **Upload** — registro de objetos subidos/transferidos al storage. diff --git a/docs/authentication.md b/docs/authentication.md new file mode 100644 index 0000000..0529118 --- /dev/null +++ b/docs/authentication.md @@ -0,0 +1,40 @@ +# Autenticación e identidad + +## Proveedor: Zitadel (OIDC/OAuth2) + +El login y la emisión de tokens los hace **Zitadel** (no la WebApi). La WebApi es un **resource server**: solo **valida** el access token (JWT) que le llega en `Authorization: Bearer`. + +- Config en la sección `Authentication` (`AuthenticationConfiguration`): metadata address, issuer, audience, client id, endpoints de authorize/token, scopes. +- `JwtBearerOptionsSetup` configura la validación (issuer, audience, claves vía metadata OIDC). +- En dev, `RequireHttpsMetadata = false`. + +### Requisito clave en la app de Zitadel +- **Auth Token Type = JWT** (si no, Zitadel emite un token **opaco** sin puntos y `JwtBearer` no lo puede validar → 401 en todo lo protegido). +- **User Info inside Token** activado si quieres que el access token traiga claims de perfil (`name`, `email`, `picture`). Por defecto el access token es mínimo (`sub`, `aud`, `iss`, `exp`…), sin perfil. + +## El `sub` es numérico → `User.Id` es `long` + +El `sub` de Zitadel es un entero de 64 bits en string (p. ej. `371953080444977155`), **no un GUID**. Por eso: +- `User.Id` y todas las FKs de usuario (`PlayList.UserId`, `UserHasTrack.UserId`, `UploadIntent.UserId`) son **`long`**. +- `User.Id` lleva `[DatabaseGenerated(None)]` para que EF **no** lo autogenere: se asigna el `sub`. +- `CurrentUser.Id` es `long?` (parsea con `long.TryParse`); `RequiredId` lanza si falta. + +**Por qué**: el código antiguo hacía `Guid.Parse(sub)` y reventaba (FormatException) → 401 en cada endpoint protegido. Se decidió guardar el `sub` tal cual como número. + +## Provisioning / sincronización de usuarios + +En cada token validado, `JwtBearerEventsHandler.TokenValidated`: +1. Lee `sub` (id), `name`/`preferred_username`/`email` (con fallback) y el claim OIDC `picture`. +2. Envía un `SyncUserCommand`. + +`SyncUserCommandHandler` hace un **upsert idempotente**: +- Si el usuario no existe → lo crea. +- Si existe → actualiza nombre/apellidos/`ProfilePictureUrl` **solo si cambiaron** (escribe en DB únicamente cuando hay cambios). + +**Por qué upsert con "escribe solo si cambió"**: `TokenValidated` se ejecuta en **cada request**; sin ese guardado condicional estaríamos escribiendo en cada petición. Y debe ser resiliente: si el provisioning falla, se loguea pero **no** tumba la autenticación. + +> Nota: `CreateUserCommand` (endpoint manual `POST /users`) sigue siendo create-only; el sync del login usa `SyncUserCommand`. + +## Foto de perfil + +`User.ProfilePictureUrl` se rellena desde el claim `picture` del token. Si Zitadel no mete la info de perfil en el access token (ver arriba), llega `null` — el código es correcto, pero no hay nada que sincronizar hasta activar esa opción. diff --git a/docs/development.md b/docs/development.md new file mode 100644 index 0000000..401d83a --- /dev/null +++ b/docs/development.md @@ -0,0 +1,91 @@ +# Entorno de desarrollo + +Cómo levantar todo en local y probarlo. + +## Dependencias (Docker) + +```powershell +# SeaweedFS (almacenamiento) +docker compose -f deploy\seaweedfs\docker-compose.yml up -d + +# PostgreSQL + pgAdmin +docker compose -f "C:\Users\XShin\OneDrive\Desktop\postgresql\docker-compose.yml" up -d + +# Zitadel (auth) +docker compose -f "C:\Users\XShin\OneDrive\Pictures\zitadel-compose\docker-compose.yml" up -d + +# RabbitMQ (contenedor suelto) +docker start rabbitmq +``` + +> Nota: usa `docker compose up` (no `docker start`) para Postgres/Zitadel: si sus redes se borraron, compose las recrea. + +## Aplicaciones (.NET) + +```powershell +dotnet run --project Musify.WebApi\WebApi # :5111 +dotnet run --project Musify.StreamingGateway\StreamingGateway # :8081 +dotnet run --project Musify.Worker\Worker # background +``` + +## Mapa de puertos + +| Servicio | Puerto | +|---|---| +| WebApi | 5111 (`/scalar/v1`, `/openapi/v1.json`) | +| StreamingGateway | 8081 (`/health`, `/media/...`) | +| SeaweedFS S3 / filer / master | 8333 / 8888 / 9333 | +| Zitadel | 8080 | +| PostgreSQL | 59000 (→ 5432 en el contenedor) | +| RabbitMQ AMQP / management | 5672 / 15672 | +| pgAdmin | 5050 | + +DB: `musify_db`, usuario `postgres`/`postgres`. Bucket S3: `webapi-storage` (credenciales `admin_access_key`/`admin_secret_key`). + +## Migraciones + +No hay migración automática al arrancar. Para aplicar cambios de esquema: + +```powershell +cd MusifyBackend\Infrastructure +dotnet ef database update --context Database +``` + +La cadena de conexión para `dotnet ef` se lee de `DesignSettings.json` / user-secrets del proyecto Infrastructure. + +## Stream-tickets (claves RS256) + +La WebApi firma los stream-tickets con una clave privada y el gateway valida con la pública: + +```powershell +openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:2048 -out stream_private.pem +openssl rsa -in stream_private.pem -pubout -out stream_public.pem +``` + +- WebApi: `StreamTicket:PrivateKeyPath` → `stream_private.pem`. +- Gateway: `StreamTicket:PublicKeyPath` → `stream_public.pem`. + +## Consola de pruebas: `player.html` + +Un único archivo HTML para probar todo sin front: + +1. Sírvelo o ábrelo directo (el CORS está abierto en Development). +2. Pega tu **access token** de Zitadel y la API base (`http://localhost:5111`). +3. Permite: crear/actualizar/eliminar playlists, listar canciones, añadir/quitar canciones de una playlist, ver las canciones de una playlist y **reproducir** (pide el ticket y abre el `.m4a` con `?t=`). + +> Nota: `player.html` todavía usa dash.js; tras el cambio a `.m4a` debe actualizarse para reproducir el fichero directo (elemento `