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.
259 lines
8.8 KiB
259 lines
8.8 KiB
/**
|
|
* 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())
|
|
};
|
|
}
|