«Cupido sin flechas»: diez canciones de verdad, con sus prompts

El primer disco real del catálogo. Hasta ahora todo era inventado salvo
«Dorina».

`npm run album:importar -- "<carpeta>"` lee la carpeta y dice lo que haría;
solo escribe con `--escribir`, y hay que pedirlo. Es idempotente por slug, así
que corregir un `.txt` y volver a lanzarlo actualiza en vez de duplicar.

El desmontaje de los `.txt` está en `scripts/album/carpeta.mjs`, compartido con
el lector: si Suno vuelve a cambiar lo que pone al copiar, se arregla en un
sitio. De cada archivo salen tres cosas —el prompt de estilo, la letra con
etiquetas y la letra limpia de detrás de la raya— y la que se publica es la
limpia; la de las etiquetas es lo que se le pega a Suno, y va en el prompt.

Los títulos salen del nombre del máster porque las carpetas están sin acentuar
—«Quèdate», «Pague»—, y `--titulo` corrige lo que ni siquiera eso acierta: el
archivo francés pone «libére» donde va «libère». El idioma se deduce contando
palabras frecuentes: nueve en español y una en francés. La fecha sale del
máster más reciente, que es lo más cerca que hay de «cuándo quedó terminado».

Y el prompt de cada canción, que es lo que obligó a la migración 0012: `prompt`
ataba a un género y estos son de canciones concretas. No sustituye a
`genero_id`, se suma, y es coherente con lo que el modelo ya preveía —
`ejemplo_id` apunta al audio que salió del prompt—.

## El hueco de la portada

Diez fichas publicadas sin imagen destaparon algo que llevaba ahí desde
siempre: un `<img>` sin `src` es el icono de imagen rota del navegador, y eso
salía en siete sitios —la ficha del disco, la del tema, las dos tarjetas, el
carrito, la cuenta y la barra del reproductor—. Nunca se había visto porque
todo el catálogo tenía imagen.

`SinPortada` ocupa el hueco con la inicial del título. No dice «falta la
portada»: eso es un problema nuestro, no de quien mira. Y va a hacer falta más,
porque el texto de un disco entra por un lado y sus archivos por otro.

El botón de reproducir ya se apagaba solo sin audio; eso estaba bien hecho.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 14addb48f4
commit 9cefeb879a

@ -0,0 +1,24 @@
-- Un prompt puede ser de una canción, no solo de un género.
--
-- El modelo ataba `prompt` a `genero`: prompts de referencia para escribir en
-- un estilo. Pero los que existen de verdad son otra cosa —el prompt exacto con
-- el que salió una canción concreta—, y eso no tenía dónde ir. Colgarlos de su
-- género sería mentir: no son la referencia del bolero, son lo que produjo
-- «Ámame esta noche».
--
-- No sustituye a `genero_id`, se suma. Los dos son opcionales y los dos tienen
-- sentido a la vez: el prompt de una canción también dice en qué género se
-- escribió.
--
-- Es coherente con lo que el modelo ya preveía: `ejemplo_id` apunta al audio
-- que salió del prompt, así que un prompt ya sabía de su resultado. Esto es lo
-- mismo mirado desde el otro lado.
--
-- Se borra con la canción. Un prompt sin la canción que produjo no es un dato,
-- es un texto suelto.
ALTER TABLE "prompt" ADD COLUMN "cancion_id" text;--> statement-breakpoint
ALTER TABLE "prompt" ADD CONSTRAINT "prompt_cancion_id_cancion_id_fk"
FOREIGN KEY ("cancion_id") REFERENCES "public"."cancion"("id")
ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
CREATE INDEX "prompt_cancion" ON "prompt" ("cancion_id");

@ -35,6 +35,8 @@
"db:versos": "node --env-file=.env scripts/db/sembrar-versos.mjs",
"db:glosario": "node --env-file=.env scripts/db/sembrar-glosario.mjs",
"db:animos": "node --env-file=.env scripts/db/sembrar-animos.mjs",
"album:leer": "node scripts/album/leer.mjs",
"album:importar": "node --env-file=.env scripts/album/importar.mjs",
"db:completar": "node --env-file=.env scripts/db/completar-catalogo.mjs",
"db:comparar": "node --experimental-strip-types --import ./scripts/db/resolver-lib.mjs --env-file=.env scripts/db/comparar-catalogo.mjs"
},

@ -0,0 +1,258 @@
/**
* Desmonta la carpeta de un álbum. Función pura: lee el disco y devuelve datos.
*
* Está aparte de `leer.mjs` y de `importar.mjs` porque los dos hacen lo mismo
* antes de hacer cosas distintas: uno informa y el otro escribe en la base. Con
* el desmontaje copiado, el día que Suno cambie otra vez lo que pone al copiar
* se arreglaría en uno de los dos y el otro seguiría importando basura.
*
* ## Qué contiene un `.txt` de letra
*
* Hasta tres cosas, en este orden y no siempre las tres:
*
* 1. **El prompt de estilo** que se le dio a Suno. Es la línea larga de arriba.
* 2. **La letra con etiquetas** —`[Verse 1]`, `[Chorus]`—, que es lo que se le
* pega a Suno, no lo que se publica.
* 3. **La letra limpia**, detrás de una raya de guiones. Esa es la de la web.
*
* Cuando no hay raya, la limpia se saca quitando las líneas de etiqueta.
*/
import fs from 'node:fs';
import path from 'node:path';
/* -------------------------------------------------------------------------
* El `.txt`
* ---------------------------------------------------------------------- */
/** Una raya de guiones separa la letra con etiquetas de la limpia. */
const esRaya = (l) => /^\s*-{10,}\s*$/.test(l);
/** Una línea que es solo una etiqueta de Suno: `[Chorus]`, `[Intro: piano]`. */
const esEtiqueta = (l) => /^\s*\[[^\]]*\]\s*$/.test(l);
/**
* Lo que Suno escribe alrededor y no es ni prompt ni letra.
*
* Sale de mirar los diez archivos, no de suponer: son las cadenas que la
* interfaz de Suno mete al copiar, y cambian entre versiones.
*/
const esRuido = (l) =>
/^\s*(Add a Caption|Cover of|Título:.*)\s*$/i.test(l) ||
/^\s*(Weirdness|Audio Influence|Style Influence)\s+\d+\s*%\s*$/i.test(l) ||
/\bartwork\s*$/i.test(l);
/** Los parámetros que Suno pone como «Weirdness 58%». */
export function ajustesDe(lineas) {
const ajustes = {};
for (const l of lineas) {
const m = l.match(/^\s*(Weirdness|Audio Influence|Style Influence)\s+(\d+)\s*%\s*$/i);
if (m) ajustes[m[1].toLowerCase().replace(/\s+/g, '-')] = Number(m[2]);
}
return ajustes;
}
/**
* El prompt de estilo.
*
* Dos formas de encontrarlo, porque hay dos formas de archivo: detrás de «Add a
* Caption», o la línea más larga de la cabecera. Lo segundo y no «la primera»
* porque en la mitad de las canciones la primera línea es el nombre del archivo
* de Suno, y así se colaba en lugar del prompt.
*/
export function estiloDe(lineas) {
const caption = lineas.findIndex((l) => /^\s*Add a Caption\s*$/i.test(l));
if (caption !== -1) {
const siguiente = lineas.slice(caption + 1).find((l) => l.trim() !== '');
if (siguiente) return siguiente.trim();
}
// Solo se mira la cabecera: a partir de la primera etiqueta ya es letra.
const finDeCabecera = lineas.findIndex((l) => esEtiqueta(l) || esRaya(l));
const cabecera = (finDeCabecera === -1 ? lineas.slice(0, 8) : lineas.slice(0, finDeCabecera))
.map((l) => l.trim())
.filter((l) => l !== '' && !esRuido(l));
if (cabecera.length === 0) return null;
return cabecera.reduce((a, b) => (b.length > a.length ? b : a));
}
const asear = (t) =>
t
.join('\n')
.replace(/^\s+|\s+$/g, '')
// Tres saltos seguidos o más son una estrofa separada con la mano: dos.
.replace(/\n{3,}/g, '\n\n');
/** La letra que se publica: la de detrás de la última raya, o sin etiquetas. */
export function letraLimpiaDe(lineas) {
const rayas = lineas.map((l, i) => (esRaya(l) ? i : -1)).filter((i) => i !== -1);
return asear(
rayas.length > 0
? lineas.slice(rayas[rayas.length - 1] + 1)
: lineas.filter((l) => !esEtiqueta(l) && !esRuido(l))
);
}
/**
* En qué idioma está la letra.
*
* Se cuentan palabras muy frecuentes y exclusivas de cada idioma en vez de
* mirar los acentos: «è» sale en francés y en italiano, y una letra española
* con una palabra suelta en inglés no cambia de idioma. Con diez canciones el
* resultado se comprueba de un vistazo, que es lo que lo hace admisible.
*/
export function idiomaDe(letra) {
if (!letra) return 'es';
const palabras = letra.toLowerCase().match(/[a-záéíóúàèùçñïüö]+/g) ?? [];
const cuenta = (lista) => palabras.filter((p) => lista.includes(p)).length;
const es = cuenta(['que', 'de', 'no', 'te', 'me', 'la', 'el', 'un', 'una', 'por', 'con', 'para']);
const fr = cuenta(['je', 'tu', 'ne', 'pas', 'les', 'des', 'est', 'mon', 'ton', 'qui', 'dans']);
return fr > es ? 'fr' : 'es';
}
/** La letra con etiquetas, que es lo que se le pega a Suno. */
export function letraConEtiquetasDe(lineas) {
const primeraEtiqueta = lineas.findIndex(esEtiqueta);
if (primeraEtiqueta === -1) return null;
const raya = lineas.findIndex(esRaya);
const hasta = raya > primeraEtiqueta ? raya : lineas.length;
return asear(lineas.slice(primeraEtiqueta, hasta));
}
/* -------------------------------------------------------------------------
* Los archivos
* ---------------------------------------------------------------------- */
const MASTER = /^Senza Paura\s*-\s*(.+)\.(mp3|wav)$/i;
/** El primero que exista, por orden de preferencia. */
const primeroQueHaya = (archivos, patrones) => {
for (const p of patrones) {
const hallado = archivos.find((a) => p.test(a));
if (hallado) return hallado;
}
return null;
};
/**
* De «Ámame esta noche, nada más» a `amame-esta-noche-nada-mas`.
*
* La dirección publicada es un compromiso, así que sale del título y no del
* nombre de la carpeta, que está sin acentuar y a veces mal.
*/
export function slugDe(texto) {
return texto
.normalize('NFD')
.replace(/[̀-ͯ]/g, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-|-$/g, '');
}
export function leerCancion(carpeta) {
const archivos = fs
.readdirSync(carpeta, { withFileTypes: true })
.filter((d) => d.isFile())
.map((d) => d.name);
const enVideo = fs.existsSync(path.join(carpeta, 'Video'))
? fs.readdirSync(path.join(carpeta, 'Video')).filter((n) => !n.endsWith('.psd'))
: [];
/*
* El título sale del nombre del máster y no de la carpeta: las carpetas
* están sin acentuar o mal acentuadas —«Quèdate», «Pague», «para que
* recordar»— y el máster lleva el título como se escribe.
*/
const master = archivos.find((a) => MASTER.test(a) && a.toLowerCase().endsWith('.mp3'));
const titulo = master ? master.match(MASTER)[1].trim() : path.basename(carpeta);
const txt = archivos.find((a) => a.toLowerCase().endsWith('.txt'));
let letra = null;
if (txt) {
const lineas = fs.readFileSync(path.join(carpeta, txt), 'utf8').split(/\r?\n/);
const limpia = letraLimpiaDe(lineas);
letra = {
archivo: txt,
estilo: estiloDe(lineas),
ajustes: ajustesDe(lineas),
conEtiquetas: letraConEtiquetasDe(lineas),
limpia,
idioma: idiomaDe(limpia)
};
}
return {
carpeta: path.basename(carpeta),
titulo,
slug: slugDe(titulo),
letra,
audio: {
mp3: master ?? null,
wav: archivos.find((a) => MASTER.test(a) && a.toLowerCase().endsWith('.wav')) ?? null,
/** Las demás tomas: coberturas, seleccionadas, versiones de trabajo. */
otras: archivos.filter((a) => /\.(mp3|wav)$/i.test(a) && !MASTER.test(a))
},
portada: primeroQueHaya(archivos, [
/^cover\.jpe?g$/i,
/^cover-album\./i,
/^cover-mp3\./i,
/^cover/i
]),
portadaEnVideo: primeroQueHaya(enVideo, [/^cover\.jpe?g$/i, /^portada-video-youtube\.jpe?g$/i]),
video:
primeroQueHaya(enVideo, [/^Senza Paura.*\.mp4$/i, /\.mp4$/i]) ??
primeroQueHaya(archivos, [/\.mp4$/i]),
miniatura: primeroQueHaya(enVideo, [
/^portada-video-youtube\.jpe?g$/i,
/^portada-video-youtube-fin(al)?\.jpe?g$/i
])
};
}
/**
* La fecha del disco, deducida del máster más reciente.
*
* No está escrita en ninguna parte de la carpeta, y `album.fecha` es
* obligatoria. La fecha del último máster es lo más cerca que hay de «cuándo
* quedó terminado», y se corrige con `--fecha` si no cuadra. Inventar el día de
* hoy sería peor: parecería un dato y no lo sería.
*/
function fechaDe(carpeta, canciones) {
let ultima = 0;
for (const x of canciones) {
if (!x.audio.mp3) continue;
const cuando = fs.statSync(path.join(carpeta, x.carpeta, x.audio.mp3)).mtimeMs;
if (cuando > ultima) ultima = cuando;
}
return ultima === 0 ? null : new Date(ultima).toISOString().slice(0, 10);
}
/** El álbum entero: su nombre y sus canciones, en el orden de la carpeta. */
export function leerAlbum(carpeta) {
const nombre = path.basename(carpeta).replace(/^album\s*-\s*/i, '');
const canciones = fs
.readdirSync(carpeta, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => leerCancion(path.join(carpeta, d.name)));
return {
carpeta,
fecha: fechaDe(carpeta, canciones),
canciones,
/* «album - cupido-sin-flechas» → «cupido-sin-flechas» → «Cupido sin flechas». */
slug: slugDe(nombre),
titulo: nombre
.replace(/-/g, ' ')
.trim()
.replace(/^./, (c) => c.toUpperCase())
};
}

@ -0,0 +1,263 @@
/**
* Importa la carpeta de un álbum a la base de datos.
*
* npm run album:importar -- "S:\\senza paura\\album - cupido-sin-flechas"
* npm run album:importar -- <carpeta> --escribir # sin esto, solo ensaya
*
* **Por defecto no escribe nada**: dice lo que haría y se para. `--escribir` es
* lo que lo hace de verdad, y hay que pedirlo. Este script mete diez canciones
* con su letra en una base que ya tiene contenido publicado; equivocarse aquí
* no se deshace con Ctrl+Z.
*
* **Idempotente por slug.** Volver a lanzarlo actualiza lo que ya está en vez
* de duplicarlo, que es lo que permite corregir un `.txt` y reimportar.
*
* ## Qué escribe y qué no
*
* Escribe el disco, las canciones con su letra y el prompt de Suno de cada una.
* **No copia audio ni imágenes**: los archivos se quedan donde están. Las fichas
* salen en la web sin poder reproducirse hasta que se coloquen los medios, que
* es un paso aparte y documentado en `media/LEEME.md`.
*
* ## La letra
*
* Se guarda la limpia, la de detrás de la raya. La que lleva `[Verse 1]` y
* `[Chorus]` es lo que se le pega a Suno, no lo que se lee: esa se guarda como
* parte del prompt, que es donde significa algo.
*/
import fs from 'node:fs';
import pg from 'pg';
import { leerAlbum } from './carpeta.mjs';
const CARPETA = process.argv[2];
const ESCRIBIR = process.argv.includes('--escribir');
if (!CARPETA || !fs.existsSync(CARPETA)) {
console.error('uso: npm run album:importar -- "<carpeta>" [--escribir] [--titulo slug=Título]');
process.exit(1);
}
/**
* Títulos corregidos a mano: `--titulo slug=Título`.
*
* El título sale del nombre del máster, que es lo mejor que hay: las carpetas
* están sin acentuar. Pero un archivo también puede estar mal escrito —«Ton
* silence me libére» lleva acento agudo donde el francés pide grave— y eso no
* hay forma de deducirlo. Antes que adivinar o que dejarlo mal, se corrige
* desde fuera y queda escrito en el comando.
*/
const TITULOS = new Map();
for (let i = 0; i < process.argv.length; i++) {
if (process.argv[i] !== '--titulo') continue;
const [slug, ...resto] = String(process.argv[i + 1] ?? '').split('=');
if (slug && resto.length > 0) TITULOS.set(slug, resto.join('='));
}
/** El estilo del sitio bajo el que cuelga el disco. */
const ESTILO = process.env.ALBUM_ESTILO ?? 'baladas-romanticas';
/** La fecha del disco: `--fecha 2026-08-16`. Si no, la del máster más nuevo. */
const FECHA_DADA = process.argv[process.argv.indexOf('--fecha') + 1];
/**
* El modelo de IA con el que se sacaron.
*
* La versión se deja sin concretar a propósito: los `.txt` no la dicen y un
* prompt de v4 no vale para v3.5, así que inventarla sería peor que dejar el
* hueco. Se corrige en el panel, en Modelos de IA, y el código de la fila se
* recalcula solo al guardarla.
*/
const MODELO = {
codigo: 'suno-sin-concretar',
familia: 'Suno',
version: 'sin concretar',
nombre: 'Suno · versión sin concretar',
notaSintaxis:
'La versión está sin poner: los archivos de letra no la dicen. Un prompt de v4 no ' +
'vale para v3.5, así que conviene concretarla.',
esquema: {
type: 'object',
required: ['estilo'],
'x-order': ['estilo', 'letra', 'weirdness', 'audioInfluence'],
properties: {
estilo: {
type: 'string',
title: 'Estilo',
maxLength: 1000,
description: 'Los descriptores que se le pasan a Suno, separados por comas.'
},
letra: {
type: 'string',
title: 'Letra con etiquetas',
description: 'La letra con [Verse], [Chorus]… que es lo que se pega en Suno.'
},
weirdness: { type: 'integer', title: 'Weirdness', minimum: 0, maximum: 100 },
audioInfluence: { type: 'integer', title: 'Audio influence', minimum: 0, maximum: 100 }
}
}
};
const album = leerAlbum(CARPETA);
// Los títulos corregidos se aplican aquí, antes de que nadie mire el slug.
for (const x of album.canciones) {
if (TITULOS.has(x.slug)) x.titulo = TITULOS.get(x.slug);
}
/* -------------------------------------------------------------------------
* Lo que se va a hacer
* ---------------------------------------------------------------------- */
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 30_000 });
await c.connect();
const { rows: estilos } = await c.query('select id, slug from estilo where slug = $1', [ESTILO]);
if (estilos.length === 0) {
console.error(`No hay ningún estilo «${ESTILO}». Ponlo en ALBUM_ESTILO.`);
await c.end();
process.exit(1);
}
const estiloId = estilos[0].id;
const { rows: yaEstan } = await c.query(`select slug from cancion where slug = any($1::text[])`, [
album.canciones.map((x) => x.slug)
]);
const conocidas = new Set(yaEstan.map((f) => f.slug));
const FECHA = /^\d{4}-\d{2}-\d{2}$/.test(FECHA_DADA ?? '') ? FECHA_DADA : album.fecha;
if (!FECHA) {
console.error('Sin fecha y sin ningún máster del que deducirla. Pásala con --fecha 2026-08-16.');
await c.end();
process.exit(1);
}
console.log(`\n${album.titulo} (${album.slug}) bajo el estilo «${ESTILO}»`);
console.log(
`fecha ${FECHA}${FECHA_DADA ? '' : ' (del máster más reciente; se cambia con --fecha)'}`
);
console.log(`${album.canciones.length} canciones, ${conocidas.size} ya estaban\n`);
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}` : '';
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}`
);
}
const sinLetra = album.canciones.filter((x) => !x.letra?.limpia);
if (sinLetra.length > 0) {
console.log(`\nSin letra, no se importan: ${sinLetra.map((x) => x.titulo).join(', ')}`);
}
if (!ESCRIBIR) {
console.log('\nEnsayo. Nada de esto se ha escrito. Añade --escribir para hacerlo.');
await c.end();
process.exit(0);
}
/* -------------------------------------------------------------------------
* Escribir
* ---------------------------------------------------------------------- */
await c.query('BEGIN');
try {
const { rows: discos } = await c.query(
`insert into album (id, slug, titulo, fecha, estilo_id, estado)
values (gen_random_uuid(), $1, $2, $3, $4, 'publicado')
on conflict (slug) do update set
titulo = excluded.titulo,
fecha = excluded.fecha,
estilo_id = excluded.estilo_id
returning id`,
[album.slug, album.titulo, FECHA, estiloId]
);
const albumId = discos[0].id;
const { rows: modelos } = await c.query(
`insert into modelo_ia (id, codigo, familia, version, nombre, nota_sintaxis, esquema)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6)
on conflict (codigo) do update set esquema = excluded.esquema
returning id`,
[
MODELO.codigo,
MODELO.familia,
MODELO.version,
MODELO.nombre,
MODELO.notaSintaxis,
JSON.stringify(MODELO.esquema)
]
);
const modeloId = modelos[0].id;
let creadas = 0;
let prompts = 0;
for (const [i, x] of album.canciones.entries()) {
if (!x.letra?.limpia) continue;
const { rows: canciones } = await c.query(
`insert into cancion (id, slug, titulo, letra, idioma, estilo_id, album_id, numero, estado)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, 'publicado')
on conflict (slug) do update set
titulo = excluded.titulo,
letra = excluded.letra,
idioma = excluded.idioma,
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]
);
const cancionId = canciones[0].id;
if (canciones[0].nueva) creadas++;
if (!x.letra.estilo) continue;
/*
* El prompt, con lo que declara el esquema del modelo. Se borra el
* anterior de esta canción en vez de actualizarlo: reimportar un `.txt`
* corregido tiene que dejar uno, no dos.
*/
await c.query('delete from prompt where cancion_id = $1 and modelo_ia_id = $2', [
cancionId,
modeloId
]);
const contenido = { estilo: x.letra.estilo };
if (x.letra.conEtiquetas) contenido.letra = x.letra.conEtiquetas;
if (x.letra.ajustes.weirdness !== undefined) contenido.weirdness = x.letra.ajustes.weirdness;
if (x.letra.ajustes['audio-influence'] !== undefined) {
contenido.audioInfluence = x.letra.ajustes['audio-influence'];
}
await c.query(
`insert into prompt (id, modelo_ia_id, cancion_id, idioma, contenido, notas,
preferido, visibilidad, estado)
values (gen_random_uuid(), $1, $2, 'en', $3, $4, true, 'privado', 'publicado')`,
[
modeloId,
cancionId,
JSON.stringify(contenido),
`Importado de «${x.letra.archivo}». Es el prompt con el que salió el tema.`
]
);
prompts++;
}
await c.query('COMMIT');
console.log(`\nHecho: ${creadas} canciones nuevas, ${prompts} prompts.`);
console.log('El audio y las imágenes NO se han copiado: siguen en su carpeta.');
console.log('Pon la versión de Suno en el panel, en Modelos de IA.');
} catch (fallo) {
await c.query('ROLLBACK');
console.error('\nNo se ha escrito nada. Falló:', fallo.message);
process.exitCode = 1;
}
await c.end();

@ -4,29 +4,16 @@
* node scripts/album/leer.mjs "S:\\senza paura\\album - cupido-sin-flechas"
* node scripts/album/leer.mjs <carpeta> --json > album.json
*
* Existe porque el material no está en un formato: está en cinco. Los `.txt`
* de letra vienen de pegar lo que devuelve Suno, y Suno ha ido cambiando lo que
* pone alrededor —«Add a Caption», «Weirdness 58%», «(Cover) artwork»—, así que
* la cabecera es distinta en casi cada canción. Un importador que diera eso por
* bueno metería en la base la palabra «artwork» como si fuera una letra.
*
* Lo que este script hace es leerlo, decir qué ha entendido de cada canción y
* qué no ha encontrado. Lo que se importe se decide mirando eso.
*
* ## Qué contiene un `.txt`
*
* Hasta tres cosas, en este orden y no siempre las tres:
*
* 1. **El prompt de estilo** que se le dio a Suno. Es la línea larga de arriba.
* 2. **La letra con etiquetas** —`[Verse 1]`, `[Chorus]`—, que es lo que se le
* pega a Suno, no lo que se publica.
* 3. **La letra limpia**, detrás de una raya de guiones. Esa es la de la web.
*
* Cuando no hay raya, la limpia se saca quitando las líneas de etiqueta.
* El desmontaje está en `carpeta.mjs`, que es el mismo que usa el importador.
* Esto solo lo cuenta: qué ha entendido de cada canción y qué no ha encontrado.
* Se mira antes de importar, porque el material no está en un formato sino en
* cinco —los `.txt` vienen de pegar lo que devuelve Suno, y Suno ha ido
* cambiando lo que pone alrededor—.
*/
import fs from 'node:fs';
import path from 'node:path';
import fs from 'node:fs';
import { leerAlbum } from './carpeta.mjs';
const CARPETA = process.argv[2];
const COMO_JSON = process.argv.includes('--json');
@ -36,191 +23,21 @@ if (!CARPETA || !fs.existsSync(CARPETA)) {
process.exit(1);
}
/* -------------------------------------------------------------------------
* El `.txt` de la letra
* ---------------------------------------------------------------------- */
/** Una raya de guiones separa la letra con etiquetas de la limpia. */
const esRaya = (l) => /^\s*-{10,}\s*$/.test(l);
/** Una línea que es solo una etiqueta de Suno: `[Chorus]`, `[Intro: piano]`. */
const esEtiqueta = (l) => /^\s*\[[^\]]*\]\s*$/.test(l);
/**
* Lo que Suno escribe alrededor y no es ni prompt ni letra.
*
* Sale de mirar los diez archivos, no de suponer: son las cadenas que la
* interfaz de Suno mete al copiar, y cambian entre versiones.
*/
const esRuido = (l) =>
/^\s*(Add a Caption|Cover of|Título:.*)\s*$/i.test(l) ||
/^\s*(Weirdness|Audio Influence|Style Influence)\s+\d+\s*%\s*$/i.test(l) ||
/\bartwork\s*$/i.test(l);
/** Los parámetros que Suno pone como «Weirdness 58%». */
function ajustesDe(lineas) {
const ajustes = {};
for (const l of lineas) {
const m = l.match(/^\s*(Weirdness|Audio Influence|Style Influence)\s+(\d+)\s*%\s*$/i);
if (m) ajustes[m[1].toLowerCase().replace(/\s+/g, '-')] = Number(m[2]);
}
return ajustes;
}
/**
* El prompt de estilo.
*
* Tres formas de encontrarlo, en orden, porque hay tres formas de archivo:
* detrás de «Add a Caption», en la primera línea si ya es el prompt, o en la
* segunda si la primera era el nombre del archivo de Suno.
*/
function estiloDe(lineas) {
const caption = lineas.findIndex((l) => /^\s*Add a Caption\s*$/i.test(l));
if (caption !== -1) {
const siguiente = lineas.slice(caption + 1).find((l) => l.trim() !== '');
if (siguiente) return siguiente.trim();
}
// Solo se mira la cabecera: a partir de la primera etiqueta ya es letra.
const finDeCabecera = lineas.findIndex((l) => esEtiqueta(l) || esRaya(l));
const cabecera = (finDeCabecera === -1 ? lineas.slice(0, 8) : lineas.slice(0, finDeCabecera))
.map((l) => l.trim())
.filter((l) => l !== '' && !esRuido(l));
if (cabecera.length === 0) return null;
/*
* La más larga de la cabecera. Un prompt de estilo son doscientos caracteres
* de descriptores separados por comas; el nombre del archivo, cuarenta. Con
* «la primera» se colaba el nombre en la mitad de las canciones.
*/
return cabecera.reduce((a, b) => (b.length > a.length ? b : a));
}
/** La letra que se publica: la de detrás de la última raya, o sin etiquetas. */
function letraLimpiaDe(lineas) {
const rayas = lineas.map((l, i) => (esRaya(l) ? i : -1)).filter((i) => i !== -1);
const cuerpo =
rayas.length > 0
? lineas.slice(rayas[rayas.length - 1] + 1)
: lineas.filter((l) => !esEtiqueta(l) && !esRuido(l));
return (
cuerpo
.join('\n')
.replace(/^\s+|\s+$/g, '')
// Tres saltos seguidos o más son una estrofa separada con la mano: dos.
.replace(/\n{3,}/g, '\n\n')
);
}
/** La letra con etiquetas, que es lo que se le pega a Suno. */
function letraConEtiquetasDe(lineas) {
const primeraEtiqueta = lineas.findIndex(esEtiqueta);
if (primeraEtiqueta === -1) return null;
const raya = lineas.findIndex(esRaya);
const hasta = raya > primeraEtiqueta ? raya : lineas.length;
return lineas
.slice(primeraEtiqueta, hasta)
.join('\n')
.replace(/^\s+|\s+$/g, '')
.replace(/\n{3,}/g, '\n\n');
}
/* -------------------------------------------------------------------------
* Los archivos de cada canción
* ---------------------------------------------------------------------- */
const MASTER = /^Senza Paura\s*-\s*(.+)\.(mp3|wav)$/i;
/** El primero que exista, por orden de preferencia. */
const primeroQueHaya = (archivos, patrones) => {
for (const p of patrones) {
const hallado = archivos.find((a) => p.test(a));
if (hallado) return hallado;
}
return null;
};
function leerCancion(carpeta) {
const dentro = fs.readdirSync(carpeta, { withFileTypes: true });
const archivos = dentro.filter((d) => d.isFile()).map((d) => d.name);
const enVideo = fs.existsSync(path.join(carpeta, 'Video'))
? fs.readdirSync(path.join(carpeta, 'Video')).filter((n) => !n.endsWith('.psd'))
: [];
/*
* El título sale del nombre del máster y no de la carpeta: las carpetas
* están sin acentuar o mal acentuadas —«Quèdate», «Pague», «para que
* recordar»— y el máster lleva el título como se escribe.
*/
const master = archivos.find((a) => MASTER.test(a) && a.toLowerCase().endsWith('.mp3'));
const titulo = master ? master.match(MASTER)[1].trim() : path.basename(carpeta);
const txt = archivos.find((a) => a.toLowerCase().endsWith('.txt'));
let letra = null;
if (txt) {
const lineas = fs.readFileSync(path.join(carpeta, txt), 'utf8').split(/\r?\n/);
letra = {
archivo: txt,
estilo: estiloDe(lineas),
ajustes: ajustesDe(lineas),
conEtiquetas: letraConEtiquetasDe(lineas),
limpia: letraLimpiaDe(lineas)
};
}
return {
carpeta: path.basename(carpeta),
titulo,
letra,
audio: {
mp3: master ?? null,
wav: archivos.find((a) => MASTER.test(a) && a.toLowerCase().endsWith('.wav')) ?? null,
/** Las demás tomas: coberturas, seleccionadas, versiones de trabajo. */
otras: archivos.filter((a) => /\.(mp3|wav)$/i.test(a) && !MASTER.test(a))
},
portada: primeroQueHaya(archivos, [
/^cover\.jpe?g$/i,
/^cover-album\./i,
/^cover-mp3\./i,
/^cover/i
]),
portadaEnVideo: primeroQueHaya(enVideo, [/^cover\.jpe?g$/i, /^portada-video-youtube\.jpe?g$/i]),
video:
primeroQueHaya(enVideo, [/^Senza Paura.*\.mp4$/i, /\.mp4$/i]) ??
primeroQueHaya(archivos, [/\.mp4$/i]),
miniatura: primeroQueHaya(enVideo, [
/^portada-video-youtube\.jpe?g$/i,
/^portada-video-youtube-fin(al)?\.jpe?g$/i
])
};
}
/* -------------------------------------------------------------------------
* Salida
* ---------------------------------------------------------------------- */
const canciones = fs
.readdirSync(CARPETA, { withFileTypes: true })
.filter((d) => d.isDirectory())
.map((d) => leerCancion(path.join(CARPETA, d.name)));
const album = leerAlbum(CARPETA);
if (COMO_JSON) {
console.log(JSON.stringify({ carpeta: CARPETA, canciones }, null, 2));
console.log(JSON.stringify(album, null, 2));
process.exit(0);
}
const si = (v) => (v ? '·' : 'FALTA');
const corto = (t, n) => (t ? (t.length > n ? t.slice(0, n - 1) + '…' : t) : '');
console.log(`\n${canciones.length} canciones en ${path.basename(CARPETA)}\n`);
console.log(`\n${album.titulo} (${album.slug})`);
console.log(`${album.canciones.length} canciones en ${path.basename(CARPETA)}\n`);
for (const c of canciones) {
console.log(`── ${c.titulo}`);
for (const c of album.canciones) {
console.log(`── ${c.titulo} ${c.slug}`);
if (c.titulo !== c.carpeta) console.log(` carpeta: ${c.carpeta}`);
if (!c.letra) {
@ -249,7 +66,7 @@ for (const c of canciones) {
console.log('');
}
const sin = (f) => canciones.filter(f).map((c) => c.titulo);
const sin = (f) => album.canciones.filter(f).map((c) => c.titulo);
const faltan = [
['sin .txt', sin((c) => !c.letra)],
['sin prompt de estilo', sin((c) => c.letra && !c.letra.estilo)],

@ -0,0 +1,80 @@
<script lang="ts">
/**
* El hueco de una portada que todavía no está.
*
* Existe porque hasta ahora todo el catálogo tenía imagen y nadie había
* visto qué pasa cuando falta: un `<img>` sin `src` es el icono de imagen
* rota del navegador, que es lo peor que puede salir en una página
* publicada. Y va a pasar más: el texto de un disco entra en la base por un
* lado y sus archivos por otro, así que el hueco tiene que ser un estado
* previsto y no un accidente.
*
* No dice «falta la portada». Eso es un problema nuestro, no del que mira:
* ocupa el sitio con la inicial del título, se distingue de las demás y no
* llama la atención sobre lo que falta.
*/
interface Props {
titulo: string;
/** El lado en píxeles. No da tamaño: hace que la inicial crezca con él. */
lado: number;
/**
* Cuando el nombre ya se dice al lado —la barra del reproductor, una fila
* del carrito—, el hueco no se anuncia: repetirlo solo alarga la lectura.
*/
muda?: boolean;
}
let { titulo, lado, muda = false }: Props = $props();
const inicial = $derived(titulo.trim().charAt(0).toUpperCase());
</script>
<div
class="hueco"
style="--lado: {lado}px"
role={muda ? undefined : 'img'}
aria-label={muda ? undefined : `Sin portada: ${titulo}`}
aria-hidden={muda ? 'true' : undefined}
>
<span aria-hidden="true">{inicial}</span>
</div>
<style>
/*
* Se comporta como la imagen que sustituye: llena su columna y es cuadrado.
* `--lado` no le pone tamaño, solo sirve para que la inicial crezca con él.
*/
.hueco {
display: grid;
place-items: center;
/*
* `flex: none` porque en el carrito y en la barra del reproductor esto
* vive dentro de una fila flexible, y sin ello se encoge hasta
* desaparecer. El `max-width` lo deja del tamaño que tendría la imagen.
*/
flex: none;
width: 100%;
max-width: var(--lado);
aspect-ratio: 1;
border: 1px solid var(--c-border);
border-radius: var(--radius-tarjeta);
/*
* Un poco más hondo que el fondo hundido, para que se lea como un hueco
* y no como una tarjeta vacía.
*/
background: var(--c-bg-sunken);
color: var(--c-text-muted);
}
/*
* La inicial ocupa un tercio del lado. En `em` sobre el tamaño del cuadro y
* no en un tamaño de la escala: aquí la letra es un dibujo, no texto, y
* tiene que crecer con el hueco.
*/
.hueco span {
font-family: var(--font-display);
font-size: calc(var(--lado) * 0.34);
line-height: 1;
opacity: 0.55;
}
</style>

@ -10,6 +10,7 @@
import { brillo } from '$lib/acciones/brillo';
import { revelar } from '$lib/acciones/revelar';
import { aPistas } from '$lib/player/pista';
import SinPortada from './SinPortada.svelte';
import type { AlbumConCanciones } from '$lib/types';
import { ano } from '$lib/utils/date';
import { duracionLarga } from '$lib/utils/format';
@ -34,13 +35,17 @@
<article class="tarjeta" use:revelar={retraso} use:brillo>
<div class="tarjeta__medio">
<img
src={album.portada}
alt="Portada de {album.titulo}"
width="320"
height="320"
loading="lazy"
/>
{#if album.portada}
<img
src={album.portada}
alt="Portada de {album.titulo}"
width="320"
height="320"
loading="lazy"
/>
{:else}
<SinPortada titulo={album.titulo} lado={320} />
{/if}
<div class="tarjeta__flotante">
<BotonReproducir {pistas} modo="album" variante="solido" tamano={16} />
</div>

@ -22,6 +22,7 @@
obtenerEstilo,
portadaDeCancion
} from '$lib/catalogo/cliente.svelte';
import SinPortada from './SinPortada.svelte';
import type { PistaReproductor } from '$lib/player/player.svelte';
import type { Cancion } from '$lib/types';
@ -44,13 +45,17 @@
<article class="tarjeta" use:revelar={retraso} use:brillo>
<div class="tarjeta__medio">
<img
src={portadaDeCancion(cancion)}
alt="Portada de {cancion.titulo}"
width="320"
height="320"
loading="lazy"
/>
{#if portadaDeCancion(cancion)}
<img
src={portadaDeCancion(cancion)}
alt="Portada de {cancion.titulo}"
width="320"
height="320"
loading="lazy"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={320} />
{/if}
<div class="tarjeta__flotante">
<BotonReproducir {pistas} {indice} variante="solido" tamano={16} />
</div>

@ -7,6 +7,7 @@
*/
import { resolve } from '$app/paths';
import Icono from '../Icono.svelte';
import SinPortada from '../SinPortada.svelte';
import BarraProgreso from './BarraProgreso.svelte';
import ControlVolumen from './ControlVolumen.svelte';
import PanelCola from './PanelCola.svelte';
@ -103,7 +104,11 @@
mismo nombre y ningun contexto que los separe.
-->
<div class="ahora" role="group" aria-label="Sonando ahora">
<img class="portada" src={pista.portada} alt="" width="48" height="48" />
{#if pista.portada}
<img class="portada" src={pista.portada} alt="" width="48" height="48" />
{:else}
<SinPortada titulo={pista.titulo} lado={48} muda />
{/if}
<div class="datos">
<p class="linea">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: pista.cancionSlug })}>

@ -697,6 +697,16 @@ export const prompt = pgTable(
.notNull()
.references(() => modeloIa.id),
generoId: text('genero_id').references(() => genero.id, { onDelete: 'cascade' }),
/**
* La canción que salió de este prompt, si salió una.
*
* No sustituye a `generoId`, se suma: los prompts que existen de verdad
* son el que produjo un tema concreto, y colgar eso de su género sería
* mentir —no es la referencia del bolero, es lo que produjo «Ámame esta
* noche»—. Los dos tienen sentido a la vez, porque el prompt de una
* canción también dice en qué género se escribió.
*/
cancionId: text('cancion_id').references(() => cancion.id, { onDelete: 'cascade' }),
animoId: text('animo_id').references(() => animo.id),
/** En inglés suelen funcionar mejor, incluso para música en español. */
idioma: text('idioma').notNull().default('en'),
@ -727,6 +737,7 @@ export const prompt = pgTable(
},
(t) => [
index('prompt_genero').on(t.generoId),
index('prompt_cancion').on(t.cancionId),
index('prompt_modelo').on(t.modeloIaId),
index('prompt_animo').on(t.animoId)
]

@ -5,6 +5,7 @@
import ListaCanciones from '$lib/components/ListaCanciones.svelte';
import RejillaCanciones from '$lib/components/RejillaCanciones.svelte';
import Meta from '$lib/components/Meta.svelte';
import SinPortada from '$lib/components/SinPortada.svelte';
import { aPistas } from '$lib/player/pista';
import { etiquetasFormato } from '$lib/site';
import { formatearFecha } from '$lib/utils/date';
@ -54,13 +55,17 @@
<Migas {migas} />
<header class="ficha">
<img
class="portada"
src={album.portada}
alt="Portada de {album.titulo}"
width="320"
height="320"
/>
{#if album.portada}
<img
class="portada"
src={album.portada}
alt="Portada de {album.titulo}"
width="320"
height="320"
/>
{:else}
<SinPortada titulo={album.titulo} lado={320} />
{/if}
<div class="datos">
<p class="eyebrow">{etiquetasTipo[album.tipo] ?? album.tipo}</p>

@ -7,6 +7,7 @@
import FichaDeCancion from '$lib/components/FichaDeCancion.svelte';
import Meta from '$lib/components/Meta.svelte';
import Pestanas, { type Pestana } from '$lib/components/Pestanas.svelte';
import SinPortada from '$lib/components/SinPortada.svelte';
import VideoYoutube from '$lib/components/VideoYoutube.svelte';
import { aPista, aPistaDeVersion, aPistas } from '$lib/player/pista';
import { CANCIONES_POR_PACK, formatearPrecio, PRECIO_PACK_CENTIMOS } from '$lib/tienda';
@ -98,13 +99,17 @@
<Migas {migas} />
<header class="ficha">
<img
class="portada"
src={data.portada}
alt="Portada de {cancion.titulo}"
width="280"
height="280"
/>
{#if data.portada}
<img
class="portada"
src={data.portada}
alt="Portada de {cancion.titulo}"
width="280"
height="280"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={280} />
{/if}
<div class="titulo">
<h1>{cancion.titulo}</h1>

@ -16,6 +16,7 @@
PRECIO_PACK_CENTIMOS,
resumirCarrito
} from '$lib/tienda';
import SinPortada from '$lib/components/SinPortada.svelte';
import type { Cancion } from '$lib/types';
let { data, form } = $props();
@ -74,14 +75,18 @@
{#each enCarrito as cancion (cancion.slug)}
{@const album = obtenerAlbum(albumDeCancion(cancion))}
<li class="fila">
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{#if portadaDeCancion(cancion)}
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={48} muda />
{/if}
<div class="datos">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}>
@ -162,14 +167,18 @@
<ul class="lista">
{#each sugerencias as cancion (cancion.slug)}
<li class="fila">
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{#if portadaDeCancion(cancion)}
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={48} muda />
{/if}
<div class="datos">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}>
{cancion.titulo}

@ -10,6 +10,7 @@
portadaDeCancion
} from '$lib/catalogo/cliente.svelte';
import { formatearPrecio } from '$lib/tienda';
import SinPortada from '$lib/components/SinPortada.svelte';
import type { Cancion } from '$lib/types';
import { formatearFecha } from '$lib/utils/date';
@ -65,14 +66,18 @@
<ul class="lista">
{#each compradas as cancion (cancion.slug)}
<li class="fila">
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{#if portadaDeCancion(cancion)}
<img
class="portada"
src={portadaDeCancion(cancion)}
alt=""
width="48"
height="48"
loading="lazy"
/>
{:else}
<SinPortada titulo={cancion.titulo} lado={48} muda />
{/if}
<div class="datos">
<a class="titulo" href={resolve('/canciones/[slug]', { slug: cancion.slug })}>

Loading…
Cancel
Save

Powered by TurnKey Linux.