Los datos del género —compás, metro, tempo, rima— pasan de una lista de definiciones apretada en una columna estrecha a la derecha a una franja a todo lo ancho encima del texto. El compás y la métrica de un género se leen juntos o no se leen, y en la lateral quedaban en letra pequeña con el texto escorado. La estructura, la temática y los rasgos bajan debajo de la prosa como bloques a lo ancho: la temática en rejilla —son etiquetas disfrazadas de frase— y los rasgos en lista, que son frases de verdad. Es el sitio donde entrarán los instrumentos, los letristas, las obras de referencia y el linaje cuando tengan datos. Y el volcado de medios: 55 archivos a la tabla `medio` con su huella, su peso y su duración MEDIDA, no escrita. De ahí salen dos cosas que no se veían: el mismo audio está copiado bajo doce nombres distintos, y las duraciones del catálogo dicen 252 segundos donde el archivo dura 6. La clave de un medio lleva su almacén delante, porque `singles/dorina.mp3` existe en audio y en muestras y sin eso serían la misma fila. Es además la ruta con la que se servirán desde m.senzapaura.es/archives. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
54df820056
commit
e0a979bf5d
@ -0,0 +1,308 @@
|
||||
/**
|
||||
* 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();
|
||||
Loading…
Reference in new issue