Las consultas del catálogo se escriben una vez, no dos

Qué es un single, cómo se arma el álbum de sueltos, en qué orden van las
etiquetas: veinte funciones que estaban atadas a los archivos y que
habría que haber reescrito para la base. Dos copias «parecidas» son dos
sitios donde un día discrepan, y el sitio ya tuvo ese problema con las
anclas de los encabezados.

Ahora viven en `$lib/catalogo/consultas`, reciben el catálogo como
argumento y no leen nada por su cuenta. `$lib/content` las ata a los
archivos y `$lib/server/catalogo` a la base; cambiar de fuente no puede
cambiar el resultado.

La letra completa se queda fuera del catálogo y se pide aparte: dieciocho
letras enteras en cada petición para pintar una lista sería mandar cien
veces lo que hace falta.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
master
dev 4 weeks ago
parent 715b28c3c0
commit 60f1253763

@ -0,0 +1,236 @@
/**
* Las consultas del catálogo, sin saber de dónde salen los datos.
*
* Todas reciben el catálogo como primer argumento y no leen nada por su
* cuenta. Es lo que permite que la web pase de los archivos a la base sin que
* cambie el resultado: la lógica —qué es un single, cómo se arma el álbum de
* sueltos, en qué orden van las etiquetas— se escribe una vez y las dos
* fuentes la comparten. Dos copias «parecidas» habrían sido dos sitios donde
* un día discrepan.
*
* Vive en `$lib/catalogo` y no en `$lib/server` porque no toca la base:
* también la usa el módulo de contenido, que corre en el navegador.
*/
import { PORTADA_SINGLES, aSlug } from '$lib/content/schema';
import type {
Album,
AlbumConCanciones,
Cancion,
Estilo,
EstiloConObra,
Etiqueta
} from '$lib/types';
/** Lo mínimo para responder a todo lo de este archivo. */
export interface Catalogo {
estilos: Estilo[];
albumes: Album[];
canciones: Cancion[];
}
export const PREFIJO_SINGLES = 'singles-';
/** Slug del álbum de singles de un estilo. */
export function slugSingles(estiloSlug: string): string {
return `${PREFIJO_SINGLES}${estiloSlug}`;
}
/** Álbum al que pertenece un tema; los sueltos caen en el de singles. */
export function albumDeCancion(cancion: Cancion): string {
return cancion.album ?? slugSingles(cancion.estilo);
}
export function obtenerEstilo(cat: Catalogo, slug: string): Estilo | undefined {
return cat.estilos.find((e) => e.slug === slug);
}
/** El estilo principal del artista, o el primero. */
export function estiloPrincipal(cat: Catalogo): Estilo | undefined {
return cat.estilos.find((e) => e.principal) ?? cat.estilos[0];
}
/** Canciones de un álbum declarado, en el orden del disco. */
export function cancionesDeAlbum(cat: Catalogo, albumSlug: string): Cancion[] {
return cat.canciones
.filter((c) => c.album === albumSlug)
.sort((a, b) => (a.numero ?? 0) - (b.numero ?? 0));
}
/**
* Temas de un estilo que no pertenecen a ningún disco. Van de lo más reciente
* a lo más antiguo: sin número de pista, la fecha es el único orden con
* sentido.
*/
export function singlesDeEstilo(cat: Catalogo, estiloSlug: string): Cancion[] {
return cat.canciones
.filter((c) => c.album === null && c.estilo === estiloSlug)
.sort((a, b) => (b.ano ?? 0) - (a.ano ?? 0) || a.titulo.localeCompare(b.titulo));
}
function conDuracion(album: Album, temas: Cancion[]): AlbumConCanciones {
return {
...album,
canciones: temas,
duracionTotal: temas.reduce((total, c) => total + (c.duracion ?? 0), 0)
};
}
function completar(cat: Catalogo, album: Album): AlbumConCanciones {
return conDuracion(album, cancionesDeAlbum(cat, album.slug));
}
/**
* Álbum «Singles» de un estilo: no existe como fila ni como archivo, se arma
* con los temas sueltos para que tengan sitio en la web igual que los de un
* disco. Devuelve `undefined` si ese estilo no tiene ninguno.
*/
export function albumSinglesDeEstilo(
cat: Catalogo,
estiloSlug: string
): AlbumConCanciones | undefined {
const temas = singlesDeEstilo(cat, estiloSlug);
if (temas.length === 0) return undefined;
const estilo = obtenerEstilo(cat, estiloSlug);
const anoMasReciente = Math.max(...temas.map((t) => t.ano ?? 0));
return conDuracion(
{
slug: slugSingles(estiloSlug),
titulo: 'Singles',
estilo: estiloSlug,
// La fecha solo se usa para ordenar y para enseñar el año.
fecha: `${anoMasReciente || new Date().getUTCFullYear()}-01-01`,
tipo: 'coleccion',
portada: PORTADA_SINGLES,
resumen: `Temas de ${(estilo?.nombre ?? estiloSlug).toLowerCase()} publicados fuera de un disco.`,
destacado: false,
enlaces: {},
sintetico: true,
// No es una edición real: no hay disco que acreditar ni catalogar.
edicion: { formatos: [], creditos: [] }
},
temas
);
}
/** Busca un álbum por slug, incluidos los de singles. */
export function obtenerAlbum(cat: Catalogo, slug: string): AlbumConCanciones | undefined {
if (slug.startsWith(PREFIJO_SINGLES)) {
return albumSinglesDeEstilo(cat, slug.slice(PREFIJO_SINGLES.length));
}
const album = cat.albumes.find((a) => a.slug === slug);
return album ? completar(cat, album) : undefined;
}
export function obtenerCancion(cat: Catalogo, slug: string): Cancion | undefined {
return cat.canciones.find((c) => c.slug === slug);
}
/**
* Los álbumes de un estilo. El de singles va el último: es un cajón de sastre,
* no una referencia más, y encabezar la lista con él descolocaría la
* discografía.
*/
export function albumesDeEstilo(cat: Catalogo, estiloSlug: string): AlbumConCanciones[] {
const declarados = cat.albumes
.filter((a) => a.estilo === estiloSlug)
.map((a) => completar(cat, a));
const singles = albumSinglesDeEstilo(cat, estiloSlug);
return singles ? [...declarados, singles] : declarados;
}
/** Todos los slugs de álbum que tienen página, incluidos los de singles. */
export function slugsDeAlbum(cat: Catalogo): string[] {
return [
...cat.albumes.map((a) => a.slug),
...cat.estilos
.map((e) => slugSingles(e.slug))
.filter((slug) => obtenerAlbum(cat, slug) !== undefined)
];
}
/**
* Los bloques de /musica: un estilo con todos sus álbumes y sus temas
* destacados.
*/
export function estilosConObra(cat: Catalogo): EstiloConObra[] {
return cat.estilos.map((estilo) => {
const albumesDelEstilo = albumesDeEstilo(cat, estilo.slug);
return {
estilo,
albumes: albumesDelEstilo,
destacadas: albumesDelEstilo.flatMap((a) => a.canciones).filter((c) => c.destacada),
totalCanciones: albumesDelEstilo.reduce((total, a) => total + a.canciones.length, 0)
};
});
}
export function obtenerEstiloConObra(cat: Catalogo, estiloSlug: string): EstiloConObra | undefined {
return estilosConObra(cat).find((s) => s.estilo.slug === estiloSlug);
}
/* -------------------------------------------------------------------------
* Etiquetas
*
* Cruzan el catálogo por donde no lo hacen ni el estilo ni el disco: de qué
* habla un tema, cómo suena, con quién se canta. El slug es lo que las agrupa
* —«Desamor» y «desamor» son la misma— y el nombre que se lee es el primero
* que apareció.
* ---------------------------------------------------------------------- */
/** Todas las etiquetas, de más usada a menos y luego por nombre. */
export function etiquetas(cat: Catalogo): Etiqueta[] {
const porSlug = new Map<string, Etiqueta>();
for (const cancion of cat.canciones) {
for (const nombre of cancion.etiquetas) {
const slug = aSlug(nombre);
if (!slug) continue;
const etiqueta = porSlug.get(slug) ?? { slug, nombre, canciones: [] };
etiqueta.canciones.push(cancion);
porSlug.set(slug, etiqueta);
}
}
return [...porSlug.values()].sort(
(a, b) => b.canciones.length - a.canciones.length || a.nombre.localeCompare(b.nombre, 'es')
);
}
/**
* Las etiquetas más usadas dentro de un estilo, de más a menos.
*
* Un estilo no las declara: salen de sus temas. Así no hay dos sitios que
* mantener ni forma de que la etiqueta de un estilo contradiga a las de sus
* canciones.
*/
export function etiquetasDeEstilo(cat: Catalogo, estiloSlug: string, cuantas = 4): string[] {
const cuenta = new Map<string, { nombre: string; veces: number }>();
for (const cancion of cat.canciones) {
if (cancion.estilo !== estiloSlug) continue;
for (const nombre of cancion.etiquetas) {
const slug = aSlug(nombre);
if (!slug) continue;
const actual = cuenta.get(slug) ?? { nombre, veces: 0 };
actual.veces++;
cuenta.set(slug, actual);
}
}
return [...cuenta.values()]
.sort((a, b) => b.veces - a.veces || a.nombre.localeCompare(b.nombre, 'es'))
.slice(0, cuantas)
.map((e) => e.nombre);
}
export function obtenerEtiqueta(cat: Catalogo, slug: string): Etiqueta | undefined {
return etiquetas(cat).find((e) => e.slug === slug);
}
/** Portada que corresponde a un tema: la suya propia o la de su álbum. */
export function portadaDeCancion(cat: Catalogo, cancion: Cancion): string {
return cancion.portada ?? obtenerAlbum(cat, albumDeCancion(cancion))?.portada ?? PORTADA_SINGLES;
}

@ -31,6 +31,7 @@ import type {
EstiloConObra
} from '$lib/types';
import { porFechaDesc } from '$lib/utils/date';
import * as q from '$lib/catalogo/consultas';
import { slugDesdeRuta } from './fields';
import {
parsearAlbum,
@ -149,17 +150,17 @@ export function vecinasDeEntrada(slug: string): {
* Album sintetico de singles
* ---------------------------------------------------------------------- */
const PREFIJO_SINGLES = 'singles-';
/** Slug del album de singles de un estilo. */
export function slugSingles(estiloSlug: string): string {
return `${PREFIJO_SINGLES}${estiloSlug}`;
}
/* -------------------------------------------------------------------------
* Consultas
*
* No se escriben aquí: viven en `$lib/catalogo/consultas`, sin saber de dónde
* salen los datos. Este módulo solo las ata a los archivos, y
* `$lib/server/catalogo` las ata a la base. Es lo que hace que cambiar de
* fuente no pueda cambiar el resultado; dos copias «parecidas» habrían sido
* dos sitios donde un día discrepan.
* ---------------------------------------------------------------------- */
/** Album al que pertenece un tema; los sueltos caen en el de singles. */
export function albumDeCancion(cancion: Cancion): string {
return cancion.album ?? slugSingles(cancion.estilo);
}
const CATALOGO: q.Catalogo = { estilos, albumes, canciones };
/* -------------------------------------------------------------------------
* Coherencia entre colecciones
@ -182,9 +183,9 @@ function validarReferencias(): void {
`El album "${album.slug}" declara el estilo "${album.estilo}", que no existe en src/content/estilos/.`
);
}
if (album.slug.startsWith(PREFIJO_SINGLES)) {
if (album.slug.startsWith(q.PREFIJO_SINGLES)) {
throw new Error(
`El album "${album.slug}" usa el prefijo "${PREFIJO_SINGLES}", reservado para los albumes de singles que se generan solos.`
`El album "${album.slug}" usa el prefijo "${q.PREFIJO_SINGLES}", reservado para los albumes de singles que se generan solos.`
);
}
}
@ -214,205 +215,36 @@ function validarReferencias(): void {
validarReferencias();
/* -------------------------------------------------------------------------
* Consultas
* ---------------------------------------------------------------------- */
export function obtenerEstilo(slug: string): Estilo | undefined {
return estilos.find((e) => e.slug === slug);
}
/** El estilo principal del artista (baladas romanticas), o el primero. */
export function estiloPrincipal(): Estilo | undefined {
return estilos.find((e) => e.principal) ?? estilos[0];
}
/** Canciones de un album declarado, en el orden del disco. */
export function cancionesDeAlbum(albumSlug: string): Cancion[] {
return canciones
.filter((c) => c.album === albumSlug)
.sort((a, b) => (a.numero ?? 0) - (b.numero ?? 0));
}
/**
* Temas de un estilo que no pertenecen a ningun disco. Van de lo mas reciente
* a lo mas antiguo: sin numero de pista, la fecha es el unico orden con
* sentido.
*/
export function singlesDeEstilo(estiloSlug: string): Cancion[] {
return canciones
.filter((c) => c.album === null && c.estilo === estiloSlug)
.sort((a, b) => (b.ano ?? 0) - (a.ano ?? 0) || a.titulo.localeCompare(b.titulo));
}
function conDuracion(album: Album, temas: Cancion[]): AlbumConCanciones {
return {
...album,
canciones: temas,
duracionTotal: temas.reduce((total, c) => total + (c.duracion ?? 0), 0)
};
}
/** Album con sus canciones resueltas y la duracion total sumada. */
function completar(album: Album): AlbumConCanciones {
return conDuracion(album, cancionesDeAlbum(album.slug));
}
/**
* Album "Singles" de un estilo: no existe como archivo, se arma con los temas
* sueltos para que tengan sitio en la web igual que los de un disco.
* Devuelve `undefined` si ese estilo no tiene ningun single.
*/
export function albumSinglesDeEstilo(estiloSlug: string): AlbumConCanciones | undefined {
const temas = singlesDeEstilo(estiloSlug);
if (temas.length === 0) return undefined;
const estilo = obtenerEstilo(estiloSlug);
const anoMasReciente = Math.max(...temas.map((t) => t.ano ?? 0));
return conDuracion(
{
slug: slugSingles(estiloSlug),
titulo: 'Singles',
estilo: estiloSlug,
// La fecha solo se usa para ordenar y mostrar el ano.
fecha: `${anoMasReciente || new Date().getUTCFullYear()}-01-01`,
tipo: 'coleccion',
portada: PORTADA_SINGLES,
resumen: `Temas de ${(estilo?.nombre ?? estiloSlug).toLowerCase()} publicados fuera de un disco.`,
destacado: false,
enlaces: {},
sintetico: true,
// No es una edicion real: no hay disco que acreditar ni catalogar.
edicion: { formatos: [], creditos: [] }
},
temas
);
}
/** Busca un album por slug, incluidos los de singles. */
export function obtenerAlbum(slug: string): AlbumConCanciones | undefined {
if (slug.startsWith(PREFIJO_SINGLES)) {
return albumSinglesDeEstilo(slug.slice(PREFIJO_SINGLES.length));
}
const album = albumes.find((a) => a.slug === slug);
return album ? completar(album) : undefined;
}
export function obtenerCancion(slug: string): Cancion | undefined {
return canciones.find((c) => c.slug === slug);
}
/**
* Los albumes de un estilo. El de singles va el ultimo: es un cajon de sastre,
* no una referencia mas, y encabezar la lista con el descolocaria la
* discografia.
*/
export function albumesDeEstilo(estiloSlug: string): AlbumConCanciones[] {
const declarados = albumes.filter((a) => a.estilo === estiloSlug).map(completar);
const singles = albumSinglesDeEstilo(estiloSlug);
return singles ? [...declarados, singles] : declarados;
}
/** Todos los slugs de album que tienen pagina, incluidos los de singles. */
export function slugsDeAlbum(): string[] {
return [
...albumes.map((a) => a.slug),
...estilos.map((e) => slugSingles(e.slug)).filter((slug) => obtenerAlbum(slug) !== undefined)
];
}
/**
* Los bloques de /musica: un estilo con todos sus albumes y sus temas
* destacados. Es la estructura que da forma a la pagina de musica.
*/
export function estilosConObra(): EstiloConObra[] {
return estilos.map((estilo) => {
const albumesDelEstilo = albumesDeEstilo(estilo.slug);
return {
estilo,
albumes: albumesDelEstilo,
destacadas: albumesDelEstilo.flatMap((a) => a.canciones).filter((c) => c.destacada),
totalCanciones: albumesDelEstilo.reduce((total, a) => total + a.canciones.length, 0)
};
});
}
export function obtenerEstiloConObra(estiloSlug: string): EstiloConObra | undefined {
return estilosConObra().find((s) => s.estilo.slug === estiloSlug);
}
export function obtenerPagina(slug: string): PaginaMeta | undefined {
return paginas.find((p) => p.slug === slug);
}
/* -------------------------------------------------------------------------
* Etiquetas
*
* Cruzan el catalogo por donde no lo hacen ni el estilo ni el disco: de que
* habla un tema, como suena, con quien se canta. Se escriben libres en el
* frontmatter, asi que el slug es lo que las agrupa —"Desamor" y "desamor" son
* la misma— y el nombre que se lee es el primero que aparecio.
* ---------------------------------------------------------------------- */
/** Todas las etiquetas del catalogo, de mas usada a menos y luego por nombre. */
export function etiquetas(): Etiqueta[] {
const porSlug = new Map<string, Etiqueta>();
for (const cancion of canciones) {
for (const nombre of cancion.etiquetas) {
const slug = aSlug(nombre);
if (!slug) continue;
const etiqueta = porSlug.get(slug) ?? { slug, nombre, canciones: [] };
etiqueta.canciones.push(cancion);
porSlug.set(slug, etiqueta);
}
}
return [...porSlug.values()].sort(
(a, b) => b.canciones.length - a.canciones.length || a.nombre.localeCompare(b.nombre, 'es')
);
}
/**
* Las etiquetas mas usadas dentro de un estilo, de mas a menos.
*
* Un estilo no las declara: salen de sus temas. Asi no hay dos sitios que
* mantener ni forma de que la etiqueta de un estilo contradiga a las de sus
* canciones.
*/
export function etiquetasDeEstilo(estiloSlug: string, cuantas = 4): string[] {
const cuenta = new Map<string, { nombre: string; veces: number }>();
for (const cancion of canciones) {
if (cancion.estilo !== estiloSlug) continue;
for (const nombre of cancion.etiquetas) {
const slug = aSlug(nombre);
if (!slug) continue;
const actual = cuenta.get(slug) ?? { nombre, veces: 0 };
actual.veces++;
cuenta.set(slug, actual);
}
}
return [...cuenta.values()]
.sort((a, b) => b.veces - a.veces || a.nombre.localeCompare(b.nombre, 'es'))
.slice(0, cuantas)
.map((e) => e.nombre);
}
export function obtenerEtiqueta(slug: string): Etiqueta | undefined {
return etiquetas().find((e) => e.slug === slug);
}
export const PREFIJO_SINGLES = q.PREFIJO_SINGLES;
export const slugSingles = q.slugSingles;
export const albumDeCancion = q.albumDeCancion;
export const obtenerEstilo = (slug: string) => q.obtenerEstilo(CATALOGO, slug);
export const estiloPrincipal = () => q.estiloPrincipal(CATALOGO);
export const cancionesDeAlbum = (albumSlug: string) => q.cancionesDeAlbum(CATALOGO, albumSlug);
export const singlesDeEstilo = (estiloSlug: string) => q.singlesDeEstilo(CATALOGO, estiloSlug);
export const albumSinglesDeEstilo = (estiloSlug: string) =>
q.albumSinglesDeEstilo(CATALOGO, estiloSlug);
export const obtenerAlbum = (slug: string) => q.obtenerAlbum(CATALOGO, slug);
export const obtenerCancion = (slug: string) => q.obtenerCancion(CATALOGO, slug);
export const albumesDeEstilo = (estiloSlug: string) => q.albumesDeEstilo(CATALOGO, estiloSlug);
export const slugsDeAlbum = () => q.slugsDeAlbum(CATALOGO);
export const estilosConObra = () => q.estilosConObra(CATALOGO);
export const obtenerEstiloConObra = (estiloSlug: string) =>
q.obtenerEstiloConObra(CATALOGO, estiloSlug);
export const etiquetas = () => q.etiquetas(CATALOGO);
export const etiquetasDeEstilo = (estiloSlug: string, cuantas = 4) =>
q.etiquetasDeEstilo(CATALOGO, estiloSlug, cuantas);
export const obtenerEtiqueta = (slug: string) => q.obtenerEtiqueta(CATALOGO, slug);
export const portadaDeCancion = (cancion: Cancion) => q.portadaDeCancion(CATALOGO, cancion);
/** Slug con el que enlazar una etiqueta escrita a mano. */
export function slugDeEtiqueta(nombre: string): string {
return aSlug(nombre);
}
/** Portada que corresponde a un tema: la suya propia o la de su album. */
export function portadaDeCancion(cancion: Cancion): string {
return cancion.portada ?? obtenerAlbum(albumDeCancion(cancion))?.portada ?? PORTADA_SINGLES;
export function obtenerPagina(slug: string): PaginaMeta | undefined {
return paginas.find((p) => p.slug === slug);
}
/* -------------------------------------------------------------------------

@ -46,6 +46,8 @@ import {
video
} from './db/schema';
import { extraerLineas } from '$lib/content/extracto';
import * as q from '$lib/catalogo/consultas';
import type { Catalogo } from '$lib/catalogo/consultas';
import type {
Album,
Cancion,
@ -58,11 +60,7 @@ import type {
VideoDeCancion
} from '$lib/types';
export interface Catalogo {
estilos: Estilo[];
albumes: Album[];
canciones: Cancion[];
}
export type { Catalogo } from '$lib/catalogo/consultas';
const VIDA_MS = 60_000;
@ -369,3 +367,73 @@ async function cargar(): Promise<Catalogo> {
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);
}
/**
* 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<string | null> {
const [fila] = await db
.select({ letra: cancion.letra })
.from(cancion)
.where(eq(cancion.slug, slug))
.limit(1);
return fila?.letra ?? null;
}

Loading…
Cancel
Save

Powered by TurnKey Linux.