// The homepage summary's inputs, gathered ONCE for both consumers: the homepage // (compose-homepage writes the whole summary) and the hub (compose-hub writes // the `official` + per-site projection, lib/hubSummary.ts). Both call // buildPoolSummary, so the two sites cannot disagree about a number. // // NOT read-only: buildStats keeps its incremental per-video state in the index // LMDB (`statsByPath`), and writes the whole-pool stats pages into `statsDir`. // Requires build:index to have populated the cues sub-DB first. import path from "node:path"; import { mkdir, readFile } from "node:fs/promises"; import type { Paths } from "../lib/paths"; import { buildStats } from "./buildStats"; import { isListedSite, listSites, type Site } from "../lib/site"; import { getSettings } from "../lib/settings"; import { publishedMemberSlugs } from "../lib/postsVisibility"; import { readChannelConfig } from "./channels"; import { statsPageFileName, type StatsManifest, type VideoStat, } from "../lib/stats"; import { buildHomepageSummary, type ChannelSitesMap, type HomepageSummary, } from "../lib/homepageSummary"; // Read the whole-pool stats dataset back from the pages buildStats just wrote, so // the lightweight summary can be derived from the same records without // re-extracting. Pages are local files (fast) capped well under memory limits. export async function readStatsPages(statsDir: string): Promise { let manifest: StatsManifest; try { manifest = JSON.parse( await readFile(path.join(statsDir, "manifest.json"), "utf8"), ) as StatsManifest; } catch { return []; } const out: VideoStat[] = []; for (let i = 0; i < manifest.pageCount; i++) { const page = JSON.parse( await readFile(path.join(statsDir, statsPageFileName(i)), "utf8"), ) as VideoStat[]; out.push(...page); } return out; } // channel slug -> the ids of the LISTED content sites that expose it: the // published `channel-sites.json`. A channel on multiple sites maps to all of // them; a pool-only channel is simply absent, and so is an unlisted site // (site.json `listed: false`) and a channel only unlisted sites expose. // // `membersOf` answers the members a site's build publishes; absent, every // member. buildPoolSummary passes lib/postsVisibility.ts's narrowing, so an X // channel a public site leaves out while X posts are private is not mapped to // that site here either. export function channelSitesOf( sites: readonly Site[], membersOf: (site: Site) => readonly string[] = (site) => site.channels.map((c) => c.slug), ): ChannelSitesMap { const channelSites: ChannelSitesMap = {}; for (const site of sites) { if (!isListedSite(site)) continue; for (const slug of membersOf(site)) { (channelSites[slug] ??= []).push(site.siteId); } } return channelSites; } export type PoolSummary = { summary: HomepageSummary; channelSites: ChannelSitesMap; sites: Site[]; }; // Refresh the whole-pool stats into `statsDir`, then summarise them. export async function buildPoolSummary(opts: { paths: Paths; statsDir: string; now?: Date; }): Promise { const { paths, statsDir } = opts; await mkdir(statsDir, { recursive: true }); // Whole-pool stats dataset (every non-excluded channel but those only // unlisted sites expose). buildStats also refreshes the per-site bundles as a // side effect, which is harmless. await buildStats({ paths, wholePoolStatsDir: statsDir }); const sites = listSites(paths); // The members each site's build publishes (lib/postsVisibility.ts). const configs = new Map>>(); for (const slug of new Set(sites.flatMap((s) => s.channels.map((c) => c.slug)))) { configs.set(slug, await readChannelConfig(paths, slug).catch(() => null)); } const settings = getSettings(); const channelSites = channelSitesOf(sites, (site) => publishedMemberSlugs(site, (slug) => configs.get(slug), settings), ); const stats = await readStatsPages(statsDir); const summary = buildHomepageSummary( stats, channelSites, sites, opts.now ?? new Date(), ); return { summary, channelSites, sites }; }