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.
senzapaura_es/scripts/album/importar.mjs

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

Powered by TurnKey Linux.