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/album/medios.mjs

294 lines
9.1 KiB

/**
* Coloca el audio y las portadas de un álbum, y los ata a sus canciones.
*
* npm run album:medios -- "S:\\senza paura\\album - cupido-sin-flechas"
* npm run album:medios -- <carpeta> --escribir
*
* **Por defecto no toca nada**: dice lo que haría y se para.
*
* El importador mete el texto; esto mete los archivos. Van aparte porque son
* dos cosas distintas: el texto son unos kilobytes que caben en una
* transacción, y esto son cien megas de másteres que se copian y se convierten.
* Y porque el texto puede entrar hoy y los archivos la semana
* que viene.
*
* ## Dónde acaba cada cosa
*
* media/audio/<disco>/<NN>-<slug>.mp3 el máster; privado, fuera de git
* media/escucha/<disco>/<NN>-<slug>.mp3 la copia completa reducida; privada
* static/ondas/<disco>/<NN>-<slug>.json la forma de onda
* static/images/<disco>/<slug>.jpg la portada
*
* La ruta relativa del máster manda. La escucha y la onda repiten esa estructura
* con su extensión técnica, así que no hay tres nombres susceptibles de divergir.
*
* ## La portada
*
* Casi todas las que hay son miniaturas de YouTube, 16:9. Se recorta el
* cuadrado del centro, que es lo único que se puede hacer sin inventar: si el
* título del vídeo va escrito a los lados, se corta. Sale un aviso por cada una
* que venga de una miniatura, para poder mirarlas.
*/
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 { leerAlbum } from './carpeta.mjs';
import { PERFIL_ESCUCHA } from '../config/audio.mjs';
const CARPETA = process.argv[2];
const ESCRIBIR = process.argv.includes('--escribir');
if (!CARPETA || !fs.existsSync(CARPETA)) {
console.error('uso: npm run album:medios -- "<carpeta>" [--escribir]');
process.exit(1);
}
const LADO = 1000;
/* -------------------------------------------------------------------------
* Herramientas
* ---------------------------------------------------------------------- */
const ffmpeg = (args) => execFileSync('ffmpeg', ['-v', 'error', '-y', ...args], { stdio: 'pipe' });
function ffprobe(archivo, entradas, flujo) {
return execFileSync(
'ffprobe',
[
'-v',
'error',
...(flujo ? ['-select_streams', flujo] : []),
'-show_entries',
entradas,
'-of',
'default=nw=1:nk=1',
archivo
],
{ encoding: 'utf8' }
)
.trim()
.split(/\r?\n/);
}
const segundosDe = (archivo) => Math.round(Number(ffprobe(archivo, 'format=duration')[0]));
const tamanoDe = (archivo) => ffprobe(archivo, 'stream=width,height', 'v:0').map(Number);
const sha256 = (archivo) => createHash('sha256').update(fs.readFileSync(archivo)).digest('hex');
const asegurar = (destino) => fs.mkdirSync(path.dirname(destino), { recursive: true });
/* -------------------------------------------------------------------------
* Qué se va a hacer
* ---------------------------------------------------------------------- */
const album = leerAlbum(CARPETA);
const disco = album.slug;
/** Dos dígitos, como en la convención de `media/LEEME.md`. */
const dosDigitos = (n) => String(n).padStart(2, '0');
const trabajo = album.canciones.map((c, i) => {
const origen = path.join(CARPETA, c.carpeta);
const numero = dosDigitos(i + 1);
return {
...c,
numero: i + 1,
masterOrigen: c.audio.mp3 ? path.join(origen, c.audio.mp3) : null,
claveAudio: `audio/${disco}/${numero}-${c.slug}.mp3`,
claveEscucha: `escucha/${disco}/${numero}-${c.slug}.mp3`,
claveImagen: `images/${disco}/${c.slug}.jpg`,
imagenOrigen: c.portada?.ruta ?? null,
/* Una miniatura de YouTube es 16:9: al cuadrarla se corta por los lados. */
esMiniatura: c.portada?.recortar ?? false
};
});
console.log(`\n${album.titulo} · ${trabajo.length} temas\n`);
for (const t of trabajo) {
const audio = t.masterOrigen ? `${segundosDe(t.masterOrigen)}s` : 'SIN MÁSTER';
const img = t.imagenOrigen
? `${tamanoDe(t.imagenOrigen).join('x')}${t.esMiniatura ? ' (miniatura 16:9, se recorta)' : ''}`
: 'SIN PORTADA';
console.log(` ${dosDigitos(t.numero)} ${t.titulo.padEnd(30)} ${audio.padStart(6)} ${img}`);
}
const recortadas = trabajo.filter((t) => t.esMiniatura).length;
if (recortadas > 0) {
console.log(
`\n${recortadas} portadas salen de una miniatura de YouTube y se recortan al centro.`
);
console.log('Míralas después: si el título del vídeo iba a los lados, se habrá cortado.');
}
if (!ESCRIBIR) {
console.log('\nEnsayo. No se ha copiado ni escrito nada. Añade --escribir.');
process.exit(0);
}
/* -------------------------------------------------------------------------
* Hacerlo
* ---------------------------------------------------------------------- */
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 });
await c.connect();
/** Alta o actualización de un medio por su clave, que es única de hecho. */
async function ponerMedio(clase, clave, archivo, extra = {}) {
const { rows } = await c.query(
`insert into medio (id, clase, clave, publico, mime, bytes, sha256, duracion_s, ancho, alto, alt)
values (gen_random_uuid(), $1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
on conflict (clave) do update set
bytes = excluded.bytes, sha256 = excluded.sha256,
duracion_s = excluded.duracion_s, ancho = excluded.ancho, alto = excluded.alto
returning id`,
[
clase,
clave,
extra.publico ?? false,
extra.mime,
fs.statSync(archivo).size,
sha256(archivo),
extra.duracion ?? null,
extra.ancho ?? null,
extra.alto ?? null,
extra.alt ?? null
]
);
return rows[0].id;
}
let audios = 0;
let imagenes = 0;
for (const t of trabajo) {
if (t.masterOrigen) {
const master = path.join('media', 'audio', disco, path.basename(t.claveAudio));
asegurar(master);
fs.copyFileSync(t.masterOrigen, master);
const escucha = path.join('media', 'escucha', disco, path.basename(t.claveEscucha));
asegurar(escucha);
ffmpeg([
'-i',
master,
'-vn',
'-map_metadata',
'-1',
'-codec:a',
PERFIL_ESCUCHA.codecFfmpeg,
'-b:a',
`${PERFIL_ESCUCHA.bitrateKbps}k`,
escucha
]);
const duracion = segundosDe(master);
const idAudio = await ponerMedio('audio', t.claveAudio, master, {
mime: 'audio/mpeg',
duracion
});
const idEscucha = await ponerMedio('audio', t.claveEscucha, escucha, {
mime: 'audio/mpeg',
duracion: segundosDe(escucha)
});
await c.query(
`update cancion set duracion_s = coalesce(duracion_s, $2)
where slug = $1`,
[t.slug, duracion]
);
await c.query(
`insert into cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps)
select id, $2, $3, 'mp3', $4 from cancion where slug = $1
on conflict (cancion_id, uso, formato) do update set
medio_id = excluded.medio_id`,
[t.slug, idAudio, 'master', null]
);
await c.query(
`insert into cancion_audio
(cancion_id, medio_id, uso, formato, bitrate_kbps)
select id, $2, 'stream', 'mp3', $3 from cancion where slug = $1
on conflict (cancion_id, uso, formato) do update set
medio_id = excluded.medio_id, bitrate_kbps = excluded.bitrate_kbps`,
[t.slug, idEscucha, PERFIL_ESCUCHA.bitrateKbps]
);
audios++;
}
if (t.imagenOrigen) {
const imagen = path.join('static', 'images', disco, path.basename(t.claveImagen));
asegurar(imagen);
// El cuadrado del centro, y a mil píxeles: es lo que pide la tarjeta.
ffmpeg([
'-i',
t.imagenOrigen,
'-vf',
`crop='min(iw,ih)':'min(iw,ih)',scale=${LADO}:${LADO}`,
'-q:v',
'3',
imagen
]);
const idImagen = await ponerMedio('imagen', t.claveImagen, imagen, {
mime: 'image/jpeg',
publico: true,
ancho: LADO,
alto: LADO,
alt: `Portada de ${t.titulo}`
});
await c.query('update cancion set portada_id = $2 where slug = $1', [t.slug, idImagen]);
imagenes++;
}
}
/*
* La portada del disco: la del primer tema que tenga una cuadrada de verdad, y
* si no la del primero que tenga cualquiera. Un disco sin portada sale con el
* hueco de la inicial, que es correcto pero no es una portada.
*/
const paraElDisco =
trabajo.find((t) => t.imagenOrigen && !t.esMiniatura) ?? trabajo.find((t) => t.imagenOrigen);
if (paraElDisco) {
const clave = `images/${disco}/${disco}.jpg`;
const destino = path.join('static', 'images', disco, `${disco}.jpg`);
asegurar(destino);
ffmpeg([
'-i',
paraElDisco.imagenOrigen,
'-vf',
`crop='min(iw,ih)':'min(iw,ih)',scale=${LADO}:${LADO}`,
'-q:v',
'3',
destino
]);
const id = await ponerMedio('imagen', clave, destino, {
mime: 'image/jpeg',
publico: true,
ancho: LADO,
alto: LADO,
alt: `Portada de ${album.titulo}`
});
await c.query('update album set portada_id = $2 where slug = $1', [disco, id]);
console.log(`\nPortada del disco: la de «${paraElDisco.titulo}».`);
}
await c.end();
/* Genera las ondas de lo recién importado y completa cualquier derivado pendiente. */
execFileSync(process.execPath, [path.join(import.meta.dirname, '..', 'medios.mjs')], {
stdio: 'inherit'
});
console.log(`\nHecho: ${audios} másteres con escucha completa, ${imagenes} portadas.`);
console.log(
'Los másteres y las escuchas van en media/, que no está en git. Cópialos al desplegar.'
);

Powered by TurnKey Linux.