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.
299 lines
9.8 KiB
299 lines
9.8 KiB
|
1 month ago
|
/**
|
||
|
|
* Coloca el audio que llega desde el panel y prepara lo que el navegador no
|
||
|
|
* puede preparar.
|
||
|
|
*
|
||
|
|
* El panel de contenido escribe archivos en el repositorio, así que subir el
|
||
|
|
* máster desde ahí es fácil. Lo que no puede hacer es recortar el fragmento de
|
||
|
|
* muestra ni medir la duración: eso es ffmpeg, y ffmpeg no corre dentro de una
|
||
|
|
* pestaña. Este script cierra ese hueco.
|
||
|
|
*
|
||
|
|
* Qué hace, por cada canción de `src/content/canciones/`:
|
||
|
|
*
|
||
|
|
* 1. Si su clave de audio apunta a la bandeja (`subidas/…`), mueve el archivo
|
||
|
|
* a la carpeta que le toca —la del disco, o `singles/`— con el nombre de
|
||
|
|
* la convención, y reescribe la clave en el Markdown.
|
||
|
|
* 2. Si falta el fragmento de muestra, o es más viejo que el máster, lo
|
||
|
|
* genera.
|
||
|
|
* 3. Si no tiene duración escrita, la mide y la escribe.
|
||
|
|
*
|
||
|
|
* Corre solo antes de `npm run dev` y de `npm run build`, así que subir un tema
|
||
|
|
* desde el panel y arrancar basta. También se puede llamar a mano:
|
||
|
|
* `npm run medios`.
|
||
|
|
*
|
||
|
|
* Sin ffmpeg no falla: avisa de lo que no ha podido hacer y sigue. Un entorno
|
||
|
|
* de despliegue no lo necesita, porque las muestras van en el repositorio.
|
||
|
|
*/
|
||
|
|
|
||
|
|
import { spawnSync } from 'node:child_process';
|
||
|
|
import fs from 'node:fs';
|
||
|
|
import path from 'node:path';
|
||
|
|
import { fileURLToPath } from 'node:url';
|
||
|
|
import { load as leerYaml } from 'js-yaml';
|
||
|
|
|
||
|
|
const RAIZ = path.resolve(import.meta.dirname, '..');
|
||
|
|
const CANCIONES = path.join(RAIZ, 'src/content/canciones');
|
||
|
|
const MASTERS = path.join(RAIZ, 'media/audio');
|
||
|
|
const MUESTRAS = path.join(RAIZ, 'static/muestras');
|
||
|
|
|
||
|
|
/** Carpeta donde deja el panel lo que se sube; nada se queda aquí. */
|
||
|
|
const BANDEJA = 'subidas';
|
||
|
|
|
||
|
|
/** Fragmento de muestra: 40 s desde el segundo 30, con 4 s de fundido final. */
|
||
|
|
const MUESTRA = { desde: 30, dura: 40, fundido: 4 };
|
||
|
|
|
||
|
|
const avisos = [];
|
||
|
|
const hechos = [];
|
||
|
|
|
||
|
|
function ejecutar(programa, argumentos) {
|
||
|
|
const r = spawnSync(programa, argumentos, { encoding: 'utf8' });
|
||
|
|
if (r.error || r.status !== 0) return undefined;
|
||
|
|
return r.stdout.trim();
|
||
|
|
}
|
||
|
|
|
||
|
|
const hayFfmpeg = Boolean(ejecutar('ffprobe', ['-version']));
|
||
|
|
|
||
|
|
/** Duración en segundos de un archivo de audio. */
|
||
|
|
function medir(archivo) {
|
||
|
|
const salida = ejecutar('ffprobe', [
|
||
|
|
'-v',
|
||
|
|
'error',
|
||
|
|
'-show_entries',
|
||
|
|
'format=duration',
|
||
|
|
'-of',
|
||
|
|
'csv=p=0',
|
||
|
|
archivo
|
||
|
|
]);
|
||
|
|
const segundos = Number(salida);
|
||
|
|
return Number.isFinite(segundos) && segundos > 0 ? segundos : undefined;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Mismo criterio que `aSlug` en el esquema: sin tildes, minúsculas y guiones. */
|
||
|
|
export function aSlug(texto) {
|
||
|
|
return String(texto)
|
||
|
|
.normalize('NFD')
|
||
|
|
.replace(/\p{Diacritic}/gu, '')
|
||
|
|
.toLowerCase()
|
||
|
|
.replace(/[^a-z0-9]+/g, '-')
|
||
|
|
.replace(/^-+|-+$/g, '');
|
||
|
|
}
|
||
|
|
|
||
|
|
export function comoDuracion(segundos) {
|
||
|
|
const total = Math.round(segundos);
|
||
|
|
return `${Math.floor(total / 60)}:${String(total % 60).padStart(2, '0')}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Una clave está en la bandeja si su primer tramo es `subidas`. Se admite con
|
||
|
|
* barra inicial porque el panel puede escribirla así.
|
||
|
|
*/
|
||
|
|
export function enLaBandeja(clave) {
|
||
|
|
return clave.replace(/^\//, '').split('/')[0] === BANDEJA;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Dónde debería vivir el audio de un tema, según la convención: agrupado por
|
||
|
|
* disco, numerado como la pista, y `singles/` para los temas sueltos.
|
||
|
|
*/
|
||
|
|
export function claveCanonica(datos, slug, clave, sufijo) {
|
||
|
|
const carpeta = datos.album ? aSlug(datos.album) : 'singles';
|
||
|
|
const numero = datos.album && datos.numero ? `${String(datos.numero).padStart(2, '0')}-` : '';
|
||
|
|
const extension = path.extname(clave).toLowerCase() || '.mp3';
|
||
|
|
return `${carpeta}/${numero}${slug}${sufijo}${extension}`;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Mueve el máster dentro del almacén privado, sin pisar nada. */
|
||
|
|
function colocar(claveVieja, claveNueva) {
|
||
|
|
const origen = path.join(MASTERS, claveVieja.replace(/^\//, ''));
|
||
|
|
const destino = path.join(MASTERS, claveNueva);
|
||
|
|
|
||
|
|
if (!fs.existsSync(origen)) {
|
||
|
|
avisos.push(`${claveVieja}: el panel lo declara pero no está en media/audio/.`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
if (fs.existsSync(destino)) {
|
||
|
|
avisos.push(`${claveNueva}: ya existe, no se pisa. Borra uno de los dos y vuelve a lanzarlo.`);
|
||
|
|
return false;
|
||
|
|
}
|
||
|
|
|
||
|
|
fs.mkdirSync(path.dirname(destino), { recursive: true });
|
||
|
|
fs.renameSync(origen, destino);
|
||
|
|
hechos.push(`colocado ${claveVieja} -> ${claveNueva}`);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Los argumentos del recorte.
|
||
|
|
*
|
||
|
|
* `-ss` va **antes** de `-i`, y eso no es cosmético. Detrás de `-i` el corte se
|
||
|
|
* hace a la salida, pero el filtro sigue viendo los tiempos del original: un
|
||
|
|
* `afade` en el segundo 36 se aplicaría en el 36 de la canción, es decir a los
|
||
|
|
* seis de empezar el fragmento, y el resto saldría mudo. Delante de `-i` los
|
||
|
|
* tiempos se reinician en cero y el fundido cae donde debe.
|
||
|
|
*/
|
||
|
|
export function argumentosDeMuestra(master, muestra, desde, dura) {
|
||
|
|
const fundido = Math.max(0, dura - MUESTRA.fundido);
|
||
|
|
return [
|
||
|
|
'-v',
|
||
|
|
'error',
|
||
|
|
'-y',
|
||
|
|
'-ss',
|
||
|
|
String(desde),
|
||
|
|
'-t',
|
||
|
|
String(dura),
|
||
|
|
'-i',
|
||
|
|
master,
|
||
|
|
'-af',
|
||
|
|
`afade=t=out:st=${fundido}:d=${MUESTRA.fundido}`,
|
||
|
|
muestra
|
||
|
|
];
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Recorta el fragmento público si falta o si el máster es más nuevo. */
|
||
|
|
function generarMuestra(clave) {
|
||
|
|
const master = path.join(MASTERS, clave);
|
||
|
|
const muestra = path.join(MUESTRAS, clave);
|
||
|
|
|
||
|
|
if (!fs.existsSync(master)) {
|
||
|
|
avisos.push(`${clave}: no hay máster en media/audio/, no se puede generar la muestra.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (fs.existsSync(muestra) && fs.statSync(muestra).mtimeMs >= fs.statSync(master).mtimeMs) {
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
if (!hayFfmpeg) {
|
||
|
|
avisos.push(`${clave}: falta el fragmento de muestra y no hay ffmpeg para generarlo.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
|
||
|
|
// Un tema corto no da para empezar en el segundo 30: se recorta desde el
|
||
|
|
// principio y se toma lo que haya, para que la muestra no salga vacía.
|
||
|
|
const total = medir(master) ?? 0;
|
||
|
|
const desde = total > MUESTRA.desde + MUESTRA.dura ? MUESTRA.desde : 0;
|
||
|
|
const dura = Math.min(MUESTRA.dura, Math.max(1, total - desde));
|
||
|
|
|
||
|
|
fs.mkdirSync(path.dirname(muestra), { recursive: true });
|
||
|
|
const hecho = ejecutar('ffmpeg', argumentosDeMuestra(master, muestra, desde, dura));
|
||
|
|
|
||
|
|
if (hecho === undefined) {
|
||
|
|
avisos.push(`${clave}: ffmpeg no pudo generar la muestra.`);
|
||
|
|
return;
|
||
|
|
}
|
||
|
|
hechos.push(`muestra ${clave} (${dura} s desde ${desde})`);
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Sustituye el valor de un campo escalar del frontmatter sin tocar el resto del
|
||
|
|
* archivo. Se hace por líneas a propósito: volcar el YAML entero reordenaría
|
||
|
|
* campos y perdería el estilo de las comillas.
|
||
|
|
*/
|
||
|
|
function cambiarCampo(lineas, campo, valor, dentroDeVersion) {
|
||
|
|
const sangria = dentroDeVersion ? /^ {4}/ : /^/;
|
||
|
|
const patron = new RegExp(`^(${dentroDeVersion ? ' {4}' : ''})${campo}:\\s`);
|
||
|
|
const i = lineas.findIndex((l) => patron.test(l) && sangria.test(l));
|
||
|
|
if (i === -1) return false;
|
||
|
|
lineas[i] = lineas[i].replace(new RegExp(`^(\\s*${campo}:\\s*).*$`), `$1${valor}`);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
/** Inserta un campo justo detrás de otro, con su misma sangría. */
|
||
|
|
function insertarTras(lineas, referencia, campo, valor) {
|
||
|
|
const i = lineas.findIndex((l) => new RegExp(`^\\s*${referencia}:\\s`).test(l));
|
||
|
|
if (i === -1) return false;
|
||
|
|
const sangria = lineas[i].match(/^\s*/)[0];
|
||
|
|
lineas.splice(i + 1, 0, `${sangria}${campo}: '${valor}'`);
|
||
|
|
return true;
|
||
|
|
}
|
||
|
|
|
||
|
|
function procesar(archivo) {
|
||
|
|
const slug = path.basename(archivo, '.md');
|
||
|
|
const bruto = fs.readFileSync(path.join(CANCIONES, archivo), 'utf8');
|
||
|
|
const corte = bruto.indexOf('\n---', 4);
|
||
|
|
if (!bruto.startsWith('---') || corte === -1) return;
|
||
|
|
|
||
|
|
const cabecera = bruto.slice(4, corte);
|
||
|
|
const datos = leerYaml(cabecera) ?? {};
|
||
|
|
const lineas = cabecera.split('\n');
|
||
|
|
let tocado = false;
|
||
|
|
|
||
|
|
// --- El máster principal.
|
||
|
|
if (typeof datos.audio === 'string') {
|
||
|
|
let clave = datos.audio.replace(/^\//, '');
|
||
|
|
|
||
|
|
if (enLaBandeja(clave)) {
|
||
|
|
const nueva = claveCanonica(datos, slug, clave, '');
|
||
|
|
if (colocar(clave, nueva)) {
|
||
|
|
cambiarCampo(lineas, 'audio', nueva, false);
|
||
|
|
clave = nueva;
|
||
|
|
tocado = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
generarMuestra(clave);
|
||
|
|
|
||
|
|
if (!datos.duracion && hayFfmpeg) {
|
||
|
|
const segundos = medir(path.join(MASTERS, clave));
|
||
|
|
if (segundos && insertarTras(lineas, 'audio', 'duracion', comoDuracion(segundos))) {
|
||
|
|
hechos.push(`duración ${slug}: ${comoDuracion(segundos)}`);
|
||
|
|
tocado = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// --- Las alternativas musicales, con el mismo criterio.
|
||
|
|
for (const version of Array.isArray(datos.versiones) ? datos.versiones : []) {
|
||
|
|
if (typeof version?.audio !== 'string') continue;
|
||
|
|
let clave = version.audio.replace(/^\//, '');
|
||
|
|
|
||
|
|
if (enLaBandeja(clave)) {
|
||
|
|
const id = version.id ?? aSlug(version.nombre ?? 'version');
|
||
|
|
const nueva = claveCanonica(datos, slug, clave, `-${id}`);
|
||
|
|
if (colocar(clave, nueva)) {
|
||
|
|
const i = lineas.findIndex((l) => l.includes(version.audio));
|
||
|
|
if (i !== -1) {
|
||
|
|
lineas[i] = lineas[i].replace(version.audio, nueva);
|
||
|
|
clave = nueva;
|
||
|
|
tocado = true;
|
||
|
|
}
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
generarMuestra(clave);
|
||
|
|
}
|
||
|
|
|
||
|
|
if (tocado) {
|
||
|
|
fs.writeFileSync(
|
||
|
|
path.join(CANCIONES, archivo),
|
||
|
|
`---\n${lineas.join('\n')}${bruto.slice(corte)}`
|
||
|
|
);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
/**
|
||
|
|
* Solo actúa cuando se le llama desde la línea de órdenes. Importado —para
|
||
|
|
* comprobar la convención de nombres— no toca ningún archivo.
|
||
|
|
*/
|
||
|
|
export function principal() {
|
||
|
|
if (fs.existsSync(CANCIONES)) {
|
||
|
|
for (const archivo of fs.readdirSync(CANCIONES).filter((f) => f.endsWith('.md'))) {
|
||
|
|
procesar(archivo);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Un archivo en la bandeja que no reclama ninguna canción se quedaría ahí para
|
||
|
|
// siempre sin que nadie lo notara.
|
||
|
|
const bandeja = path.join(MASTERS, BANDEJA);
|
||
|
|
if (fs.existsSync(bandeja)) {
|
||
|
|
for (const suelto of fs.readdirSync(bandeja)) {
|
||
|
|
avisos.push(`${BANDEJA}/${suelto}: no lo reclama ninguna canción. ¿Falta su ficha?`);
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
for (const linea of hechos) console.log(` ${linea}`);
|
||
|
|
for (const aviso of avisos) console.warn(` aviso: ${aviso}`);
|
||
|
|
if (hechos.length === 0 && avisos.length === 0) console.log(' medios al día.');
|
||
|
|
}
|
||
|
|
|
||
|
|
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
|
||
|
|
principal();
|
||
|
|
}
|