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/src/lib/server/catalogo.ts

372 lines
11 KiB

/**
* 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 };
}

Powered by TurnKey Linux.