Antes de cambiar veinte rutas de sitio, poder demostrar que sale lo mismo. `npm run db:comparar` pone lado a lado las dieciocho canciones, los cuatro discos y los cuatro estilos, campo a campo, y dice dónde difieren con la ruta exacta —`dorina.registro.reparto[0].porcentaje`—. Confiar en que las pruebas de interfaz noten una diferencia de un año o de un porcentaje sería confiar de más: pintan lo que les den. La primera pasada encontró ciento y pico diferencias, y no eran del comparador: - **Ningún medio estaba enlazado.** El volcado del catálogo y el de archivos se hicieron por separado, así que las veintitrés canciones tenían `audio_id`, `muestra_id` y `portada_id` a nulo. La web habría dejado de reproducir en cuanto leyera de la base. No se vio al volcar porque aquello comprobaba las letras carácter a carácter —lo delicado— y no que las relaciones estuvieran puestas. - **Las etiquetas volvían en orden alfabético.** No es cosmético: la primera etiqueta de un tema dice de qué va y las de después matizan. Ahora `cancion_etiqueta` tiene `orden`. - **La nota de cada versión no tenía columna** donde caer. - El país de edición se guardaba en ninguna parte, y los cuatro discos se quedaron sin él. `scripts/db/completar-catalogo.mjs` ata esos cabos y es idempotente. Los alias de `medio` son de verdad y no la misma tabla dos veces: sin ellos Postgres devuelve la misma fila para el audio y para la portada. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>master
parent
2506e90bde
commit
715b28c3c0
@ -0,0 +1,11 @@
|
||||
-- Dos columnas que faltaban, y que se vieron comparando.
|
||||
--
|
||||
-- El comparador de la fase 4 pone lado a lado el catálogo del archivo y el de
|
||||
-- la base, campo a campo. Salió que la nota de cada versión —«Voz y guitarra,
|
||||
-- grabada en el salón de casa»— no tenía columna donde caer, y que las
|
||||
-- etiquetas volvían en orden alfabético y no en el que se escribieron. Lo
|
||||
-- segundo no es cosmético: la primera etiqueta de un tema dice de qué va, y
|
||||
-- las de después matizan.
|
||||
|
||||
ALTER TABLE "cancion" ADD COLUMN "version_nota" text;--> statement-breakpoint
|
||||
ALTER TABLE "cancion_etiqueta" ADD COLUMN "orden" integer DEFAULT 0 NOT NULL;
|
||||
File diff suppressed because it is too large
Load Diff
@ -0,0 +1,145 @@
|
||||
/**
|
||||
* Compara el catálogo de la base con el de los archivos, campo a campo.
|
||||
*
|
||||
* npm run db:comparar
|
||||
*
|
||||
* Es la red de la fase 4. Cambiar de dónde salen los datos de veinte rutas sin
|
||||
* poder comprobar que salen iguales sería confiar en que las pruebas de
|
||||
* interfaz noten una diferencia de un año o de un porcentaje de autoría, y no
|
||||
* la notarían: pintan lo que les den.
|
||||
*
|
||||
* Lo que se compara es el objeto entero de cada canción, álbum y estilo, con
|
||||
* las mismas claves y ordenado igual. Las diferencias se listan una a una con
|
||||
* su ruta —`dorina.registro.reparto[0].porcentaje`— porque un «no coinciden»
|
||||
* sin más no sirve para arreglar nada.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { load as leerYaml } from 'js-yaml';
|
||||
|
||||
import { parsearAlbum, parsearCancion, parsearEstilo } from '$lib/content/schema';
|
||||
import { extraerLineas } from '$lib/content/extracto';
|
||||
import { leerCatalogo } from '$lib/server/catalogo';
|
||||
|
||||
/*
|
||||
* Los archivos se leen a mano y no por `$lib/content`, que usa
|
||||
* `import.meta.glob`: eso lo resuelve Vite al construir y en Node no existe.
|
||||
* Los analizadores sí son los mismos, que es lo que hace que la comparación
|
||||
* signifique algo.
|
||||
*/
|
||||
const RAIZ = 'src/content';
|
||||
|
||||
const SALTO = String.fromCharCode(10);
|
||||
|
||||
function partir(texto) {
|
||||
const lineas = texto.split(new RegExp(String.raw`\r?\n`));
|
||||
if (lineas[0]?.trim() !== '---') return { datos: {}, cuerpo: texto.trim() };
|
||||
const cierre = lineas.indexOf('---', 1);
|
||||
if (cierre === -1) return { datos: {}, cuerpo: texto.trim() };
|
||||
return {
|
||||
datos: leerYaml(lineas.slice(1, cierre).join(SALTO)) ?? {},
|
||||
cuerpo: lineas
|
||||
.slice(cierre + 1)
|
||||
.join(SALTO)
|
||||
.trim()
|
||||
};
|
||||
}
|
||||
|
||||
function leerCarpeta(carpeta, parser) {
|
||||
const dir = path.join(RAIZ, carpeta);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((n) => n.endsWith('.md'))
|
||||
.sort()
|
||||
.map((nombre) => {
|
||||
const ruta = path.join(dir, nombre);
|
||||
const { datos, cuerpo } = partir(fs.readFileSync(ruta, 'utf8'));
|
||||
const slug = nombre.replace(/\.md$/, '');
|
||||
const parseado = parser(datos, slug, ruta);
|
||||
// El extracto lo inyecta un preprocesador al construir, no el parser.
|
||||
if (carpeta === 'canciones') parseado.extracto = extraerLineas(cuerpo);
|
||||
return parseado;
|
||||
});
|
||||
}
|
||||
|
||||
const deArchivo = leerCarpeta('canciones', parsearCancion);
|
||||
const albumesArchivo = leerCarpeta('albumes', parsearAlbum);
|
||||
const estilosArchivo = leerCarpeta('estilos', parsearEstilo);
|
||||
|
||||
/** Recorre dos objetos a la vez y devuelve dónde difieren. */
|
||||
function diferencias(a, b, ruta = '') {
|
||||
const fuera = [];
|
||||
|
||||
if (a === b) return fuera;
|
||||
|
||||
if (Array.isArray(a) && Array.isArray(b)) {
|
||||
if (a.length !== b.length) {
|
||||
fuera.push(`${ruta}: archivo ${a.length} elementos, base ${b.length}`);
|
||||
}
|
||||
for (let i = 0; i < Math.max(a.length, b.length); i++) {
|
||||
fuera.push(...diferencias(a[i], b[i], `${ruta}[${i}]`));
|
||||
}
|
||||
return fuera;
|
||||
}
|
||||
|
||||
if (a && b && typeof a === 'object' && typeof b === 'object') {
|
||||
for (const clave of new Set([...Object.keys(a), ...Object.keys(b)])) {
|
||||
fuera.push(...diferencias(a[clave], b[clave], ruta ? `${ruta}.${clave}` : clave));
|
||||
}
|
||||
return fuera;
|
||||
}
|
||||
|
||||
// `undefined` y ausente son lo mismo para este sitio: el tipo los declara
|
||||
// opcionales y la plantilla los trata igual.
|
||||
if ((a ?? undefined) === (b ?? undefined)) return fuera;
|
||||
|
||||
fuera.push(`${ruta}: archivo ${JSON.stringify(a)} · base ${JSON.stringify(b)}`);
|
||||
return fuera;
|
||||
}
|
||||
|
||||
const base = await leerCatalogo();
|
||||
|
||||
const grupos = [
|
||||
['canciones', deArchivo, base.canciones],
|
||||
['álbumes', albumesArchivo, base.albumes],
|
||||
['estilos', estilosArchivo, base.estilos]
|
||||
];
|
||||
|
||||
let problemas = 0;
|
||||
|
||||
for (const [nombre, archivo, deBase] of grupos) {
|
||||
console.log(`\n${nombre.toUpperCase()} · archivo ${archivo.length} · base ${deBase.length}`);
|
||||
|
||||
const porSlug = new Map(deBase.map((x) => [x.slug, x]));
|
||||
|
||||
for (const uno of archivo) {
|
||||
const otro = porSlug.get(uno.slug);
|
||||
if (!otro) {
|
||||
console.log(` ✗ ${uno.slug}: no está en la base`);
|
||||
problemas++;
|
||||
continue;
|
||||
}
|
||||
porSlug.delete(uno.slug);
|
||||
|
||||
const fuera = diferencias(uno, otro, uno.slug);
|
||||
if (fuera.length === 0) continue;
|
||||
|
||||
problemas += fuera.length;
|
||||
console.log(` ✗ ${uno.slug}`);
|
||||
for (const d of fuera) console.log(` ${d}`);
|
||||
}
|
||||
|
||||
for (const sobrante of porSlug.keys()) {
|
||||
console.log(` · ${sobrante}: solo en la base (no es un fallo si se añadió después)`);
|
||||
}
|
||||
}
|
||||
|
||||
console.log(
|
||||
problemas === 0
|
||||
? '\nIdénticos. La base puede sustituir a los archivos sin que cambie nada.'
|
||||
: `\n${problemas} diferencias. Cada una es algo que la web enseñaría distinto.`
|
||||
);
|
||||
|
||||
process.exit(problemas === 0 ? 0 : 1);
|
||||
@ -0,0 +1,226 @@
|
||||
/**
|
||||
* Ata los cabos que el volcado dejó sueltos.
|
||||
*
|
||||
* npm run db:completar
|
||||
*
|
||||
* El volcado del catálogo y el de medios se hicieron por separado: el primero
|
||||
* creó las canciones y el segundo las filas de `medio` con su sha256 y su
|
||||
* duración, pero **nadie enlazó unas con otras**. Las veintitrés canciones
|
||||
* tenían `audio_id`, `muestra_id` y `portada_id` a nulo, así que la web habría
|
||||
* dejado de poder reproducir nada en cuanto leyera de la base.
|
||||
*
|
||||
* No se vio al volcar porque el volcado comprobaba las letras carácter a
|
||||
* carácter —que era lo delicado— y no que las relaciones estuvieran puestas.
|
||||
* Se vio al comparar el catálogo entero contra los archivos, que es lo que
|
||||
* hace `npm run db:comparar`.
|
||||
*
|
||||
* Rellena, además, lo que no tenía dónde ir hasta la migración 0009: la nota de
|
||||
* cada versión y el orden en que se escribieron las etiquetas.
|
||||
*
|
||||
* Es idempotente: son `UPDATE`. Se puede volver a pasar cuando haga falta.
|
||||
*/
|
||||
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import { load as leerYaml } from 'js-yaml';
|
||||
import pg from 'pg';
|
||||
|
||||
const RAIZ = 'src/content';
|
||||
const SALTO = String.fromCharCode(10);
|
||||
|
||||
/** Separa el frontmatter del cuerpo, igual que el volcado. */
|
||||
function partir(texto) {
|
||||
const lineas = texto.split(new RegExp(String.raw`\r?\n`));
|
||||
if (lineas[0]?.trim() !== '---') return { datos: {}, cuerpo: texto.trim() };
|
||||
const cierre = lineas.indexOf('---', 1);
|
||||
if (cierre === -1) return { datos: {}, cuerpo: texto.trim() };
|
||||
return { datos: leerYaml(lineas.slice(1, cierre).join(SALTO)) ?? {}, cuerpo: '' };
|
||||
}
|
||||
|
||||
function leerCarpeta(carpeta) {
|
||||
const dir = path.join(RAIZ, carpeta);
|
||||
if (!fs.existsSync(dir)) return [];
|
||||
return fs
|
||||
.readdirSync(dir)
|
||||
.filter((n) => n.endsWith('.md'))
|
||||
.sort()
|
||||
.map((nombre) => ({
|
||||
slug: nombre.replace(new RegExp(String.raw`\.md$`), ''),
|
||||
datos: partir(fs.readFileSync(path.join(dir, nombre), 'utf8')).datos
|
||||
}));
|
||||
}
|
||||
|
||||
const c = new pg.Client({ connectionString: process.env.DATABASE_URL, query_timeout: 60_000 });
|
||||
await c.connect();
|
||||
await c.query('BEGIN');
|
||||
|
||||
const contado = {
|
||||
audio: 0,
|
||||
muestra: 0,
|
||||
portadaCancion: 0,
|
||||
portadaAlbum: 0,
|
||||
paisAlbum: 0,
|
||||
nota: 0,
|
||||
orden: 0
|
||||
};
|
||||
const avisos = [];
|
||||
|
||||
try {
|
||||
/* --- Índices de lo que ya hay ----------------------------------------- */
|
||||
|
||||
const { rows: medios } = await c.query('SELECT id, clave FROM medio');
|
||||
const idDeMedio = new Map(medios.map((m) => [m.clave, m.id]));
|
||||
|
||||
const { rows: canciones } = await c.query(
|
||||
'SELECT id, slug, version_de_id, version_nombre FROM cancion'
|
||||
);
|
||||
const idDeCancion = new Map(canciones.filter((f) => !f.version_de_id).map((f) => [f.slug, f.id]));
|
||||
|
||||
/*
|
||||
* Las versiones se buscan por su nombre y no por el slug: el volcado lo
|
||||
* compuso a partir del nombre —«Versión orquestal» →
|
||||
* `como-si-nada-version-orquestal`— y rehacer esa cuenta aquí sería tener
|
||||
* dos formas de calcular lo mismo, que es como acaban discrepando.
|
||||
*/
|
||||
const versionesDe = new Map();
|
||||
for (const fila of canciones.filter((f) => f.version_de_id)) {
|
||||
const suyas = versionesDe.get(fila.version_de_id) ?? new Map();
|
||||
suyas.set(fila.version_nombre, fila.id);
|
||||
versionesDe.set(fila.version_de_id, suyas);
|
||||
}
|
||||
|
||||
const { rows: albumes } = await c.query('SELECT id, slug FROM album');
|
||||
const idDeAlbum = new Map(albumes.map((f) => [f.slug, f.id]));
|
||||
|
||||
const { rows: etiquetas } = await c.query('SELECT id, nombre FROM etiqueta');
|
||||
const idDeEtiqueta = new Map(etiquetas.map((f) => [f.nombre, f.id]));
|
||||
|
||||
/*
|
||||
* El país de edición se escribe con su nombre —«España»— y la base guarda
|
||||
* el código ISO. El volcado se lo saltó y los cuatro discos quedaron sin
|
||||
* país; la ficha de edición lo enseña.
|
||||
*/
|
||||
const { rows: paises } = await c.query('SELECT codigo, nombre FROM pais');
|
||||
const codigoDePais = new Map(paises.map((f) => [f.nombre, f.codigo]));
|
||||
|
||||
/**
|
||||
* La clave de un medio a partir de lo que dice el archivo.
|
||||
*
|
||||
* En el frontmatter el audio es una ruta relativa dentro del almacén
|
||||
* privado; en `medio` la clave lleva delante el almacén, porque el mismo
|
||||
* nombre existe en dos —`singles/dorina.mp3` está en audio y en muestras— y
|
||||
* sin el prefijo chocaban.
|
||||
*/
|
||||
const enAlmacen = (almacen, ruta) => {
|
||||
if (!ruta) return null;
|
||||
// Las portadas se escriben como ruta pública —`/images/…`— y el audio
|
||||
// como ruta dentro del almacén. La clave lleva el almacén delante, una
|
||||
// sola vez.
|
||||
const limpia = ruta.replace(/^\//, '').replace(new RegExp(`^${almacen}/`), '');
|
||||
return `${almacen}/${limpia}`;
|
||||
};
|
||||
|
||||
/* --- Canciones ---------------------------------------------------------- */
|
||||
|
||||
for (const { slug, datos } of leerCarpeta('canciones')) {
|
||||
const cancionId = idDeCancion.get(slug);
|
||||
if (!cancionId) {
|
||||
avisos.push(`la canción «${slug}» no está en la base`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const audio = idDeMedio.get(enAlmacen('audio', datos.audio));
|
||||
const muestra = idDeMedio.get(enAlmacen('muestras', datos.audio));
|
||||
const portada = idDeMedio.get(enAlmacen('images', datos.portada));
|
||||
|
||||
if (datos.audio && !audio) avisos.push(`${slug}: no encuentro el audio ${datos.audio}`);
|
||||
if (datos.portada && !portada) avisos.push(`${slug}: no encuentro la portada ${datos.portada}`);
|
||||
|
||||
await c.query(
|
||||
`UPDATE cancion SET audio_id = COALESCE($2, audio_id),
|
||||
muestra_id = COALESCE($3, muestra_id), portada_id = COALESCE($4, portada_id)
|
||||
WHERE id = $1`,
|
||||
[cancionId, audio ?? null, muestra ?? null, portada ?? null]
|
||||
);
|
||||
if (audio) contado.audio++;
|
||||
if (muestra) contado.muestra++;
|
||||
if (portada) contado.portadaCancion++;
|
||||
|
||||
/* Las versiones: su audio, su nota y su nombre. */
|
||||
for (const version of datos.versiones ?? []) {
|
||||
const versionId = versionesDe.get(cancionId)?.get(version.nombre);
|
||||
if (!versionId) {
|
||||
avisos.push(`${slug}: no encuentro la versión «${version.nombre}»`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const audioVersion = idDeMedio.get(enAlmacen('audio', version.audio));
|
||||
const muestraVersion = idDeMedio.get(enAlmacen('muestras', version.audio));
|
||||
|
||||
await c.query(
|
||||
`UPDATE cancion SET audio_id = COALESCE($2, audio_id),
|
||||
muestra_id = COALESCE($3, muestra_id), version_nota = $4 WHERE id = $1`,
|
||||
[versionId, audioVersion ?? null, muestraVersion ?? null, version.nota ?? null]
|
||||
);
|
||||
if (audioVersion) contado.audio++;
|
||||
if (muestraVersion) contado.muestra++;
|
||||
if (version.nota) contado.nota++;
|
||||
}
|
||||
|
||||
/* El orden de las etiquetas, que es el del archivo. */
|
||||
for (const [i, nombre] of (datos.etiquetas ?? []).entries()) {
|
||||
const etiquetaId = idDeEtiqueta.get(nombre);
|
||||
if (!etiquetaId) {
|
||||
avisos.push(`${slug}: no conozco la etiqueta «${nombre}»`);
|
||||
continue;
|
||||
}
|
||||
await c.query(
|
||||
'UPDATE cancion_etiqueta SET orden = $3 WHERE cancion_id = $1 AND etiqueta_id = $2',
|
||||
[cancionId, etiquetaId, i]
|
||||
);
|
||||
contado.orden++;
|
||||
}
|
||||
}
|
||||
|
||||
/* --- Álbumes ------------------------------------------------------------ */
|
||||
|
||||
for (const { slug, datos } of leerCarpeta('albumes')) {
|
||||
const albumId = idDeAlbum.get(slug);
|
||||
if (!albumId) {
|
||||
avisos.push(`el álbum «${slug}» no está en la base`);
|
||||
continue;
|
||||
}
|
||||
|
||||
const portada = idDeMedio.get(enAlmacen('images', datos.portada));
|
||||
if (datos.portada && !portada) {
|
||||
avisos.push(`${slug}: no encuentro la portada ${datos.portada}`);
|
||||
}
|
||||
|
||||
const nombrePais = datos.edicion?.pais ?? datos.pais;
|
||||
const codigoPais = nombrePais ? codigoDePais.get(nombrePais) : null;
|
||||
if (nombrePais && !codigoPais) avisos.push(`${slug}: no conozco el país «${nombrePais}»`);
|
||||
|
||||
await c.query(
|
||||
`UPDATE album SET portada_id = COALESCE($2, portada_id),
|
||||
pais_edicion_codigo = COALESCE($3, pais_edicion_codigo) WHERE id = $1`,
|
||||
[albumId, portada ?? null, codigoPais ?? null]
|
||||
);
|
||||
if (portada) contado.portadaAlbum++;
|
||||
if (codigoPais) contado.paisAlbum++;
|
||||
}
|
||||
|
||||
await c.query('COMMIT');
|
||||
} catch (fallo) {
|
||||
await c.query('ROLLBACK').catch(() => {});
|
||||
console.error('Falló, no se ha escrito nada:');
|
||||
console.error(' ' + fallo.message);
|
||||
await c.end();
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
await c.end();
|
||||
|
||||
for (const [que, n] of Object.entries(contado)) console.log(` ${que.padEnd(16)} ${n}`);
|
||||
for (const aviso of avisos) console.log(` aviso: ${aviso}`);
|
||||
console.log('');
|
||||
console.log('Completado. Comprueba con: npm run db:comparar');
|
||||
@ -0,0 +1,8 @@
|
||||
/**
|
||||
* Lo mínimo de `$env/dynamic/private` para un script.
|
||||
*
|
||||
* SvelteKit lo inyecta al compilar; fuera de la aplicación no existe. El
|
||||
* `--env-file=.env` de Node ya ha llenado `process.env` cuando esto se carga,
|
||||
* así que basta con exponerlo con el nombre que espera el código del sitio.
|
||||
*/
|
||||
export const env = process.env;
|
||||
@ -0,0 +1,371 @@
|
||||
/**
|
||||
* El catálogo, leído de PostgreSQL.
|
||||
*
|
||||
* Devuelve **exactamente** los tipos que ya usaba el sitio cuando el catálogo
|
||||
* vivía en `src/content`: `Cancion`, `Album`, `Estilo`. Es deliberado. Cambiar
|
||||
* a la vez de dónde salen los datos y qué forma tienen habría dejado veinte
|
||||
* archivos sin poder comprobar cuál de las dos cosas rompió; así las pruebas
|
||||
* que ya existen valen de red tal cual.
|
||||
*
|
||||
* ## Por qué se carga entero
|
||||
*
|
||||
* Son dieciocho temas. Hasta ahora viajaban en el paquete de JavaScript —los
|
||||
* cargaba `import.meta.glob` al construir—, así que el sitio ya contaba con
|
||||
* tenerlos todos a mano: el carrito resuelve sus slugs por su cuenta, el
|
||||
* reproductor arma la cola, las tarjetas miran si el tema está comprado. Leer
|
||||
* el catálogo entero y guardarlo en memoria mantiene eso funcionando y no pide
|
||||
* una consulta por tarjeta.
|
||||
*
|
||||
* Lo que **no** se carga aquí es la letra completa, que es lo único pesado: de
|
||||
* ella solo sale el extracto de las tarjetas. La letra entera la pide la ficha
|
||||
* del tema, y solo la suya.
|
||||
*
|
||||
* La caché se tira sola cada minuto. No hay administración todavía; cuando la
|
||||
* haya, esto tendrá que invalidarse al guardar, no por reloj.
|
||||
*/
|
||||
|
||||
import { asc, eq } from 'drizzle-orm';
|
||||
import { alias } from 'drizzle-orm/pg-core';
|
||||
import { db } from './db';
|
||||
import {
|
||||
album,
|
||||
albumEnlace,
|
||||
autoria,
|
||||
cancion,
|
||||
cancionEnlace,
|
||||
cancionEtiqueta,
|
||||
cancionPersona,
|
||||
creditoAlbum,
|
||||
estilo,
|
||||
etiqueta,
|
||||
medio,
|
||||
pais,
|
||||
persona,
|
||||
registroObra,
|
||||
traduccion,
|
||||
video
|
||||
} from './db/schema';
|
||||
import { extraerLineas } from '$lib/content/extracto';
|
||||
import type {
|
||||
Album,
|
||||
Cancion,
|
||||
Enlaces,
|
||||
Estilo,
|
||||
FormatoDeEdicion,
|
||||
Plataforma,
|
||||
RegistroDeObra,
|
||||
VersionMusical,
|
||||
VideoDeCancion
|
||||
} from '$lib/types';
|
||||
|
||||
export interface Catalogo {
|
||||
estilos: Estilo[];
|
||||
albumes: Album[];
|
||||
canciones: Cancion[];
|
||||
}
|
||||
|
||||
const VIDA_MS = 60_000;
|
||||
|
||||
let guardado: { catalogo: Catalogo; hasta: number } | null = null;
|
||||
|
||||
/** Tira la caché. La llamará la administración el día que exista. */
|
||||
export function olvidarCatalogo(): void {
|
||||
guardado = null;
|
||||
}
|
||||
|
||||
export async function leerCatalogo(): Promise<Catalogo> {
|
||||
if (guardado && Date.now() < guardado.hasta) return guardado.catalogo;
|
||||
|
||||
const catalogo = await cargar();
|
||||
guardado = { catalogo, hasta: Date.now() + VIDA_MS };
|
||||
return catalogo;
|
||||
}
|
||||
|
||||
/** Agrupa filas por una clave, que es lo que se hace ocho veces aquí abajo. */
|
||||
function porClave<T, K>(filas: T[], clave: (f: T) => K): Map<K, T[]> {
|
||||
const mapa = new Map<K, T[]>();
|
||||
for (const fila of filas) {
|
||||
const k = clave(fila);
|
||||
const lista = mapa.get(k);
|
||||
if (lista) lista.push(fila);
|
||||
else mapa.set(k, [fila]);
|
||||
}
|
||||
return mapa;
|
||||
}
|
||||
|
||||
/** Los enlaces llegan como filas y el sitio los espera como objeto. */
|
||||
function comoEnlaces(filas: { plataforma: string; url: string }[]): Enlaces {
|
||||
const enlaces: Enlaces = {};
|
||||
for (const f of filas) enlaces[f.plataforma as Plataforma] = f.url;
|
||||
return enlaces;
|
||||
}
|
||||
|
||||
/**
|
||||
* De clave de almacén a ruta pública.
|
||||
*
|
||||
* Las imágenes se sirven de `/static`, así que su clave —`images/…`— es ya la
|
||||
* ruta con una barra delante. El audio no: ese vive en un almacén privado y no
|
||||
* tiene URL pública, por eso no pasa por aquí.
|
||||
*/
|
||||
function rutaPublica(clave: string | null): string | undefined {
|
||||
return clave ? `/${clave}` : undefined;
|
||||
}
|
||||
|
||||
async function cargar(): Promise<Catalogo> {
|
||||
/*
|
||||
* Alias de verdad y no la misma tabla dos veces: una canción se une a
|
||||
* `medio` por su audio y otra vez por su portada, y sin alias Postgres ve
|
||||
* un solo `medio` y devuelve la misma fila para los dos.
|
||||
*/
|
||||
const audio = alias(medio, 'audio');
|
||||
const portadaCancion = alias(medio, 'portada_cancion');
|
||||
const portadaAlbum = alias(medio, 'portada_album');
|
||||
|
||||
const [
|
||||
filasEstilo,
|
||||
filasAlbum,
|
||||
filasCancion,
|
||||
personas,
|
||||
etiquetas,
|
||||
enlacesCancion,
|
||||
enlacesAlbum,
|
||||
videos,
|
||||
traducciones,
|
||||
registros,
|
||||
partes,
|
||||
creditos
|
||||
] = await Promise.all([
|
||||
db.select().from(estilo).orderBy(asc(estilo.orden), asc(estilo.nombre)),
|
||||
|
||||
db
|
||||
.select({
|
||||
fila: album,
|
||||
portadaClave: portadaAlbum.clave,
|
||||
paisNombre: pais.nombre
|
||||
})
|
||||
.from(album)
|
||||
.leftJoin(portadaAlbum, eq(portadaAlbum.id, album.portadaId))
|
||||
// El archivo decía «España» y la base guarda «ES»: el nombre sale de
|
||||
// la tabla de países, que es donde vive una sola vez.
|
||||
.leftJoin(pais, eq(pais.codigo, album.paisEdicionCodigo))
|
||||
.orderBy(asc(album.fecha)),
|
||||
|
||||
db
|
||||
.select({
|
||||
fila: cancion,
|
||||
estiloSlug: estilo.slug,
|
||||
albumSlug: album.slug,
|
||||
audioClave: audio.clave,
|
||||
audioDuracion: audio.duracionS,
|
||||
portadaClave: portadaCancion.clave
|
||||
})
|
||||
.from(cancion)
|
||||
.innerJoin(estilo, eq(estilo.id, cancion.estiloId))
|
||||
.leftJoin(album, eq(album.id, cancion.albumId))
|
||||
.leftJoin(audio, eq(audio.id, cancion.audioId))
|
||||
.leftJoin(portadaCancion, eq(portadaCancion.id, cancion.portadaId))
|
||||
.orderBy(asc(cancion.numero), asc(cancion.titulo)),
|
||||
|
||||
db
|
||||
.select({
|
||||
cancionId: cancionPersona.cancionId,
|
||||
rol: cancionPersona.rol,
|
||||
nombre: persona.nombre,
|
||||
orden: cancionPersona.orden
|
||||
})
|
||||
.from(cancionPersona)
|
||||
.innerJoin(persona, eq(persona.id, cancionPersona.personaId))
|
||||
.orderBy(asc(cancionPersona.orden)),
|
||||
|
||||
db
|
||||
.select({ cancionId: cancionEtiqueta.cancionId, nombre: etiqueta.nombre })
|
||||
.from(cancionEtiqueta)
|
||||
.innerJoin(etiqueta, eq(etiqueta.id, cancionEtiqueta.etiquetaId))
|
||||
// Por `orden` y no alfabéticamente: la primera etiqueta de un tema
|
||||
// dice de qué va, y las de después matizan.
|
||||
.orderBy(asc(cancionEtiqueta.orden)),
|
||||
|
||||
db
|
||||
.select({
|
||||
cancionId: cancionEnlace.cancionId,
|
||||
plataforma: cancionEnlace.plataforma,
|
||||
url: cancionEnlace.url
|
||||
})
|
||||
.from(cancionEnlace),
|
||||
|
||||
db
|
||||
.select({
|
||||
albumId: albumEnlace.albumId,
|
||||
plataforma: albumEnlace.plataforma,
|
||||
url: albumEnlace.url
|
||||
})
|
||||
.from(albumEnlace),
|
||||
|
||||
db
|
||||
.select({ cancionId: video.cancionId, youtube: video.youtubeId, titulo: video.titulo })
|
||||
.from(video)
|
||||
.orderBy(asc(video.orden)),
|
||||
|
||||
db
|
||||
.select({ cancionId: traduccion.cancionId, texto: traduccion.texto })
|
||||
.from(traduccion)
|
||||
.where(eq(traduccion.idioma, 'es')),
|
||||
|
||||
db.select().from(registroObra),
|
||||
|
||||
db
|
||||
.select({
|
||||
cancionId: autoria.cancionId,
|
||||
rol: autoria.rol,
|
||||
porcentaje: autoria.porcentaje,
|
||||
nombre: persona.nombre
|
||||
})
|
||||
.from(autoria)
|
||||
.innerJoin(persona, eq(persona.id, autoria.personaId)),
|
||||
|
||||
db
|
||||
.select({
|
||||
albumId: creditoAlbum.albumId,
|
||||
rol: creditoAlbum.rol,
|
||||
nombre: creditoAlbum.nombre,
|
||||
personaNombre: persona.nombre
|
||||
})
|
||||
.from(creditoAlbum)
|
||||
.leftJoin(persona, eq(persona.id, creditoAlbum.personaId))
|
||||
.orderBy(asc(creditoAlbum.orden))
|
||||
]);
|
||||
|
||||
/* --- Índices para no recorrer listas dentro de bucles ------------------ */
|
||||
|
||||
const porCancion = {
|
||||
personas: porClave(personas, (p) => p.cancionId),
|
||||
etiquetas: porClave(etiquetas, (e) => e.cancionId),
|
||||
enlaces: porClave(enlacesCancion, (e) => e.cancionId),
|
||||
videos: porClave(videos, (v) => v.cancionId),
|
||||
partes: porClave(partes, (p) => p.cancionId)
|
||||
};
|
||||
const traduccionDe = new Map(traducciones.map((t) => [t.cancionId, t.texto]));
|
||||
const registroDe = new Map(registros.map((r) => [r.cancionId, r]));
|
||||
const enlacesDeAlbum = porClave(enlacesAlbum, (e) => e.albumId);
|
||||
const creditosDeAlbum = porClave(creditos, (c) => c.albumId);
|
||||
|
||||
/* --- Estilos ----------------------------------------------------------- */
|
||||
|
||||
const estilos: Estilo[] = filasEstilo.map((e) => ({
|
||||
slug: e.slug,
|
||||
nombre: e.nombre,
|
||||
resumen: e.resumen,
|
||||
orden: e.orden,
|
||||
principal: e.principal,
|
||||
imagen: undefined
|
||||
}));
|
||||
|
||||
/* --- Álbumes ----------------------------------------------------------- */
|
||||
|
||||
const slugDeEstilo = new Map(filasEstilo.map((e) => [e.id, e.slug]));
|
||||
|
||||
const albumes: Album[] = filasAlbum.map(({ fila, portadaClave, paisNombre }) => ({
|
||||
slug: fila.slug,
|
||||
titulo: fila.titulo,
|
||||
estilo: slugDeEstilo.get(fila.estiloId) ?? '',
|
||||
fecha: fila.fecha,
|
||||
tipo: fila.tipo,
|
||||
portada: rutaPublica(portadaClave) ?? '',
|
||||
sello: fila.sello ?? undefined,
|
||||
resumen: fila.resumen,
|
||||
destacado: fila.destacado,
|
||||
enlaces: comoEnlaces(enlacesDeAlbum.get(fila.id) ?? []),
|
||||
// El álbum sintético de singles no existe en la base: lo compone quien
|
||||
// lo necesita, igual que antes lo componía el módulo de contenido.
|
||||
sintetico: false,
|
||||
edicion: {
|
||||
numeroCatalogo: fila.numeroCatalogo ?? undefined,
|
||||
formatos: (fila.formatos ?? []) as FormatoDeEdicion[],
|
||||
pais: paisNombre ?? undefined,
|
||||
creditos: (creditosDeAlbum.get(fila.id) ?? []).map((c) => ({
|
||||
rol: c.rol,
|
||||
nombre: c.personaNombre ?? c.nombre ?? ''
|
||||
}))
|
||||
}
|
||||
}));
|
||||
|
||||
/* --- Canciones ---------------------------------------------------------- */
|
||||
|
||||
/*
|
||||
* Las versiones son filas de `cancion` con `version_de_id`, no una tabla
|
||||
* aparte: se decidió así porque una versión puede cambiar de estilo, de
|
||||
* etiquetas y de todo lo demás. Aquí se separan las principales de sus
|
||||
* versiones y cada una vuelve con las suyas colgando.
|
||||
*/
|
||||
const versionesDe = porClave(
|
||||
filasCancion.filter((f) => f.fila.versionDeId !== null),
|
||||
(f) => f.fila.versionDeId!
|
||||
);
|
||||
|
||||
const canciones: Cancion[] = filasCancion
|
||||
.filter((f) => f.fila.versionDeId === null)
|
||||
.map(({ fila, estiloSlug, albumSlug, audioClave, audioDuracion, portadaClave }) => {
|
||||
const suyas = porCancion.personas.get(fila.id) ?? [];
|
||||
const registro = registroDe.get(fila.id);
|
||||
const misPartes = porCancion.partes.get(fila.id) ?? [];
|
||||
|
||||
const versiones: VersionMusical[] = (versionesDe.get(fila.id) ?? [])
|
||||
.sort((a, b) => a.fila.versionOrden - b.fila.versionOrden)
|
||||
.map((v) => ({
|
||||
id: v.fila.slug.replace(`${fila.slug}-`, ''),
|
||||
nombre: v.fila.versionNombre ?? '',
|
||||
audio: v.audioClave?.replace(/^audio\//, '') ?? '',
|
||||
duracion: v.fila.duracionS ?? v.audioDuracion ?? undefined,
|
||||
nota: v.fila.versionNota ?? undefined
|
||||
}));
|
||||
|
||||
const registroDeObra: RegistroDeObra = {
|
||||
iswc: registro?.iswc ?? undefined,
|
||||
isrc: registro?.isrc ?? undefined,
|
||||
entidad: registro?.entidad ?? undefined,
|
||||
numero: registro?.numero ?? undefined,
|
||||
editorial: registro?.editorial ?? undefined,
|
||||
reparto: misPartes.map((p) => ({
|
||||
nombre: p.nombre,
|
||||
rol: p.rol,
|
||||
porcentaje: Number(p.porcentaje)
|
||||
}))
|
||||
};
|
||||
|
||||
const videosDe: VideoDeCancion[] = (porCancion.videos.get(fila.id) ?? []).map((v) => ({
|
||||
youtube: v.youtube,
|
||||
titulo: v.titulo
|
||||
}));
|
||||
|
||||
return {
|
||||
slug: fila.slug,
|
||||
titulo: fila.titulo,
|
||||
estilo: estiloSlug,
|
||||
album: albumSlug ?? null,
|
||||
numero: fila.numero,
|
||||
audio: audioClave?.replace(/^audio\//, ''),
|
||||
duracion: fila.duracionS ?? audioDuracion ?? undefined,
|
||||
ano: fila.ano ?? undefined,
|
||||
letra: suyas.filter((p) => p.rol === 'letra').map((p) => p.nombre),
|
||||
musica: suyas.filter((p) => p.rol === 'musica').map((p) => p.nombre),
|
||||
interpretes: suyas.filter((p) => p.rol === 'interprete').map((p) => p.nombre),
|
||||
destacada: fila.destacada,
|
||||
etiquetas: (porCancion.etiquetas.get(fila.id) ?? []).map((e) => e.nombre),
|
||||
enlaces: comoEnlaces(porCancion.enlaces.get(fila.id) ?? []),
|
||||
instrumental: fila.instrumental,
|
||||
idioma: fila.idioma,
|
||||
traduccion: traduccionDe.get(fila.id) ?? undefined,
|
||||
portada: rutaPublica(portadaClave),
|
||||
inspiracion: fila.inspiracion ? fila.inspiracion.split('\n\n').filter(Boolean) : [],
|
||||
// El mismo extracto que inyectaba el preprocesador, con la misma
|
||||
// función: son los primeros versos, no un resumen.
|
||||
extracto: extraerLineas(fila.letra ?? ''),
|
||||
versiones,
|
||||
videos: videosDe,
|
||||
alaVenta: fila.alaVenta,
|
||||
registro: registroDeObra
|
||||
} satisfies Cancion;
|
||||
});
|
||||
|
||||
return { estilos, albumes, canciones };
|
||||
}
|
||||
Loading…
Reference in new issue