Implement musical domain and full-track listening

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 3708c84e90
commit 46fe708bc2

@ -4,8 +4,8 @@ DATABASE_URL=postgresql://senza_paura_web:CLAVE@192.168.18.171:5432/senzapaura
# --- Audio -------------------------------------------------------------------
# Directorio privado con los archivos completos. NO debe servirse como estático:
# es lo que se entrega solo a quien ha comprado. Los fragmentos de muestra viven
# aparte, en static/muestras/, y esos sí son públicos.
# es lo que se entrega solo a quien ha comprado. Las canciones completas de
# escucha viven a menor calidad en static/escucha/ y son públicas.
MEDIA_DIR=./media/audio
# --- Pagos (Stripe) ----------------------------------------------------------

@ -6,9 +6,9 @@ clásica, rock y pop melódico—, se escucha desde un **reproductor persistente
que no se corta al navegar, y se puede **comprar en packs de diez canciones**
que se entregan como descarga en MP3.
El catálogo vive en Markdown dentro del repositorio: publicar una canción es
añadir un archivo. La base de datos guarda solo lo que generan las personas que
visitan la web —cuentas, carritos, pedidos y compras—.
El catálogo y el contenido editorial viven en PostgreSQL. Los Markdown de
`src/content` se conservan como fuente de importación y respaldo, pero el sitio
público y el panel leen el modelo relacional.
## Estado
@ -81,20 +81,22 @@ el botón de pagar avisa de que no hay pasarela. Sin clave de correo, en
desarrollo **el código de acceso se imprime por consola**, así que se puede
entrar en una cuenta sin configurar nada.
| Comando | Qué hace |
| --------------------- | --------------------------------------------- |
| `npm run dev` | Servidor de desarrollo (panel en `/admin`) |
| `npm run build` | Compila a `build/` (adapter-node) |
| `npm run preview` | Sirve la compilación de producción en `:4173` |
| `npm run check` | Comprobación de tipos |
| `npm run lint` | Prettier + ESLint |
| `npm run format` | Formatea el proyecto |
| `npm run test:unit` | Tests unitarios y de componente (Vitest) |
| `npm run test:e2e` | Compila y ejecuta los end-to-end (Playwright) |
| `npm test` | Todo lo anterior |
| `npm run db:generate` | Genera una migración a partir del esquema |
| `npm run db:migrate` | Aplica las migraciones pendientes |
| `npm run db:studio` | Abre el visor de la base de datos |
| Comando | Qué hace |
| ---------------------------------------- | ------------------------------------------------- |
| `npm run dev` | Servidor de desarrollo (panel en `/admin`) |
| `npm run build` | Compila a `build/` (adapter-node) |
| `npm run preview` | Sirve la compilación de producción en `:4173` |
| `npm run check` | Comprobación de tipos |
| `npm run lint` | Prettier + ESLint |
| `npm run format` | Formatea el proyecto |
| `npm run test:unit` | Tests unitarios y de componente (Vitest) |
| `npm run test:e2e` | Compila y ejecuta los end-to-end (Playwright) |
| `npm test` | Todo lo anterior |
| `npm run db:generate` | Genera una migración a partir del esquema |
| `npm run db:migrate` | Aplica las migraciones pendientes |
| `npm run db:migrate:check -- <archivos>` | Prueba migraciones y revierte la transacción |
| `npm run db:auditar-musica` | Comprueba códigos, créditos y streams tras migrar |
| `npm run db:studio` | Abre el visor de la base de datos |
## El panel
@ -121,9 +123,8 @@ que se desincronizaran nadie se enteraría hasta que el panel guardara en una
columna que ya no está.
La portada del panel dice qué hay y qué falta —capítulos sin publicar, géneros
sin ficha, marcas del glosario sin confirmar—, que es la pregunta que se hace
quien entra. Desde ahí se editan los capítulos, las fichas de género y los
prompts de IA.
sin ficha, artistas incompletos y canciones sin audio de escucha—. Desde ahí se
editan capítulos, géneros, artistas, créditos, versiones musicales y prompts.
### Los prompts
@ -473,14 +474,17 @@ los principios a las fichas es cambiar de cosa, no seguir leyendo.
## Dónde va el audio
Como el catálogo se vende, **el archivo completo no es público**:
Cada grabación tiene representaciones técnicas distintas. La escucha pública
contiene la canción completa a calidad reducida; el máster y la descarga de
compra siguen siendo privados.
Todo va agrupado **por disco**, con el mismo nombre de carpeta en los tres
árboles:
```
media/audio/<album>/NN-<cancion>.mp3 archivo completo — privado, nunca se sirve
static/muestras/<album>/NN-<cancion>.mp3 fragmento — público, es lo que suena en la web
media/audio/<album>/NN-<cancion>.wav máster o descarga — privado
static/escucha/<album>/NN-<cancion>.mp3 canción completa a 96 kbps — pública
static/muestras/<album>/NN-<cancion>.mp3 fragmento antiguo — legado temporal
static/images/<album>/portada.jpg portada del disco
static/images/<album>/<cancion>.jpg portada propia de un tema
static/images/sin-portada.svg reserva cuando no hay ninguna
@ -491,7 +495,7 @@ del álbum «Singles» que se genera solo.
La `<clave>` del audio es lo que declara el campo `audio` de la canción, sin
barra inicial: `como-si-nada/01-como-si-nada.mp3`. De ahí salen la ruta privada
y la de la muestra, así que no hay que escribirlas por duplicado.
y la copia de escucha, así que no hay que escribirlas por duplicado.
El directorio privado se configura con `MEDIA_DIR`. Los detalles de formato, la
convención de nombres y cómo sustituir los archivos de marcador de posición
@ -500,16 +504,17 @@ están en [`media/LEEME.md`](media/LEEME.md).
### Subir un tema sin tocar el disco
El campo **Archivo de audio** del panel sube el máster a `media/audio/subidas/`.
Lo que el navegador no puede hacer —recortar la muestra y medir la duración— lo
Lo que el navegador no puede hacer —transcodificar la escucha y medir el audio— lo
hace [`scripts/medios.mjs`](scripts/medios.mjs), que corre solo antes de
`npm run dev` y de `npm run build`, y también a mano con `npm run medios`:
coloca el archivo en la carpeta de su disco con el nombre de la convención,
reescribe la clave en el Markdown, genera el fragmento y rellena `duracion` si
reescribe la clave en el Markdown, genera la canción completa a 96 kbps y rellena `duracion` si
estaba vacía.
Necesita **ffmpeg** en el equipo de quien edita. Si falta, avisa de lo que no ha
podido generar y sigue; no rompe el arranque ni el build. El servidor de
despliegue no lo necesita: las muestras están en el repositorio.
despliegue no lo necesita: los derivados están en el repositorio. El perfil se
define una sola vez en `scripts/config/audio.mjs`.
Un archivo que se quede en `subidas/` sin ficha que lo reclame se avisa en cada
arranque, para que no se pierda ahí.
@ -807,8 +812,8 @@ música sigue sonando mientras se recorre la web. Su estado es una única
instancia de módulo ([`src/lib/player/player.svelte.ts`](src/lib/player/player.svelte.ts)),
no un contexto de Svelte, precisamente por eso.
**Lo que suena siempre es el fragmento de muestra**, y la barra lo dice. El
archivo completo solo se obtiene comprando.
**Lo que suena es la canción completa en calidad de escucha.** La compra entrega
la representación privada de mayor calidad e incluye las versiones musicales.
Qué hace: cola con orden propio, aleatorio (respetando el tema en curso),
repetición de cola o de tema, búsqueda dentro del tema, volumen recordado entre
@ -920,9 +925,9 @@ Quien tiene cuenta puede puntuar un tema de 1 a 5 desde su ficha. La media y el
número de votos se ven siempre, también sin entrar: saber cómo está valorado un
tema no exige cuenta; lo que la exige es votar, para que un voto sea de alguien.
**No hace falta haber comprado el tema.** Lo que suena en la web es un fragmento,
y opinar sobre lo que se ha escuchado es el uso normal; exigir la compra
convertiría la valoración en una reseña de producto.
**No hace falta haber comprado el tema.** La web permite escucharlo completo en
calidad reducida, y opinar sobre lo que se ha escuchado es el uso normal; exigir
la compra convertiría la valoración en una reseña de producto.
Un voto por cuenta y tema, y volver a votar reemplaza el anterior. Eso lo impone
la **clave primaria compuesta** de la tabla `valoracion`, no el código de la
@ -1037,16 +1042,17 @@ npm run test:unit -- --run
npm run test:e2e
```
## El contenido se va a PostgreSQL
## El contenido en PostgreSQL
Lo que hay hoy en `src/content/**` son registros disfrazados de documento, y eso
tiene un coste concreto: cinco tablas apuntan a una canción por una cadena de
texto sin clave foránea, así que **renombrar el slug de un tema deja huérfana una
compra sin que salte ningún error**.
El modelo completo —41 tablas, con el porqué de cada decisión— está en
[`docs/modelo-de-datos.md`](docs/modelo-de-datos.md). Está entregado y pendiente
de verificación; no hay código escrito todavía.
El modelo completo, con el porqué de cada decisión, está en
[`docs/modelo-de-datos.md`](docs/modelo-de-datos.md). Las migraciones, el panel
y las lecturas públicas ya están implementados; los volcamientos totales quedan
reservados para bases sin actividad comercial.
## Despliegue

@ -53,7 +53,7 @@ server {
# mientras el DNS no haya propagado del todo.
server_name senzapaura.es www.senzapaura.es _;
# Los mp3 completos pesan varios megas; los fragmentos, cientos de kilos.
# Los MP3 de escucha completa pesan varios megas.
client_max_body_size 20m;
gzip on;
@ -79,7 +79,19 @@ server {
try_files $uri =404;
}
# Imágenes, fuentes, vídeo, los fragmentos de muestra y el panel. No llevan
# El audio de escucha es público y completo, pero de calidad reducida. Nginx
# lo entrega directamente y anuncia rangos para que el reproductor pueda
# buscar dentro de la pista sin descargarla de nuevo desde el principio.
location /escucha/ {
root /var/www/senzapaura/build/client;
access_log off;
expires 1d;
add_header Cache-Control "public";
add_header Accept-Ranges "bytes" always;
try_files $uri =404;
}
# Imágenes, fuentes, vídeo, las muestras antiguas y el panel. No llevan
# huella, así que se cachean un día: lo justo para no pedirlos en cada
# visita sin que un cambio tarde una semana en verse.
location ~* ^/(images|background|video|muestras|fuentes|admin)/ {
@ -95,9 +107,9 @@ server {
#
# Incluidas /descargas/… —que comprueban la compra antes de entregar el
# archivo—, la vuelta de Google y Facebook, y el webhook de Stripe. Ojo:
# las descargas NO se pueden servir desde Nginx; el audio completo vive
# fuera de `build/` justamente para que no haya forma de pedirlo por URL
# sin haberlo comprado.
# las descargas NO se pueden servir desde Nginx; el audio original o de alta
# calidad vive fuera de `build/` justamente para que no haya forma de
# pedirlo por URL sin haberlo comprado.
# -----------------------------------------------------------------------
location / {
proxy_pass http://127.0.0.1:3000;

@ -1,14 +1,14 @@
# Modelo de datos
Diseño de la migración del contenido a PostgreSQL. **Fase 1 cerrada**: el
modelo está completo y las decisiones tomadas. No hay código escrito todavía.
Diseño y estado de la migración del contenido a PostgreSQL. El modelo base y
el dominio musical están implementados en `src/lib/server/db/schema` y en las
migraciones `0000`–`0014`.
Versión presentable, con diagramas entidad-relación:
<https://claude.ai/code/artifact/d87ceb4d-6038-402f-852e-13ff5d86f02f>
Este archivo es la especificación; el artefacto es la misma información puesta
para leerse. Cuando se implemente, la verdad pasa a ser
`src/lib/server/db/schema.ts` y esto queda como el porqué.
Este archivo conserva el porqué; la verdad ejecutable es
`src/lib/server/db/schema`.
## Por qué
@ -100,8 +100,14 @@ que hoy leen `$lib/content`.
además lo correcto: la maqueta de 2018 no tiene la misma letra, le falta el
puente.
- **`persona`** _(nueva)_ — nombre, nombre_legal, ipi. Hoy autores e intérpretes
son cadenas sueltas repetidas en cada archivo.
- **`persona`** — identidad física/legal para autoría, IPI y datos personales.
- **`artista`** — identidad pública con código estable, slug, tipo
(persona/grupo/proyecto), descripción, biografía, imagen, país y estado.
- **`artista_persona`** — vínculo opcional entre una identidad pública y las
personas físicas que la encarnan o integran.
- **`artista_identificador`** — identificadores externos por sistema.
- **`cancion_interprete`** — crédito de una grabación concreta a un artista,
con rol, instrumento, nombre acreditado y orden.
- **`cancion_persona`** _(nueva)_ — rol (letra · musica · interprete · arreglos ·
produccion), orden.
- ~~**`version`**~~ — **desaparece como tabla**: cada versión es una fila de
@ -415,8 +421,8 @@ subrayado.
### 6 · Medios
- **`medio`** _(nueva)_ — clase, clave, **`publico`** (los fragmentos sí, el
audio completo nunca; hoy eso lo garantiza la carpeta), mime, bytes,
- **`medio`** — clase, clave, **`publico`** (la copia completa de escucha sí;
másteres y descargas no), mime, bytes,
duracion_s, ancho, alto, sha256, alt. Para que la administración pueda subir
audio, un archivo tiene que ser una fila.
@ -430,7 +436,7 @@ no hay que tocar ninguna URL —la base del dominio es configuración, y
descargas no usan URLs firmadas «porque el servidor ya tiene la sesión delante».
En otro dominio la sesión **ya no va delante**: esa es justo la gracia. Así que:
- **Lo público** —portadas, muestras— se sirve directo y cacheado.
- **Lo público** —portadas y copias completas de escucha— se sirve directo y cacheado.
- **El audio completo** pasa a necesitar **URL firmada con caducidad corta**: la
firma la pone la aplicación después de comprobar la compra, y Nginx la valida
con `secure_link`. Sin eso, el dominio de medios sería una puerta abierta al

@ -11,6 +11,12 @@ principal y sus versiones musicales, pero dejará de confundir:
- un intérprete y su ficha pública;
- una representación técnica de audio para escucha, descarga o archivo.
> Estado a 10 de septiembre de 2026: implementadas las guardas, transacciones,
> migraciones aditivas, read models, fichas públicas, panel, pipeline de audio y
> nueva versión de las condiciones de uso. La activación en una base compartida
> requiere ejecutar `db:migrate` y después `db:medios`; las migraciones se han
> ensayado dentro de una transacción revertida.
El trabajo será incremental. Las migraciones serán aditivas hasta que las
lecturas nuevas estén verificadas; ninguna columna ni relación actual se
retirará en la misma entrega que introduce su sustituta.

@ -201,3 +201,18 @@ INNER JOIN persona p ON p.id = cp.persona_id
INNER JOIN artista a ON a.slug = p.slug
WHERE cp.rol = 'interprete'
ON CONFLICT DO NOTHING;
--> statement-breakpoint
-- El legado solo acreditaba la principal. Una versión empieza heredando una
-- copia de esos créditos, que después puede editarse de forma independiente.
INSERT INTO cancion_interprete (
cancion_id, artista_id, rol, instrumento, acreditado_como, orden
)
SELECT v.id, ci.artista_id, ci.rol, ci.instrumento, ci.acreditado_como, ci.orden
FROM cancion v
INNER JOIN cancion_interprete ci ON ci.cancion_id = v.version_de_id
WHERE v.version_de_id IS NOT NULL
AND NOT EXISTS (
SELECT 1 FROM cancion_interprete propia WHERE propia.cancion_id = v.id
)
ON CONFLICT DO NOTHING;

@ -0,0 +1,84 @@
-- Publica una versión nueva de las condiciones: las versiones legales ya
-- vigentes no se modifican porque documentan lo aceptado en compras anteriores.
INSERT INTO version_legal (
id, documento_id, cuerpo, vigente_desde, resumen_cambios
)
SELECT
'legal-condiciones-uso-2026-09-10',
d.id,
$condiciones$
> **Pendiente de revisión legal.** Es un borrador razonable, no un documento
> validado por un abogado.
Usar esta web supone aceptar estas condiciones. Si no estás de acuerdo con
alguna, no la uses.
## La cuenta
Para comprar hace falta una cuenta, y para crearla basta un correo electrónico
al que llega un código. **No hay contraseña**: cada vez que entras se envía un
código nuevo. Eso significa que quien tenga acceso a tu correo tiene acceso a
tu cuenta, así que protégelo.
Al crear la cuenta te comprometes a usar una dirección **tuya**. Escribir la de
otra persona para que le lleguen los códigos es un uso indebido, y el sistema
lo limita activamente.
Puedes pedir que se cierre tu cuenta cuando quieras escribiendo a
hola@senzapaura.es. Las compras y los datos de facturación se conservan el
tiempo que obliga la ley, según se explica en la
[política de privacidad](/legal/privacidad).
## Lo que suena y lo que se compra
En la web se puede escuchar **la canción completa en una copia de calidad
reducida**. Esa copia es solo para escucharla desde el sitio: que sea accesible
no concede permiso para descargarla, redistribuirla ni reutilizarla.
Al comprar recibes el archivo de mayor calidad disponible, con todas sus
versiones alternativas, y queda vinculado a tu cuenta: puedes volver a
descargarlo desde ella siempre que quieras.
## Lo que no se puede hacer
Con las canciones compradas:
- Redistribuirlas, subirlas a ninguna plataforma ni compartirlas públicamente.
- Usarlas en vídeos, anuncios, espectáculos o cualquier producción.
- Incorporarlas a otra obra o hacer versiones para explotarlas.
Todo eso requiere una licencia aparte. Los identificadores de registro están en
la ficha de cada tema para poder pedirla.
Con la web:
- Intentar acceder a partes que no te corresponden, o a los archivos originales
o de descarga sin haberlos comprado.
- Automatizar peticiones de forma que degraden el servicio.
- Usar el formulario de acceso o el de contacto para molestar a terceros.
## El taller de letras
Los textos del taller se publican para leerlos y aprender. Puedes usarlos para
escribir tus propias canciones —para eso están— pero no republicarlos como
propios ni venderlos.
## Cambios
Estas condiciones pueden cambiar. La fecha de arriba dice cuándo se revisaron
por última vez, y lo que se aplica a una compra son las condiciones vigentes en
el momento de hacerla.
$condiciones$,
TIMESTAMPTZ '2026-09-10 00:00:00+02',
'La escucha pública pasa de un fragmento a la canción completa en calidad reducida.'
FROM documento_legal d
WHERE d.slug = 'condiciones-de-uso'
ON CONFLICT (id) DO NOTHING;
--> statement-breakpoint
UPDATE documento_legal d
SET version_vigente_id = v.id
FROM version_legal v
WHERE d.slug = 'condiciones-de-uso'
AND v.id = 'legal-condiciones-uso-2026-09-10'
AND v.documento_id = d.id;

@ -0,0 +1,2 @@
-- Sin cambios SQL. Punto de enlace creado por Drizzle antes de reconciliar las
-- migraciones manuales 0011–0015 con el esquema declarado.

@ -0,0 +1,3 @@
-- Sin cambios SQL. La instantánea asociada representa el esquema resultante de
-- 0011–0015; esas operaciones ya viven en sus migraciones manuales y repetirlas
-- aquí intentaría crear de nuevo las mismas tablas, columnas e índices.

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

@ -78,6 +78,55 @@
"when": 1788898884661,
"tag": "0010_texto_de_estilos_y_discos",
"breakpoints": true
},
{
"idx": 11,
"version": "7",
"when": 1788949140000,
"tag": "0011_administradores",
"breakpoints": true
},
{
"idx": 12,
"version": "7",
"when": 1788965520000,
"tag": "0012_prompts_de_una_cancion",
"breakpoints": true
},
{
"idx": 13,
"version": "7",
"when": 1789041720000,
"tag": "0013_integridad_concurrente",
"breakpoints": true
},
{
"idx": 14,
"version": "7",
"when": 1789042140000,
"tag": "0014_artistas_y_audio",
"breakpoints": true
},
{
"idx": 15,
"version": "7",
"when": 1789042620000,
"tag": "0015_condiciones_escucha_completa",
"breakpoints": true
},
{
"idx": 16,
"version": "7",
"when": 1789053311680,
"tag": "0016_sincronizar_migraciones_manuales",
"breakpoints": true
},
{
"idx": 17,
"version": "7",
"when": 1789053373570,
"tag": "0017_verificar_sincronizacion",
"breakpoints": true
}
]
}

@ -23,6 +23,8 @@
"db:push": "drizzle-kit push",
"db:generate": "drizzle-kit generate",
"db:migrate": "node --env-file=.env scripts/db/migrar.mjs",
"db:migrate:check": "node --env-file=.env scripts/db/comprobar-migraciones.mjs",
"db:auditar-musica": "node --env-file=.env scripts/db/auditar-dominio-musical.mjs",
"db:studio": "drizzle-kit studio",
"medios": "node scripts/medios.mjs",
"predev": "node scripts/medios.mjs",

@ -0,0 +1,7 @@
/** Perfil único de derivados públicos; cambiarlo obliga a regenerarlos. */
export const PERFIL_ESCUCHA = Object.freeze({
id: 'mp3-96-v1',
formato: 'mp3',
codecFfmpeg: 'libmp3lame',
bitrateKbps: 96
});

@ -0,0 +1,50 @@
/** Informe de paridad tras migrar artistas, versiones y representaciones. */
import pg from 'pg';
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
query_timeout: 30_000
});
await cliente.connect();
const {
rows: [n]
} = await cliente.query(`
select
(select count(*) from cancion where version_de_id is null)::int as canciones,
(select count(*) from cancion where version_de_id is not null)::int as versiones,
(select count(*) from cancion where codigo is null)::int as canciones_sin_codigo,
(select count(*) from artista)::int as artistas,
(select count(*) from cancion_persona where rol = 'interprete')::int as creditos_anteriores,
(select count(*) from cancion_interprete)::int as creditos_nuevos,
(select count(*) from cancion_audio where uso = 'master')::int as masters,
(select count(*) from cancion_audio where uso = 'stream')::int as streams,
(select count(*) from cancion c where c.estado = 'publicado'
and not exists (select 1 from cancion_audio ca where ca.cancion_id = c.id and ca.uso = 'stream'))::int
as publicadas_sin_stream
`);
const { rows: problemas } = await cliente.query(`
select 'intérprete sin migrar' as tipo, c.slug as referencia
from cancion_persona cp
join cancion c on c.id = cp.cancion_id
where cp.rol = 'interprete'
and not exists (select 1 from cancion_interprete ci where ci.cancion_id = cp.cancion_id)
union all
select 'artista sin código', slug from artista where codigo is null or btrim(codigo) = ''
union all
select 'versión sin código', slug from cancion
where version_de_id is not null and (version_codigo is null or btrim(version_codigo) = '')
order by tipo, referencia
`);
console.table(n);
if (problemas.length) console.table(problemas);
await cliente.end();
if (problemas.length || n.canciones_sin_codigo || n.publicadas_sin_stream) {
console.error('La migración todavía no cumple la paridad requerida.');
process.exit(1);
}
console.log('Dominio musical íntegro: códigos, créditos y streams completos.');

@ -0,0 +1,42 @@
/** Ejecuta migraciones dentro de una transacción que siempre se revierte. */
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const nombres = process.argv.slice(2);
if (nombres.length === 0)
throw new Error('Indica al menos una migración, por ejemplo 0014_artistas_y_audio.sql.');
const sentenciasDe = (texto) =>
texto
.split('--> statement-breakpoint')
.map((s) => s.trim())
.filter((s) => s && s.split('\n').some((l) => l.trim() && !l.trim().startsWith('--')));
const cliente = new pg.Client({
connectionString: process.env.DATABASE_URL,
query_timeout: 120_000
});
await cliente.connect();
await cliente.query('BEGIN');
try {
for (const nombre of nombres) {
const ruta = path.join('drizzle', nombre);
const sentencias = sentenciasDe(fs.readFileSync(ruta, 'utf8'));
for (const [indice, sentencia] of sentencias.entries()) {
try {
await cliente.query(sentencia);
} catch (error) {
throw new Error(`${nombre}, sentencia ${indice + 1}: ${error.message}`, { cause: error });
}
}
console.log(` ${nombre}: ${sentencias.length} sentencias válidas`);
}
} finally {
await cliente.query('ROLLBACK').catch(() => {});
await cliente.end();
}
console.log('Comprobadas y revertidas: la base no ha cambiado.');

@ -1,6 +1,6 @@
/**
* Vuelca los archivos —audio, muestras y portadas— a la tabla `medio` y los
* enlaza con quien los usa.
* Vuelca másteres, copias completas de escucha, muestras antiguas y portadas a
* `medio`, y reconstruye sus relaciones.
*
* npm run db:medios
* npm run db:medios -- --seco
@ -20,20 +20,45 @@ import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
import { load as leerYaml } from 'js-yaml';
import { PERFIL_ESCUCHA } from '../config/audio.mjs';
const SECO = process.argv.includes('--seco');
/** Dónde vive cada clase de archivo y si se puede pedir por URL. */
const ALMACENES = [
{ clase: 'audio', raiz: 'media/audio', publico: false, carpeta: 'audio', prefijo: '' },
{
nombre: 'máster',
clase: 'audio',
raiz: 'media/audio',
publico: false,
carpeta: 'audio',
prefijo: ''
},
{
nombre: 'escucha',
clase: 'audio',
raiz: 'static/escucha',
publico: true,
carpeta: 'escucha',
prefijo: '/escucha/'
},
{
nombre: 'muestra',
clase: 'muestra',
raiz: 'static/muestras',
publico: true,
carpeta: 'muestras',
prefijo: '/muestras/'
},
{ clase: 'imagen', raiz: 'static/images', publico: true, carpeta: 'images', prefijo: '/images/' }
{
nombre: 'imagen',
clase: 'imagen',
raiz: 'static/images',
publico: true,
carpeta: 'images',
prefijo: '/images/'
}
];
const MIMES = {
@ -57,23 +82,50 @@ function recorrer(dir) {
});
}
const aSlug = (texto) =>
String(texto ?? '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
/**
* Duración en segundos, medida con ffprobe.
*
* Medida y no escrita: es el dato que más fácil se queda viejo. Si ffprobe no
* está, se deja en nulo y se avisa; no se inventa.
*/
function duracionDe(ruta) {
function datosDeAudio(ruta) {
try {
const salida = execFileSync(
'ffprobe',
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', ruta],
[
'-v',
'error',
'-select_streams',
'a:0',
'-show_entries',
'format=duration,bit_rate:stream=sample_rate,channels',
'-of',
'json',
ruta
],
{ encoding: 'utf8', timeout: 20_000 }
);
const segundos = Number.parseFloat(salida.trim());
return Number.isFinite(segundos) ? Math.round(segundos) : null;
const datos = JSON.parse(salida);
const segundos = Number.parseFloat(datos.format?.duration);
const bitrate = Number.parseInt(datos.format?.bit_rate, 10);
const frecuencia = Number.parseInt(datos.streams?.[0]?.sample_rate, 10);
const canales = Number.parseInt(datos.streams?.[0]?.channels, 10);
return {
duracionS: Number.isFinite(segundos) ? Math.round(segundos) : null,
bitrateKbps: Number.isFinite(bitrate) ? Math.round(bitrate / 1000) : null,
frecuenciaHz: Number.isFinite(frecuencia) ? frecuencia : null,
canales: Number.isFinite(canales) ? canales : null
};
} catch {
return null;
return { duracionS: null, bitrateKbps: null, frecuenciaHz: null, canales: null };
}
}
@ -114,8 +166,12 @@ for (const almacen of ALMACENES) {
const contenido = fs.readFileSync(ruta);
const relativa = path.relative(almacen.raiz, ruta).split(path.sep).join('/');
const esImagen = mime.startsWith('image/');
const audio = esImagen
? { duracionS: null, bitrateKbps: null, frecuenciaHz: null, canales: null }
: datosDeAudio(ruta);
encontrados.push({
almacen: almacen.nombre,
clase: almacen.clase,
// La clave lleva el almacen delante: `singles/dorina.mp3` existe en audio
// y en muestras, y sin el prefijo serian la misma fila. Ademas es la ruta
@ -126,7 +182,7 @@ for (const almacen of ALMACENES) {
mime,
bytes: contenido.length,
sha256: createHash('sha256').update(contenido).digest('hex'),
duracionS: esImagen ? null : duracionDe(ruta),
...audio,
...(esImagen ? medidasDe(ruta) : { ancho: null, alto: null })
});
}
@ -134,10 +190,10 @@ for (const almacen of ALMACENES) {
console.log(`Encontrados ${encontrados.length} archivos\n`);
for (const almacen of ALMACENES) {
const suyos = encontrados.filter((m) => m.clase === almacen.clase);
const suyos = encontrados.filter((m) => m.almacen === almacen.nombre);
const pesan = suyos.reduce((n, m) => n + m.bytes, 0);
console.log(
` ${almacen.clase.padEnd(9)} ${String(suyos.length).padStart(3)} archivos · ${(pesan / 1048576).toFixed(1)} MB`
` ${almacen.nombre.padEnd(9)} ${String(suyos.length).padStart(3)} archivos · ${(pesan / 1048576).toFixed(1)} MB`
);
}
@ -170,28 +226,30 @@ await c.connect();
await c.query('BEGIN');
try {
/*
* Se sueltan los enlaces antes de vaciar la tabla. Las columnas son
* `ON DELETE set null`, así que Postgres lo haría solo, pero decirlo aquí
* deja claro que el volcado no pierde nada: lo que se rehace es la fila del
* archivo, no la canción.
*/
await c.query('UPDATE cancion SET audio_id = NULL, muestra_id = NULL, portada_id = NULL');
await c.query('UPDATE album SET portada_id = NULL');
await c.query('DELETE FROM medio');
const idPorClave = new Map();
const medioPorClave = new Map();
for (const m of encontrados) {
const id = crypto.randomUUID();
await c.query(
const { rows } = await c.query(
`INSERT INTO medio (id, clase, clave, publico, mime, bytes, duracion_s, ancho, alto, sha256)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)
ON CONFLICT (clave) DO UPDATE SET
clase = excluded.clase,
publico = excluded.publico,
mime = excluded.mime,
bytes = excluded.bytes,
duracion_s = excluded.duracion_s,
ancho = excluded.ancho,
alto = excluded.alto,
sha256 = excluded.sha256
RETURNING id`,
[id, m.clase, m.clave, m.publico, m.mime, m.bytes, m.duracionS, m.ancho, m.alto, m.sha256]
);
idPorClave.set(`${m.clase}:${m.clave}`, id);
if (m.clase === 'imagen') idPorClave.set(`url:${m.url}`, id);
idPorClave.set(`${m.clase}:${m.clave}`, rows[0].id);
medioPorClave.set(`${m.clase}:${m.clave}`, m);
if (m.clase === 'imagen') idPorClave.set(`url:${m.url}`, rows[0].id);
}
console.log(`\n ${encontrados.length} filas en medio`);
console.log(`\n ${encontrados.length} medios insertados o actualizados`);
/* --- Enlazar con lo que los usa ---------------------------------------
*
@ -199,7 +257,7 @@ try {
* volcado del catálogo no guardó esas rutas: guardarlas como texto habría
* sido repetir el problema que esta tabla viene a resolver.
*/
const enlazados = { audio: 0, muestra: 0, portadaCancion: 0, portadaAlbum: 0 };
const enlazados = { master: 0, stream: 0, muestra: 0, portadaCancion: 0, portadaAlbum: 0 };
const huerfanos = [];
const canciones = fs
@ -211,22 +269,81 @@ try {
}));
for (const { slug, texto } of canciones) {
const audio = /^audio:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
const cabecera = /^---\r?\n([\s\S]*?)\r?\n---/.exec(texto)?.[1];
const datos = cabecera ? (leerYaml(cabecera) ?? {}) : {};
const portada = /^portada:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
if (audio) {
const audios = [
{ slug, audio: datos.audio },
...(Array.isArray(datos.versiones)
? datos.versiones.map((version) => ({
slug: `${slug}-${version.id ?? aSlug(version.nombre)}`,
audio: version.audio
}))
: [])
];
for (const entidad of audios) {
const { rows: destinos } = await c.query('SELECT id FROM cancion WHERE slug = $1', [
entidad.slug
]);
if (destinos.length === 0) {
huerfanos.push(`${entidad.slug}: la canción no existe en la base`);
continue;
}
await c.query('DELETE FROM cancion_audio WHERE cancion_id = $1', [destinos[0].id]);
await c.query('UPDATE cancion SET audio_id = NULL, muestra_id = NULL WHERE id = $1', [
destinos[0].id
]);
const audio =
typeof entidad.audio === 'string' ? entidad.audio.replace(/^\//, '') : undefined;
if (!audio) continue;
const id = idPorClave.get(`audio:audio/${audio}`);
if (id) {
await c.query('UPDATE cancion SET audio_id = $1 WHERE slug = $2', [id, slug]);
enlazados.audio++;
const master = medioPorClave.get(`audio:audio/${audio}`);
await c.query('UPDATE cancion SET audio_id = $1 WHERE slug = $2', [id, entidad.slug]);
await c.query(
`INSERT INTO cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps, frecuencia_hz, canales)
SELECT id, $1, 'master', $2, $3, $4, $5 FROM cancion WHERE slug = $6
ON CONFLICT DO NOTHING`,
[
id,
path.extname(audio).slice(1).toLowerCase() || 'audio',
master?.bitrateKbps ?? null,
master?.frecuenciaHz ?? null,
master?.canales ?? null,
entidad.slug
]
);
enlazados.master++;
} else {
huerfanos.push(`${entidad.slug}: el audio ${audio} no existe`);
}
const escucha = audio.replace(/\.[^.]+$/, '.mp3');
const idEscucha = idPorClave.get(`audio:escucha/${escucha}`);
if (idEscucha) {
const stream = medioPorClave.get(`audio:escucha/${escucha}`);
await c.query(
`INSERT INTO cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps, frecuencia_hz, canales)
SELECT id, $1, 'stream', 'mp3', $2, $3, $4 FROM cancion WHERE slug = $5
ON CONFLICT DO NOTHING`,
[
idEscucha,
stream?.bitrateKbps ?? PERFIL_ESCUCHA.bitrateKbps,
stream?.frecuenciaHz ?? null,
stream?.canales ?? null,
entidad.slug
]
);
enlazados.stream++;
} else {
huerfanos.push(`${slug}: el audio ${audio} no existe`);
huerfanos.push(`${entidad.slug}: falta la escucha completa ${escucha}`);
}
/*
@ -237,11 +354,15 @@ try {
const muestra = audio.replace(/\.[^.]+$/, '.mp3');
const idMuestra = idPorClave.get(`muestra:muestras/${muestra}`);
if (idMuestra) {
await c.query('UPDATE cancion SET muestra_id = $1 WHERE slug = $2', [idMuestra, slug]);
await c.query('UPDATE cancion SET muestra_id = $1 WHERE slug = $2', [
idMuestra,
entidad.slug
]);
enlazados.muestra++;
}
}
await c.query('UPDATE cancion SET portada_id = NULL WHERE slug = $1', [slug]);
if (portada) {
const id = idPorClave.get(`url:${portada}`);
if (id) {
@ -260,6 +381,7 @@ try {
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
await c.query('UPDATE album SET portada_id = NULL WHERE slug = $1', [slug]);
if (!portada) continue;
const id = idPorClave.get(`url:${portada}`);
@ -272,7 +394,7 @@ try {
}
console.log(
` enlazados: ${enlazados.audio} audios, ${enlazados.muestra} muestras, ` +
` enlazados: ${enlazados.master} másteres, ${enlazados.stream} escuchas, ${enlazados.muestra} muestras antiguas, ` +
`${enlazados.portadaCancion} portadas de tema, ${enlazados.portadaAlbum} de disco`
);

@ -102,6 +102,16 @@ const slugificar = (nombre) =>
async function volcar(c, contenido, informe) {
const { estilos, albumes, canciones, capitulos, entradas, legales, paginas } = contenido;
const { rows: actividad } = await c.query(`
select
(select count(*) from compra)::int as compras,
(select count(*) from pedido_item)::int as lineas_pedido
`);
if (actividad[0].compras > 0 || actividad[0].lineas_pedido > 0) {
throw new Error(
'El volcado total es solo para una base vacía: ya hay compras o pedidos. Usa el panel y migraciones aditivas.'
);
}
/* --- Se borra lo volcado antes de reescribirlo -----------------------
*
@ -132,6 +142,8 @@ async function volcar(c, contenido, informe) {
'pagina',
'autoria',
'registro_obra',
'cancion_audio',
'cancion_interprete',
'cancion_persona',
'cancion_enlace',
'cancion_etiqueta',
@ -143,6 +155,9 @@ async function volcar(c, contenido, informe) {
'album',
'etiqueta',
'estilo',
'artista_identificador',
'artista_persona',
'artista',
'persona'
];
for (const tabla of aVaciar) await c.query(`DELETE FROM "${tabla}"`);
@ -168,6 +183,30 @@ async function volcar(c, contenido, informe) {
return id;
};
/* Identidad pública: separada de la persona física que firma la obra. */
const artistas = new Map();
const artista = async (nombre, personaId) => {
const limpio = nombre.trim();
if (artistas.has(limpio)) return artistas.get(limpio);
const id = nuevoId();
const tipo =
limpio === 'Los del Puerto' ? 'grupo' : limpio === 'Senza Paura' ? 'proyecto' : 'persona';
await c.query(
`INSERT INTO artista (id, slug, tipo, nombre, estado)
VALUES ($1,$2,$3,$4,'publicado')`,
[id, slugificar(limpio), tipo, limpio]
);
if (tipo === 'persona') {
await c.query(
`INSERT INTO artista_persona (artista_id, persona_id, relacion)
VALUES ($1,$2,'identidad')`,
[id, personaId]
);
}
artistas.set(limpio, id);
return id;
};
/* --- Etiquetas ------------------------------------------------------- */
const etiquetas = new Map();
const etiqueta = async (nombre) => {
@ -267,7 +306,7 @@ async function volcar(c, contenido, informe) {
for (const [rol, nombres] of [
['letra', datos.letra],
['musica', datos.musica],
['interprete', datos.interpretes]
['interprete', datos.interpretes.map((interprete) => interprete.nombre)]
]) {
for (const [i, nombre] of (nombres ?? []).entries()) {
await c.query(
@ -277,6 +316,15 @@ async function volcar(c, contenido, informe) {
);
}
}
for (const [i, interprete] of datos.interpretes.entries()) {
const personaId = await persona(interprete.nombre);
const artistaId = await artista(interprete.nombre, personaId);
await c.query(
`INSERT INTO cancion_interprete (cancion_id, artista_id, rol, acreditado_como, orden)
VALUES ($1,$2,$3,$4,$5)`,
[id, artistaId, interprete.rol, interprete.nombre, i]
);
}
for (const nombre of datos.etiquetas ?? []) {
await c.query(
@ -338,17 +386,19 @@ async function volcar(c, contenido, informe) {
* «no lo sé» o «lo mismo de arriba».
*/
for (const [i, v] of (datos.versiones ?? []).entries()) {
const versionId = nuevoId();
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre,
version_orden, duracion_s, ano, letra, idioma, instrumental, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,'publicado')`,
version_codigo, version_orden, duracion_s, ano, letra, idioma, instrumental, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,'publicado')`,
[
nuevoId(),
versionId,
`${datos.slug}-${v.id}`,
datos.titulo,
idEstilo.get(datos.estilo),
id,
v.nombre,
v.id,
i,
v.duracion ?? null,
datos.ano ?? null,
@ -357,6 +407,15 @@ async function volcar(c, contenido, informe) {
datos.instrumental
]
);
for (const [orden, interprete] of datos.interpretes.entries()) {
const personaId = await persona(interprete.nombre);
const artistaId = await artista(interprete.nombre, personaId);
await c.query(
`INSERT INTO cancion_interprete (cancion_id, artista_id, rol, acreditado_como, orden)
VALUES ($1,$2,$3,$4,$5)`,
[versionId, artistaId, interprete.rol, interprete.nombre, orden]
);
}
versiones++;
}
}

@ -3,17 +3,16 @@
* puede preparar.
*
* El panel de contenido escribe archivos en el repositorio, así que subir el
* máster desde ahí es fácil. Lo que no puede hacer es recortar el fragmento de
* muestra ni medir la duración: eso es ffmpeg, y ffmpeg no corre dentro de una
* pestaña. Este script cierra ese hueco.
* máster desde ahí es fácil. Lo que no puede hacer es generar la copia pública
* completa de menor calidad ni medir la duración: eso es ffmpeg.
*
* Qué hace, por cada canción de `src/content/canciones/`:
*
* 1. Si su clave de audio apunta a la bandeja (`subidas/…`), mueve el archivo
* a la carpeta que le toca —la del disco, o `singles/`— con el nombre de
* la convención, y reescribe la clave en el Markdown.
* 2. Si falta el fragmento de muestra, o es más viejo que el máster, lo
* genera.
* 2. Si falta la copia completa de escucha, o es más vieja que el máster, la
* genera a 96 kbps.
* 3. Si no tiene duración escrita, la mide y la escribe.
*
* Corre solo antes de `npm run dev` y de `npm run build`, así que subir un tema
@ -21,7 +20,8 @@
* `npm run medios`.
*
* Sin ffmpeg no falla: avisa de lo que no ha podido hacer y sigue. Un entorno
* de despliegue no lo necesita, porque las muestras van en el repositorio.
* de despliegue no lo necesita, porque las copias de escucha van en el
* repositorio.
*/
import { spawnSync } from 'node:child_process';
@ -29,17 +29,17 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { load as leerYaml } from 'js-yaml';
import { PERFIL_ESCUCHA } from './config/audio.mjs';
const RAIZ = path.resolve(import.meta.dirname, '..');
const CANCIONES = path.join(RAIZ, 'src/content/canciones');
const MASTERS = path.join(RAIZ, 'media/audio');
const MUESTRAS = path.join(RAIZ, 'static/muestras');
const ESCUCHAS = path.join(RAIZ, 'static/escucha');
/** Carpeta donde deja el panel lo que se sube; nada se queda aquí. */
const BANDEJA = 'subidas';
/** Fragmento de muestra: 40 s desde el segundo 30, con 4 s de fundido final. */
const MUESTRA = { desde: 30, dura: 40, fundido: 4 };
/** Perfil audible pero claramente inferior al archivo entregado al comprar. */
const avisos = [];
const hechos = [];
@ -121,64 +121,57 @@ function colocar(claveVieja, claveNueva) {
return true;
}
/**
* Los argumentos del recorte.
*
* `-ss` va **antes** de `-i`, y eso no es cosmético. Detrás de `-i` el corte se
* hace a la salida, pero el filtro sigue viendo los tiempos del original: un
* `afade` en el segundo 36 se aplicaría en el 36 de la canción, es decir a los
* seis de empezar el fragmento, y el resto saldría mudo. Delante de `-i` los
* tiempos se reinician en cero y el fundido cae donde debe.
*/
export function argumentosDeMuestra(master, muestra, desde, dura) {
const fundido = Math.max(0, dura - MUESTRA.fundido);
/** Nombre equivalente de la copia pública: siempre MP3. */
export function claveDeEscucha(clave) {
return clave.replace(/\.[^.]+$/, '') + '.mp3';
}
/** Argumentos deterministas de la transcodificación completa de escucha. */
export function argumentosDeEscucha(master, escucha) {
return [
'-v',
'error',
'-y',
'-ss',
String(desde),
'-t',
String(dura),
'-i',
master,
'-af',
`afade=t=out:st=${fundido}:d=${MUESTRA.fundido}`,
muestra
'-vn',
'-map_metadata',
'-1',
'-codec:a',
PERFIL_ESCUCHA.codecFfmpeg,
'-b:a',
`${PERFIL_ESCUCHA.bitrateKbps}k`,
escucha
];
}
/** Recorta el fragmento público si falta o si el máster es más nuevo. */
function generarMuestra(clave) {
/** Genera la canción pública completa si falta o si el máster es más nuevo. */
function generarEscucha(clave) {
const master = path.join(MASTERS, clave);
const muestra = path.join(MUESTRAS, clave);
const escucha = path.join(ESCUCHAS, claveDeEscucha(clave));
if (!fs.existsSync(master)) {
avisos.push(`${clave}: no hay máster en media/audio/, no se puede generar la muestra.`);
avisos.push(`${clave}: no hay máster en media/audio/, no se puede generar la escucha.`);
return;
}
if (fs.existsSync(muestra) && fs.statSync(muestra).mtimeMs >= fs.statSync(master).mtimeMs) {
if (fs.existsSync(escucha) && fs.statSync(escucha).mtimeMs >= fs.statSync(master).mtimeMs) {
return;
}
if (!hayFfmpeg) {
avisos.push(`${clave}: falta el fragmento de muestra y no hay ffmpeg para generarlo.`);
avisos.push(`${clave}: falta la copia de escucha y no hay ffmpeg para generarla.`);
return;
}
// Un tema corto no da para empezar en el segundo 30: se recorta desde el
// principio y se toma lo que haya, para que la muestra no salga vacía.
const total = medir(master) ?? 0;
const desde = total > MUESTRA.desde + MUESTRA.dura ? MUESTRA.desde : 0;
const dura = Math.min(MUESTRA.dura, Math.max(1, total - desde));
fs.mkdirSync(path.dirname(muestra), { recursive: true });
const hecho = ejecutar('ffmpeg', argumentosDeMuestra(master, muestra, desde, dura));
fs.mkdirSync(path.dirname(escucha), { recursive: true });
const hecho = ejecutar('ffmpeg', argumentosDeEscucha(master, escucha));
if (hecho === undefined) {
avisos.push(`${clave}: ffmpeg no pudo generar la muestra.`);
avisos.push(`${clave}: ffmpeg no pudo generar la escucha completa.`);
return;
}
hechos.push(`muestra ${clave} (${dura} s desde ${desde})`);
hechos.push(
`escucha ${claveDeEscucha(clave)} (${PERFIL_ESCUCHA.bitrateKbps} kbps, ${PERFIL_ESCUCHA.id})`
);
}
/**
@ -228,7 +221,7 @@ function procesar(archivo) {
}
}
generarMuestra(clave);
generarEscucha(clave);
if (!datos.duracion && hayFfmpeg) {
const segundos = medir(path.join(MASTERS, clave));
@ -257,7 +250,7 @@ function procesar(archivo) {
}
}
generarMuestra(clave);
generarEscucha(clave);
}
if (tocado) {

@ -1,5 +1,12 @@
import { describe, expect, it } from 'vitest';
import { argumentosDeMuestra, aSlug, claveCanonica, comoDuracion, enLaBandeja } from './medios.mjs';
import {
argumentosDeEscucha,
aSlug,
claveCanonica,
claveDeEscucha,
comoDuracion,
enLaBandeja
} from './medios.mjs';
describe('claveCanonica', () => {
it('agrupa por disco y numera como la pista', () => {
@ -68,34 +75,20 @@ describe('aSlug', () => {
});
});
describe('argumentosDeMuestra', () => {
const args = argumentosDeMuestra(
'media/audio/singles/dorina.mp3',
'static/muestras/singles/dorina.mp3',
30,
40
);
it('corta a la entrada, no a la salida', () => {
// Con `-ss` detrás de `-i` el filtro sigue viendo los tiempos del
// original: el fundido cae seis segundos después de empezar y el resto
// del fragmento sale mudo. Pasó de verdad.
expect(args.indexOf('-ss')).toBeLessThan(args.indexOf('-i'));
});
it('funde los últimos cuatro segundos, no otros', () => {
expect(args).toContain('afade=t=out:st=36:d=4');
describe('escucha pública', () => {
it('conserva la jerarquía y normaliza el formato', () => {
expect(claveDeEscucha('disco/01-tema.wav')).toBe('disco/01-tema.mp3');
});
it('no funde antes de empezar en un fragmento corto', () => {
// Un tema de tres segundos no da para un fundido de cuatro: el inicio del
// fundido no puede ser negativo.
const cortos = argumentosDeMuestra('a.mp3', 'b.mp3', 0, 3);
expect(cortos).toContain('afade=t=out:st=0:d=4');
});
it('recorta lo que se le pide', () => {
expect(args[args.indexOf('-ss') + 1]).toBe('30');
expect(args[args.indexOf('-t') + 1]).toBe('40');
it('codifica la canción completa a menor calidad', () => {
const args = argumentosDeEscucha(
'media/audio/singles/dorina.wav',
'static/escucha/singles/dorina.mp3'
);
expect(args).not.toContain('-ss');
expect(args).not.toContain('-t');
expect(args).toContain('libmp3lame');
expect(args).toContain('96k');
expect(args.at(-1)).toBe('static/escucha/singles/dorina.mp3');
});
});

@ -1,7 +1,7 @@
---
titulo: 'Condiciones de uso'
resumen: 'Qué puedes hacer en esta web y qué no.'
actualizada: 2026-09-07
actualizada: 2026-09-10
orden: 2
---
@ -29,12 +29,13 @@ tiempo que obliga la ley, según se explica en la
## Lo que suena y lo que se compra
En la web **suena un fragmento** de cada tema, no la canción entera. Es una
muestra para que decidas.
En la web se puede escuchar **la canción completa en una copia de calidad
reducida**. Esa copia es solo para escucharla desde el sitio: que sea accesible
no concede permiso para descargarla, redistribuirla ni reutilizarla.
Al comprar recibes el archivo completo en MP3, con todas sus versiones
alternativas, y queda vinculado a tu cuenta: puedes volver a descargarlo desde
ella siempre que quieras.
Al comprar recibes el archivo de mayor calidad disponible, con todas sus
versiones alternativas, y queda vinculado a tu cuenta: puedes volver a
descargarlo desde ella siempre que quieras.
## Lo que no se puede hacer
@ -49,8 +50,8 @@ la ficha de cada tema para poder pedirla.
Con la web:
- Intentar acceder a partes que no te corresponden, o a los archivos completos
sin haberlos comprado.
- Intentar acceder a partes que no te corresponden, o a los archivos originales
o de descarga sin haberlos comprado.
- Automatizar peticiones de forma que degraden el servicio.
- Usar el formulario de acceso o el de contacto para molestar a terceros.

@ -1,33 +1,33 @@
/**
* Rutas de audio.
*
* El catalogo se vende, asi que el archivo completo no vive en `static/`: se
* guarda en un directorio privado y solo lo entrega el servidor a quien lo ha
* comprado. Lo unico publico es un fragmento de muestra.
* La escucha pública contiene la canción completa con una codificación de
* menor calidad. El máster y la descarga siguen en un almacén privado.
*
* En el contenido, cada tema declara una *clave* relativa
* (`como-si-nada/01-como-si-nada.mp3`). De esa clave salen las dos rutas:
*
* muestra publica static/muestras/<clave> -> /muestras/<clave>
* escucha publica static/escucha/<clave> -> /escucha/<clave>
* archivo completo <MEDIA_DIR>/<clave> -> /descargas/<cancion>
*/
import { resolve } from '$app/paths';
import type { ResolvedPathname } from '$app/types';
/** Prefijo publico de los fragmentos de muestra. */
export const RUTA_MUESTRAS = '/muestras';
/** Prefijo público de las copias completas de escucha. */
export const RUTA_ESCUCHA = '/escucha';
/**
* URL publica del fragmento de muestra de una clave de audio.
* URL pública de escucha de una clave de audio.
*
* No pasa por `asset()` porque las claves salen del contenido y son dinamicas,
* mientras que `asset()` exige un archivo conocido en tiempo de compilacion. Si
* algun dia el sitio se sirviera bajo un subdirectorio, esta funcion es el
* unico sitio donde habria que anteponer la base.
*/
export function urlMuestra(clave: string): string {
return `${RUTA_MUESTRAS}/${clave}`;
export function urlEscucha(clave: string): string {
if (clave.startsWith('/')) return clave;
return `${RUTA_ESCUCHA}/${clave}`;
}
/**

@ -125,7 +125,19 @@
<dd>{listaDeNombres(cancion.musica)}</dd>
{#if cancion.interpretes.length > 0}
<dt>Interpretación</dt>
<dd>{listaDeNombres(cancion.interpretes)}</dd>
<dd>
{#each cancion.interpretes as interprete, i (interprete.id + interprete.rol)}
{#if i > 0},
{/if}
{#if interprete.slug}
<a href={resolve('/interpretes/[slug]', { slug: interprete.slug })}
>{interprete.nombre}</a
>
{:else}
{interprete.nombre}
{/if}
{/each}
</dd>
{/if}
</dl>
</section>

@ -348,7 +348,8 @@
<span class="mas muted">+{c.etiquetas.length - CUANTAS_ETIQUETAS}</span>
{/if}
{#if c.generos.length === 0 && c.etiquetas.length === 0}
<span class="muted sin">{listaDeNombres(c.interpretes)}</span>
<span class="muted sin">{listaDeNombres(c.interpretes.map((i) => i.nombre))}</span
>
{/if}
</div>
</th>

@ -1,4 +1,4 @@
import { page } from '@vitest/browser/context';
import { page } from 'vitest/browser';
import { describe, expect, it, vi } from 'vitest';
import { render } from 'vitest-browser-svelte';
import TarjetaCancion from './TarjetaCancion.svelte';
@ -54,6 +54,7 @@ vi.mock('$app/state', () => ({
function cancion(parcial: Partial<Cancion> = {}): Cancion {
return {
codigo: 'CAN-000001',
slug: 'como-si-nada',
titulo: 'Como si nada',
estilo: 'baladas-romanticas',
@ -64,7 +65,16 @@ function cancion(parcial: Partial<Cancion> = {}): Cancion {
ano: 2024,
letra: ['Senza Paura'],
musica: ['Senza Paura'],
interpretes: ['Marta Cifuentes'],
interpretes: [
{
id: 'marta-cifuentes',
codigo: 'ART-000001',
slug: 'marta-cifuentes',
nombre: 'Marta Cifuentes',
tipo: 'persona',
rol: 'principal'
}
],
destacada: true,
etiquetas: ['Desamor', 'Dueto'],
generos: [],
@ -86,7 +96,7 @@ function pista(id = 'como-si-nada'): PistaReproductor {
id,
cancionSlug: id,
titulo: 'Como si nada',
audio: '/muestras/como-si-nada/01-como-si-nada.mp3',
audio: '/escucha/como-si-nada/01-como-si-nada.mp3',
albumTitulo: 'Como si nada',
albumSlug: 'como-si-nada',
portada: '/images/albumes/como-si-nada.svg',

@ -114,12 +114,7 @@
<a class="titulo" href={resolve('/canciones/[slug]', { slug: pista.cancionSlug })}>
{pista.titulo}
</a>
<!--
Lo que suena en la web es siempre un fragmento; el archivo completo
solo se entrega por descarga. Decirlo evita que parezca que el
reproductor se corta solo.
-->
<span class="muestra">Muestra</span>
<span class="calidad">Calidad de escucha</span>
</p>
<span class="contexto muted">
<a href={resolve('/albumes/[slug]', { slug: pista.albumSlug })}>{pista.albumTitulo}</a>
@ -259,7 +254,7 @@
min-width: 0;
}
.muestra {
.calidad {
flex: none;
padding: 0 0.4em;
border: 1px solid var(--c-border);
@ -350,7 +345,7 @@
/*
* El mismo metal que los botones del sitio: la clase `.oro` de `app.css`
* pone el degradado, el filo de luz y el barrido al pasar por encima. Antes
* era `--c-accent` plano y, al lado de un «Escuchar la muestra» dorado,
* era `--c-accent` plano y, al lado de un botón de escucha dorado,
* parecía otro color.
*/
.control.principal {

@ -208,10 +208,13 @@ function parsearVersion(datos: Datos, indice: number, archivo: string): VersionM
// Sin `id` explicito se deriva del nombre: basta para distinguirlas
// dentro del tema, que es el unico ambito en el que viven.
id: textoOpcional(datos, 'id', `${archivo} (${campo})`) ?? aSlug(nombre),
codigo: `LEG-V-${indice + 1}`,
slug: aSlug(nombre),
nombre,
audio,
duracion: duracion(datos, 'duracion', `${archivo} (${campo})`),
nota: textoOpcional(datos, 'nota', `${archivo} (${campo})`)
nota: textoOpcional(datos, 'nota', `${archivo} (${campo})`),
interpretes: []
};
}
@ -237,6 +240,7 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
}
return {
codigo: `LEG-${slug.toUpperCase()}`,
slug,
titulo: texto(datos, 'titulo', archivo),
estilo: texto(datos, 'estilo', archivo),
@ -250,7 +254,13 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
// La autoria por defecto es del artista: es su propia web.
letra: listaDeTextos(datos, 'letra', archivo, autoria),
musica: listaDeTextos(datos, 'musica', archivo, autoria),
interpretes: listaDeTextos(datos, 'interpretes', archivo),
interpretes: listaDeTextos(datos, 'interpretes', archivo).map((nombre, indice) => ({
id: `legacy-${aSlug(nombre)}`,
codigo: `LEG-ART-${indice + 1}`,
nombre,
tipo: 'persona',
rol: 'principal'
})),
destacada: booleano(datos, 'destacada', archivo),
etiquetas: listaDeTextos(datos, 'etiquetas', archivo),
// Los generos viven en la base, no en el frontmatter: este lector es del

@ -14,6 +14,7 @@ import type { ResolvedPathname } from '$app/types';
*/
export const navegacion: { href: ResolvedPathname; texto: string }[] = [
{ href: resolve('/musica'), texto: 'Música' },
{ href: resolve('/interpretes'), texto: 'Intérpretes' },
{ href: resolve('/taller'), texto: 'Taller de letras' },
{ href: resolve('/generos'), texto: 'Géneros' },
{ href: resolve('/blog'), texto: 'Blog' },

@ -5,11 +5,10 @@
* desnormalizadas, con su portada y sus etiquetas resueltas. Esta es la unica
* conversion entre los dos mundos.
*
* Lo que suena en la web es siempre el fragmento de muestra, nunca el archivo
* completo: ese solo se entrega por descarga a quien lo ha comprado.
* Lo que suena en la web es la canción completa en calidad de escucha. El
* archivo de compra permanece privado y se entrega por una ruta autorizada.
*/
import { urlMuestra } from '$lib/audio';
/*
* Del catálogo que baja por `page.data`, no de los archivos. `aPistas` se
* llama siempre dentro de un `$derived` de un componente, así que la lectura
@ -43,7 +42,7 @@ export function aPista(cancion: Cancion): PistaReproductor {
id: cancion.slug,
cancionSlug: cancion.slug,
titulo: cancion.titulo,
audio: cancion.audio ? urlMuestra(cancion.audio) : '',
audio: cancion.audio ?? '',
duracion: cancion.duracion,
...contexto(cancion)
};
@ -60,7 +59,7 @@ export function aPistaDeVersion(cancion: Cancion, version: VersionMusical): Pist
id: `${cancion.slug}#${version.id}`,
cancionSlug: cancion.slug,
titulo: `${cancion.titulo} · ${version.nombre}`,
audio: urlMuestra(version.audio),
audio: version.audio ?? '',
duracion: version.duracion,
...contexto(cancion)
};

@ -31,7 +31,7 @@ export interface PistaReproductor {
/** Slug del tema al que pertenece, para enlazar a su ficha. */
cancionSlug: string;
titulo: string;
/** URL publica del fragmento de muestra. */
/** URL pública de la canción completa en calidad de escucha. */
audio: string;
albumTitulo: string;
albumSlug: string;

@ -0,0 +1,138 @@
import { and, asc, eq } from 'drizzle-orm';
import { alias } from 'drizzle-orm/pg-core';
import { db } from './db';
import { artista, artistaIdentificador, medio, pais } from './db/schema';
import { leerCatalogo } from './catalogo';
import { componer } from './markdown';
import type { Cancion, TipoArtista } from '$lib/types';
export interface ResumenDeArtista {
id: string;
codigo: string;
slug: string;
nombre: string;
tipo: TipoArtista;
descripcion: string;
imagen?: string;
pais?: string;
lugarOrigen?: string;
inicio?: number;
fin?: number;
totalCanciones: number;
}
export interface FichaDeArtista extends ResumenDeArtista {
biografia: string;
sitioWeb?: string;
identificadores: { sistema: string; valor: string }[];
canciones: Cancion[];
}
function rutaPublica(clave: string | null): string | undefined {
return clave ? `/${clave}` : undefined;
}
function participa(cancion: Cancion, artistaId: string): boolean {
return (
cancion.interpretes.some((i) => i.id === artistaId) ||
cancion.versiones.some((v) => v.interpretes.some((i) => i.id === artistaId))
);
}
/** Directorio público de identidades artísticas, nunca personas en borrador. */
export async function artistasPublicos(): Promise<ResumenDeArtista[]> {
const imagen = alias(medio, 'imagen_artista_publica');
const [filas, catalogo] = await Promise.all([
db
.select({
id: artista.id,
codigo: artista.codigo,
slug: artista.slug,
nombre: artista.nombre,
tipo: artista.tipo,
descripcion: artista.descripcionCorta,
imagenClave: imagen.clave,
pais: pais.nombre,
lugarOrigen: artista.lugarOrigen,
inicio: artista.inicio,
fin: artista.fin
})
.from(artista)
.leftJoin(imagen, eq(imagen.id, artista.imagenId))
.leftJoin(pais, eq(pais.codigo, artista.paisCodigo))
.where(eq(artista.estado, 'publicado'))
.orderBy(asc(artista.nombre)),
leerCatalogo()
]);
return filas.map((fila) => ({
id: fila.id,
codigo: fila.codigo,
slug: fila.slug,
nombre: fila.nombre,
tipo: fila.tipo,
descripcion: fila.descripcion,
imagen: rutaPublica(fila.imagenClave),
pais: fila.pais ?? undefined,
lugarOrigen: fila.lugarOrigen ?? undefined,
inicio: fila.inicio ?? undefined,
fin: fila.fin ?? undefined,
totalCanciones: catalogo.canciones.filter((c) => participa(c, fila.id)).length
}));
}
/** Ficha pública y discografía de una identidad artística. */
export async function obtenerArtistaPublico(slug: string): Promise<FichaDeArtista | undefined> {
const imagen = alias(medio, 'imagen_artista_ficha');
const [fila] = await db
.select({
id: artista.id,
codigo: artista.codigo,
slug: artista.slug,
nombre: artista.nombre,
tipo: artista.tipo,
descripcion: artista.descripcionCorta,
biografia: artista.biografia,
imagenClave: imagen.clave,
pais: pais.nombre,
lugarOrigen: artista.lugarOrigen,
inicio: artista.inicio,
fin: artista.fin,
sitioWeb: artista.sitioWeb
})
.from(artista)
.leftJoin(imagen, eq(imagen.id, artista.imagenId))
.leftJoin(pais, eq(pais.codigo, artista.paisCodigo))
.where(and(eq(artista.slug, slug), eq(artista.estado, 'publicado')))
.limit(1);
if (!fila) return undefined;
const [catalogo, identificadores] = await Promise.all([
leerCatalogo(),
db
.select({ sistema: artistaIdentificador.sistema, valor: artistaIdentificador.valor })
.from(artistaIdentificador)
.where(eq(artistaIdentificador.artistaId, fila.id))
.orderBy(asc(artistaIdentificador.sistema))
]);
const canciones = catalogo.canciones.filter((c) => participa(c, fila.id));
return {
id: fila.id,
codigo: fila.codigo,
slug: fila.slug,
nombre: fila.nombre,
tipo: fila.tipo,
descripcion: fila.descripcion,
biografia: componer(fila.biografia),
imagen: rutaPublica(fila.imagenClave),
pais: fila.pais ?? undefined,
lugarOrigen: fila.lugarOrigen ?? undefined,
inicio: fila.inicio ?? undefined,
fin: fila.fin ?? undefined,
sitioWeb: fila.sitioWeb ?? undefined,
identificadores,
canciones,
totalCanciones: canciones.length
};
}

@ -0,0 +1,76 @@
/** Selección servidor-a-servidor del archivo privado que incluye una compra. */
import { and, asc, eq, inArray, isNull, ne, sql } from 'drizzle-orm';
import { db } from './db';
import { cancion, cancionAudio, medio } from './db/schema';
export interface ArchivoComprado {
cancionId: string;
cancionSlug: string;
principalSlug: string;
titulo: string;
versionNombre?: string;
clave: string;
}
/**
* Resuelve primero la entidad musical y después una representación privada.
* La descarga explícita gana al máster; ninguno de los dos cruza el contrato
* público del catálogo.
*/
export async function archivoComprado(
principalSlug: string,
versionCodigo?: string
): Promise<ArchivoComprado | undefined> {
const [principal] = await db
.select({ id: cancion.id, slug: cancion.slug, titulo: cancion.titulo })
.from(cancion)
.where(and(eq(cancion.slug, principalSlug), isNull(cancion.versionDeId)))
.limit(1);
if (!principal) return undefined;
const destino = versionCodigo
? (
await db
.select({
id: cancion.id,
slug: cancion.slug,
titulo: cancion.titulo,
versionNombre: cancion.versionNombre
})
.from(cancion)
.where(
and(
eq(cancion.versionDeId, principal.id),
eq(cancion.versionCodigo, versionCodigo),
ne(cancion.estado, 'borrador')
)
)
.limit(1)
)[0]
: { ...principal, versionNombre: null };
if (!destino) return undefined;
const [audio] = await db
.select({ clave: medio.clave })
.from(cancionAudio)
.innerJoin(medio, eq(medio.id, cancionAudio.medioId))
.where(
and(eq(cancionAudio.cancionId, destino.id), inArray(cancionAudio.uso, ['descarga', 'master']))
)
.orderBy(
sql`case when ${cancionAudio.uso} = 'descarga' then 0 else 1 end`,
asc(cancionAudio.orden)
)
.limit(1);
if (!audio) return undefined;
return {
cancionId: destino.id,
cancionSlug: destino.slug,
principalSlug: principal.slug,
titulo: principal.titulo,
versionNombre: destino.versionNombre ?? undefined,
clave: audio.clave.replace(/^audio\//, '')
};
}

@ -30,11 +30,14 @@ import { db } from './db';
import {
album,
albumEnlace,
artista,
autoria,
cancion,
cancionAudio,
cancionEnlace,
cancionEtiqueta,
cancionGenero,
cancionInterprete,
cancionPersona,
creditoAlbum,
estilo,
@ -118,15 +121,17 @@ async function cargar(): Promise<Catalogo> {
* `medio` por su audio y otra vez por su portada, y sin alias Postgres ve
* un solo `medio` y devuelve la misma fila para los dos.
*/
const audio = alias(medio, 'audio');
const portadaCancion = alias(medio, 'portada_cancion');
const portadaAlbum = alias(medio, 'portada_album');
const imagenArtista = alias(medio, 'imagen_artista');
const [
filasEstilo,
filasAlbum,
filasCancion,
personas,
interpretes,
audiosDeEscucha,
etiquetas,
enlacesCancion,
enlacesAlbum,
@ -163,14 +168,11 @@ async function cargar(): Promise<Catalogo> {
fila: cancion,
estiloSlug: estilo.slug,
albumSlug: album.slug,
audioClave: audio.clave,
audioDuracion: audio.duracionS,
portadaClave: portadaCancion.clave
})
.from(cancion)
.innerJoin(estilo, eq(estilo.id, cancion.estiloId))
.leftJoin(album, eq(album.id, cancion.albumId))
.leftJoin(audio, eq(audio.id, cancion.audioId))
.leftJoin(portadaCancion, eq(portadaCancion.id, cancion.portadaId))
.where(
and(
@ -192,6 +194,36 @@ async function cargar(): Promise<Catalogo> {
.innerJoin(persona, eq(persona.id, cancionPersona.personaId))
.orderBy(asc(cancionPersona.orden)),
db
.select({
cancionId: cancionInterprete.cancionId,
id: artista.id,
codigo: artista.codigo,
slug: artista.slug,
nombre: artista.nombre,
tipo: artista.tipo,
estado: artista.estado,
rol: cancionInterprete.rol,
instrumento: cancionInterprete.instrumento,
acreditadoComo: cancionInterprete.acreditadoComo,
imagenClave: imagenArtista.clave
})
.from(cancionInterprete)
.innerJoin(artista, eq(artista.id, cancionInterprete.artistaId))
.leftJoin(imagenArtista, eq(imagenArtista.id, artista.imagenId))
.orderBy(asc(cancionInterprete.orden), asc(artista.nombre)),
db
.select({
cancionId: cancionAudio.cancionId,
clave: medio.clave,
duracion: medio.duracionS
})
.from(cancionAudio)
.innerJoin(medio, eq(medio.id, cancionAudio.medioId))
.where(and(eq(cancionAudio.uso, 'stream'), eq(medio.publico, true)))
.orderBy(asc(cancionAudio.orden)),
db
.select({ cancionId: cancionEtiqueta.cancionId, nombre: etiqueta.nombre })
.from(cancionEtiqueta)
@ -265,12 +297,14 @@ async function cargar(): Promise<Catalogo> {
const porCancion = {
personas: porClave(personas, (p) => p.cancionId),
interpretes: porClave(interpretes, (i) => i.cancionId),
etiquetas: porClave(etiquetas, (e) => e.cancionId),
generos: porClave(generosDeCancion, (g) => g.cancionId),
enlaces: porClave(enlacesCancion, (e) => e.cancionId),
videos: porClave(videos, (v) => v.cancionId),
partes: porClave(partes, (p) => p.cancionId)
};
const audioDeEscucha = new Map(audiosDeEscucha.map((a) => [a.cancionId, a]));
const traduccionDe = new Map(traducciones.map((t) => [t.cancionId, t.texto]));
const registroDe = new Map(registros.map((r) => [r.cancionId, r]));
const enlacesDeAlbum = porClave(enlacesAlbum, (e) => e.albumId);
@ -331,20 +365,39 @@ async function cargar(): Promise<Catalogo> {
const canciones: Cancion[] = filasCancion
.filter((f) => f.fila.versionDeId === null)
.map(({ fila, estiloSlug, albumSlug, audioClave, audioDuracion, portadaClave }) => {
.map(({ fila, estiloSlug, albumSlug, portadaClave }) => {
const suyas = porCancion.personas.get(fila.id) ?? [];
const susInterpretes = porCancion.interpretes.get(fila.id) ?? [];
const escucha = audioDeEscucha.get(fila.id);
const registro = registroDe.get(fila.id);
const misPartes = porCancion.partes.get(fila.id) ?? [];
const versiones: VersionMusical[] = (versionesDe.get(fila.id) ?? [])
.sort((a, b) => a.fila.versionOrden - b.fila.versionOrden)
.map((v) => ({
id: v.fila.slug.replace(`${fila.slug}-`, ''),
nombre: v.fila.versionNombre ?? '',
audio: v.audioClave?.replace(/^audio\//, '') ?? '',
duracion: v.fila.duracionS ?? v.audioDuracion ?? undefined,
nota: v.fila.versionNota ?? undefined
}));
.map((v) => {
const escuchaVersion = audioDeEscucha.get(v.fila.id);
const interpretesVersion = porCancion.interpretes.get(v.fila.id) ?? [];
return {
id: v.fila.versionCodigo ?? v.fila.slug.replace(`${fila.slug}-`, ''),
codigo: v.fila.codigo,
slug: v.fila.slug,
nombre: v.fila.versionNombre ?? '',
audio: rutaPublica(escuchaVersion?.clave ?? null),
duracion: v.fila.duracionS ?? escuchaVersion?.duracion ?? undefined,
nota: v.fila.versionNota ?? undefined,
interpretes: interpretesVersion.map((i) => ({
id: i.id,
codigo: i.codigo,
slug: i.estado === 'publicado' ? i.slug : undefined,
nombre: i.acreditadoComo ?? i.nombre,
tipo: i.tipo,
rol: i.rol,
instrumento: i.instrumento ?? undefined,
acreditadoComo: i.acreditadoComo ?? undefined,
imagen: i.estado === 'publicado' ? rutaPublica(i.imagenClave) : undefined
}))
};
});
const registroDeObra: RegistroDeObra = {
iswc: registro?.iswc ?? undefined,
@ -365,17 +418,28 @@ async function cargar(): Promise<Catalogo> {
}));
return {
codigo: fila.codigo,
slug: fila.slug,
titulo: fila.titulo,
estilo: estiloSlug,
album: albumSlug ?? null,
numero: fila.numero,
audio: audioClave?.replace(/^audio\//, ''),
duracion: fila.duracionS ?? audioDuracion ?? undefined,
audio: rutaPublica(escucha?.clave ?? null),
duracion: fila.duracionS ?? escucha?.duracion ?? undefined,
ano: fila.ano ?? undefined,
letra: suyas.filter((p) => p.rol === 'letra').map((p) => p.nombre),
musica: suyas.filter((p) => p.rol === 'musica').map((p) => p.nombre),
interpretes: suyas.filter((p) => p.rol === 'interprete').map((p) => p.nombre),
interpretes: susInterpretes.map((i) => ({
id: i.id,
codigo: i.codigo,
slug: i.estado === 'publicado' ? i.slug : undefined,
nombre: i.acreditadoComo ?? i.nombre,
tipo: i.tipo,
rol: i.rol,
instrumento: i.instrumento ?? undefined,
acreditadoComo: i.acreditadoComo ?? undefined,
imagen: i.estado === 'publicado' ? rutaPublica(i.imagenClave) : undefined
})),
destacada: fila.destacada,
etiquetas: (porCancion.etiquetas.get(fila.id) ?? []).map((e) => e.nombre),
generos: (porCancion.generos.get(fila.id) ?? []).map((g) => ({

@ -6,6 +6,7 @@ import {
check,
index,
integer,
pgSequence,
pgTable,
primaryKey,
text,
@ -16,6 +17,7 @@ import { ahora, estado, id, instante, procedencia } from './comun';
export const TIPOS_ARTISTA = ['persona', 'grupo', 'proyecto'] as const;
export const RELACIONES_ARTISTA_PERSONA = ['identidad', 'miembro', 'fundador'] as const;
export const artistaCodigoSeq = pgSequence('artista_codigo_seq');
/**
* Una identidad acreditada ante el público. No equivale necesariamente a una

@ -48,10 +48,9 @@ export const medio = pgTable(
/**
* Si se puede pedir por URL sin más.
*
* Los fragmentos de muestra sí; el audio completo no, nunca. Hasta ahora
* eso lo garantizaba la carpeta en la que estaba el archivo —un directorio
* privado fuera de `build/`—, que es una garantía que depende de que nadie
* mueva nada. Aquí es un dato, y el que sirve los archivos lo comprueba.
* Las copias completas de escucha sí; los másteres y descargas no. Antes
* eso dependía solo de la carpeta física. Aquí es un dato y cada consumidor
* selecciona además el uso concreto en `cancion_audio`.
*
* Con los medios en su propio dominio esto se vuelve más importante, no
* menos: allí la cookie de sesión no viaja, así que lo privado se entrega

@ -16,6 +16,7 @@ import {
index,
integer,
numeric,
pgSequence,
pgTable,
primaryKey,
text,
@ -25,6 +26,8 @@ import { ahora, estado, id, instante } from './comun';
import { medio, pais, persona } from './base';
import { artista } from './artistas';
export const cancionCodigoSeq = pgSequence('cancion_codigo_seq');
/**
* Una sección del catálogo: baladas románticas, clásica, rock, pop melódico.
*

@ -1,12 +1,11 @@
/** Registro auditable del inicio de una descarga autorizada. */
import { eq } from 'drizzle-orm';
import { db } from './db';
import { cancion, descarga } from './db/schema';
import { descarga } from './db/schema';
export interface EntregaIniciada {
usuarioId: string;
cancionSlug: string;
cancionId: string;
ip: string | null;
agente: string | null;
}
@ -16,17 +15,9 @@ export interface EntregaIniciada {
* navegador haya terminado de guardarlo: HTTP no proporciona ese acuse.
*/
export async function registrarEntregaIniciada(datos: EntregaIniciada): Promise<void> {
const [tema] = await db
.select({ id: cancion.id })
.from(cancion)
.where(eq(cancion.slug, datos.cancionSlug))
.limit(1);
if (!tema) throw new Error(`No existe la canción descargada «${datos.cancionSlug}».`);
await db.insert(descarga).values({
usuarioId: datos.usuarioId,
cancionId: tema.id,
cancionId: datos.cancionId,
ip: datos.ip,
agente: datos.agente
});

@ -0,0 +1,16 @@
import { describe, expect, it } from 'vitest';
import { componer } from './markdown';
describe('componer', () => {
it('conserva Markdown editorial', () => {
expect(componer('## Historia\n\nUna **voz**.')).toContain('<strong>voz</strong>');
expect(componer('## Historia')).toContain('id="historia"');
});
it('no deja ejecutar HTML ni protocolos activos', () => {
const html = componer('<script>alert(1)</script>\n\n[abre](javascript:alert(1))');
expect(html).not.toContain('<script>');
expect(html).not.toContain('href="javascript:');
expect(html).toContain('&lt;script&gt;');
});
});

@ -14,6 +14,18 @@
import { marked } from 'marked';
import { ancla } from '$lib/content/anclas';
const escapar = (texto: string) =>
texto
.replaceAll('&', '&amp;')
.replaceAll('<', '&lt;')
.replaceAll('>', '&gt;')
.replaceAll('"', '&quot;');
/** Solo protocolos navegables; nunca javascript:, data: ni esquemas opacos. */
function enlaceSeguro(href: string): boolean {
return /^(https?:\/\/|mailto:|\/|#)/i.test(href);
}
/**
* Caché por contenido, no por identificador.
*
@ -34,6 +46,19 @@ marked.use({
breaks: true,
gfm: true,
renderer: {
// Desde que el panel edita Markdown, el HTML crudo ya no es de confianza.
html({ text }) {
return escapar(text);
},
link({ href, title, tokens }) {
const contenido = this.parser.parseInline(tokens);
if (!enlaceSeguro(href)) return contenido;
return `<a href="${escapar(href)}"${title ? ` title="${escapar(title)}"` : ''}>${contenido}</a>`;
},
image({ href, title, text }) {
if (!enlaceSeguro(href)) return escapar(text);
return `<img src="${escapar(href)}" alt="${escapar(text)}"${title ? ` title="${escapar(title)}"` : ''}>`;
},
/*
* Anclas en los encabezados, con la misma función que usa el índice. Las
* dos tienen que dar la misma cadena o el índice enlazaría a sitios que no
@ -52,10 +77,8 @@ marked.use({
/**
* Markdown a HTML.
*
* **No se sanea**, y es una decisión con fecha de caducidad: hoy el único autor
* es el titular y el contenido entra por scripts que él controla. En cuanto
* exista la administración —o alguien más pueda escribir— hay que meter un
* saneador aquí, en un solo sitio, antes de que haya nada que saneáramos tarde.
* El HTML crudo se escapa y los enlaces se restringen arriba. El panel admite
* Markdown, no HTML ejecutable: esta frontera se aplica a todas las lecturas.
*/
export function componer(markdown: string | null | undefined): string {
if (!markdown) return '';

@ -0,0 +1,141 @@
/** Edición de identidades artísticas y sus identificadores externos. */
import { asc, eq, sql } from 'drizzle-orm';
import { db } from './db';
import { artista, artistaIdentificador, medio, pais } from './db/schema';
import type { TipoArtista } from '$lib/types';
export type Estado = 'borrador' | 'publicado' | 'retirado';
export interface CambiosDeArtista {
nombre: string;
tipo: TipoArtista;
descripcion: string;
biografia: string;
imagenId: string | null;
paisCodigo: string | null;
lugarOrigen: string;
inicio: number | null;
fin: number | null;
sitioWeb: string;
estado: Estado;
identificadores: { sistema: string; valor: string }[];
}
export type ErroresDeArtista = Partial<
Record<'nombre' | 'periodo' | 'sitioWeb' | 'identificadores', string>
>;
export function revisarArtista(cambios: CambiosDeArtista): ErroresDeArtista {
const errores: ErroresDeArtista = {};
if (cambios.nombre.trim().length < 2)
errores.nombre = 'Escribe un nombre de al menos dos caracteres.';
if (cambios.inicio !== null && (!Number.isInteger(cambios.inicio) || cambios.inicio < 1800)) {
errores.periodo = 'El año de inicio no es válido.';
}
if (cambios.fin !== null && (!Number.isInteger(cambios.fin) || cambios.fin < 1800)) {
errores.periodo = 'El año final no es válido.';
}
if (cambios.inicio !== null && cambios.fin !== null && cambios.fin < cambios.inicio) {
errores.periodo = 'El final no puede ser anterior al inicio.';
}
if (cambios.sitioWeb && !/^https?:\/\//i.test(cambios.sitioWeb)) {
errores.sitioWeb = 'La dirección debe empezar por https:// o http://.';
}
const claves = cambios.identificadores.map((i) => i.sistema.toLowerCase());
if (new Set(claves).size !== claves.length) {
errores.identificadores = 'Cada sistema de identificación puede aparecer una sola vez.';
}
return errores;
}
export async function listaDeArtistas() {
return db
.select({
id: artista.id,
codigo: artista.codigo,
slug: artista.slug,
nombre: artista.nombre,
tipo: artista.tipo,
estado: artista.estado,
descripcion: artista.descripcionCorta,
grabaciones: sql<number>`(
select count(*)::int from cancion_interprete ci where ci.artista_id = ${artista.id}
)`
})
.from(artista)
.orderBy(asc(artista.nombre));
}
export async function opcionesDeArtista() {
const [paises, imagenes] = await Promise.all([
db.select({ codigo: pais.codigo, nombre: pais.nombre }).from(pais).orderBy(asc(pais.nombre)),
db
.select({ id: medio.id, clave: medio.clave })
.from(medio)
.where(eq(medio.clase, 'imagen'))
.orderBy(asc(medio.clave))
]);
return { paises, imagenes };
}
export async function artistaParaEditar(slug: string) {
const [fila] = await db.select().from(artista).where(eq(artista.slug, slug)).limit(1);
if (!fila) return null;
const identificadores = await db
.select({ sistema: artistaIdentificador.sistema, valor: artistaIdentificador.valor })
.from(artistaIdentificador)
.where(eq(artistaIdentificador.artistaId, fila.id))
.orderBy(asc(artistaIdentificador.sistema));
return { ...fila, identificadores };
}
export async function crearArtista(
nombre: string,
slug: string,
tipo: TipoArtista
): Promise<string> {
const [creado] = await db
.insert(artista)
.values({ nombre: nombre.trim(), slug, tipo })
.returning({ slug: artista.slug });
return creado.slug;
}
export async function guardarArtista(slug: string, cambios: CambiosDeArtista): Promise<boolean> {
const [fila] = await db
.select({ id: artista.id })
.from(artista)
.where(eq(artista.slug, slug))
.limit(1);
if (!fila) return false;
await db.transaction(async (tx) => {
await tx
.update(artista)
.set({
nombre: cambios.nombre.trim(),
tipo: cambios.tipo,
descripcionCorta: cambios.descripcion.trim(),
biografia: cambios.biografia.trim(),
imagenId: cambios.imagenId,
paisCodigo: cambios.paisCodigo,
lugarOrigen: cambios.lugarOrigen.trim() || null,
inicio: cambios.inicio,
fin: cambios.fin,
sitioWeb: cambios.sitioWeb.trim() || null,
estado: cambios.estado,
actualizadoEn: new Date()
})
.where(eq(artista.id, fila.id));
await tx.delete(artistaIdentificador).where(eq(artistaIdentificador.artistaId, fila.id));
if (cambios.identificadores.length > 0) {
await tx.insert(artistaIdentificador).values(
cambios.identificadores.map((i) => ({
artistaId: fila.id,
sistema: i.sistema.toLowerCase(),
valor: i.valor
}))
);
}
});
return true;
}

@ -13,6 +13,9 @@ const bueno = (cambios: Partial<CambiosDeCancion> = {}): CambiosDeCancion => ({
instrumental: false,
destacada: false,
alaVenta: true,
interpreteIds: ['artista-1'],
versiones: [],
nuevaVersion: { nombre: '', codigo: '' },
...cambios
});

@ -14,9 +14,9 @@
* invalidarla al escribir, no por reloj.
*/
import { and, asc, eq, isNull, sql } from 'drizzle-orm';
import { and, asc, eq, inArray, isNull, sql } from 'drizzle-orm';
import { db } from './db';
import { album, cancion, estilo } from './db/schema';
import { album, artista, cancion, cancionAudio, cancionInterprete, estilo } from './db/schema';
import { olvidarCatalogo } from './catalogo';
import { ESTADOS } from './db/schema/comun';
@ -53,7 +53,9 @@ export async function listaDeCanciones(): Promise<CancionEnLista[]> {
albumTitulo: album.titulo,
albumSlug: album.slug,
estiloNombre: estilo.nombre,
audioId: cancion.audioId,
tieneAudio: sql<boolean>`exists (
select 1 from cancion_audio ca where ca.cancion_id = ${cancion.id} and ca.uso = 'stream'
)`,
portadaId: cancion.portadaId,
versos: sql<number>`(
select count(*)::int from regexp_split_to_table(coalesce(${cancion.letra}, ''), '\n') l
@ -66,14 +68,15 @@ export async function listaDeCanciones(): Promise<CancionEnLista[]> {
.where(isNull(cancion.versionDeId))
.orderBy(asc(album.titulo), asc(cancion.numero), asc(cancion.titulo));
return filas.map(({ audioId, portadaId, ...resto }) => ({
return filas.map(({ portadaId, ...resto }) => ({
...resto,
tieneAudio: audioId !== null,
tienePortada: portadaId !== null
}));
}
export interface CancionParaEditar {
id: string;
codigo: string;
slug: string;
titulo: string;
letra: string;
@ -90,11 +93,24 @@ export interface CancionParaEditar {
estiloNombre: string;
tieneAudio: boolean;
tienePortada: boolean;
interpreteIds: string[];
artistas: { id: string; codigo: string; nombre: string; estado: Estado }[];
versiones: {
id: string;
codigo: string;
versionCodigo: string;
nombre: string;
nota: string;
estado: Estado;
audios: { uso: string; formato: string }[];
}[];
}
export async function cancionParaEditar(slug: string): Promise<CancionParaEditar | null> {
const [fila] = await db
.select({
id: cancion.id,
codigo: cancion.codigo,
slug: cancion.slug,
titulo: cancion.titulo,
letra: cancion.letra,
@ -109,7 +125,9 @@ export async function cancionParaEditar(slug: string): Promise<CancionParaEditar
albumTitulo: album.titulo,
albumSlug: album.slug,
estiloNombre: estilo.nombre,
audioId: cancion.audioId,
tieneAudio: sql<boolean>`exists (
select 1 from cancion_audio ca where ca.cancion_id = ${cancion.id} and ca.uso = 'stream'
)`,
portadaId: cancion.portadaId
})
.from(cancion)
@ -120,13 +138,66 @@ export async function cancionParaEditar(slug: string): Promise<CancionParaEditar
if (!fila) return null;
const { audioId, portadaId, ...resto } = fila;
const [creditos, artistas, versiones] = await Promise.all([
db
.select({ artistaId: cancionInterprete.artistaId })
.from(cancionInterprete)
.where(and(eq(cancionInterprete.cancionId, fila.id), eq(cancionInterprete.rol, 'principal')))
.orderBy(asc(cancionInterprete.orden)),
db
.select({
id: artista.id,
codigo: artista.codigo,
nombre: artista.nombre,
estado: artista.estado
})
.from(artista)
.orderBy(asc(artista.nombre)),
db
.select({
id: cancion.id,
codigo: cancion.codigo,
versionCodigo: cancion.versionCodigo,
nombre: cancion.versionNombre,
nota: cancion.versionNota,
estado: cancion.estado
})
.from(cancion)
.where(eq(cancion.versionDeId, fila.id))
.orderBy(asc(cancion.versionOrden))
]);
const idsVersion = versiones.map((v) => v.id);
const audios =
idsVersion.length === 0
? []
: await db
.select({
cancionId: cancionAudio.cancionId,
uso: cancionAudio.uso,
formato: cancionAudio.formato
})
.from(cancionAudio)
.where(inArray(cancionAudio.cancionId, idsVersion));
const { portadaId, ...resto } = fila;
return {
...resto,
letra: resto.letra ?? '',
inspiracion: resto.inspiracion ?? '',
tieneAudio: audioId !== null,
tienePortada: portadaId !== null
tienePortada: portadaId !== null,
interpreteIds: creditos.map((c) => c.artistaId),
artistas,
versiones: versiones.map((v) => ({
id: v.id,
codigo: v.codigo,
versionCodigo: v.versionCodigo ?? '',
nombre: v.nombre ?? '',
nota: v.nota ?? '',
estado: v.estado,
audios: audios
.filter((a) => a.cancionId === v.id)
.map(({ uso, formato }) => ({ uso, formato }))
}))
};
}
@ -141,10 +212,24 @@ export interface CambiosDeCancion {
instrumental: boolean;
destacada: boolean;
alaVenta: boolean;
interpreteIds: string[];
versiones: { id: string; nombre: string; nota: string; estado: Estado }[];
nuevaVersion: { nombre: string; codigo: string };
}
export type ErroresDeCancion = Partial<
Record<'titulo' | 'letra' | 'numero' | 'ano' | 'idioma' | 'estado', string>
Record<
| 'titulo'
| 'letra'
| 'numero'
| 'ano'
| 'idioma'
| 'estado'
| 'audio'
| 'interpretes'
| 'versiones',
string
>
>;
const ESTE_ANO = () => new Date().getFullYear();
@ -176,32 +261,113 @@ export function revisarCancion(cambios: CambiosDeCancion): ErroresDeCancion {
if (cambios.estado === 'publicado' && !cambios.instrumental && cambios.letra.trim() === '') {
errores.letra = 'No se puede publicar un tema sin letra. Si no la tiene, márcalo instrumental.';
}
if (cambios.estado === 'publicado' && cambios.interpreteIds.length === 0) {
errores.interpretes = 'Una canción publicada necesita al menos un intérprete.';
}
if (cambios.versiones.some((v) => !v.nombre.trim() || !ESTADOS.includes(v.estado))) {
errores.versiones = 'Todas las versiones necesitan nombre y un estado válido.';
}
if (
(cambios.nuevaVersion.nombre.trim() || cambios.nuevaVersion.codigo.trim()) &&
(!cambios.nuevaVersion.nombre.trim() ||
!/^[a-z0-9][a-z0-9-]*$/.test(cambios.nuevaVersion.codigo))
) {
errores.versiones = 'La versión nueva necesita nombre y un código como «acustica».';
}
return errores;
}
/** Devuelve `false` si ese slug ya no existe. */
export async function guardarCancion(slug: string, cambios: CambiosDeCancion): Promise<boolean> {
const hechas = await db
.update(cancion)
.set({
titulo: cambios.titulo.trim(),
// La letra NO se toca por dentro: el corte del verso es contenido.
letra: cambios.letra.trim() || null,
inspiracion: cambios.inspiracion.trim() || null,
idioma: cambios.idioma,
numero: cambios.numero,
ano: cambios.ano,
estado: cambios.estado,
instrumental: cambios.instrumental,
destacada: cambios.destacada,
alaVenta: cambios.alaVenta,
actualizadoEn: new Date()
})
const [fila] = await db
.select({ id: cancion.id })
.from(cancion)
.where(and(eq(cancion.slug, slug), isNull(cancion.versionDeId)))
.returning({ slug: cancion.slug });
.limit(1);
if (!fila) return false;
await db.transaction(async (tx) => {
await tx
.update(cancion)
.set({
titulo: cambios.titulo.trim(),
// La letra NO se toca por dentro: el corte del verso es contenido.
letra: cambios.letra.trim() || null,
inspiracion: cambios.inspiracion.trim() || null,
idioma: cambios.idioma,
numero: cambios.numero,
ano: cambios.ano,
estado: cambios.estado,
instrumental: cambios.instrumental,
destacada: cambios.destacada,
alaVenta: cambios.alaVenta,
actualizadoEn: new Date()
})
.where(eq(cancion.id, fila.id));
await tx
.delete(cancionInterprete)
.where(and(eq(cancionInterprete.cancionId, fila.id), eq(cancionInterprete.rol, 'principal')));
if (cambios.interpreteIds.length > 0) {
await tx.insert(cancionInterprete).values(
cambios.interpreteIds.map((artistaId, orden) => ({
cancionId: fila.id,
artistaId,
rol: 'principal' as const,
orden
}))
);
}
if (hechas.length === 0) return false;
for (const version of cambios.versiones) {
await tx
.update(cancion)
.set({
versionNombre: version.nombre.trim(),
versionNota: version.nota.trim() || null,
estado: version.estado,
actualizadoEn: new Date()
})
.where(and(eq(cancion.id, version.id), eq(cancion.versionDeId, fila.id)));
}
if (cambios.nuevaVersion.nombre.trim() || cambios.nuevaVersion.codigo.trim()) {
const [principal] = await tx.select().from(cancion).where(eq(cancion.id, fila.id)).limit(1);
const [orden] = await tx
.select({ valor: sql<number>`coalesce(max(${cancion.versionOrden}), -1)::int + 1` })
.from(cancion)
.where(eq(cancion.versionDeId, fila.id));
const [creada] = await tx
.insert(cancion)
.values({
slug: `${principal.slug}-${cambios.nuevaVersion.codigo}`,
titulo: principal.titulo,
versionDeId: principal.id,
versionNombre: cambios.nuevaVersion.nombre.trim(),
versionCodigo: cambios.nuevaVersion.codigo,
versionOrden: orden.valor,
estiloId: principal.estiloId,
ano: principal.ano,
letra: principal.letra,
idioma: principal.idioma,
inspiracion: principal.inspiracion,
instrumental: principal.instrumental,
portadaId: principal.portadaId,
estado: 'borrador'
})
.returning({ id: cancion.id });
if (cambios.interpreteIds.length > 0) {
await tx.insert(cancionInterprete).values(
cambios.interpreteIds.map((artistaId, orden) => ({
cancionId: creada.id,
artistaId,
rol: 'principal' as const,
orden
}))
);
}
}
});
// El catálogo vive en memoria un minuto. Sin esto, el cambio no se vería.
olvidarCatalogo();

@ -33,7 +33,7 @@ export interface FilaDelPanel {
* `resolve()`, contra su propio mapa de rutas, y así una que se escriba mal
* deja de compilar en vez de dar un 404 al pulsarla.
*/
pantalla: 'capitulos' | 'generos' | 'canciones' | 'prompts' | 'modelos' | null;
pantalla: 'capitulos' | 'generos' | 'artistas' | 'canciones' | 'prompts' | 'modelos' | null;
}
export interface GrupoDelPanel {
@ -92,6 +92,11 @@ export async function estadoDelContenido(): Promise<GrupoDelPanel[]> {
(select count(*) from estilo)::int as estilos,
(select count(*) from album)::int as albumes,
(select count(*) from artista)::int as artistas,
(select count(*) from artista a where a.estado <> 'publicado'
or nullif(btrim(a.descripcion_corta), '') is null
or not exists (select 1 from cancion_interprete ci where ci.artista_id = a.id))::int
as artistas_a_medias,
(select count(*) from cancion where version_de_id is null)::int as canciones,
(select count(*) from cancion where version_de_id is not null)::int as versiones,
(select count(*) from cancion where version_de_id is null and estado <> 'publicado')::int
@ -148,6 +153,11 @@ export async function estadoDelContenido(): Promise<GrupoDelPanel[]> {
and not exists (select 1 from autoria a where a.cancion_id = c.id)
order by titulo limit ${NOMBRES_A_LA_VISTA})
union all
(select 'artistas', nombre from artista a
where a.estado <> 'publicado' or nullif(btrim(a.descripcion_corta), '') is null
or not exists (select 1 from cancion_interprete ci where ci.artista_id = a.id)
order by nombre limit ${NOMBRES_A_LA_VISTA})
union all
(select 'entradas', titulo from entrada
where estado <> 'publicado' order by titulo limit ${NOMBRES_A_LA_VISTA})
`)
@ -178,6 +188,14 @@ export async function estadoDelContenido(): Promise<GrupoDelPanel[]> {
titulo: 'Taller y géneros',
nota: 'Lo que se escribe: los principios del oficio y la ficha de cada estilo.',
filas: [
{
titulo: 'Artistas',
cuantas: n.artistas,
unidad: 'identidades',
detalle: 'Personas, grupos y proyectos acreditados',
pendiente: falta(n.artistas_a_medias, 'con ficha o créditos incompletos', 'artistas'),
pantalla: 'artistas'
},
{
titulo: 'Capítulos',
cuantas: n.capitulos,

@ -6,9 +6,9 @@
* primaria compuesta de la tabla, no este módulo; aquí solo se escribe de
* forma que la base de datos pueda hacer su trabajo.
*
* No hace falta haber comprado el tema para valorarlo: lo que suena en la web
* es un fragmento, y opinar sobre lo que se ha escuchado es justo el uso
* normal. Exigir la compra convertiría la valoración en una reseña de producto.
* No hace falta haber comprado el tema para valorarlo: la web permite
* escucharlo completo a calidad reducida. Exigir la compra convertiría la
* valoración en una reseña de producto.
*/
import { and, eq, sql } from 'drizzle-orm';

@ -50,18 +50,40 @@ export interface Estilo {
export interface VersionMusical {
/** Identificador estable dentro del tema, p. ej. "orquestal". */
id: string;
/** Código interno estable de la grabación. */
codigo: string;
/** Slug propio de la fila que representa esta versión. */
slug: string;
/** Nombre visible del arreglo, p. ej. "Version orquestal". */
nombre: string;
/**
* Clave del archivo dentro del almacen privado de audio.
* Es una ruta relativa sin barra inicial, p. ej.
* "como-si-nada/01-como-si-nada-orquestal.mp3".
* URL pública de la copia completa de escucha, codificada con menor calidad
* que el archivo privado entregado tras la compra.
*/
audio: string;
audio?: string;
/** Duracion en segundos. */
duracion?: number;
/** Nota breve sobre el arreglo. */
nota?: string;
/** Créditos propios de esta grabación; pueden diferir de la principal. */
interpretes: InterpreteDeCancion[];
}
export type TipoArtista = 'persona' | 'grupo' | 'proyecto';
export type RolDeInterprete = 'principal' | 'invitado' | 'coros' | 'instrumentista';
/** Identidad artística acreditada en una grabación. */
export interface InterpreteDeCancion {
id: string;
codigo: string;
/** Solo se informa cuando la identidad tiene ficha pública. */
slug?: string;
nombre: string;
tipo: TipoArtista;
rol: RolDeInterprete;
instrumento?: string;
acreditadoComo?: string;
imagen?: string;
}
/**
@ -118,6 +140,8 @@ export interface RegistroDeObra {
/** Un tema. Puede pertenecer a un album o ser un single suelto. */
export interface Cancion {
/** Código interno estable, independiente del título y de la URL. */
codigo: string;
slug: string;
titulo: string;
/** Slug del estilo al que pertenece. */
@ -130,8 +154,8 @@ export interface Cancion {
/** Posicion dentro del album. `null` en los singles. */
numero: number | null;
/**
* Clave del archivo de audio de la version principal, dentro del almacen
* privado. Sin barra inicial: no es una URL publica.
* URL pública de la copia completa de escucha. El máster y la descarga no
* forman parte del contrato que llega al navegador.
*/
audio?: string;
/** Duracion en segundos. Se calcula desde "m:ss" en el frontmatter. */
@ -143,7 +167,7 @@ export interface Cancion {
/** Quien firma la musica. Por defecto, el artista. */
musica: string[];
/** Quien la interpreta en esta grabacion. */
interpretes: string[];
interpretes: InterpreteDeCancion[];
/** Destacada en portada y cabecera de estilo. */
destacada: boolean;
/**

@ -30,6 +30,7 @@
{ href: resolve('/(dentro)'), texto: 'El contenido' },
{ href: resolve('/(dentro)/capitulos'), texto: 'Capítulos' },
{ href: resolve('/(dentro)/generos'), texto: 'Géneros' },
{ href: resolve('/(dentro)/artistas'), texto: 'Artistas' },
{ href: resolve('/(dentro)/canciones'), texto: 'Catálogo' }
];

@ -23,6 +23,7 @@
const PANTALLAS = {
capitulos: resolve('/(dentro)/capitulos'),
generos: resolve('/(dentro)/generos'),
artistas: resolve('/(dentro)/artistas'),
canciones: resolve('/(dentro)/canciones'),
prompts: resolve('/(dentro)/generos'),
modelos: resolve('/(dentro)/prompts/modelos')

@ -0,0 +1,32 @@
import { fail, redirect } from '@sveltejs/kit';
import { crearArtista, listaDeArtistas } from '$lib/server/panel-artistas';
import type { TipoArtista } from '$lib/types';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async () => ({ artistas: await listaDeArtistas() });
function aSlug(texto: string): string {
return texto
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export const actions: Actions = {
crear: async ({ request }) => {
const datos = await request.formData();
const nombre = String(datos.get('nombre') ?? '').trim();
const slug = aSlug(String(datos.get('slug') ?? '') || nombre);
const tipo = String(datos.get('tipo') ?? 'persona') as TipoArtista;
if (nombre.length < 2 || !slug)
return fail(400, { error: 'Escribe un nombre y un slug válidos.' });
try {
await crearArtista(nombre, slug, tipo);
} catch {
return fail(409, { error: 'Ya existe una identidad con ese nombre o slug.' });
}
redirect(303, `/artistas/${encodeURIComponent(slug)}`);
}
};

@ -0,0 +1,106 @@
<script lang="ts">
import { resolve } from '$app/paths';
let { data, form } = $props();
let consulta = $state('');
const encontrados = $derived(
data.artistas.filter((a) =>
`${a.nombre} ${a.codigo} ${a.slug}`.toLowerCase().includes(consulta.toLowerCase().trim())
)
);
</script>
<svelte:head><title>Artistas · Panel</title></svelte:head>
<header>
<h1>Artistas</h1>
<p class="muted">Personas, grupos y proyectos acreditados en las grabaciones.</p>
</header>
<form class="nuevo" method="POST" action="?/crear">
<label>Nombre <input name="nombre" required /></label>
<label>Slug <input name="slug" placeholder="se deriva del nombre" /></label>
<label
>Tipo <select name="tipo"
><option value="persona">Persona</option><option value="grupo">Grupo</option><option
value="proyecto">Proyecto</option
></select
></label
>
<button class="btn btn--primary">Crear ficha</button>
</form>
{#if form?.error}<p class="fallo">{form.error}</p>{/if}
<label class="buscar">Buscar <input type="search" bind:value={consulta} /></label>
<table>
<thead><tr><th>Identidad</th><th>Tipo</th><th>Estado</th><th>Grabaciones</th></tr></thead><tbody>
{#each encontrados as artista (artista.id)}
<tr
><th
><a href={resolve('/(dentro)/artistas/[slug]', { slug: artista.slug })}
>{artista.nombre}</a
><small>{artista.codigo} · {artista.slug}</small></th
><td>{artista.tipo}</td><td>{artista.estado}</td><td>{artista.grabaciones}</td></tr
>
{/each}
</tbody>
</table>
<style>
header {
padding-bottom: var(--sp-5);
}
h1 {
margin: 0;
font-size: var(--fs-2xl);
}
.nuevo {
display: grid;
grid-template-columns: 2fr 2fr 1fr auto;
align-items: end;
gap: var(--sp-3);
padding: var(--sp-4);
border: 1px solid var(--c-border);
background: var(--c-bg-raised);
}
label {
display: grid;
gap: var(--sp-1);
font-size: var(--fs-sm);
}
input,
select {
width: 100%;
padding: var(--sp-2);
border: 1px solid var(--c-border);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
}
.buscar {
max-width: 30rem;
margin-block: var(--sp-6) var(--sp-3);
}
table {
width: 100%;
border-collapse: collapse;
}
th,
td {
padding: var(--sp-3);
border-bottom: 1px solid var(--c-border);
text-align: left;
}
tbody th {
font-weight: 500;
}
small {
display: block;
color: var(--c-text-muted);
font-family: var(--font-mono);
}
@media (max-width: 48rem) {
.nuevo {
grid-template-columns: 1fr;
}
}
</style>

@ -0,0 +1,58 @@
import { error, fail } from '@sveltejs/kit';
import {
artistaParaEditar,
guardarArtista,
opcionesDeArtista,
revisarArtista,
type CambiosDeArtista,
type Estado
} from '$lib/server/panel-artistas';
import type { TipoArtista } from '$lib/types';
import type { Actions, PageServerLoad } from './$types';
export const load: PageServerLoad = async ({ params }) => {
const [artista, opciones] = await Promise.all([
artistaParaEditar(params.slug),
opcionesDeArtista()
]);
if (!artista) error(404, 'No existe esa identidad artística.');
return { artista, ...opciones };
};
const numero = (valor: FormDataEntryValue | null) =>
String(valor ?? '').trim() ? Number(valor) : null;
export const actions: Actions = {
guardar: async ({ request, params }) => {
const datos = await request.formData();
const identificadores = String(datos.get('identificadores') ?? '')
.split('\n')
.map((l) => l.trim())
.filter(Boolean)
.map((linea) => {
const corte = linea.indexOf(':');
return {
sistema: corte < 0 ? '' : linea.slice(0, corte).trim(),
valor: corte < 0 ? linea : linea.slice(corte + 1).trim()
};
});
const cambios: CambiosDeArtista = {
nombre: String(datos.get('nombre') ?? ''),
tipo: String(datos.get('tipo') ?? 'persona') as TipoArtista,
descripcion: String(datos.get('descripcion') ?? ''),
biografia: String(datos.get('biografia') ?? ''),
imagenId: String(datos.get('imagenId') ?? '') || null,
paisCodigo: String(datos.get('paisCodigo') ?? '') || null,
lugarOrigen: String(datos.get('lugarOrigen') ?? ''),
inicio: numero(datos.get('inicio')),
fin: numero(datos.get('fin')),
sitioWeb: String(datos.get('sitioWeb') ?? ''),
estado: String(datos.get('estado') ?? 'borrador') as Estado,
identificadores
};
const errores = revisarArtista(cambios);
if (Object.keys(errores).length) return fail(400, { errores });
if (!(await guardarArtista(params.slug, cambios))) error(404, 'La ficha ya no existe.');
return { guardado: true };
}
};

@ -0,0 +1,146 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
let { data, form } = $props();
</script>
<svelte:head><title>{data.artista.nombre} · Panel</title></svelte:head>
<p><a href={resolve('/(dentro)/artistas')}>← Artistas</a></p>
<form method="POST" action="?/guardar" use:enhance>
<header>
<div>
<p class="codigo">{data.artista.codigo} · {data.artista.slug}</p>
<h1>
<label class="visually-hidden" for="nombre">Nombre</label><input
id="nombre"
name="nombre"
value={data.artista.nombre}
required
/>
</h1>
</div>
<button class="btn btn--primary">Guardar</button>
</header>
{#if form?.guardado}<p class="guardado">Guardado</p>{/if}
<section>
<h2>Ficha pública</h2>
<div class="rejilla">
<label
>Tipo <select name="tipo" value={data.artista.tipo}
><option value="persona">Persona</option><option value="grupo">Grupo</option><option
value="proyecto">Proyecto</option
></select
></label
>
<label
>Estado <select name="estado" value={data.artista.estado}
><option value="borrador">Borrador</option><option value="publicado">Publicado</option
><option value="retirado">Retirado</option></select
></label
>
<label
>País <select name="paisCodigo" value={data.artista.paisCodigo ?? ''}
><option value="">Sin indicar</option>{#each data.paises as p (p.codigo)}<option
value={p.codigo}>{p.nombre}</option
>{/each}</select
></label
>
<label
>Imagen <select name="imagenId" value={data.artista.imagenId ?? ''}
><option value="">Sin imagen</option>{#each data.imagenes as i (i.id)}<option value={i.id}
>{i.clave}</option
>{/each}</select
></label
>
<label
>Lugar de origen <input name="lugarOrigen" value={data.artista.lugarOrigen ?? ''} /></label
>
<label
>Año de inicio <input
type="number"
name="inicio"
value={data.artista.inicio ?? ''}
/></label
>
<label>Año final <input type="number" name="fin" value={data.artista.fin ?? ''} /></label>
<label
>Sitio web <input type="url" name="sitioWeb" value={data.artista.sitioWeb ?? ''} /></label
>
</div>
{#if form?.errores}<p class="fallo">{Object.values(form.errores).join(' ')}</p>{/if}
<label
>Descripción corta <textarea name="descripcion" rows="2"
>{data.artista.descripcionCorta}</textarea
></label
>
<label
>Biografía <span class="muted">Markdown</span><textarea name="biografia" rows="14"
>{data.artista.biografia}</textarea
></label
>
<label
>Identificadores <span class="muted">Uno por línea: sistema: valor</span><textarea
name="identificadores"
rows="5"
>{data.artista.identificadores.map((i) => `${i.sistema}: ${i.valor}`).join('\n')}</textarea
></label
>
</section>
</form>
<style>
form header {
display: flex;
justify-content: space-between;
align-items: end;
gap: var(--sp-4);
}
h1 {
margin: 0;
}
h1 input {
font: inherit;
font-size: var(--fs-2xl);
}
.codigo {
color: var(--c-text-muted);
font-family: var(--font-mono);
font-size: var(--fs-xs);
}
section {
display: grid;
gap: var(--sp-4);
padding-top: var(--sp-6);
}
.rejilla {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: var(--sp-4);
}
label {
display: grid;
gap: var(--sp-1);
font-weight: 600;
}
input,
select,
textarea {
width: 100%;
padding: var(--sp-2);
border: 1px solid var(--c-border);
background: var(--c-bg-sunken);
color: inherit;
font: inherit;
}
textarea {
resize: vertical;
}
.guardado {
color: var(--c-dato);
}
@media (max-width: 40rem) {
.rejilla {
grid-template-columns: 1fr;
}
}
</style>

@ -24,6 +24,17 @@ function numeroDe(valor: FormDataEntryValue | null): number | null {
export const actions: Actions = {
default: async ({ request, params }) => {
const datos = await request.formData();
const versiones = datos.getAll('versionId').map((valor) => {
const id = String(valor);
return {
id,
nombre: String(datos.get(`versionNombre:${id}`) ?? ''),
nota: String(datos.get(`versionNota:${id}`) ?? ''),
estado: String(datos.get(`versionEstado:${id}`) ?? 'borrador') as Estado
};
});
const nombreVersion = String(datos.get('nuevaVersionNombre') ?? '').trim();
const codigoVersion = String(datos.get('nuevaVersionCodigo') ?? '').trim();
const cambios: CambiosDeCancion = {
titulo: String(datos.get('titulo') ?? ''),
@ -35,10 +46,17 @@ export const actions: Actions = {
estado: String(datos.get('estado') ?? '') as Estado,
instrumental: datos.get('instrumental') !== null,
destacada: datos.get('destacada') !== null,
alaVenta: datos.get('alaVenta') !== null
alaVenta: datos.get('alaVenta') !== null,
interpreteIds: datos.getAll('interpreteId').map(String),
versiones,
nuevaVersion: { nombre: nombreVersion, codigo: codigoVersion }
};
const errores = revisarCancion(cambios);
const actual = await cancionParaEditar(params.slug);
if (cambios.estado === 'publicado' && !actual?.tieneAudio) {
errores.audio = 'No se puede publicar sin una representación completa de escucha.';
}
if (Object.keys(errores).length > 0) return fail(400, { errores, cambios });
const guardado = await guardarCancion(params.slug, cambios);

@ -15,7 +15,7 @@
let { data, form } = $props();
const copiar = (c: CambiosDeCancion): CambiosDeCancion => ({
const copiar = (c: CambiosDeCancion | typeof data.cancion): CambiosDeCancion => ({
titulo: c.titulo,
letra: c.letra,
inspiracion: c.inspiracion,
@ -25,16 +25,22 @@
estado: c.estado,
instrumental: c.instrumental,
destacada: c.destacada,
alaVenta: c.alaVenta
alaVenta: c.alaVenta,
interpreteIds: [...c.interpreteIds],
versiones: c.versiones.map((v) => ({
id: v.id,
nombre: v.nombre,
nota: v.nota,
estado: v.estado
})),
nuevaVersion: 'nuevaVersion' in c ? { ...c.nuevaVersion } : { nombre: '', codigo: '' }
});
let campos = $state(untrack(() => copiar(form?.cambios ?? data.cancion)));
let guardado = $state(untrack(() => copiar(data.cancion)));
let guardando = $state(false);
const sinGuardar = $derived(
(Object.keys(guardado) as (keyof CambiosDeCancion)[]).some((k) => campos[k] !== guardado[k])
);
const sinGuardar = $derived(JSON.stringify(campos) !== JSON.stringify(guardado));
const enLaWeb = $derived(`${site.url}/canciones/${data.cancion.slug}`);
const errores = $derived(form?.errores ?? {});
@ -113,6 +119,7 @@
{/if}
{#if errores.titulo}<p class="fallo">{errores.titulo}</p>{/if}
{#if errores.audio}<p class="fallo">{errores.audio}</p>{/if}
</div>
<div class="acciones">
@ -175,6 +182,85 @@
</label>
</div>
<fieldset class="interpretes">
<legend>Intérpretes principales</legend>
<p class="ayuda muted">Son identidades públicas, no nombres copiados dentro de la canción.</p>
<div class="opciones">
{#each data.cancion.artistas as artista (artista.id)}
<label class:retirado={artista.estado === 'retirado'}>
<input
type="checkbox"
name="interpreteId"
value={artista.id}
bind:group={campos.interpreteIds}
/>
{artista.nombre} <small>{artista.codigo}</small>
</label>
{/each}
</div>
{#if errores.interpretes}<span class="fallo">{errores.interpretes}</span>{/if}
</fieldset>
<section class="versiones">
<h2>Versiones musicales</h2>
<p class="ayuda muted">
Cada versión es otra fila de canción vinculada a esta principal; sus audios son
representaciones aparte.
</p>
{#if campos.versiones.length === 0}
<p class="muted">No hay versiones.</p>
{:else}
<ul class="lista-versiones">
{#each campos.versiones as version (version.id)}
{@const datosVersion = data.cancion.versiones.find((v) => v.id === version.id)}
<li>
<input type="hidden" name="versionId" value={version.id} />
<label>
Nombre
<input name="versionNombre:{version.id}" bind:value={version.nombre} />
</label>
<label>
Nota
<input name="versionNota:{version.id}" bind:value={version.nota} />
</label>
<label>
Estado
<select name="versionEstado:{version.id}" bind:value={version.estado}>
<option value="borrador">Borrador</option>
<option value="publicado">Publicado</option>
<option value="retirado">Retirado</option>
</select>
</label>
<small class="muted">
{datosVersion?.versionCodigo} · {datosVersion?.codigo}{#if datosVersion?.audios.length}
· {datosVersion.audios.map((a) => `${a.uso}/${a.formato}`).join(' · ')}{/if}
</small>
</li>
{/each}
</ul>
{/if}
<div class="nueva-version">
<label>
Nueva versión
<input
name="nuevaVersionNombre"
placeholder="Versión acústica"
bind:value={campos.nuevaVersion.nombre}
/>
</label>
<label>
Código
<input
name="nuevaVersionCodigo"
placeholder="acustica"
pattern="[a-z0-9][a-z0-9-]*"
bind:value={campos.nuevaVersion.codigo}
/>
</label>
</div>
{#if errores.versiones}<span class="fallo">{errores.versiones}</span>{/if}
</section>
<p class="campo">
<label for="inspiracion">De dónde sale</label>
<span class="ayuda muted">
@ -348,6 +434,60 @@
cursor: pointer;
}
.interpretes {
margin: 0;
padding: var(--sp-4);
border: 1px solid var(--c-border);
}
.interpretes legend {
padding-inline: var(--sp-2);
}
.opciones {
display: flex;
flex-wrap: wrap;
gap: var(--sp-2) var(--sp-5);
margin-top: var(--sp-3);
}
.opciones label {
display: flex;
align-items: center;
gap: 0.45rem;
color: var(--c-text);
}
.opciones small {
color: var(--c-text-muted);
font-family: var(--font-mono);
}
.opciones .retirado {
opacity: 0.65;
}
.versiones {
padding-block: var(--sp-4);
border-block: 1px solid var(--c-border);
}
.lista-versiones {
display: grid;
gap: var(--sp-3);
padding: 0;
list-style: none;
}
.lista-versiones li {
display: grid;
grid-template-columns: 1fr 2fr minmax(8rem, 0.6fr);
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--c-bg-raised);
}
.lista-versiones small {
grid-column: 1 / -1;
}
.nueva-version {
display: grid;
grid-template-columns: 2fr 1fr;
gap: var(--sp-3);
margin-top: var(--sp-4);
}
/*
* La letra se lleva la altura que queda de ventana. Es donde se pasa el
* rato, y en una caja de seis líneas se escribe mirando por una rendija.

@ -37,16 +37,23 @@ export async function load({ locals, cookies, url }) {
}
const usuario = locals.usuario;
const [carrito, compras, favoritos, legales, catalogo] = await Promise.all([
leerCarrito(cookies, usuario?.id ?? null),
usuario ? comprasDeUsuario(usuario.id) : Promise.resolve([]),
usuario ? favoritosDeUsuario(usuario.id) : Promise.resolve([]),
indiceLegal(),
leerCatalogo()
]);
return {
// Solo lo que la interfaz necesita: nada de fechas internas ni ids de
// sesión viajando al cliente.
usuario: usuario ? { id: usuario.id, email: usuario.email } : null,
carrito: await leerCarrito(cookies, usuario?.id ?? null),
compras: usuario ? await comprasDeUsuario(usuario.id) : [],
carrito,
compras,
// Los favoritos viajan enteros, como el carrito: son unas decenas de
// slugs y cualquier tarjeta necesita saber si el suyo esta dentro.
favoritos: usuario ? await favoritosDeUsuario(usuario.id) : [],
favoritos,
/*
* Con qué proveedores se puede entrar en ESTE servidor. Sale de si hay
* credenciales puestas, que es información del servidor, y lo necesita el
@ -60,7 +67,7 @@ export async function load({ locals, cookies, url }) {
* viven en la base tienen que bajar por aquí, porque un componente no
* puede consultar.
*/
legales: await indiceLegal(),
legales,
/*
* El catálogo entero, una vez. Hasta ahora viajaba en el paquete de
* JavaScript —lo cargaba `import.meta.glob` al construir— y por eso
@ -72,6 +79,6 @@ export async function load({ locals, cookies, url }) {
* Son veintiún kilobytes, menos de lo que ocupaba en el paquete. La
* letra completa no va: esa la pide la ficha de cada tema.
*/
catalogo: await leerCatalogo()
catalogo
};
}

@ -69,7 +69,7 @@
titulo="{CANCIONES_POR_PACK} canciones por un café"
texto="{formatearPrecio(
PRECIO_PACK_CENTIMOS
)} el pack, en MP3 y con todas sus versiones. En la web suena un fragmento; la descarga es el archivo completo, para llevártelo donde quieras."
)} el pack, en MP3 y con todas sus versiones. En la web se escucha la canción completa a calidad reducida; la compra entrega el archivo de mayor calidad."
>
{#snippet acciones()}
<a class="btn btn--primary" href={resolve('/musica')}>Elegir canciones</a>

@ -168,8 +168,10 @@
<div class="pie">
{#if cancion.alaVenta}
<p class="venta muted">
En la web suena un fragmento. La descarga completa en MP3, con todas sus versiones, va
en packs de {CANCIONES_POR_PACK} canciones por {formatearPrecio(PRECIO_PACK_CENTIMOS)}.
Escúchala completa aquí a calidad reducida. La compra entrega el archivo de mayor
calidad, con todas sus versiones, en packs de {CANCIONES_POR_PACK} canciones por {formatearPrecio(
PRECIO_PACK_CENTIMOS
)}.
</p>
{/if}
@ -180,7 +182,7 @@
<BotonReproducir
{pistas}
indice={Math.max(0, indice)}
texto="Escuchar la muestra"
texto="Escuchar completa"
variante="solido"
tamano={18}
/>
@ -260,6 +262,17 @@
{#if version.nota}
<p class="nota muted">{version.nota}</p>
{/if}
{#if version.interpretes.length > 0}
<p class="interpretes muted">
{#each version.interpretes as interprete, j (`${interprete.id}-${interprete.rol}`)}
{#if j > 0},
{/if}{#if interprete.slug}<a
href={resolve('/interpretes/[slug]', { slug: interprete.slug })}
>{interprete.nombre}</a
>{:else}{interprete.nombre}{/if}
{/each}
</p>
{/if}
</div>
<span class="duracion muted">
{version.duracion ? segundosADuracion(version.duracion) : '--:--'}
@ -496,6 +509,10 @@
font-size: var(--fs-xs);
}
.interpretes {
font-size: var(--fs-xs);
}
.duracion {
font-size: var(--fs-sm);
font-variant-numeric: tabular-nums;

@ -1,6 +1,6 @@
import { error } from '@sveltejs/kit';
import { extname } from 'node:path';
import { obtenerCancion } from '$lib/server/catalogo';
import { archivoComprado } from '$lib/server/audios';
import { respuestaDeDescarga } from '$lib/server/medios';
import { tieneCompra } from '$lib/server/pedidos';
import { registrarEntregaIniciada } from '$lib/server/descargas';
@ -19,34 +19,25 @@ export const GET: RequestHandler = async ({ params, locals, request, getClientAd
error(401, 'Entra en tu cuenta para descargar este tema.');
}
const cancion = await obtenerCancion(params.cancion);
if (!cancion) error(404, 'Ese tema no existe.');
const archivo = await archivoComprado(params.cancion, params.version);
if (!archivo) error(404, 'Ese tema, versión o archivo no existe.');
if (!(await tieneCompra(locals.usuario.id, cancion.slug))) {
if (!(await tieneCompra(locals.usuario.id, archivo.principalSlug))) {
error(403, 'Este tema no está entre tus compras.');
}
// Una versión alternativa va incluida en la compra del tema: quien lo ha
// comprado se lleva todos sus arreglos.
const version = params.version
? cancion.versiones.find((v) => v.id === params.version)
: undefined;
const nombre = archivo.versionNombre
? `${archivo.titulo} (${archivo.versionNombre})${extname(archivo.clave)}`
: `${archivo.titulo}${extname(archivo.clave)}`;
if (params.version && !version) error(404, 'Esa versión no existe.');
const clave = version?.audio ?? cancion.audio;
if (!clave) error(404, 'Este tema todavía no tiene archivo descargable.');
const nombre = version
? `${cancion.titulo} (${version.nombre})${extname(clave)}`
: `${cancion.titulo}${extname(clave)}`;
const respuesta = await respuestaDeDescarga(clave, nombre);
const respuesta = await respuestaDeDescarga(archivo.clave, nombre);
if (!respuesta) error(404, 'El archivo no está disponible ahora mismo.');
await registrarEntregaIniciada({
usuarioId: locals.usuario.id,
cancionSlug: cancion.slug,
cancionId: archivo.cancionId,
ip: getClientAddress(),
agente: request.headers.get('user-agent')
});

@ -0,0 +1,5 @@
import { artistasPublicos } from '$lib/server/artistas';
export async function load() {
return { artistas: await artistasPublicos() };
}

@ -0,0 +1,105 @@
<script lang="ts">
import { resolve } from '$app/paths';
import Meta from '$lib/components/Meta.svelte';
let { data } = $props();
</script>
<Meta
titulo="Intérpretes"
descripcion="Las voces, grupos y proyectos acreditados en las grabaciones del catálogo."
/>
<header class="portada">
<p class="ceja">Créditos de grabación</p>
<h1>Intérpretes</h1>
<p class="entradilla">
Las identidades que dan voz y sonido a cada versión, con su ficha y la discografía en la que
participan.
</p>
</header>
<ul class="artistas">
{#each data.artistas as artista (artista.id)}
<li>
<a href={resolve('/interpretes/[slug]', { slug: artista.slug })}>
{#if artista.imagen}
<img src={artista.imagen} alt="" loading="lazy" />
{/if}
<span class="texto">
<span class="tipo">{artista.tipo}</span>
<strong>{artista.nombre}</strong>
{#if artista.descripcion}<span>{artista.descripcion}</span>{/if}
<small>{artista.totalCanciones} {artista.totalCanciones === 1 ? 'tema' : 'temas'}</small>
</span>
</a>
</li>
{/each}
</ul>
{#if data.artistas.length === 0}
<p class="vacio muted">Aún no hay intérpretes publicados.</p>
{/if}
<style>
.portada {
max-width: 62ch;
padding-block: var(--sp-6);
}
.ceja,
.tipo {
color: var(--c-accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: var(--fs-xs);
}
h1 {
margin: 0 0 var(--sp-2);
font-size: var(--fs-3xl);
}
.entradilla {
color: var(--c-text-muted);
font-size: var(--fs-lg);
}
.artistas {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(min(18rem, 100%), 1fr));
gap: var(--sp-4);
margin: 0;
padding: 0 0 var(--sp-8);
list-style: none;
}
.artistas a {
display: grid;
grid-template-columns: 5rem 1fr;
gap: var(--sp-4);
height: 100%;
padding: var(--sp-4);
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
background: var(--c-bg-raised);
text-decoration: none;
}
.artistas img {
width: 5rem;
aspect-ratio: 1;
object-fit: cover;
border-radius: 50%;
}
.texto {
display: grid;
align-content: start;
gap: var(--sp-1);
}
.texto strong {
font-family: var(--font-display);
font-size: var(--fs-xl);
}
.texto > span:not(.tipo),
small {
color: var(--c-text-muted);
}
.vacio {
padding-bottom: var(--sp-8);
}
</style>

@ -0,0 +1,8 @@
import { error } from '@sveltejs/kit';
import { obtenerArtistaPublico } from '$lib/server/artistas';
export async function load({ params }) {
const artista = await obtenerArtistaPublico(params.slug);
if (!artista) error(404, 'Ese intérprete no existe.');
return { artista };
}

@ -0,0 +1,122 @@
<script lang="ts">
import Meta from '$lib/components/Meta.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
let { data } = $props();
const periodo = $derived(
data.artista.inicio ? `${data.artista.inicio}–${data.artista.fin ?? 'actualidad'}` : undefined
);
</script>
<Meta
titulo={data.artista.nombre}
descripcion={data.artista.descripcion || `Ficha y discografía de ${data.artista.nombre}.`}
imagen={data.artista.imagen}
/>
<header class="ficha">
{#if data.artista.imagen}
<img src={data.artista.imagen} alt="Retrato de {data.artista.nombre}" />
{/if}
<div>
<p class="ceja">{data.artista.tipo} · {data.artista.codigo}</p>
<h1>{data.artista.nombre}</h1>
{#if data.artista.descripcion}<p class="entradilla">{data.artista.descripcion}</p>{/if}
<p class="datos">
{[data.artista.lugarOrigen, data.artista.pais, periodo].filter(Boolean).join(' · ')}
</p>
{#if data.artista.sitioWeb}
<a href={data.artista.sitioWeb} rel="external">Sitio oficial</a>
{/if}
</div>
</header>
{#if data.artista.biografia}
<!-- El HTML sale de `componer`, que escapa HTML crudo y protocolos activos. -->
<!-- eslint-disable-next-line svelte/no-at-html-tags -->
<section class="biografia prosa">{@html data.artista.biografia}</section>
{/if}
<section class="discografia">
<h2>Discografía</h2>
{#if data.artista.canciones.length > 0}
<RejillaCanciones canciones={data.artista.canciones} />
{:else}
<p class="muted">No hay grabaciones publicadas todavía.</p>
{/if}
</section>
{#if data.artista.identificadores.length > 0}
<dl class="identificadores">
{#each data.artista.identificadores as identificador (identificador.sistema)}
<dt>{identificador.sistema}</dt>
<dd>{identificador.valor}</dd>
{/each}
</dl>
{/if}
<style>
.ficha {
display: grid;
grid-template-columns: minmax(9rem, 18rem) minmax(0, 1fr);
align-items: center;
gap: clamp(var(--sp-5), 7vw, var(--sp-8));
padding-block: var(--sp-7);
}
.ficha img {
width: 100%;
aspect-ratio: 1;
object-fit: cover;
border-radius: 50%;
}
.ceja {
color: var(--c-accent);
text-transform: uppercase;
letter-spacing: 0.1em;
font-size: var(--fs-xs);
}
h1 {
margin: 0;
font-size: clamp(var(--fs-3xl), 8vw, var(--fs-4xl));
}
.entradilla {
max-width: 48ch;
color: var(--c-text-muted);
font-size: var(--fs-lg);
}
.datos {
color: var(--c-text-muted);
}
.biografia {
max-width: 72ch;
padding-block: var(--sp-5);
}
.discografia {
padding-block: var(--sp-6) var(--sp-8);
}
h2 {
font-size: var(--fs-2xl);
}
.identificadores {
display: grid;
grid-template-columns: max-content 1fr;
gap: var(--sp-2) var(--sp-4);
padding-bottom: var(--sp-8);
}
.identificadores dt {
color: var(--c-text-muted);
text-transform: uppercase;
}
.identificadores dd {
margin: 0;
font-family: var(--font-mono);
}
@media (max-width: 36rem) {
.ficha {
grid-template-columns: 1fr;
}
.ficha img {
max-width: 12rem;
}
}
</style>

@ -31,7 +31,7 @@
String(cancion.ano ?? ''),
...cancion.etiquetas,
...cancion.generos.map((g) => g.nombre),
...cancion.interpretes
...cancion.interpretes.map((i) => i.nombre)
].join(' ')
)
}))

@ -3,6 +3,7 @@ import { entradasPublicadas, paginasPublicadas } from '$lib/server/editorial';
import { site } from '$lib/site';
import { indiceDelTaller } from '$lib/server/taller';
import { indiceLegal } from '$lib/server/legal';
import { artistasPublicos } from '$lib/server/artistas';
import type { RequestHandler } from './$types';
/**
@ -44,7 +45,8 @@ export const GET: RequestHandler = async () => {
discos,
temas,
entradas,
paginas
paginas,
artistas
] = await Promise.all([
indiceDelTaller(),
indiceLegal(),
@ -52,12 +54,14 @@ export const GET: RequestHandler = async () => {
slugsDeAlbum(),
etiquetas(),
entradasPublicadas(),
paginasPublicadas()
paginasPublicadas(),
artistasPublicos()
]);
const rutas = [
url('/', PRIORIDAD.portada),
url('/musica', PRIORIDAD.seccion),
url('/interpretes', PRIORIDAD.seccion),
url('/taller', PRIORIDAD.seccion),
url('/blog', PRIORIDAD.seccion),
url('/etiquetas', PRIORIDAD.resto),
@ -68,6 +72,7 @@ export const GET: RequestHandler = async () => {
// Incluye los álbumes de singles, que son sintéticos pero tienen página.
...discos.map((slug) => url(`/albumes/${slug}`, PRIORIDAD.obra)),
...canciones.map((c) => url(`/canciones/${c.slug}`, PRIORIDAD.obra)),
...artistas.map((a) => url(`/interpretes/${a.slug}`, PRIORIDAD.obra)),
url('/generos', PRIORIDAD.seccion),
url('/generos/glosario', PRIORIDAD.resto),
url('/generos/letristas', PRIORIDAD.resto),

Binary file not shown.
Loading…
Cancel
Save

Powered by TurnKey Linux.