/** * 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();