You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
264 lines
9.1 KiB
264 lines
9.1 KiB
/**
|
|
* 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();
|