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

309 lines
10 KiB

/**
* Vuelca los archivos —audio, muestras y portadas— a la tabla `medio` y los
* enlaza con quien los usa.
*
* npm run db:medios
* npm run db:medios -- --seco
*
* Va aparte del volcado del catálogo porque no depende de él en un sentido
* importante: **estos datos no salen de los archivos de contenido, salen de los
* archivos de verdad**. El tamaño, la huella y la duración se miden; nadie los
* escribe. Hasta ahora una canción decía durar «4:12» porque alguien lo tecleó,
* y si el mp3 duraba otra cosa nadie se enteraba.
*
* Lo que no encuentra, lo dice. Un audio referenciado que no existe es
* exactamente lo que hoy no se ve hasta que alguien le da al play.
*/
import { createHash } from 'node:crypto';
import { execFileSync } from 'node:child_process';
import fs from 'node:fs';
import path from 'node:path';
import pg from 'pg';
const SECO = process.argv.includes('--seco');
/** Dónde vive cada clase de archivo y si se puede pedir por URL. */
const ALMACENES = [
{ clase: 'audio', raiz: 'media/audio', publico: false, carpeta: 'audio', prefijo: '' },
{
clase: 'muestra',
raiz: 'static/muestras',
publico: true,
carpeta: 'muestras',
prefijo: '/muestras/'
},
{ clase: 'imagen', raiz: 'static/images', publico: true, carpeta: 'images', prefijo: '/images/' }
];
const MIMES = {
'.wav': 'audio/wav',
'.mp3': 'audio/mpeg',
'.m4a': 'audio/mp4',
'.flac': 'audio/flac',
'.svg': 'image/svg+xml',
'.png': 'image/png',
'.jpg': 'image/jpeg',
'.jpeg': 'image/jpeg',
'.webp': 'image/webp',
'.avif': 'image/avif'
};
function recorrer(dir) {
if (!fs.existsSync(dir)) return [];
return fs.readdirSync(dir, { withFileTypes: true }).flatMap((e) => {
const completa = path.join(dir, e.name);
return e.isDirectory() ? recorrer(completa) : [completa];
});
}
/**
* Duración en segundos, medida con ffprobe.
*
* Medida y no escrita: es el dato que más fácil se queda viejo. Si ffprobe no
* está, se deja en nulo y se avisa; no se inventa.
*/
function duracionDe(ruta) {
try {
const salida = execFileSync(
'ffprobe',
['-v', 'error', '-show_entries', 'format=duration', '-of', 'csv=p=0', ruta],
{ encoding: 'utf8', timeout: 20_000 }
);
const segundos = Number.parseFloat(salida.trim());
return Number.isFinite(segundos) ? Math.round(segundos) : null;
} catch {
return null;
}
}
/**
* Medidas de una imagen.
*
* En los SVG salen del `viewBox` o de los atributos, que es donde están; para
* el resto haría falta descodificar, y hoy todas las portadas son SVG.
*/
function medidasDe(ruta) {
if (path.extname(ruta).toLowerCase() !== '.svg') return { ancho: null, alto: null };
const texto = fs.readFileSync(ruta, 'utf8').slice(0, 2000);
const caja = /viewBox\s*=\s*["']\s*[\d.-]+\s+[\d.-]+\s+([\d.]+)\s+([\d.]+)/.exec(texto);
if (caja) return { ancho: Math.round(+caja[1]), alto: Math.round(+caja[2]) };
const ancho = /\bwidth\s*=\s*["'](\d+)/.exec(texto);
const alto = /\bheight\s*=\s*["'](\d+)/.exec(texto);
return { ancho: ancho ? +ancho[1] : null, alto: alto ? +alto[1] : null };
}
/* -------------------------------------------------------------------------
* Recoger
* ---------------------------------------------------------------------- */
const encontrados = [];
const avisos = [];
for (const almacen of ALMACENES) {
for (const ruta of recorrer(almacen.raiz)) {
const extension = path.extname(ruta).toLowerCase();
const mime = MIMES[extension];
if (!mime) {
avisos.push(`no sé qué es ${ruta}, lo dejo fuera`);
continue;
}
const contenido = fs.readFileSync(ruta);
const relativa = path.relative(almacen.raiz, ruta).split(path.sep).join('/');
const esImagen = mime.startsWith('image/');
encontrados.push({
clase: almacen.clase,
// La clave lleva el almacen delante: `singles/dorina.mp3` existe en audio
// y en muestras, y sin el prefijo serian la misma fila. Ademas es la ruta
// con la que se serviran desde m.senzapaura.es/archives.
clave: almacen.carpeta + '/' + relativa,
url: almacen.prefijo + relativa,
publico: almacen.publico,
mime,
bytes: contenido.length,
sha256: createHash('sha256').update(contenido).digest('hex'),
duracionS: esImagen ? null : duracionDe(ruta),
...(esImagen ? medidasDe(ruta) : { ancho: null, alto: null })
});
}
}
console.log(`Encontrados ${encontrados.length} archivos\n`);
for (const almacen of ALMACENES) {
const suyos = encontrados.filter((m) => m.clase === almacen.clase);
const pesan = suyos.reduce((n, m) => n + m.bytes, 0);
console.log(
` ${almacen.clase.padEnd(9)} ${String(suyos.length).padStart(3)} archivos · ${(pesan / 1048576).toFixed(1)} MB`
);
}
const sinDuracion = encontrados.filter((m) => m.clase !== 'imagen' && m.duracionS === null);
if (sinDuracion.length) {
console.log(`\n ${sinDuracion.length} sin duración medida (¿falta ffprobe?)`);
}
/* Archivos repetidos: la huella los delata aunque tengan otro nombre. */
const porHuella = new Map();
for (const m of encontrados) {
porHuella.set(m.sha256, [...(porHuella.get(m.sha256) ?? []), m.clave]);
}
for (const [, claves] of porHuella) {
if (claves.length > 1) avisos.push(`mismo contenido en: ${claves.join(', ')}`);
}
if (SECO) {
for (const aviso of avisos) console.log(` aviso: ${aviso}`);
console.log('\n(en seco: no se ha tocado la base)');
process.exit(0);
}
/* -------------------------------------------------------------------------
* Escribir y enlazar
* ---------------------------------------------------------------------- */
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 });
await c.connect();
await c.query('BEGIN');
try {
/*
* Se sueltan los enlaces antes de vaciar la tabla. Las columnas son
* `ON DELETE set null`, así que Postgres lo haría solo, pero decirlo aquí
* deja claro que el volcado no pierde nada: lo que se rehace es la fila del
* archivo, no la canción.
*/
await c.query('UPDATE cancion SET audio_id = NULL, muestra_id = NULL, portada_id = NULL');
await c.query('UPDATE album SET portada_id = NULL');
await c.query('DELETE FROM medio');
const idPorClave = new Map();
for (const m of encontrados) {
const id = crypto.randomUUID();
await c.query(
`INSERT INTO medio (id, clase, clave, publico, mime, bytes, duracion_s, ancho, alto, sha256)
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10)`,
[id, m.clase, m.clave, m.publico, m.mime, m.bytes, m.duracionS, m.ancho, m.alto, m.sha256]
);
idPorClave.set(`${m.clase}:${m.clave}`, id);
if (m.clase === 'imagen') idPorClave.set(`url:${m.url}`, id);
}
console.log(`\n ${encontrados.length} filas en medio`);
/* --- Enlazar con lo que los usa ---------------------------------------
*
* Se releen los archivos de contenido para saber qué apunta a qué. El
* volcado del catálogo no guardó esas rutas: guardarlas como texto habría
* sido repetir el problema que esta tabla viene a resolver.
*/
const enlazados = { audio: 0, muestra: 0, portadaCancion: 0, portadaAlbum: 0 };
const huerfanos = [];
const canciones = fs
.readdirSync('src/content/canciones')
.filter((n) => n.endsWith('.md'))
.map((n) => ({
slug: n.replace(/\.md$/, ''),
texto: fs.readFileSync(path.join('src/content/canciones', n), 'utf8')
}));
for (const { slug, texto } of canciones) {
const audio = /^audio:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
const portada = /^portada:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
if (audio) {
const id = idPorClave.get(`audio:audio/${audio}`);
if (id) {
await c.query('UPDATE cancion SET audio_id = $1 WHERE slug = $2', [id, slug]);
enlazados.audio++;
} else {
huerfanos.push(`${slug}: el audio ${audio} no existe`);
}
/*
* La muestra se deduce del nombre del audio: es la convención que ya usa
* `scripts/medios.mjs` al generarlas. Si algún día deja de serlo, esto
* se queda sin encontrar nada y lo dirá, que es lo que se quiere.
*/
const muestra = audio.replace(/\.[^.]+$/, '.mp3');
const idMuestra = idPorClave.get(`muestra:muestras/${muestra}`);
if (idMuestra) {
await c.query('UPDATE cancion SET muestra_id = $1 WHERE slug = $2', [idMuestra, slug]);
enlazados.muestra++;
}
}
if (portada) {
const id = idPorClave.get(`url:${portada}`);
if (id) {
await c.query('UPDATE cancion SET portada_id = $1 WHERE slug = $2', [id, slug]);
enlazados.portadaCancion++;
} else {
huerfanos.push(`${slug}: la portada ${portada} no existe`);
}
}
}
for (const nombre of fs.readdirSync('src/content/albumes').filter((n) => n.endsWith('.md'))) {
const slug = nombre.replace(/\.md$/, '');
const texto = fs.readFileSync(path.join('src/content/albumes', nombre), 'utf8');
const portada = /^portada:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
if (!portada) continue;
const id = idPorClave.get(`url:${portada}`);
if (id) {
await c.query('UPDATE album SET portada_id = $1 WHERE slug = $2', [id, slug]);
enlazados.portadaAlbum++;
} else {
huerfanos.push(`${slug}: la portada ${portada} no existe`);
}
}
console.log(
` enlazados: ${enlazados.audio} audios, ${enlazados.muestra} muestras, ` +
`${enlazados.portadaCancion} portadas de tema, ${enlazados.portadaAlbum} de disco`
);
/* --- Y la duración medida contra la escrita a mano --------------------- */
const { rows: desajustes } = await c.query(`
SELECT c.slug, c.duracion_s AS escrita, m.duracion_s AS medida
FROM cancion c JOIN medio m ON m.id = c.audio_id
WHERE m.duracion_s IS NOT NULL AND abs(c.duracion_s - m.duracion_s) > 2
ORDER BY abs(c.duracion_s - m.duracion_s) DESC`);
await c.query('COMMIT');
for (const aviso of avisos) console.log(` aviso: ${aviso}`);
for (const h of huerfanos) console.log(` falta: ${h}`);
if (desajustes.length) {
console.log(`\n La duración escrita a mano no coincide con la del archivo:`);
for (const d of desajustes) {
console.log(` ${d.slug.padEnd(24)} dice ${d.escrita} s, dura ${d.medida} s`);
}
} else {
console.log('\n Las duraciones escritas coinciden con las de los archivos.');
}
console.log('\nMedios volcados.');
} catch (fallo) {
await c.query('ROLLBACK').catch(() => {});
console.error('\nFalló, no se ha escrito nada:\n ' + fallo.message);
await c.end();
process.exit(1);
}
await c.end();

Powered by TurnKey Linux.