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.
491 lines
16 KiB
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();
|