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

368 lines
12 KiB

/**
* 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 generar la copia protegida
* completa de menor calidad ni medir la duración: eso es ffmpeg.
*
* Qué hace:
*
* 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. Recorre todos los másteres de `media/audio`, también los importados
* directamente como álbum, y genera a 96 kbps cualquier copia completa de
* escucha que falte o esté desactualizada.
* 3. Resume cada escucha en una forma de onda ligera, lista para dibujar sin
* descargar ni decodificar el audio en el navegador.
* 4. 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 si recibe ya preparado el almacén de escuchas.
*/
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';
import {
PERFIL_ESCUCHA,
PERFIL_ONDA,
argumentosDeEscucha,
argumentosDeOnda,
claveDeEscucha,
claveDeOnda,
resumirPicos
} from '../src/lib/procesado-audio.mjs';
export {
argumentosDeEscucha,
argumentosDeOnda,
claveDeEscucha,
claveDeOnda,
resumirPicos
} from '../src/lib/procesado-audio.mjs';
const RAIZ = path.resolve(import.meta.dirname, '..');
const CANCIONES = path.join(RAIZ, 'src/content/canciones');
const MASTERS = path.join(RAIZ, 'media/audio');
const ESCUCHAS = path.join(RAIZ, 'media/escucha');
const ONDAS = path.join(RAIZ, 'static/ondas');
/** Carpeta donde deja el panel lo que se sube; nada se queda aquí. */
const BANDEJA = 'subidas';
const EXTENSIONES_AUDIO = new Set(['.wav', '.mp3', '.m4a', '.flac', '.aac', '.ogg']);
/** Perfil audible pero claramente inferior al archivo entregado al comprar. */
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();
}
/** ffmpeg escribe el PCM por stdout; aquí no se puede convertir a texto. */
function ejecutarBinario(programa, argumentos) {
const r = spawnSync(programa, argumentos, {
encoding: null,
// A 8 kHz, una hora en mono ocupa 57,6 MB. Las canciones reales quedan
// muy por debajo, pero el margen evita el límite de 1 MB de spawnSync.
maxBuffer: 64 * 1024 * 1024
});
if (r.error || r.status !== 0) return undefined;
return r.stdout;
}
const hayFfmpeg = Boolean(ejecutar('ffprobe', ['-version']) && ejecutar('ffmpeg', ['-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;
}
/** Genera la canción protegida de escucha si falta o si el máster es más nuevo. */
function generarEscucha(clave) {
const master = path.join(MASTERS, clave);
const escucha = path.join(ESCUCHAS, claveDeEscucha(clave));
if (!fs.existsSync(master)) {
avisos.push(`${clave}: no hay máster en media/audio/, no se puede generar la escucha.`);
return;
}
if (fs.existsSync(escucha) && fs.statSync(escucha).mtimeMs >= fs.statSync(master).mtimeMs) {
return;
}
if (!hayFfmpeg) {
avisos.push(`${clave}: falta la copia de escucha y no hay ffmpeg para generarla.`);
return;
}
fs.mkdirSync(path.dirname(escucha), { recursive: true });
const hecho = ejecutar('ffmpeg', argumentosDeEscucha(master, escucha));
if (hecho === undefined) {
avisos.push(`${clave}: ffmpeg no pudo generar la escucha completa.`);
return;
}
hechos.push(
`escucha ${claveDeEscucha(clave)} (${PERFIL_ESCUCHA.bitrateKbps} kbps, ${PERFIL_ESCUCHA.id})`
);
}
/** `true` si el resumen existe, corresponde al perfil actual y no está viejo. */
function ondaVigente(onda, escucha) {
if (!fs.existsSync(onda) || fs.statSync(onda).mtimeMs < fs.statSync(escucha).mtimeMs)
return false;
try {
const datos = JSON.parse(fs.readFileSync(onda, 'utf8'));
return (
datos.perfil === PERFIL_ONDA.id &&
Array.isArray(datos.picos) &&
datos.picos.length === PERFIL_ONDA.picos &&
datos.picos.every(
(valor) => Number.isInteger(valor) && Number(valor) >= 0 && Number(valor) <= 255
)
);
} catch {
return false;
}
}
/** Genera el resumen de la misma copia de baja calidad que oirá la persona. */
function generarOnda(clave) {
const escucha = path.join(ESCUCHAS, claveDeEscucha(clave));
const relativa = claveDeOnda(clave);
const onda = path.join(ONDAS, relativa);
if (!fs.existsSync(escucha)) {
avisos.push(`${clave}: falta la escucha completa; no se puede generar su onda.`);
return;
}
if (ondaVigente(onda, escucha)) return;
if (!hayFfmpeg) {
avisos.push(`${clave}: falta la onda y no hay ffmpeg para generarla.`);
return;
}
const pcm = ejecutarBinario('ffmpeg', argumentosDeOnda(escucha));
if (!pcm) {
avisos.push(`${clave}: ffmpeg no pudo analizar la forma de onda.`);
return;
}
fs.mkdirSync(path.dirname(onda), { recursive: true });
fs.writeFileSync(
onda,
`${JSON.stringify({ perfil: PERFIL_ONDA.id, picos: resumirPicos(pcm) })}\n`
);
hechos.push(`onda ${relativa} (${PERFIL_ONDA.picos} picos)`);
}
/** Todos los másteres reales, sin confundir la bandeja de entrada con catálogo. */
export function recorrerMasters(directorio = MASTERS) {
if (!fs.existsSync(directorio)) return [];
return fs.readdirSync(directorio, { withFileTypes: true }).flatMap((entrada) => {
if (entrada.name === BANDEJA) return [];
const absoluta = path.join(directorio, entrada.name);
if (entrada.isDirectory()) return recorrerMasters(absoluta);
return EXTENSIONES_AUDIO.has(path.extname(entrada.name).toLowerCase()) ? [absoluta] : [];
});
}
/**
* 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;
}
}
generarEscucha(clave);
generarOnda(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;
}
}
}
generarEscucha(clave);
generarOnda(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);
}
}
for (const master of recorrerMasters()) {
const clave = path.relative(MASTERS, master).split(path.sep).join('/');
generarEscucha(clave);
generarOnda(clave);
}
// 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();
}

Powered by TurnKey Linux.