Model song language lyrics and vocal formation

codex/redisenio-dominio-musical
dev 4 weeks ago
parent 0a611a4f59
commit 0a50def3c8

@ -274,6 +274,8 @@ album: nombre-del-disco # quítalo y el tema pasa a ser un single suelto
numero: 1 # obligatorio si hay álbum; se ignora en los singles
duracion: '4:12'
ano: 2026
idioma: es # etiqueta BCP 47: es, fr, en, pt-BR…
formacionVocal: mujer # hombre | mujer | duetos y formaciones múltiples
audio: nombre-del-disco/01-nombre-del-tema.mp3 # clave privada, sin barra inicial
portada: /images/canciones/nombre-del-tema.jpg # opcional: si falta, se usa la del álbum
interpretes:
@ -325,8 +327,13 @@ Detalles que ahorran trabajo:
- **La autoría se presupone.** Sin `letra:` ni `musica:`, ambas se atribuyen al
artista. Solo hay que escribirlas cuando firma alguien más.
- **`instrumental: true`** marca las piezas sin letra (las de clásica, por
ejemplo). El cuerpo del Markdown pasa a leerse como notas de la pieza.
- **El modelo guarda `tiene_letra` directamente.** El lector del contenido
Markdown antiguo sigue aceptando `instrumental: true` y lo convierte a
`tiene_letra = false`; así no se conservan dos booleanos que puedan
contradecirse.
- **La formación vocal no sustituye a los intérpretes.** `formacionVocal`
describe si canta un hombre, una mujer, un dueto o una formación múltiple;
los nombres acreditados siguen siendo entidades propias.
- **Las versiones no se venden aparte.** Quien compra un tema se lleva todos sus
arreglos.
- **`audio` es opcional.** Sin él, la ficha y la letra se publican igual, el
@ -357,8 +364,8 @@ se pinta.
## Letras en otro idioma
Una canción declara su `idioma` (ISO 639, español por defecto). Cuando no está
en español y trae `traduccion`, la pestaña de la letra se parte en **dos
Una canción declara su `idioma` como etiqueta BCP 47 (`es`, `fr`, `pt-BR` o
`zh-Hant`). Cuando no está en español y trae `traduccion`, la pestaña de la letra se parte en **dos
columnas**: la original a la izquierda y el castellano a la derecha, verso a
verso. Con una sola de las dos cosas se queda en una columna, porque una columna
vacía sería peor que ninguna.

@ -0,0 +1,67 @@
CREATE TYPE "public"."formacion_vocal" AS ENUM('hombre', 'mujer', 'dueto-femenino', 'dueto-masculino', 'dueto-mixto', 'multiple-mixto', 'multiple-femenino', 'multiple-masculino');--> statement-breakpoint
DROP VIEW "cancion_principal";--> statement-breakpoint
ALTER TABLE "cancion" RENAME COLUMN "instrumental" TO "tiene_letra";--> statement-breakpoint
-- La columna anterior expresaba el concepto al revés: instrumental=true
-- significaba que no había letra. El renombrado conserva físicamente el dato;
-- esta inversión conserva su significado.
ALTER TABLE "cancion" ALTER COLUMN "tiene_letra" DROP DEFAULT;--> statement-breakpoint
UPDATE "cancion" SET "tiene_letra" = NOT "tiene_letra";--> statement-breakpoint
ALTER TABLE "cancion" ALTER COLUMN "tiene_letra" SET DEFAULT true;--> statement-breakpoint
ALTER TABLE "cancion" ALTER COLUMN "idioma" DROP DEFAULT;--> statement-breakpoint
ALTER TABLE "cancion" ADD COLUMN "formacion_vocal" "formacion_vocal";--> statement-breakpoint
-- Recupera únicamente las voces que el prompt nombra de forma explícita. Si
-- no aparece una pista inequívoca, el campo permanece nulo para que el panel
-- muestre «Sin indicar» en vez de fabricar un dato.
WITH estilos AS (
SELECT DISTINCT ON (p."cancion_id")
p."cancion_id",
lower(p."contenido" ->> 'estilo') AS texto
FROM "prompt" p
WHERE p."contenido" ->> 'estilo' IS NOT NULL
ORDER BY p."cancion_id", p."preferido" DESC, p."creado_en" DESC
), detectadas AS (
SELECT "cancion_id",
CASE
WHEN texto ~ '(^|[^a-z])(choir|vocal ensemble|ensemble vocal|multiple vocals?|multiple voices|coro|coral)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(female|woman|women|feminine|femme|mujer|mujeres)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(male|man|men|masculine|homme|hombre|hombres|baritone|tenor|crooner)([^a-z]|$)'
THEN 'multiple-mixto'
WHEN texto ~ '(^|[^a-z])(choir|vocal ensemble|ensemble vocal|multiple vocals?|multiple voices|coro|coral)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(female|woman|women|feminine|femme|mujer|mujeres)([^a-z]|$)'
THEN 'multiple-femenino'
WHEN texto ~ '(^|[^a-z])(choir|vocal ensemble|ensemble vocal|multiple vocals?|multiple voices|coro|coral)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(male|man|men|masculine|homme|hombre|hombres|baritone|tenor|crooner)([^a-z]|$)'
THEN 'multiple-masculino'
WHEN texto ~ '(homme/femme|femme/homme|male/female|female/male)'
OR (
texto ~ '(^|[^a-z])(duet|duo|dueto|double vocals?|doble voz)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(female|woman|women|feminine|femme|mujer|mujeres)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(male|man|men|masculine|homme|hombre|hombres|baritone|tenor|crooner)([^a-z]|$)'
)
THEN 'dueto-mixto'
WHEN texto ~ '(^|[^a-z])(duet|duo|dueto|double vocals?|doble voz)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(female|woman|women|feminine|femme|mujer|mujeres)([^a-z]|$)'
THEN 'dueto-femenino'
WHEN texto ~ '(^|[^a-z])(duet|duo|dueto|double vocals?|doble voz)([^a-z]|$)'
AND texto ~ '(^|[^a-z])(male|man|men|masculine|homme|hombre|hombres|baritone|tenor|crooner)([^a-z]|$)'
THEN 'dueto-masculino'
WHEN texto ~ '(^|[^a-z])(female|woman|women|feminine|femme|mujer|mujeres)([^a-z]|$)'
THEN 'mujer'
WHEN texto ~ '(^|[^a-z])(male|man|men|masculine|homme|hombre|hombres|baritone|tenor|crooner)([^a-z]|$)'
THEN 'hombre'
END AS formacion
FROM estilos
)
UPDATE "cancion" c
SET "formacion_vocal" = d.formacion::formacion_vocal
FROM detectadas d
WHERE d."cancion_id" = c."id" AND d.formacion IS NOT NULL;--> statement-breakpoint
-- Una versión nueva hereda estos datos; las ya existentes hacen lo mismo una
-- sola vez durante la migración y después pueden editarse por separado.
UPDATE "cancion" v
SET "formacion_vocal" = p."formacion_vocal"
FROM "cancion" p
WHERE v."version_de_id" = p."id" AND v."formacion_vocal" IS NULL;--> statement-breakpoint
ALTER TABLE "cancion" ADD CONSTRAINT "cancion_idioma_bcp47" CHECK ("cancion"."idioma" ~ '^[a-z]{2,8}(-[A-Za-z0-9]{1,8})*$');--> statement-breakpoint
CREATE VIEW "cancion_principal" AS
SELECT * FROM "cancion" WHERE "version_de_id" IS NULL;

File diff suppressed because it is too large Load Diff

@ -134,6 +134,13 @@
"when": 1789056928786,
"tag": "0018_eliminar_catalogo_provisional",
"breakpoints": true
},
{
"idx": 19,
"version": "7",
"when": 1789077453697,
"tag": "0019_atributos-de-cancion",
"breakpoints": true
}
]
}

@ -128,6 +128,17 @@ test.describe('Página de música', () => {
await expect(page.getByRole('heading', { name: 'Todos los temas' })).toBeVisible();
await expect(page.getByRole('heading', { name: 'Dorina' })).toBeVisible();
});
test('la ficha publica el idioma real de la letra', async ({ page }) => {
await page.goto('/canciones/ton-silence-me-libere');
const ficha = page.getByRole('heading', { name: 'Ficha' }).locator('..');
await expect(ficha.getByText('Idioma', { exact: true })).toBeVisible();
await expect(ficha.getByText(/francés/i)).toBeVisible();
await expect(ficha.getByText('Voces', { exact: true })).toBeVisible();
await expect(ficha.getByText('Dueto mixto', { exact: true })).toBeVisible();
await expect(page.locator('.texto[lang="fr"]')).toBeVisible();
});
});
test.describe('Etiquetas', () => {

@ -115,6 +115,43 @@ export function idiomaDe(letra) {
return fr > es ? 'fr' : 'es';
}
/**
* Formación vocal declarada en el prompt de estilo.
*
* Solo devuelve un valor cuando el texto lo dice: no deduce el sexo por el
* título, el idioma ni el nombre del intérprete. Los términos se normalizan
* para cubrir los prompts en español, inglés y francés que ya existen.
*/
export function formacionVocalDe(estilo) {
if (!estilo) return null;
const texto = estilo.normalize('NFD').replace(/[̀-ͯ]/g, '').toLowerCase();
const femenina = /\b(female|woman|women|feminine|femme|mujer|mujeres)\b/.test(texto);
const masculina = /\b(male|man|men|masculine|homme|hombre|hombres|baritone|tenor|crooner)\b/.test(
texto
);
const mixta = /\b(mixed|mixto|mixta|mixte)\b/.test(texto);
const multiple =
/\b(choir|vocal ensemble|ensemble vocal|multiple vocals?|multiple voices|coro|coral)\b/.test(
texto
);
const dueto = /\b(duet|duo|dueto|double vocals?|doble voz)\b/.test(texto);
if (multiple) {
if (mixta || (femenina && masculina)) return 'multiple-mixto';
if (femenina) return 'multiple-femenino';
if (masculina) return 'multiple-masculino';
}
if (dueto || (femenina && masculina)) {
if (mixta || (femenina && masculina)) return 'dueto-mixto';
if (femenina) return 'dueto-femenino';
if (masculina) return 'dueto-masculino';
}
if (femenina) return 'mujer';
if (masculina) return 'hombre';
return null;
}
/** La letra con etiquetas, que es lo que se le pega a Suno. */
export function letraConEtiquetasDe(lineas) {
const primeraEtiqueta = lineas.findIndex(esEtiqueta);
@ -213,13 +250,15 @@ export function leerCancion(carpeta) {
if (txt) {
const lineas = fs.readFileSync(path.join(carpeta, txt), 'utf8').split(/\r?\n/);
const limpia = letraLimpiaDe(lineas);
const estilo = estiloDe(lineas);
letra = {
archivo: txt,
estilo: estiloDe(lineas),
estilo,
ajustes: ajustesDe(lineas),
conEtiquetas: letraConEtiquetasDe(lineas),
limpia,
idioma: idiomaDe(limpia)
idioma: idiomaDe(limpia),
formacionVocal: formacionVocalDe(estilo)
};
}

@ -0,0 +1,23 @@
import { describe, expect, it } from 'vitest';
import { formacionVocalDe } from './carpeta.mjs';
describe('formacionVocalDe', () => {
it.each([
['powerful female vocal', 'mujer'],
['warm baritone vocals', 'hombre'],
['Voix Homme/Femme, double vocals', 'dueto-mixto'],
['mixed duet with piano', 'dueto-mixto'],
['female duet with piano', 'dueto-femenino'],
['male duet with acoustic guitars', 'dueto-masculino'],
['female vocal ensemble', 'multiple-femenino'],
['male vocal ensemble', 'multiple-masculino'],
['mixed choir', 'multiple-mixto']
])('reconoce %s', (prompt, formacion) => {
expect(formacionVocalDe(prompt)).toBe(formacion);
});
it('no inventa una voz cuando el prompt no la declara', () => {
expect(formacionVocalDe('pop rock, alternative rock, atmospheric')).toBeNull();
expect(formacionVocalDe(null)).toBeNull();
});
});

@ -143,9 +143,10 @@ for (const [i, x] of album.canciones.entries()) {
const que = conocidas.has(x.slug) ? 'actualiza' : 'crea';
const prompt = x.letra?.estilo ? ' · prompt' : '';
const idioma = x.letra?.idioma && x.letra.idioma !== 'es' ? ` · ${x.letra.idioma}` : '';
const voces = x.letra?.formacionVocal ? ` · ${x.letra.formacionVocal}` : '';
console.log(
` ${String(i + 1).padStart(2)}. ${que.padEnd(9)} ${x.slug.padEnd(34)} ` +
`${x.letra?.limpia?.split('\n').filter((l) => l.trim()).length ?? 0} versos${prompt}${idioma}`
`${x.letra?.limpia?.split('\n').filter((l) => l.trim()).length ?? 0} versos${prompt}${idioma}${voces}`
);
}
@ -210,17 +211,27 @@ try {
* que no puede deshacer un `.txt` corregido.
*/
const { rows: canciones } = await c.query(
`insert into cancion (id, slug, titulo, letra, idioma, estilo_id, album_id, numero,
estado, ala_venta)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'publicado', true)
`insert into cancion (id, slug, titulo, letra, idioma, formacion_vocal, estilo_id,
album_id, numero, estado, ala_venta)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, 'publicado', true)
on conflict (slug) do update set
titulo = excluded.titulo,
letra = excluded.letra,
idioma = excluded.idioma,
formacion_vocal = coalesce(cancion.formacion_vocal, excluded.formacion_vocal),
album_id = excluded.album_id,
numero = excluded.numero
returning id, (xmax = 0) as nueva`,
[x.slug, x.titulo, x.letra.limpia, x.letra.idioma, estiloId, albumId, i + 1]
[
x.slug,
x.titulo,
x.letra.limpia,
x.letra.idioma,
x.letra.formacionVocal,
estiloId,
albumId,
i + 1
]
);
const cancionId = canciones[0].id;
if (canciones[0].nueva) creadas++;

@ -116,11 +116,11 @@ async function permite(nombre, sql, valores = []) {
await c.query(`insert into pais (codigo, nombre) values ('ZZ', 'País de prueba')`);
await c.query(`insert into estilo (id, slug, nombre) values ('e1', 'zz-baladas', 'Baladas')`);
await c.query(
`insert into cancion (id, slug, titulo, estilo_id) values ('c1', 'zz-como-si-nada', 'Como si nada', 'e1')`
`insert into cancion (id, slug, titulo, estilo_id, idioma) values ('c1', 'zz-como-si-nada', 'Como si nada', 'e1', 'es')`
);
await c.query(
`insert into cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre)
values ('c2', 'zz-como-si-nada-maqueta', 'Como si nada', 'e1', 'c1', 'Maqueta 2018')`
`insert into cancion (id, slug, titulo, estilo_id, idioma, version_de_id, version_nombre, version_codigo)
values ('c2', 'zz-como-si-nada-maqueta', 'Como si nada', 'e1', 'es', 'c1', 'Maqueta 2018', 'maqueta')`
);
await c.query(`insert into persona (id, slug, nombre) values ('p1', 'zz-juan', 'Juan')`);
await c.query(`insert into genero (id, slug, nombre) values ('g1', 'zz-bolero', 'Bolero')`);
@ -135,13 +135,13 @@ await c.query(
apuntar('LAS VERSIONES');
await permite(
'una canción puede tener versiones',
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
values ('c3', 'zz-orquestal', 'Como si nada', 'e1', 'c1')`
`insert into cancion (id, slug, titulo, estilo_id, idioma, version_de_id, version_nombre, version_codigo)
values ('c3', 'zz-orquestal', 'Como si nada', 'e1', 'es', 'c1', 'Orquestal', 'orquestal')`
);
await rechaza(
'una versión no puede tener versiones',
`insert into cancion (id, slug, titulo, estilo_id, version_de_id)
values ('c4', 'zz-x', 'X', 'e1', 'c2')`
`insert into cancion (id, slug, titulo, estilo_id, idioma, version_de_id, version_nombre, version_codigo)
values ('c4', 'zz-x', 'X', 'e1', 'es', 'c2', 'X', 'x')`
);
await rechaza(
'una canción no es versión de sí misma',
@ -159,6 +159,21 @@ apuntar(
` ${bien ? '✓' : '✗'} cancion tiene ${todas.rows[0].n} filas y cancion_principal ${vista.rows[0].n}, que son las principales`
);
apuntar('\nLOS ATRIBUTOS DE CANCIÓN');
await permite(
'idioma, formación vocal y letra se guardan por grabación',
`update cancion set idioma = 'pt-BR', formacion_vocal = 'dueto-mixto', tiene_letra = false
where id = 'c2'`
);
await rechaza(
'el idioma tiene que ser una etiqueta BCP 47',
`update cancion set idioma = 'francés' where id = 'c1'`
);
await rechaza(
'no se puede inventar una formación vocal',
`update cancion set formacion_vocal = 'trío' where id = 'c1'`
);
apuntar('\nEL LINAJE DE GÉNEROS');
await permite(
'el bolero-son deriva de dos géneros',
@ -194,7 +209,7 @@ await rechazaAlCerrar(
'un reparto que suma noventa, no',
[
`insert into estilo (id, slug, nombre) values ('z1', 'zz-z', 'Z')`,
`insert into cancion (id, slug, titulo, estilo_id) values ('z2', 'zz-z', 'Z', 'z1')`,
`insert into cancion (id, slug, titulo, estilo_id, idioma) values ('z2', 'zz-z', 'Z', 'z1', 'es')`,
`insert into persona (id, slug, nombre) values ('z3', 'zz-z', 'Z')`
],
`insert into autoria (cancion_id, persona_id, rol, porcentaje)

@ -282,8 +282,8 @@ async function volcar(c, contenido, informe) {
const id = nuevoId();
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, album_id, numero, duracion_s, ano,
letra, idioma, inspiracion, instrumental, destacada, ala_venta, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'publicado')`,
letra, idioma, inspiracion, tiene_letra, formacion_vocal, destacada, ala_venta, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,'publicado')`,
[
id,
datos.slug,
@ -296,7 +296,8 @@ async function volcar(c, contenido, informe) {
cuerpo || null,
datos.idioma,
datos.inspiracion?.length ? datos.inspiracion.join('\n\n') : null,
datos.instrumental,
datos.tieneLetra,
datos.formacionVocal ?? null,
datos.destacada,
datos.alaVenta
]
@ -389,8 +390,9 @@ async function volcar(c, contenido, informe) {
const versionId = nuevoId();
await c.query(
`INSERT INTO cancion (id, slug, titulo, estilo_id, version_de_id, version_nombre,
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')`,
version_codigo, version_orden, duracion_s, ano, letra, idioma, tiene_letra,
formacion_vocal, estado)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,'publicado')`,
[
versionId,
`${datos.slug}-${v.id}`,
@ -403,8 +405,9 @@ async function volcar(c, contenido, informe) {
v.duracion ?? null,
datos.ano ?? null,
cuerpo || null,
datos.idioma,
datos.instrumental
v.idioma,
v.tieneLetra,
v.formacionVocal ?? null
]
);
for (const [orden, interprete] of datos.interpretes.entries()) {

@ -0,0 +1,33 @@
/**
* Composición de las voces que cantan una grabación.
*
* No sustituye a los intérpretes: estos valores describen la formación vocal;
* `cancion_interprete` conserva las identidades concretas y sus créditos.
*/
export const FORMACIONES_VOCALES = [
'hombre',
'mujer',
'dueto-femenino',
'dueto-masculino',
'dueto-mixto',
'multiple-mixto',
'multiple-femenino',
'multiple-masculino'
] as const;
export type FormacionVocal = (typeof FORMACIONES_VOCALES)[number];
export const NOMBRES_FORMACION_VOCAL: Record<FormacionVocal, string> = {
hombre: 'Hombre',
mujer: 'Mujer',
'dueto-femenino': 'Dueto femenino',
'dueto-masculino': 'Dueto masculino',
'dueto-mixto': 'Dueto mixto',
'multiple-mixto': 'Múltiple mixto',
'multiple-femenino': 'Múltiple femenino',
'multiple-masculino': 'Múltiple masculino'
};
export function esFormacionVocal(valor: string): valor is FormacionVocal {
return FORMACIONES_VOCALES.includes(valor as FormacionVocal);
}

@ -15,6 +15,8 @@
import Valoracion from './Valoracion.svelte';
import type { ResumenDeValoracion } from '$lib/server/valoraciones';
import type { AlbumConCanciones, Cancion, Estilo } from '$lib/types';
import { nombreDeIdioma } from '$lib/idiomas';
import { NOMBRES_FORMACION_VOCAL } from '$lib/canciones';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
interface Props {
@ -91,6 +93,16 @@
<dd>{segundosADuracion(cancion.duracion)}</dd>
{/if}
{#if cancion.tieneLetra}
<dt>Idioma</dt>
<dd title={cancion.idioma}>{nombreDeIdioma(cancion.idioma)}</dd>
{/if}
{#if cancion.formacionVocal}
<dt>Voces</dt>
<dd>{NOMBRES_FORMACION_VOCAL[cancion.formacionVocal]}</dd>
{/if}
{#if cancion.versiones.length > 0}
<dt>Versiones</dt>
<dd>{cancion.versiones.length + 1}</dd>
@ -117,7 +129,7 @@
<section class="bloque">
<h2>Créditos</h2>
<dl>
{#if !cancion.instrumental}
{#if cancion.tieneLetra}
<dt>Letra</dt>
<dd>{listaDeNombres(cancion.letra)}</dd>
{/if}

@ -354,10 +354,10 @@
</div>
{/if}
{#if c.instrumental || c.etiquetas.length > 0}
{#if !c.tieneLetra || c.etiquetas.length > 0}
<div class="senas">
{#if c.instrumental}
<span class="marca">Instrumental</span>
{#if !c.tieneLetra}
<span class="marca">Sin letra</span>
{/if}
<Etiquetas
etiquetas={c.etiquetas.slice(0, CUANTAS_ETIQUETAS)}

@ -79,7 +79,8 @@ function cancion(parcial: Partial<Cancion> = {}): Cancion {
etiquetas: ['Desamor', 'Dueto'],
generos: [],
enlaces: {},
instrumental: false,
tieneLetra: true,
formacionVocal: 'mujer',
idioma: 'es',
inspiracion: [],
extracto: ['Entras en la casa y no preguntas nada,', 'dejas las llaves donde siempre,'],

@ -103,7 +103,50 @@ describe('parsearCancion', () => {
'preludio-i',
ARCHIVO
);
expect(pieza.instrumental).toBe(true);
expect(pieza.tieneLetra).toBe(false);
});
it('valida la formación vocal cuando el contenido antiguo la declara', () => {
expect(
parsearCancion({ ...minimo, formacionVocal: 'dueto-mixto' }, 'media-verdad', ARCHIVO)
.formacionVocal
).toBe('dueto-mixto');
expect(() =>
parsearCancion({ ...minimo, formacionVocal: 'coro' }, 'media-verdad', ARCHIVO)
).toThrow(/formacionVocal/);
});
it('las versiones heredan idioma, letra y voces, pero pueden cambiarlos', () => {
const cancion = parsearCancion(
{
...minimo,
idioma: 'fr',
formacionVocal: 'mujer',
versiones: [
{ nombre: 'Misma voz', audio: 'audio/misma.mp3' },
{
nombre: 'Instrumental brasileña',
audio: 'audio/instrumental.mp3',
idioma: 'pt-BR',
instrumental: true,
formacionVocal: 'dueto-mixto'
}
]
},
'media-verdad',
ARCHIVO
);
expect(cancion.versiones[0]).toMatchObject({
idioma: 'fr',
tieneLetra: true,
formacionVocal: 'mujer'
});
expect(cancion.versiones[1]).toMatchObject({
idioma: 'pt-BR',
tieneLetra: false,
formacionVocal: 'dueto-mixto'
});
});
it('exige el numero de pista cuando hay album', () => {

@ -22,6 +22,7 @@ import type {
VideoDeCancion
} from '$lib/types';
import { site } from '$lib/site';
import { esFormacionVocal, type FormacionVocal } from '$lib/canciones';
import {
booleano,
claveAudio,
@ -194,11 +195,31 @@ function parsearVideos(datos: Datos, archivo: string): VideoDeCancion[] {
});
}
interface DatosHeredablesDeVersion {
tieneLetra: boolean;
formacionVocal?: FormacionVocal;
idioma: string;
}
/** Una alternativa musical dentro de la lista `versiones` de un tema. */
function parsearVersion(datos: Datos, indice: number, archivo: string): VersionMusical {
function parsearVersion(
datos: Datos,
indice: number,
archivo: string,
heredados: DatosHeredablesDeVersion
): VersionMusical {
const campo = `versiones[${indice}]`;
const donde = `${archivo} (${campo})`;
const nombre = texto(datos, 'nombre', `${archivo} (${campo})`);
const audio = claveAudio(datos, 'audio', `${archivo} (${campo})`);
const valorFormacionVocal = textoOpcional(datos, 'formacionVocal', donde);
if (valorFormacionVocal && !esFormacionVocal(valorFormacionVocal)) {
throw new ErrorDeContenido(
archivo,
`${campo}.formacionVocal`,
'no es una formación vocal conocida'
);
}
if (!audio) {
throw new ErrorDeContenido(archivo, `${campo}.audio`, 'es obligatorio');
@ -214,6 +235,9 @@ function parsearVersion(datos: Datos, indice: number, archivo: string): VersionM
audio,
duracion: duracion(datos, 'duracion', `${archivo} (${campo})`),
nota: textoOpcional(datos, 'nota', `${archivo} (${campo})`),
tieneLetra: !booleano(datos, 'instrumental', donde, !heredados.tieneLetra),
formacionVocal: (valorFormacionVocal as FormacionVocal | undefined) ?? heredados.formacionVocal,
idioma: textoConDefecto(datos, 'idioma', donde, heredados.idioma),
interpretes: []
};
}
@ -222,9 +246,16 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
const autoria = [site.nombre];
const album = textoOpcional(datos, 'album', archivo) ?? null;
const audio = claveAudio(datos, 'audio', archivo);
const valorFormacionVocal = textoOpcional(datos, 'formacionVocal', archivo);
if (valorFormacionVocal && !esFormacionVocal(valorFormacionVocal)) {
throw new ErrorDeContenido(archivo, 'formacionVocal', 'no es una formación vocal conocida');
}
const formacionVocal = valorFormacionVocal as FormacionVocal | undefined;
const tieneLetra = !booleano(datos, 'instrumental', archivo);
const idioma = textoConDefecto(datos, 'idioma', archivo, 'es');
const versiones = listaDeObjetos(datos, 'versiones', archivo).map((v, i) =>
parsearVersion(v, i, archivo)
parsearVersion(v, i, archivo, { tieneLetra, formacionVocal, idioma })
);
const ids = new Set<string>();
@ -267,8 +298,9 @@ export function parsearCancion(datos: Datos, slug: string, archivo: string): Can
// material antiguo y nunca los tuvo.
generos: [],
enlaces: enlaces(datos, 'enlaces', archivo, PLATAFORMAS),
instrumental: booleano(datos, 'instrumental', archivo),
idioma: textoConDefecto(datos, 'idioma', archivo, 'es'),
tieneLetra,
formacionVocal: formacionVocal || undefined,
idioma,
traduccion: textoOpcional(datos, 'traduccion', archivo),
portada: rutaPublica(datos, 'portada', archivo),
inspiracion: parrafos(datos, 'inspiracion', archivo),

@ -0,0 +1,18 @@
import { describe, expect, it } from 'vitest';
import { nombreDeIdioma, normalizarIdioma } from './idiomas';
describe('idiomas de las canciones', () => {
it('normaliza etiquetas BCP 47 sin reducirlas a dos letras', () => {
expect(normalizarIdioma(' pt_br ')).toBe('pt-BR');
expect(normalizarIdioma('zh-hant')).toBe('zh-Hant');
});
it('rechaza nombres libres y códigos inexistentes', () => {
expect(normalizarIdioma('español')).toBeUndefined();
expect(normalizarIdioma('')).toBeUndefined();
});
it('presenta el nombre sin una tabla duplicada', () => {
expect(nombreDeIdioma('fr')).toMatch(/francés/i);
});
});

@ -0,0 +1,32 @@
/**
* Idiomas de las canciones.
*
* Se guardan como etiquetas BCP 47 porque es el mismo formato que entiende el
* atributo HTML `lang`: `es`, `fr`, `en`, pero también `pt-BR` o `zh-Hant`.
* No hay una lista cerrada en código; añadir un idioma no debería exigir un
* despliegue.
*/
/** Devuelve la etiqueta canónica, o `undefined` si no es un código BCP 47. */
export function normalizarIdioma(valor: string): string | undefined {
const limpio = valor.trim().replaceAll('_', '-');
if (!limpio) return undefined;
try {
return Intl.getCanonicalLocales(limpio)[0];
} catch {
return undefined;
}
}
/** Nombre localizado sin mantener una tabla de idiomas propia. */
export function nombreDeIdioma(codigo: string, locale = 'es'): string {
const normalizado = normalizarIdioma(codigo);
if (!normalizado) return codigo;
try {
return new Intl.DisplayNames([locale], { type: 'language' }).of(normalizado) ?? normalizado;
} catch {
return normalizado;
}
}

@ -400,6 +400,9 @@ async function cargar(): Promise<Catalogo> {
onda: rutaDeOnda(escuchaVersion?.clave ?? null, escuchaVersion?.huella ?? null),
duracion: v.fila.duracionS ?? escuchaVersion?.duracion ?? undefined,
nota: v.fila.versionNota ?? undefined,
tieneLetra: v.fila.tieneLetra,
formacionVocal: v.fila.formacionVocal ?? undefined,
idioma: v.fila.idioma,
interpretes: interpretesVersion.map((i) => ({
id: i.id,
codigo: i.codigo,
@ -463,7 +466,8 @@ async function cargar(): Promise<Catalogo> {
nombre: g.nombre
})),
enlaces: comoEnlaces(porCancion.enlaces.get(fila.id) ?? []),
instrumental: fila.instrumental,
tieneLetra: fila.tieneLetra,
formacionVocal: fila.formacionVocal ?? undefined,
idioma: fila.idioma,
traduccion: traduccionDe.get(fila.id) ?? undefined,
portada: rutaPublica(portadaClave),

@ -16,6 +16,7 @@ import {
index,
integer,
numeric,
pgEnum,
pgSequence,
pgTable,
primaryKey,
@ -25,8 +26,10 @@ import {
import { ahora, estado, id, instante } from './comun';
import { medio, pais, persona } from './base';
import { artista } from './artistas';
import { FORMACIONES_VOCALES } from '../../../canciones';
export const cancionCodigoSeq = pgSequence('cancion_codigo_seq');
export const formacionVocalEnum = pgEnum('formacion_vocal', FORMACIONES_VOCALES);
/**
* Una sección del catálogo: baladas románticas, clásica, rock, pop melódico.
@ -175,12 +178,15 @@ export const cancion = pgTable(
* eso va a `cancion_persona`.
*/
letra: text('letra'),
/** ISO 639. Decide si la ficha enseña una columna o dos. */
idioma: text('idioma').notNull().default('es'),
/** BCP 47: sirve también directamente como atributo HTML `lang`. */
idioma: text('idioma').notNull(),
/** De dónde sale el tema. Del frontmatter, en párrafos. */
inspiracion: text('inspiracion'),
instrumental: boolean('instrumental').notNull().default(false),
/** Si existe una letra verbal; no es lo mismo que tener audio o voz. */
tieneLetra: boolean('tiene_letra').notNull().default(true),
/** Composición vocal; las identidades concretas viven en cancion_interprete. */
formacionVocal: formacionVocalEnum('formacion_vocal'),
destacada: boolean('destacada').notNull().default(false),
portadaId: text('portada_id').references(() => medio.id, { onDelete: 'set null' }),
@ -205,6 +211,7 @@ export const cancion = pgTable(
sql`(${t.versionDeId} is null and ${t.versionCodigo} is null) or (${t.versionDeId} is not null and nullif(btrim(${t.versionCodigo}), '') is not null)`
),
check('cancion_version_no_se_vende', sql`${t.versionDeId} is null or not ${t.alaVenta}`),
check('cancion_idioma_bcp47', sql`${t.idioma} ~ '^[a-z]{2,8}(-[A-Za-z0-9]{1,8})*$'`),
check('cancion_estado_conocido', sql`${t.estado} in ('borrador', 'publicado', 'retirado')`)
]
);

@ -7,10 +7,11 @@ const bueno = (cambios: Partial<CambiosDeCancion> = {}): CambiosDeCancion => ({
letra: 'Me prometiste besos\nque apenas llegaron.',
inspiracion: '',
idioma: 'es',
formacionVocal: 'mujer',
numero: 7,
ano: 2026,
estado: 'publicado',
instrumental: false,
tieneLetra: true,
destacada: false,
alaVenta: true,
interpreteIds: ['artista-1'],
@ -34,8 +35,8 @@ describe('revisarCancion', () => {
expect(revisarCancion(bueno({ letra: ' ' })).letra).toBeTruthy();
});
it('salvo que sea instrumental, que entonces es lo correcto', () => {
expect(revisarCancion(bueno({ letra: '', instrumental: true }))).toEqual({});
it('salvo que declare que no tiene letra', () => {
expect(revisarCancion(bueno({ letra: '', tieneLetra: false }))).toEqual({});
});
it('y en borrador se puede escribir a medias', () => {
@ -43,10 +44,48 @@ describe('revisarCancion', () => {
});
});
it('el idioma son dos letras', () => {
it('el idioma es una etiqueta BCP 47', () => {
expect(revisarCancion(bueno({ idioma: 'español' })).idioma).toBeTruthy();
expect(revisarCancion(bueno({ idioma: '' })).idioma).toBeTruthy();
expect(revisarCancion(bueno({ idioma: 'fr' })).idioma).toBeUndefined();
expect(revisarCancion(bueno({ idioma: 'pt-BR' })).idioma).toBeUndefined();
});
it('solo admite las formaciones vocales del dominio', () => {
expect(revisarCancion(bueno({ formacionVocal: 'dueto-mixto' })).formacionVocal).toBeUndefined();
expect(
revisarCancion(bueno({ formacionVocal: 'trío' as CambiosDeCancion['formacionVocal'] }))
.formacionVocal
).toBeTruthy();
});
it('valida también los atributos propios de cada versión', () => {
const version = {
id: 'version-1',
nombre: 'Versión francesa',
nota: '',
estado: 'borrador' as const,
idioma: 'fr',
formacionVocal: 'dueto-mixto' as const,
tieneLetra: true
};
expect(revisarCancion(bueno({ versiones: [version] })).versiones).toBeUndefined();
expect(
revisarCancion(bueno({ versiones: [{ ...version, idioma: 'francés' }] })).versiones
).toBeTruthy();
expect(
revisarCancion(
bueno({
versiones: [
{
...version,
formacionVocal: 'coro' as CambiosDeCancion['versiones'][number]['formacionVocal']
}
]
})
).versiones
).toBeTruthy();
});
it('la pista empieza en uno, y puede no haberla', () => {

@ -19,6 +19,8 @@ import { db } from './db';
import { album, artista, cancion, cancionAudio, cancionInterprete, estilo } from './db/schema';
import { olvidarCatalogo } from './catalogo';
import { ESTADOS } from './db/schema/comun';
import { normalizarIdioma } from '$lib/idiomas';
import { esFormacionVocal, type FormacionVocal } from '$lib/canciones';
export type Estado = (typeof ESTADOS)[number];
@ -30,6 +32,9 @@ export interface CancionEnLista {
albumTitulo: string | null;
albumSlug: string | null;
estiloNombre: string;
idioma: string;
tieneLetra: boolean;
formacionVocal: FormacionVocal | null;
/** Cuánta letra hay escrita. Un cero canta más que un estado. */
versos: number;
tieneAudio: boolean;
@ -53,6 +58,9 @@ export async function listaDeCanciones(): Promise<CancionEnLista[]> {
albumTitulo: album.titulo,
albumSlug: album.slug,
estiloNombre: estilo.nombre,
idioma: cancion.idioma,
tieneLetra: cancion.tieneLetra,
formacionVocal: cancion.formacionVocal,
tieneAudio: sql<boolean>`exists (
select 1 from cancion_audio ca where ca.cancion_id = ${cancion.id} and ca.uso = 'stream'
)`,
@ -82,10 +90,11 @@ export interface CancionParaEditar {
letra: string;
inspiracion: string;
idioma: string;
formacionVocal: FormacionVocal | null;
numero: number | null;
ano: number | null;
estado: Estado;
instrumental: boolean;
tieneLetra: boolean;
destacada: boolean;
alaVenta: boolean;
albumTitulo: string | null;
@ -102,6 +111,9 @@ export interface CancionParaEditar {
nombre: string;
nota: string;
estado: Estado;
idioma: string;
formacionVocal: FormacionVocal | null;
tieneLetra: boolean;
audios: { uso: string; formato: string }[];
}[];
}
@ -116,10 +128,11 @@ export async function cancionParaEditar(slug: string): Promise<CancionParaEditar
letra: cancion.letra,
inspiracion: cancion.inspiracion,
idioma: cancion.idioma,
formacionVocal: cancion.formacionVocal,
numero: cancion.numero,
ano: cancion.ano,
estado: cancion.estado,
instrumental: cancion.instrumental,
tieneLetra: cancion.tieneLetra,
destacada: cancion.destacada,
alaVenta: cancion.alaVenta,
albumTitulo: album.titulo,
@ -160,7 +173,10 @@ export async function cancionParaEditar(slug: string): Promise<CancionParaEditar
versionCodigo: cancion.versionCodigo,
nombre: cancion.versionNombre,
nota: cancion.versionNota,
estado: cancion.estado
estado: cancion.estado,
idioma: cancion.idioma,
formacionVocal: cancion.formacionVocal,
tieneLetra: cancion.tieneLetra
})
.from(cancion)
.where(eq(cancion.versionDeId, fila.id))
@ -194,6 +210,9 @@ export async function cancionParaEditar(slug: string): Promise<CancionParaEditar
nombre: v.nombre ?? '',
nota: v.nota ?? '',
estado: v.estado,
idioma: v.idioma,
formacionVocal: v.formacionVocal,
tieneLetra: v.tieneLetra,
audios: audios
.filter((a) => a.cancionId === v.id)
.map(({ uso, formato }) => ({ uso, formato }))
@ -206,14 +225,23 @@ export interface CambiosDeCancion {
letra: string;
inspiracion: string;
idioma: string;
formacionVocal: FormacionVocal | null;
numero: number | null;
ano: number | null;
estado: Estado;
instrumental: boolean;
tieneLetra: boolean;
destacada: boolean;
alaVenta: boolean;
interpreteIds: string[];
versiones: { id: string; nombre: string; nota: string; estado: Estado }[];
versiones: {
id: string;
nombre: string;
nota: string;
estado: Estado;
idioma: string;
formacionVocal: FormacionVocal | null;
tieneLetra: boolean;
}[];
nuevaVersion: { nombre: string; codigo: string };
}
@ -224,6 +252,7 @@ export type ErroresDeCancion = Partial<
| 'numero'
| 'ano'
| 'idioma'
| 'formacionVocal'
| 'estado'
| 'audio'
| 'interpretes'
@ -240,8 +269,11 @@ export function revisarCancion(cambios: CambiosDeCancion): ErroresDeCancion {
if (cambios.titulo.trim().length < 1) errores.titulo = 'El título no puede quedar vacío.';
if (!ESTADOS.includes(cambios.estado)) errores.estado = 'Ese estado no existe.';
if (!/^[a-z]{2}$/.test(cambios.idioma)) {
errores.idioma = 'El idioma son dos letras: es, fr, en.';
if (!normalizarIdioma(cambios.idioma)) {
errores.idioma = 'Usa un código de idioma BCP 47: es, fr, en o pt-BR.';
}
if (cambios.formacionVocal !== null && !esFormacionVocal(cambios.formacionVocal)) {
errores.formacionVocal = 'Esa formación vocal no existe.';
}
if (cambios.numero !== null && (!Number.isInteger(cambios.numero) || cambios.numero < 1)) {
@ -255,17 +287,25 @@ export function revisarCancion(cambios: CambiosDeCancion): ErroresDeCancion {
}
/*
* Publicar un tema sin letra deja en la web una página con el título y nada
* debajo. Salvo que sea instrumental, que entonces es lo correcto.
* Publicar un tema que declara tener letra sin escribirla deja en la web una
* página con el título y nada debajo.
*/
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.tieneLetra && cambios.letra.trim() === '') {
errores.letra = 'La canción declara que tiene letra, pero la letra está vacía.';
}
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.versiones.some(
(v) =>
!v.nombre.trim() ||
!ESTADOS.includes(v.estado) ||
!normalizarIdioma(v.idioma) ||
(v.formacionVocal !== null && !esFormacionVocal(v.formacionVocal))
)
) {
errores.versiones = 'Revisa el nombre, estado, idioma y voces de todas las versiones.';
}
if (
(cambios.nuevaVersion.nombre.trim() || cambios.nuevaVersion.codigo.trim()) &&
@ -280,6 +320,9 @@ export function revisarCancion(cambios: CambiosDeCancion): ErroresDeCancion {
/** Devuelve `false` si ese slug ya no existe. */
export async function guardarCancion(slug: string, cambios: CambiosDeCancion): Promise<boolean> {
const idioma = normalizarIdioma(cambios.idioma);
if (!idioma) throw new Error('No se puede guardar una canción con un idioma inválido.');
const [fila] = await db
.select({ id: cancion.id })
.from(cancion)
@ -295,11 +338,12 @@ export async function guardarCancion(slug: string, cambios: CambiosDeCancion): P
// 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,
idioma,
formacionVocal: cambios.formacionVocal,
numero: cambios.numero,
ano: cambios.ano,
estado: cambios.estado,
instrumental: cambios.instrumental,
tieneLetra: cambios.tieneLetra,
destacada: cambios.destacada,
alaVenta: cambios.alaVenta,
actualizadoEn: new Date()
@ -320,12 +364,18 @@ export async function guardarCancion(slug: string, cambios: CambiosDeCancion): P
}
for (const version of cambios.versiones) {
const idiomaVersion = normalizarIdioma(version.idioma);
if (!idiomaVersion)
throw new Error('No se puede guardar una versión con un idioma inválido.');
await tx
.update(cancion)
.set({
versionNombre: version.nombre.trim(),
versionNota: version.nota.trim() || null,
estado: version.estado,
idioma: idiomaVersion,
formacionVocal: version.formacionVocal,
tieneLetra: version.tieneLetra,
actualizadoEn: new Date()
})
.where(and(eq(cancion.id, version.id), eq(cancion.versionDeId, fila.id)));
@ -350,8 +400,9 @@ export async function guardarCancion(slug: string, cambios: CambiosDeCancion): P
ano: principal.ano,
letra: principal.letra,
idioma: principal.idioma,
formacionVocal: principal.formacionVocal,
inspiracion: principal.inspiracion,
instrumental: principal.instrumental,
tieneLetra: principal.tieneLetra,
portadaId: principal.portadaId,
estado: 'borrador'
})

@ -6,6 +6,8 @@
* `src/lib/content/schema.ts`; el frontmatter crudo nunca sale de ahi.
*/
import type { FormacionVocal } from './canciones';
/** Plataformas de escucha externas soportadas en los enlaces. */
export type Plataforma =
| 'spotify'
@ -67,6 +69,12 @@ export interface VersionMusical {
duracion?: number;
/** Nota breve sobre el arreglo. */
nota?: string;
/** Declara de forma directa si esta grabación contiene letra verbal. */
tieneLetra: boolean;
/** Formación de las voces de esta grabación, cuando se conoce. */
formacionVocal?: FormacionVocal;
/** Etiqueta BCP 47 del idioma de la letra de esta grabación. */
idioma: string;
/** Créditos propios de esta grabación; pueden diferir de la principal. */
interpretes: InterpreteDeCancion[];
}
@ -187,10 +195,13 @@ export interface Cancion {
generos: { slug: string; nombre: string }[];
enlaces: Enlaces;
/** El cuerpo del Markdown es la letra completa del tema. */
instrumental: boolean;
/** Declara de forma directa si la obra tiene letra verbal. */
tieneLetra: boolean;
/** Formación de las voces; no sustituye a las identidades de intérprete. */
formacionVocal?: FormacionVocal;
/**
* Codigo del idioma en que esta escrita la letra, ISO 639. Por defecto
* espanol. Es lo que decide si la ficha ensena una columna o dos.
* Etiqueta BCP 47 del idioma de la letra (`es`, `fr`, `pt-BR`). Es también
* el valor que se publica en el atributo HTML `lang`.
*/
idioma: string;
/**

@ -9,6 +9,8 @@
* los archivos por otro—, pero conviene saberlo.
*/
import { resolve } from '$app/paths';
import { nombreDeIdioma } from '$lib/idiomas';
import { NOMBRES_FORMACION_VOCAL } from '$lib/canciones';
let { data } = $props();
@ -20,7 +22,9 @@
const q = plano(consulta.trim());
if (!q) return data.canciones;
return data.canciones.filter((c) =>
plano(`${c.titulo} ${c.slug} ${c.albumTitulo ?? ''}`).includes(q)
plano(
`${c.titulo} ${c.slug} ${c.albumTitulo ?? ''} ${c.idioma} ${nombreDeIdioma(c.idioma)} ${c.formacionVocal ? NOMBRES_FORMACION_VOCAL[c.formacionVocal] : ''}`
).includes(q)
);
});
@ -66,7 +70,7 @@
id="buscar"
type="search"
bind:value={consulta}
placeholder="Buscar por título o por disco"
placeholder="Buscar por título, disco, idioma o voces"
autocomplete="off"
/>
<p class="cuenta muted" role="status">{encontradas.length} de {data.canciones.length}</p>
@ -86,15 +90,21 @@
<th scope="row">
<a href={resolve('/(dentro)/canciones/[slug]', { slug: c.slug })}>{c.titulo}</a>
<span class="donde muted">{c.estiloNombre}</span>
<span class="donde muted">
{c.estiloNombre} ·
<span title={c.idioma}>{nombreDeIdioma(c.idioma)}</span>{#if c.formacionVocal}
· {NOMBRES_FORMACION_VOCAL[c.formacionVocal]}{/if}
</span>
</th>
<td>
<div class="marcas">
<span class="pastilla" data-estado={c.estado}>{ESTADO[c.estado] ?? c.estado}</span
>
{#if c.versos === 0}
<span class="pastilla" data-aviso="si">Sin letra</span>
{#if c.tieneLetra && c.versos === 0}
<span class="pastilla" data-aviso="si">Letra pendiente</span>
{:else if !c.tieneLetra}
<span class="pastilla">Sin letra</span>
{/if}
{#if !c.tieneAudio}
<span class="pastilla" data-aviso="si">Sin audio</span>

@ -7,6 +7,8 @@ import {
type Estado
} from '$lib/server/panel-canciones';
import type { Actions, PageServerLoad } from './$types';
import { normalizarIdioma } from '$lib/idiomas';
import { type FormacionVocal } from '$lib/canciones';
export const load: PageServerLoad = async ({ params }) => {
const cancion = await cancionParaEditar(params.slug);
@ -26,25 +28,33 @@ export const actions: Actions = {
const datos = await request.formData();
const versiones = datos.getAll('versionId').map((valor) => {
const id = String(valor);
const idiomaRecibido = String(datos.get(`versionIdioma:${id}`) ?? 'es');
const formacionVocal = String(datos.get(`versionFormacionVocal:${id}`) ?? '').trim();
return {
id,
nombre: String(datos.get(`versionNombre:${id}`) ?? ''),
nota: String(datos.get(`versionNota:${id}`) ?? ''),
estado: String(datos.get(`versionEstado:${id}`) ?? 'borrador') as Estado
estado: String(datos.get(`versionEstado:${id}`) ?? 'borrador') as Estado,
idioma: normalizarIdioma(idiomaRecibido) ?? idiomaRecibido.trim(),
formacionVocal: formacionVocal ? (formacionVocal as FormacionVocal) : null,
tieneLetra: datos.get(`versionTieneLetra:${id}`) !== null
};
});
const nombreVersion = String(datos.get('nuevaVersionNombre') ?? '').trim();
const codigoVersion = String(datos.get('nuevaVersionCodigo') ?? '').trim();
const idiomaRecibido = String(datos.get('idioma') ?? 'es');
const formacionVocal = String(datos.get('formacionVocal') ?? '').trim();
const cambios: CambiosDeCancion = {
titulo: String(datos.get('titulo') ?? ''),
letra: String(datos.get('letra') ?? ''),
inspiracion: String(datos.get('inspiracion') ?? ''),
idioma: String(datos.get('idioma') ?? 'es'),
idioma: normalizarIdioma(idiomaRecibido) ?? idiomaRecibido.trim(),
formacionVocal: formacionVocal ? (formacionVocal as FormacionVocal) : null,
numero: numeroDe(datos.get('numero')),
ano: numeroDe(datos.get('ano')),
estado: String(datos.get('estado') ?? '') as Estado,
instrumental: datos.get('instrumental') !== null,
tieneLetra: datos.get('tieneLetra') !== null,
destacada: datos.get('destacada') !== null,
alaVenta: datos.get('alaVenta') !== null,
interpreteIds: datos.getAll('interpreteId').map(String),

@ -2,8 +2,8 @@
/**
* Editar un tema.
*
* La letra se lleva la pantalla porque es lo que se escribe; lo demás son
* cuatro datos. Sin editor enriquecido y sin justificar nada: en una letra el
* La letra se lleva la pantalla porque es lo que se escribe. Sin editor
* enriquecido y sin justificar nada: en una letra el
* corte del verso es contenido, y lo que se guarda tiene que ser exactamente
* lo que se ve aquí.
*/
@ -12,6 +12,7 @@
import { resolve } from '$app/paths';
import { site } from '$lib/site';
import type { CambiosDeCancion } from '$lib/server/panel-canciones';
import { FORMACIONES_VOCALES, NOMBRES_FORMACION_VOCAL } from '$lib/canciones';
let { data, form } = $props();
@ -20,10 +21,11 @@
letra: c.letra,
inspiracion: c.inspiracion,
idioma: c.idioma,
formacionVocal: c.formacionVocal,
numero: c.numero,
ano: c.ano,
estado: c.estado,
instrumental: c.instrumental,
tieneLetra: c.tieneLetra,
destacada: c.destacada,
alaVenta: c.alaVenta,
interpreteIds: [...c.interpreteIds],
@ -31,7 +33,10 @@
id: v.id,
nombre: v.nombre,
nota: v.nota,
estado: v.estado
estado: v.estado,
idioma: v.idioma,
formacionVocal: v.formacionVocal,
tieneLetra: v.tieneLetra
})),
nuevaVersion: 'nuevaVersion' in c ? { ...c.nuevaVersion } : { nombre: '', codigo: '' }
});
@ -162,15 +167,41 @@
<p class="campo">
<label for="idioma">Idioma</label>
<input id="idioma" name="idioma" maxlength="2" size="2" bind:value={campos.idioma} />
<span class="ayuda muted">Código BCP 47: es, fr, en, pt-BR; zxx si no hay letra.</span>
<input
id="idioma"
name="idioma"
maxlength="35"
autocomplete="off"
autocapitalize="none"
spellcheck="false"
bind:value={campos.idioma}
aria-invalid={!!errores.idioma}
/>
{#if errores.idioma}<span class="fallo">{errores.idioma}</span>{/if}
</p>
<p class="campo">
<label for="formacion-vocal">Quién la canta</label>
<select
id="formacion-vocal"
name="formacionVocal"
bind:value={campos.formacionVocal}
aria-invalid={!!errores.formacionVocal}
>
<option value={null}>Sin indicar</option>
{#each FORMACIONES_VOCALES as formacion (formacion)}
<option value={formacion}>{NOMBRES_FORMACION_VOCAL[formacion]}</option>
{/each}
</select>
{#if errores.formacionVocal}<span class="fallo">{errores.formacionVocal}</span>{/if}
</p>
</div>
<div class="casillas">
<label>
<input type="checkbox" name="instrumental" bind:checked={campos.instrumental} />
Instrumental
<input type="checkbox" name="tieneLetra" bind:checked={campos.tieneLetra} />
Tiene letra
</label>
<label>
<input type="checkbox" name="destacada" bind:checked={campos.destacada} />
@ -231,6 +262,32 @@
<option value="retirado">Retirado</option>
</select>
</label>
<label>
Idioma
<input
name="versionIdioma:{version.id}"
maxlength="35"
autocomplete="off"
bind:value={version.idioma}
/>
</label>
<label>
Quién la canta
<select name="versionFormacionVocal:{version.id}" bind:value={version.formacionVocal}>
<option value={null}>Sin indicar</option>
{#each FORMACIONES_VOCALES as formacion (formacion)}
<option value={formacion}>{NOMBRES_FORMACION_VOCAL[formacion]}</option>
{/each}
</select>
</label>
<label class="version-con-letra">
<input
type="checkbox"
name="versionTieneLetra:{version.id}"
bind:checked={version.tieneLetra}
/>
Tiene letra
</label>
<small class="muted">
{datosVersion?.versionCodigo} · {datosVersion?.codigo}{#if datosVersion?.audios.length}
· {datosVersion.audios.map((a) => `${a.uso}/${a.formato}`).join(' · ')}{/if}
@ -473,7 +530,7 @@
}
.lista-versiones li {
display: grid;
grid-template-columns: 1fr 2fr minmax(8rem, 0.6fr);
grid-template-columns: repeat(auto-fit, minmax(min(12rem, 100%), 1fr));
gap: var(--sp-3);
padding: var(--sp-3);
background: var(--c-bg-raised);
@ -481,6 +538,12 @@
.lista-versiones small {
grid-column: 1 / -1;
}
.version-con-letra {
display: flex;
align-items: center;
gap: 0.5rem;
color: var(--c-text);
}
.nueva-version {
display: grid;
grid-template-columns: 2fr 1fr;

@ -52,7 +52,7 @@ export async function load({ params, locals }) {
hermanas,
anterior: posicion > 0 ? hermanas[posicion - 1] : undefined,
siguiente: posicion < hermanas.length - 1 ? hermanas[posicion + 1] : undefined,
/** La letra, o las notas si el tema es instrumental. */
/** La letra, o las notas si el tema declara que no tiene letra. */
letra: componer(letra),
valoracion,
miValoracion

@ -13,6 +13,8 @@
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
import Migas from '$lib/components/Migas.svelte';
import Etiquetas from '$lib/components/Etiquetas.svelte';
import { nombreDeIdioma } from '$lib/idiomas';
import { NOMBRES_FORMACION_VOCAL } from '$lib/canciones';
import { listaDeNombres, segundosADuracion } from '$lib/utils/format';
let { data } = $props();
@ -39,7 +41,7 @@
* principal de la ficha. Las otras dos solo aparecen si el tema las tiene.
*/
const pestanas: Pestana[] = $derived([
{ id: 'letra', etiqueta: cancion.instrumental ? 'Sobre la pieza' : 'Letra' },
{ id: 'letra', etiqueta: cancion.tieneLetra ? 'Letra' : 'Sobre la pieza' },
...(cancion.inspiracion.length > 0 ? [{ id: 'inspiracion', etiqueta: 'De dónde sale' }] : []),
...(cancion.versiones.length > 0
? [{ id: 'versiones', etiqueta: 'Alternativas musicales' }]
@ -72,22 +74,9 @@
* verso y no una letra partida.
*/
const estrofas = $derived((letra?.match(/<p[\s>]/g) ?? []).length);
const enColumnas = $derived(!aDosColumnas && !cancion.instrumental && estrofas >= 6);
const enColumnas = $derived(!aDosColumnas && cancion.tieneLetra && estrofas >= 6);
/**
* El nombre del idioma en español, sin tabla que mantener. Si el navegador o
* el servidor no saben traducirlo, se queda el código, que sigue diciendo
* algo.
*/
const nombreIdioma = $derived.by(() => {
try {
return (
new Intl.DisplayNames(['es'], { type: 'language' }).of(cancion.idioma) ?? cancion.idioma
);
} catch {
return cancion.idioma;
}
});
const nombreIdioma = $derived(nombreDeIdioma(cancion.idioma));
const descripcion = $derived(
`${cancion.titulo}. Letra de ${listaDeNombres(cancion.letra)}, música de ${listaDeNombres(cancion.musica)}.`
@ -104,7 +93,12 @@
]
: []),
...(data.album
? [{ texto: data.album.titulo, href: resolve('/(sitio)/albumes/[slug]', { slug: data.album.slug }) }]
? [
{
texto: data.album.titulo,
href: resolve('/(sitio)/albumes/[slug]', { slug: data.album.slug })
}
]
: []),
{ texto: cancion.titulo }
]);
@ -143,7 +137,9 @@
</a>
{:else if data.album}
Del disco
<a href={resolve('/(sitio)/albumes/[slug]', { slug: data.album.slug })}>{data.album.titulo}</a>
<a href={resolve('/(sitio)/albumes/[slug]', { slug: data.album.slug })}
>{data.album.titulo}</a
>
{/if}
{#if cancion.ano}
<span aria-hidden="true">&middot;</span> {cancion.ano}
@ -202,7 +198,7 @@
<div class="letra" class:pareada={aDosColumnas}>
<div
class="texto"
class:versos={!cancion.instrumental}
class:versos={cancion.tieneLetra}
class:columnas={enColumnas}
lang={cancion.idioma}
>
@ -228,7 +224,7 @@
</div>
{:else}
<p class="muted">
{cancion.instrumental
{!cancion.tieneLetra
? 'Todavía no hay notas publicadas de esta pieza.'
: 'La letra de este tema aún no está publicada.'}
</p>
@ -259,6 +255,13 @@
<BotonReproducir pistas={pistasDeVersiones} indice={i + 1} />
<div class="datos">
<p class="nombre">{version.nombre}</p>
<p class="metadatos-version muted">
{nombreDeIdioma(version.idioma)}{#if version.formacionVocal}
· {NOMBRES_FORMACION_VOCAL[
version.formacionVocal
]}{/if}{#if !version.tieneLetra}
· Sin letra{/if}
</p>
{#if version.nota}
<p class="nota muted">{version.nota}</p>
{/if}
@ -267,8 +270,9 @@
{#each version.interpretes as interprete, j (`${interprete.id}-${interprete.rol}`)}
{#if j > 0},
{/if}{#if interprete.slug}<a
href={resolve('/(sitio)/interpretes/[slug]', { slug: interprete.slug })}
>{interprete.nombre}</a
href={resolve('/(sitio)/interpretes/[slug]', {
slug: interprete.slug
})}>{interprete.nombre}</a
>{:else}{interprete.nombre}{/if}
{/each}
</p>
@ -509,6 +513,10 @@
font-size: var(--fs-xs);
}
.metadatos-version {
font-size: var(--fs-xs);
}
.interpretes {
font-size: var(--fs-xs);
}

Loading…
Cancel
Save

Powered by TurnKey Linux.