/** * 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();