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