/** * 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 { and, asc, eq, isNull, or } from 'drizzle-orm'; import { alias } from 'drizzle-orm/pg-core'; import { db } from './db'; import { album, albumEnlace, artista, autoria, cancion, cancionAudio, cancionEnlace, cancionEtiqueta, cancionGenero, cancionInterprete, cancionPersona, creditoAlbum, estilo, etiqueta, genero, medio, pais, persona, registroObra, traduccion, video } from './db/schema'; import { extraerLineas } from '$lib/content/extracto'; import { componer } from './markdown'; import * as q from '$lib/catalogo/consultas'; import type { Catalogo } from '$lib/catalogo/consultas'; import type { Album, Cancion, Enlaces, Estilo, FormatoDeEdicion, Plataforma, RegistroDeObra, VersionMusical, VideoDeCancion } from '$lib/types'; export type { Catalogo } from '$lib/catalogo/consultas'; 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 { 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(filas: T[], clave: (f: T) => K): Map { const mapa = new Map(); 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; } /** * La onda es un derivado estático de la escucha y comparte su ruta relativa. * No es una columna editorial: si cambia el audio, `scripts/medios.mjs` vuelve * a calcularla y conserva esta dirección estable. */ function rutaDeOnda(clave: string | null, huella: string | null): string | undefined { if (!clave?.startsWith('escucha/')) return undefined; const ruta = `/${clave.replace(/^escucha\//, 'ondas/').replace(/\.[^/.]+$/, '.json')}`; // La dirección lógica es estable, pero la huella evita que el navegador // conserve la onda anterior cuando se sustituye el archivo de escucha. return huella ? `${ruta}?v=${huella.slice(0, 12)}` : ruta; } async function cargar(): Promise { /* * 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 portadaCancion = alias(medio, 'portada_cancion'); const portadaAlbum = alias(medio, 'portada_album'); const imagenArtista = alias(medio, 'imagen_artista'); const [ filasEstilo, filasAlbum, filasCancion, personas, interpretes, audiosDeEscucha, etiquetas, enlacesCancion, enlacesAlbum, videos, traducciones, registros, partes, creditos, generosDeCancion ] = await Promise.all([ db .select() .from(estilo) .where(eq(estilo.estado, 'publicado')) .orderBy(asc(estilo.orden), asc(estilo.nombre)), db .select({ fila: album, portadaClave: portadaAlbum.clave, paisNombre: pais.nombre }) .from(album) .innerJoin(estilo, eq(estilo.id, album.estiloId)) .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)) .where(and(eq(album.estado, 'publicado'), eq(estilo.estado, 'publicado'))) .orderBy(asc(album.fecha)), db .select({ fila: cancion, estiloSlug: estilo.slug, albumSlug: album.slug, portadaClave: portadaCancion.clave }) .from(cancion) .innerJoin(estilo, eq(estilo.id, cancion.estiloId)) .leftJoin(album, eq(album.id, cancion.albumId)) .leftJoin(portadaCancion, eq(portadaCancion.id, cancion.portadaId)) .where( and( eq(cancion.estado, 'publicado'), eq(estilo.estado, 'publicado'), or(isNull(cancion.albumId), eq(album.estado, 'publicado')) ) ) .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: cancionInterprete.cancionId, id: artista.id, codigo: artista.codigo, slug: artista.slug, nombre: artista.nombre, tipo: artista.tipo, estado: artista.estado, rol: cancionInterprete.rol, instrumento: cancionInterprete.instrumento, acreditadoComo: cancionInterprete.acreditadoComo, imagenClave: imagenArtista.clave }) .from(cancionInterprete) .innerJoin(artista, eq(artista.id, cancionInterprete.artistaId)) .leftJoin(imagenArtista, eq(imagenArtista.id, artista.imagenId)) .orderBy(asc(cancionInterprete.orden), asc(artista.nombre)), db .select({ cancionId: cancionAudio.cancionId, clave: medio.clave, huella: medio.sha256, duracion: medio.duracionS }) .from(cancionAudio) .innerJoin(medio, eq(medio.id, cancionAudio.medioId)) .where(and(eq(cancionAudio.uso, 'stream'), eq(medio.publico, true))) .orderBy(asc(cancionAudio.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)), /* * El género musical del tema —bolero, bachata—, que no es lo mismo que el * estilo: el estilo es cómo se ordena la obra en esta web y es uno solo; * los géneros son de qué está hecha la canción y pueden ser varios. */ db .select({ cancionId: cancionGenero.cancionId, nombre: genero.nombre, slug: genero.slug }) .from(cancionGenero) .innerJoin(genero, eq(genero.id, cancionGenero.generoId)) .orderBy(asc(genero.nombre)) ]); /* --- Índices para no recorrer listas dentro de bucles ------------------ */ const porCancion = { personas: porClave(personas, (p) => p.cancionId), interpretes: porClave(interpretes, (i) => i.cancionId), etiquetas: porClave(etiquetas, (e) => e.cancionId), generos: porClave(generosDeCancion, (g) => g.cancionId), enlaces: porClave(enlacesCancion, (e) => e.cancionId), videos: porClave(videos, (v) => v.cancionId), partes: porClave(partes, (p) => p.cancionId) }; const audioDeEscucha = new Map(audiosDeEscucha.map((a) => [a.cancionId, a])); 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, portadaClave }) => { const suyas = porCancion.personas.get(fila.id) ?? []; const susInterpretes = porCancion.interpretes.get(fila.id) ?? []; const escucha = audioDeEscucha.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) => { const escuchaVersion = audioDeEscucha.get(v.fila.id); const interpretesVersion = porCancion.interpretes.get(v.fila.id) ?? []; return { id: v.fila.versionCodigo ?? v.fila.slug.replace(`${fila.slug}-`, ''), codigo: v.fila.codigo, slug: v.fila.slug, nombre: v.fila.versionNombre ?? '', audio: rutaPublica(escuchaVersion?.clave ?? null), onda: rutaDeOnda(escuchaVersion?.clave ?? null, escuchaVersion?.huella ?? null), duracion: v.fila.duracionS ?? escuchaVersion?.duracion ?? undefined, nota: v.fila.versionNota ?? undefined, interpretes: interpretesVersion.map((i) => ({ id: i.id, codigo: i.codigo, slug: i.estado === 'publicado' ? i.slug : undefined, nombre: i.acreditadoComo ?? i.nombre, tipo: i.tipo, rol: i.rol, instrumento: i.instrumento ?? undefined, acreditadoComo: i.acreditadoComo ?? undefined, imagen: i.estado === 'publicado' ? rutaPublica(i.imagenClave) : 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 { codigo: fila.codigo, slug: fila.slug, titulo: fila.titulo, estilo: estiloSlug, album: albumSlug ?? null, numero: fila.numero, audio: rutaPublica(escucha?.clave ?? null), onda: rutaDeOnda(escucha?.clave ?? null, escucha?.huella ?? null), duracion: fila.duracionS ?? escucha?.duracion ?? 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: susInterpretes.map((i) => ({ id: i.id, codigo: i.codigo, slug: i.estado === 'publicado' ? i.slug : undefined, nombre: i.acreditadoComo ?? i.nombre, tipo: i.tipo, rol: i.rol, instrumento: i.instrumento ?? undefined, acreditadoComo: i.acreditadoComo ?? undefined, imagen: i.estado === 'publicado' ? rutaPublica(i.imagenClave) : undefined })), destacada: fila.destacada, etiquetas: (porCancion.etiquetas.get(fila.id) ?? []).map((e) => e.nombre), generos: (porCancion.generos.get(fila.id) ?? []).map((g) => ({ slug: g.slug, nombre: g.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 }; } /* ------------------------------------------------------------------------- * Las consultas * * Las mismas que usa el módulo de contenido —viven en `$lib/catalogo/consultas` * y no saben de dónde salen los datos—, aquí atadas a la base. Es lo que hace * que una ruta pueda cambiar de fuente sin cambiar de resultado. * * Son asíncronas porque leer el catálogo lo es; lo demás es idéntico. * ---------------------------------------------------------------------- */ export async function obtenerCancion(slug: string) { return q.obtenerCancion(await leerCatalogo(), slug); } export async function obtenerAlbum(slug: string) { return q.obtenerAlbum(await leerCatalogo(), slug); } export async function obtenerEstilo(slug: string) { return q.obtenerEstilo(await leerCatalogo(), slug); } export async function estiloPrincipal() { return q.estiloPrincipal(await leerCatalogo()); } export async function albumesDeEstilo(estiloSlug: string) { return q.albumesDeEstilo(await leerCatalogo(), estiloSlug); } export async function slugsDeAlbum() { return q.slugsDeAlbum(await leerCatalogo()); } export async function estilosConObra() { return q.estilosConObra(await leerCatalogo()); } export async function obtenerEstiloConObra(estiloSlug: string) { return q.obtenerEstiloConObra(await leerCatalogo(), estiloSlug); } export async function etiquetas() { return q.etiquetas(await leerCatalogo()); } export async function etiquetasDeEstilo(estiloSlug: string, cuantas = 4) { return q.etiquetasDeEstilo(await leerCatalogo(), estiloSlug, cuantas); } export async function obtenerEtiqueta(slug: string) { return q.obtenerEtiqueta(await leerCatalogo(), slug); } export async function portadaDeCancion(cancion: Cancion) { return q.portadaDeCancion(await leerCatalogo(), cancion); } /* No necesita catálogo: sale de la propia canción. */ export const albumDeCancion = q.albumDeCancion; /** * La letra completa de un tema, que no viaja con el catálogo. * * Es lo único pesado que hay: dieciocho letras enteras en cada petición para * pintar una lista sería mandar cien veces lo que hace falta. La ficha pide la * suya y nada más. */ export async function letraDeCancion(slug: string): Promise { const [fila] = await db .select({ letra: cancion.letra }) .from(cancion) .where(and(eq(cancion.slug, slug), eq(cancion.estado, 'publicado'))) .limit(1); return fila?.letra ?? null; } /** * La introducción de un estilo, ya compuesta. * * Va aparte del catálogo por lo mismo que la letra: solo la usa la página de * ese estilo, y mandarla en cada petición sería repartir cuatro textos para * pintar una tarjeta. */ export async function textoDeEstilo(slug: string): Promise { const [fila] = await db .select({ cuerpo: estilo.cuerpo }) .from(estilo) .where(and(eq(estilo.slug, slug), eq(estilo.estado, 'publicado'))) .limit(1); return componer(fila?.cuerpo); } /** Las notas de un disco, ya compuestas. Un álbum de singles no tiene. */ export async function notasDeAlbum(slug: string): Promise { const [fila] = await db .select({ cuerpo: album.cuerpo }) .from(album) .where(and(eq(album.slug, slug), eq(album.estado, 'publicado'))) .limit(1); return componer(fila?.cuerpo); }