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/carpeta.mjs

259 lines
8.8 KiB

«Cupido sin flechas»: diez canciones de verdad, con sus prompts 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>
4 weeks ago
/**
* 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())
};
}

Powered by TurnKey Linux.