import { readFileSync } from "node:fs"; import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { inspectChannelMedia } from "yt-dlp-transcript-common/lib/channelMedia"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { locationLabelOfDataDir } from "yt-dlp-transcript-common/lib/storageLocations"; import { getSite, listSiteIds, siteChannelSlugs, } from "yt-dlp-transcript-common/lib/site"; import { getActionableSummary, widgetActionableRows, } from "./lib/actionable/loadActionable"; import { readActiveSite } from "./lib/activeSiteServer"; import { buildActiveJobsPayload } from "./jobs/active/buildActiveJobs"; import { buildWorkersPayload } from "./workers/buildWorkers"; import { buildWidgetSyncPayload } from "yt-dlp-transcript-common/views/widgetSync"; import { widgetSyncInputs } from "./widget/lib/syncInputs"; import { DashboardCockpit } from "./components/dashboard/DashboardCockpit"; import { buildChannelRowView, channelVolumeOf, neutralChannelPriority, type ChannelRowView, } from "yt-dlp-transcript-common/views/channelRow"; import { buildWidgetActionablePayload } from "yt-dlp-transcript-common/views/widgetActionable"; import type { ActionableRow } from "./lib/actionable/loadActionable"; export const dynamic = "force-dynamic"; // Next.js title.template defined in app/layout.tsx applies to *child* route // segments, not the root page. Build the templated form ourselves so the home // page still gets the site name in its document title. export async function generateMetadata(): Promise { const settings = getSettings(); return { title: `Dashboard — ${settings.adminTitle}` }; } function loadChangelog(): string | null { try { return readFileSync(getPaths().editorChangelogFile, "utf8"); } catch { return null; } } export default async function Dashboard({ searchParams, }: { searchParams: Promise<{ site?: string }>; }) { const paths = getPaths(); // The active site: a `?site=` link's for this request, else the cookie's. const { site } = await searchParams; const active = await readActiveSite(site, listSiteIds(paths)); const summary = await getActionableSummary(paths); // Scope the channel-derived data to the active site's membership. Under "all // sites" the full pool is shown. Job/worker state stays global (pool state). const inScope = (() => { if (active.isAll || !active.siteId) return () => true; const slugs = siteChannelSlugs(getSite(active.siteId, paths)); return (r: ActionableRow) => slugs.has(r.channel.slug); })(); const rows = summary.rows.filter(inScope); // Enriched channels table rows (pass-through, not client state, so a global // AutoRefresh re-seeds them live). // WHERE EACH CHANNEL'S MEDIA IS — two stats and a small JSON read per row, // with the config already in hand. Not a corpus walk. A row whose drive is // not mounted reports every other number as zero, which is exactly why the // dashboard has to be able to say so rather than drawing a confident zero. const mediaBySlug = new Map( await Promise.all( rows.map( async (r) => [ r.channel.slug, await inspectChannelMedia(paths, r.channel.slug, r.channel.config), ] as const, ), ), ); // Read once for the whole table, not once per row. const locations = getSettings().storage.locations; // The shared channel row (common/views/channelRow.ts), drawn by the shared // table with the dashboard's six columns. No pipeline bands and a neutral // priority: this table draws neither. const channels: ChannelRowView[] = rows.map((r) => { const media = mediaBySlug.get(r.channel.slug) ?? null; return buildChannelRowView({ slug: r.channel.slug, config: r.channel.config, // From the snapshot rather than a corpus walk, so the Videos figure lags // a sync by the snapshot scheduler's debounce. snapshot: r.snapshot, playlistCount: null, bands: [], priority: neutralChannelPriority(), media, volume: channelVolumeOf(r.channel.config, locations), // The named location, matched against the configured roots from the // media TARGET, as this table always has. A pure prefix match, never a // probe. mediaLocationLabel: media ? locationLabelOfDataDir(media.target, locations) : undefined, }); }); // "Needs work" payload, same shape (and the same two functions) the // /api/view/widgetActionable handler the cockpit polls serves, scoped to the // active site for the SSR seed. const actionable = buildWidgetActionablePayload(widgetActionableRows(rows)); const [jobs, sync] = await Promise.all([ buildActiveJobsPayload(), widgetSyncInputs().then(buildWidgetSyncPayload), ]); const workers = buildWorkersPayload(); const changelogSource = loadChangelog(); return ( ); }