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
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");
|
||||||
@ -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();
|
||||||
@ -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>
|
||||||
Loading…
Reference in new issue