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

491 lines
16 KiB

/**
* Vuelca másteres, copias completas de escucha y portadas a
* `medio`, y reconstruye sus relaciones.
*
* 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';
import { load as leerYaml } from 'js-yaml';
import { PERFIL_ESCUCHA } from '../config/audio.mjs';
const SECO = process.argv.includes('--seco');
/** Dónde vive cada clase de archivo y si se puede pedir por URL. */
const ALMACENES = [
{
nombre: 'máster',
clase: 'audio',
raiz: 'media/audio',
publico: false,
carpeta: 'audio',
prefijo: ''
},
{
nombre: 'escucha',
clase: 'audio',
raiz: 'media/escucha',
publico: false,
carpeta: 'escucha',
prefijo: ''
},
{
nombre: 'imagen',
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];
});
}
const aSlug = (texto) =>
String(texto ?? '')
.normalize('NFD')
.replace(/\p{Diacritic}/gu, '')
.toLowerCase()
.replace(/[^a-z0-9]+/g, '-')
.replace(/^-+|-+$/g, '');
/**
* 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 datosDeAudio(ruta) {
try {
const salida = execFileSync(
'ffprobe',
[
'-v',
'error',
'-select_streams',
'a:0',
'-show_entries',
'format=duration,bit_rate:stream=sample_rate,channels',
'-of',
'json',
ruta
],
{ encoding: 'utf8', timeout: 20_000 }
);
const datos = JSON.parse(salida);
const segundos = Number.parseFloat(datos.format?.duration);
const bitrate = Number.parseInt(datos.format?.bit_rate, 10);
const frecuencia = Number.parseInt(datos.streams?.[0]?.sample_rate, 10);
const canales = Number.parseInt(datos.streams?.[0]?.channels, 10);
return {
duracionS: Number.isFinite(segundos) ? Math.round(segundos) : null,
bitrateKbps: Number.isFinite(bitrate) ? Math.round(bitrate / 1000) : null,
frecuenciaHz: Number.isFinite(frecuencia) ? frecuencia : null,
canales: Number.isFinite(canales) ? canales : null
};
} catch {
return { duracionS: null, bitrateKbps: null, frecuenciaHz: null, canales: 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/');
const audio = esImagen
? { duracionS: null, bitrateKbps: null, frecuenciaHz: null, canales: null }
: datosDeAudio(ruta);
encontrados.push({
almacen: almacen.nombre,
clase: almacen.clase,
// La clave lleva el almacén delante: el máster y la escucha comparten
// ruta relativa, pero son representaciones y archivos distintos.
clave: almacen.carpeta + '/' + relativa,
url: almacen.prefijo + relativa,
publico: almacen.publico,
mime,
bytes: contenido.length,
sha256: createHash('sha256').update(contenido).digest('hex'),
...audio,
...(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.almacen === almacen.nombre);
const pesan = suyos.reduce((n, m) => n + m.bytes, 0);
console.log(
` ${almacen.nombre.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 {
const idPorClave = new Map();
const medioPorClave = new Map();
for (const m of encontrados) {
const id = crypto.randomUUID();
const { rows } = 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)
ON CONFLICT (clave) DO UPDATE SET
clase = excluded.clase,
publico = excluded.publico,
mime = excluded.mime,
bytes = excluded.bytes,
duracion_s = excluded.duracion_s,
ancho = excluded.ancho,
alto = excluded.alto,
sha256 = excluded.sha256
RETURNING id`,
[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}`, rows[0].id);
medioPorClave.set(`${m.clase}:${m.clave}`, m);
if (m.clase === 'imagen') idPorClave.set(`url:${m.url}`, rows[0].id);
}
console.log(`\n ${encontrados.length} medios insertados o actualizados`);
/* --- 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 = { master: 0, stream: 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 cabecera = /^---\r?\n([\s\S]*?)\r?\n---/.exec(texto)?.[1];
const datos = cabecera ? (leerYaml(cabecera) ?? {}) : {};
const portada = /^portada:\s*(.+)$/m
.exec(texto)?.[1]
.trim()
.replace(/^['"]|['"]$/g, '');
const audios = [
{ slug, audio: datos.audio },
...(Array.isArray(datos.versiones)
? datos.versiones.map((version) => ({
slug: `${slug}-${version.id ?? aSlug(version.nombre)}`,
audio: version.audio
}))
: [])
];
for (const entidad of audios) {
const { rows: destinos } = await c.query('SELECT id FROM cancion WHERE slug = $1', [
entidad.slug
]);
if (destinos.length === 0) {
huerfanos.push(`${entidad.slug}: la canción no existe en la base`);
continue;
}
await c.query('DELETE FROM cancion_audio WHERE cancion_id = $1', [destinos[0].id]);
const audio =
typeof entidad.audio === 'string' ? entidad.audio.replace(/^\//, '') : undefined;
if (!audio) continue;
const id = idPorClave.get(`audio:audio/${audio}`);
if (id) {
const master = medioPorClave.get(`audio:audio/${audio}`);
await c.query(
`INSERT INTO cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps, frecuencia_hz, canales)
SELECT id, $1, 'master', $2, $3, $4, $5 FROM cancion WHERE slug = $6
ON CONFLICT DO NOTHING`,
[
id,
path.extname(audio).slice(1).toLowerCase() || 'audio',
master?.bitrateKbps ?? null,
master?.frecuenciaHz ?? null,
master?.canales ?? null,
entidad.slug
]
);
enlazados.master++;
} else {
huerfanos.push(`${entidad.slug}: el audio ${audio} no existe`);
}
const escucha = audio.replace(/\.[^.]+$/, '.mp3');
const idEscucha = idPorClave.get(`audio:escucha/${escucha}`);
if (idEscucha) {
const stream = medioPorClave.get(`audio:escucha/${escucha}`);
await c.query(
`INSERT INTO cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps, frecuencia_hz, canales)
SELECT id, $1, 'stream', 'mp3', $2, $3, $4 FROM cancion WHERE slug = $5
ON CONFLICT DO NOTHING`,
[
idEscucha,
stream?.bitrateKbps ?? PERFIL_ESCUCHA.bitrateKbps,
stream?.frecuenciaHz ?? null,
stream?.canales ?? null,
entidad.slug
]
);
enlazados.stream++;
} else {
huerfanos.push(`${entidad.slug}: falta la escucha completa ${escucha}`);
}
}
await c.query('UPDATE cancion SET portada_id = NULL WHERE slug = $1', [slug]);
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`);
}
}
}
/*
* Los álbumes importados desde una carpeta no tienen ficha Markdown. Se
* enlazan por la convención canónica de archivos, no por una segunda FK en
* `cancion`: una representación solo vive en `cancion_audio`.
*/
const { rows: pendientes } = await c.query(`
SELECT c.id, c.slug, c.numero, a.slug AS album_slug,
NOT EXISTS (
SELECT 1 FROM cancion_audio ca
WHERE ca.cancion_id = c.id AND ca.uso = 'master'
) AS falta_master,
NOT EXISTS (
SELECT 1 FROM cancion_audio ca
WHERE ca.cancion_id = c.id AND ca.uso = 'stream'
) AS falta_stream
FROM cancion c
LEFT JOIN album a ON a.id = c.album_id
WHERE NOT EXISTS (
SELECT 1 FROM cancion_audio ca
WHERE ca.cancion_id = c.id AND ca.uso = 'master'
) OR NOT EXISTS (
SELECT 1 FROM cancion_audio ca
WHERE ca.cancion_id = c.id AND ca.uso = 'stream'
)
`);
for (const pendiente of pendientes) {
const numero = pendiente.numero ? `${String(pendiente.numero).padStart(2, '0')}-` : '';
const base = pendiente.album_slug
? `${pendiente.album_slug}/${numero}${pendiente.slug}`
: `singles/${pendiente.slug}`;
const master = encontrados.find(
(m) => m.almacen === 'máster' && m.clave.replace(/\.[^/.]+$/, '') === `audio/${base}`
);
if (pendiente.falta_master) {
const idMaster = master ? idPorClave.get(`audio:${master.clave}`) : undefined;
if (master && idMaster) {
await c.query(
`INSERT INTO cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps, frecuencia_hz, canales)
VALUES ($1, $2, 'master', $3, $4, $5, $6)
ON CONFLICT DO NOTHING`,
[
pendiente.id,
idMaster,
path.extname(master.clave).slice(1).toLowerCase() || 'audio',
master.bitrateKbps,
master.frecuenciaHz,
master.canales
]
);
enlazados.master++;
} else {
huerfanos.push(`${pendiente.slug}: falta el máster ${base}`);
}
}
if (pendiente.falta_stream) {
const claveEscucha = `escucha/${base}.mp3`;
const idEscucha = idPorClave.get(`audio:${claveEscucha}`);
const stream = medioPorClave.get(`audio:${claveEscucha}`);
if (idEscucha) {
await c.query(
`INSERT INTO cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps, frecuencia_hz, canales)
VALUES ($1, $2, 'stream', 'mp3', $3, $4, $5)
ON CONFLICT DO NOTHING`,
[
pendiente.id,
idEscucha,
stream?.bitrateKbps ?? PERFIL_ESCUCHA.bitrateKbps,
stream?.frecuenciaHz ?? null,
stream?.canales ?? null
]
);
enlazados.stream++;
} else {
huerfanos.push(`${pendiente.slug}: falta la escucha completa ${base}.mp3`);
}
}
}
const albumesDeContenido = fs.existsSync('src/content/albumes')
? fs.readdirSync('src/content/albumes').filter((n) => n.endsWith('.md'))
: [];
for (const nombre of albumesDeContenido) {
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, '');
await c.query('UPDATE album SET portada_id = NULL WHERE slug = $1', [slug]);
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.master} másteres, ${enlazados.stream} escuchas, ` +
`${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 cancion_audio ca ON ca.cancion_id = c.id AND ca.uso = 'master'
JOIN medio m ON m.id = ca.medio_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.