From 9cefeb879a24c3de60d025dd2f5d4ee3efa45036 Mon Sep 17 00:00:00 2001 From: dev Date: Wed, 9 Sep 2026 19:10:53 +0200 Subject: [PATCH] =?UTF-8?q?=C2=ABCupido=20sin=20flechas=C2=BB:=20diez=20ca?= =?UTF-8?q?nciones=20de=20verdad,=20con=20sus=20prompts?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit El primer disco real del catálogo. Hasta ahora todo era inventado salvo «Dorina». `npm run album:importar -- ""` 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 `` 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) --- drizzle/0012_prompts_de_una_cancion.sql | 24 ++ package.json | 2 + scripts/album/carpeta.mjs | 258 +++++++++++++++++ scripts/album/importar.mjs | 263 ++++++++++++++++++ scripts/album/leer.mjs | 211 +------------- src/lib/components/SinPortada.svelte | 80 ++++++ src/lib/components/TarjetaAlbum.svelte | 19 +- src/lib/components/TarjetaCancion.svelte | 19 +- .../components/player/BarraReproductor.svelte | 7 +- src/lib/server/db/schema/taller.ts | 11 + src/routes/albumes/[slug]/+page.svelte | 19 +- src/routes/canciones/[slug]/+page.svelte | 19 +- src/routes/carrito/+page.svelte | 41 +-- src/routes/cuenta/+page.svelte | 21 +- 14 files changed, 744 insertions(+), 250 deletions(-) create mode 100644 drizzle/0012_prompts_de_una_cancion.sql create mode 100644 scripts/album/carpeta.mjs create mode 100644 scripts/album/importar.mjs create mode 100644 src/lib/components/SinPortada.svelte diff --git a/drizzle/0012_prompts_de_una_cancion.sql b/drizzle/0012_prompts_de_una_cancion.sql new file mode 100644 index 0000000..1408b71 --- /dev/null +++ b/drizzle/0012_prompts_de_una_cancion.sql @@ -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"); diff --git a/package.json b/package.json index 9f5e88e..fae5a51 100644 --- a/package.json +++ b/package.json @@ -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" }, diff --git a/scripts/album/carpeta.mjs b/scripts/album/carpeta.mjs new file mode 100644 index 0000000..0591818 --- /dev/null +++ b/scripts/album/carpeta.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()) + }; +} diff --git a/scripts/album/importar.mjs b/scripts/album/importar.mjs new file mode 100644 index 0000000..783866a --- /dev/null +++ b/scripts/album/importar.mjs @@ -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 -- --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 -- "" [--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(); diff --git a/scripts/album/leer.mjs b/scripts/album/leer.mjs index 3bd28ad..0326c44 100644 --- a/scripts/album/leer.mjs +++ b/scripts/album/leer.mjs @@ -4,29 +4,16 @@ * node scripts/album/leer.mjs "S:\\senza paura\\album - cupido-sin-flechas" * node scripts/album/leer.mjs --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)], diff --git a/src/lib/components/SinPortada.svelte b/src/lib/components/SinPortada.svelte new file mode 100644 index 0000000..e20ffa1 --- /dev/null +++ b/src/lib/components/SinPortada.svelte @@ -0,0 +1,80 @@ + + +
+ +
+ + diff --git a/src/lib/components/TarjetaAlbum.svelte b/src/lib/components/TarjetaAlbum.svelte index 4e4b76a..b8b2705 100644 --- a/src/lib/components/TarjetaAlbum.svelte +++ b/src/lib/components/TarjetaAlbum.svelte @@ -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 @@
- Portada de {album.titulo} + {#if album.portada} + Portada de {album.titulo} + {:else} + + {/if}
diff --git a/src/lib/components/TarjetaCancion.svelte b/src/lib/components/TarjetaCancion.svelte index 8d31a7f..548c5b4 100644 --- a/src/lib/components/TarjetaCancion.svelte +++ b/src/lib/components/TarjetaCancion.svelte @@ -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 @@
- Portada de {cancion.titulo} + {#if portadaDeCancion(cancion)} + Portada de {cancion.titulo} + {:else} + + {/if}
diff --git a/src/lib/components/player/BarraReproductor.svelte b/src/lib/components/player/BarraReproductor.svelte index 8786526..6997834 100644 --- a/src/lib/components/player/BarraReproductor.svelte +++ b/src/lib/components/player/BarraReproductor.svelte @@ -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. -->
- + {#if pista.portada} + + {:else} + + {/if}

diff --git a/src/lib/server/db/schema/taller.ts b/src/lib/server/db/schema/taller.ts index 46625d1..ed08865 100644 --- a/src/lib/server/db/schema/taller.ts +++ b/src/lib/server/db/schema/taller.ts @@ -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) ] diff --git a/src/routes/albumes/[slug]/+page.svelte b/src/routes/albumes/[slug]/+page.svelte index 203ebd7..23fe349 100644 --- a/src/routes/albumes/[slug]/+page.svelte +++ b/src/routes/albumes/[slug]/+page.svelte @@ -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 @@

- Portada de {album.titulo} + {#if album.portada} + Portada de {album.titulo} + {:else} + + {/if}

{etiquetasTipo[album.tipo] ?? album.tipo}

diff --git a/src/routes/canciones/[slug]/+page.svelte b/src/routes/canciones/[slug]/+page.svelte index ce2e771..f84fe5f 100644 --- a/src/routes/canciones/[slug]/+page.svelte +++ b/src/routes/canciones/[slug]/+page.svelte @@ -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 @@
- Portada de {cancion.titulo} + {#if data.portada} + Portada de {cancion.titulo} + {:else} + + {/if}