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.
309 lines
10 KiB
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();
|