import path from "node:path"; import type { ReactNode } from "react"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; import { countPosts, listPostShards, readPostAvailability, readPostFetchState, } from "yt-dlp-transcript-common/lib/posts-server"; import { isPostGone } from "yt-dlp-transcript-common/lib/posts"; import { resolveSocialFetcher } from "yt-dlp-transcript-common/social/fetchers"; import { describeOlderBackfill } from "yt-dlp-transcript-common/social/olderBackfill"; import "yt-dlp-transcript-common/social/blueskyFetcher"; import "yt-dlp-transcript-common/social/xGalleryDlFetcher"; import "yt-dlp-transcript-common/social/xPlaywrightFetcher"; import "yt-dlp-transcript-common/social/xNitterFetcher"; import "yt-dlp-transcript-common/social/xenforoFetcher"; import { readForumSessionStatus } from "yt-dlp-transcript-common/social/forumSession"; import { parseXenforoThreadUrl } from "yt-dlp-transcript-common/social/xenforoParse"; import { SocialChannelPanel } from "./components/SocialChannelPanel"; import { listPostFetchersFor } from "./socialActions"; import { NoReportYet } from "./components/NoReportYet"; import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { digestWorkOf, excludedDownloadIdSet, normalizeAvailability, normalizeExcludedFromDownload, normalizeMaybeMissing, readChannelSnapshot, } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { countPlaylist } from "yt-dlp-transcript-common/controller/channels"; import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { savedVideoTotals } from "yt-dlp-transcript-common/controller/savedVideoInventory"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { locationLabelOfDataDir } from "yt-dlp-transcript-common/lib/storageLocations"; import { probeAllLocations } from "yt-dlp-transcript-common/controller/storageLocations"; import { STORAGE_STATUS_LABEL } from "yt-dlp-transcript-common/views/storage"; import { loadShardConfig, type ShardConfig, type ShardOp, } from "yt-dlp-transcript-common/controller/shard"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { channelMediaStall, inspectChannelMedia, } from "yt-dlp-transcript-common/lib/channelMedia"; import { mediaHoldText } from "yt-dlp-transcript-common/lib/channelMediaHold"; import { MediaNotAnswering } from "./components/MediaNotAnswering"; import { isDriveNotAnswering, onDrive, } from "yt-dlp-transcript-common/lib/storageHealth"; import { getFreeBytes } from "yt-dlp-transcript-common/lib/diskSpace"; import { platformQueueKey, queueKeyForUrl, TRANSCRIPTION_QUEUE, } from "yt-dlp-transcript-common/lib/platform"; import { BACKFILL_QUEUE, DIGEST_LOCAL_QUEUE, DIGEST_REMOTE_QUEUE, } from "yt-dlp-transcript-common/lib/queueKeys"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { channelMediaBusyReason } from "../lib/mediaBusy"; import { listSites } from "yt-dlp-transcript-common/lib/site"; import { sortGroups } from "yt-dlp-transcript-common/lib/channelGroups"; import { ChannelFormClient } from "../components/ChannelFormClient"; import type { InitialMembership, SiteMembershipOption, } from "../components/SiteMembershipsSection"; import { DeleteChannelForm } from "../components/DeleteChannelForm"; import { RenameChannelForm } from "../components/RenameChannelForm"; import { RunningJobsList } from "../../jobs/components/RunningJobsList"; import { liveJobRows } from "../../jobs/active/buildActiveJobs"; import { CleanupStage } from "./components/stages/CleanupStage"; import { DiagnosticsStage } from "./components/stages/DiagnosticsStage"; import { DownloadStage } from "./components/stages/DownloadStage"; import { PlaylistStage } from "./components/stages/PlaylistStage"; import { MATCHED_PREVIEW_LIMIT, type MetadataScanMatch, } from "./lib/metadataScanView"; import { loadMetadataScan } from "yt-dlp-transcript-common/controller/metadataScanStore"; import { classifyAgainstFilter, compileDownloadFilter, } from "yt-dlp-transcript-common/lib/downloadFilters"; import { TranscribeStage } from "./components/stages/TranscribeStage"; import { DigestStage } from "./components/stages/DigestStage"; import { SpeakersStage } from "./components/stages/SpeakersStage"; import { StorageStage } from "./components/stages/StorageStage"; import { getOperation, backfillLaneOperations, operationsActionLabel, operationsGroupLabel, OPERATION_GROUP_ORDER, } from "yt-dlp-transcript-common/lib/operations"; import { ChannelLine } from "./components/flow/ChannelLine"; import { NextAction } from "./components/flow/NextAction"; import { AttentionStrip } from "./components/flow/AttentionStrip"; import { StageSwitcher } from "./components/flow/StageSwitcher"; import { OverviewPanel } from "./components/flow/OverviewPanel"; import { computeChannelFlow } from "yt-dlp-transcript-common/views/pipeline/channelFlow"; import { readChannelConfigCached } from "./lib/channelConfigCache"; import { computeStageStatuses, normalizeBuckets, GROUP_STAGES, type StageId, } from "yt-dlp-transcript-common/views/pipeline/stageStatus"; import { deleteChannelAction, renameChannelAction, updateChannelAction, type ActionResult, } from "../actions"; export const dynamic = "force-dynamic"; export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; const config = await readChannelConfigCached(slug); const subject = config?.name ?? slug; return { title: `${subject} — Channel` }; } export default async function ChannelDetailPage({ params, searchParams, }: { params: Promise<{ slug: string }>; searchParams?: Promise>; }) { const { slug } = await params; const sp = (await searchParams) ?? {}; const paths = getPaths(); const config = await readChannelConfigCached(slug); if (!config) notFound(); // A social (posts) channel short-circuits the whole video pipeline view: it // has no downloads, audio, transcripts or availability, so none of the six // stations or their pathology buckets apply. It gets a minimal Fetch → Index // rail instead (see SocialChannelPanel). Giving it the same line shape is a // good follow-up, not this change. if (isSocialChannel(config)) { const channelRoot = path.join(paths.channelsDir, slug); const [postCount, shards, fetchState, postAvailability] = await Promise.all( [ countPosts(channelRoot), listPostShards(channelRoot), readPostFetchState(channelRoot), readPostAvailability(channelRoot), ], ); const availabilityRecords = Object.values(postAvailability); const fetcher = resolveSocialFetcher(config.postFetcher, config.url); // Through the one builder, so this list has the same progress bars /jobs // does (it used to drop `progress`, `tasks`, `drainable` and the reorder // bounds). const socialRunningJobs = await liveJobRows((j) => j.channelSlug === slug); // A forum thread's browser session (one profile per forum host). const forumThread = config.platform === "xenforo" ? parseXenforoThreadUrl(config.url ?? "") : null; const forumSession = forumThread ? await readForumSessionStatus(paths, forumThread.host) : undefined; return (
isPostGone(r.availability), ).length, checkedCount: availabilityRecords.length, canCheckAvailability: typeof fetcher?.checkAvailability === "function", canFetchOlder: typeof fetcher?.fetchOlder === "function", olderStatus: describeOlderBackfill(fetchState?.older), handle: config.socialHandle ?? slug, accountUrl: config.url, platform: config.platform, ...(forumSession ? { forumSession } : {}), }} /> {/* THE SAME DANGER ZONE A VIDEO CHANNEL HAS, which a social channel used to lack entirely: this branch returns before the stage list, so a misspelled X channel could not be renamed from the editor. The forms, actions and busy guard are the video page's own; collapsed, because it is a chore and not this page's subject, and opened by the same `?stage=danger` link a video channel answers to. */}
Danger zone
Promise } />
Promise } />
); } const registry = getRegistry(); const existingQueues = registry.activeQueueNames(); // Through the one builder, so this list has the same progress bars /jobs does // (it used to drop `progress`, `tasks`, `drainable` and the reorder bounds). const runningJobs = await liveJobRows((j) => j.channelSlug === slug); const platformDefaultQueueKey = config.platform ? platformQueueKey(config.platform) : queueKeyForUrl(config.url); // ⚠️ This used to be `existing ?? await generateChannelSnapshot(paths, slug)` // — a full channel analysis run INSIDE A GET. On the 11,224-video channel // that is a multi-minute request that walks every video directory, and the // only thing that triggers it is opening a link. A render must never // generate; it reads what's there and OFFERS to generate what isn't. // // With no report, the page renders normally against an empty one rather than // replacing itself with a placeholder: the pipeline controls (Download videos, // Transcribe, Sync) are exactly what you want available on a channel that has // never been analysed. Only the derived buckets and counts are empty, and // NoReportYet says so at the top. const existing = await readChannelSnapshot(paths, slug); const snapshot: ChannelSnapshot = existing ?? { generatedAt: "", totals: { videos: 0, transcribed: 0, downloaded: 0 }, buckets: normalizeBuckets(undefined), undownloadedIds: [], }; const settings = getSettings(); // The retry-failures panels are interactive (a click mutates the file), so // read them fresh on every render — the snapshot bucket only reflects state // at refresh time. const rawFailedVideoIds = await loadFailedTranscriptions(paths, slug); // ONE small readFile — the first station's denominator. Explicitly not the // corpus walk that noCorpusWalkInRenderPaths.test.ts bans. const playlistCount = await countPlaylist( path.join(paths.channelsDir, slug, "playlist"), ); // WHERE THE MEDIA IS. Two stats and one small JSON read, with the config // already in hand so nothing re-reads it — explicitly not the corpus walk // noCorpusWalkInRenderPaths.test.ts bans. It is read on every render of this // page rather than cached because an unmounted drive is exactly the kind of // fact that must never be served stale: the whole point of the badge is that // it is true NOW. const media = await inspectChannelMedia(paths, slug, config); const buckets = normalizeBuckets(snapshot.buckets); const undownloadedIds = snapshot.undownloadedIds ?? []; const excludedFromDownload = normalizeExcludedFromDownload( snapshot.excludedFromDownload, ); // Hide failed-transcription entries whose video can no longer be acted on // (members_only / deleted / private). Same rationale as the snapshot's // bucket filter; this path is fresh-loaded so it needs its own pass. const excludedDownloadIds = excludedDownloadIdSet(snapshot); const failedVideoIds = rawFailedVideoIds.filter( (id) => !excludedDownloadIds.has(id), ); const actionableNoTranscriptIds = buckets.noTranscript.filter( (id) => !excludedDownloadIds.has(id), ); const actionableDownloadedNoTranscriptIds = buckets.downloadedNoTranscript.filter((id) => !excludedDownloadIds.has(id)); // Enabled lane backfills. A settings read, no I/O. // // backfillLaneOperations, still: this is the BACKFILL lane's list, and the snapshot // now carries an entry for every catalog operation including digest, which has // its own station and its own queue key. const laneOperations = backfillLaneOperations(settings); const stages = computeStageStatuses({ snapshot, failedVideoIds, config, runningJobs, backfillEnabled: laneOperations.length > 0, backfillKindIds: laneOperations.map((k) => k.id), media, }); // THE MIDDLE COMES FROM THE REGISTRY, in OPERATION_GROUP_ORDER. Same array as // the literal it replaces, in the same order — this is a compile-time // membership check, not a re-shaping: GROUP_STAGES is an exhaustive // Record, so a new operation group does not compile // until it says which stage(s) hold it. // // The ends stay hand-listed. configure/playlist and cleanup/diagnostics/danger // are channel chores, not operations — see GROUP_STAGES. // // `media`, `transcript`, `digest`, `speakers` each spread their stages; there // is no per-channel filter — every stage in the Record applies to every // channel. const stageOrder: StageId[] = [ "configure", "playlist", ...OPERATION_GROUP_ORDER.flatMap((g) => GROUP_STAGES[g]), "cleanup", "diagnostics", "storage", "danger", ]; // `?stage=` replaces the old `#stage-` hash: the selection is server-rendered, // shareable, and survives the global AutoRefresh's router.refresh(). An // unknown or absent value lands on the overview rather than 404ing — a saved // link to a stage that no longer exists (`?stage=transcode`, retired // 2026-08-30) should still open the channel. const rawStage = typeof sp.stage === "string" ? sp.stage : undefined; // RETIRED STAGE IDS, so a bookmark does not silently land somewhere else. // An unknown `?stage=` resolves to the OVERVIEW at 200 (pinned by // channel-stage-selection.spec.ts), which is right for a stage that no longer // applies — but wrong for one that was RENAMED: `?stage=backfill` would open // the overview with no hint that the panel it named is still there under a new // id. Resolve the alias first and the old link keeps working. const STAGE_ALIASES: Record = { backfill: "speakers" }; const resolvedStage = rawStage ? (STAGE_ALIASES[rawStage] ?? rawStage) : undefined; const selectedStage: StageId | null = (stageOrder.find((id) => id === resolvedStage) as StageId | undefined) ?? null; const flow = computeChannelFlow({ snapshot, stages, config, failedVideoIds, laneOperations, playlistCount, }); // ONLY THE SELECTED PANEL IS BUILT. // // Every load below this line belongs to exactly one panel, and each is behind // the branch that needs it — the shard configs, the saved-video totals, the // site list. Rendering all ten panels on every request is what put four file // reads and a directory walk in front of a page whose default view needs // neither. `panel` is null on the overview, which does no I/O at all. const panel = selectedStage ? await buildPanel(selectedStage) : null; async function buildPanel(id: StageId): Promise { const summarize = (c: ShardConfig | null) => c ? { totalShards: c.totalShards, shardIndex: c.shardIndex, itemCount: c.items.length, createdAt: c.createdAt, } : null; const shard = async (op: ShardOp) => summarize(await loadShardConfig(paths, slug, op)); switch (id) { case "configure": { // Sites membership section: every configured site plus this channel's // current membership (and group) on each. const allSites = listSites(paths); const siteOptions: SiteMembershipOption[] = allSites.map((s) => ({ siteId: s.siteId, siteTitle: s.siteTitle, defaultGroupId: s.defaultGroupId, groups: sortGroups(s.groups).map((g) => ({ id: g.id, name: g.name })), })); const initialMemberships: InitialMembership[] = allSites.flatMap( (s) => { const m = s.channels.find((c) => c.slug === slug); if (!m) return []; return [ m.groupId ? { siteId: s.siteId, groupId: m.groupId } : { siteId: s.siteId }, ]; }, ); return ( Promise } initial={{ slug, config: config! }} submitLabel="Save changes" sites={siteOptions} initialMemberships={initialMemberships} /> ); } case "playlist": { // THE METADATA SCAN'S VIEW. Built here rather than in a view module // because it needs the store off disk, and `views/` may not touch // node:fs. Two reads: one small JSON file and the playlist the page has // already counted. const scanStore = await loadMetadataScan(paths, slug); const compiledFilter = compileDownloadFilter(config!.downloadFilter); const matched: MetadataScanMatch[] = []; let filteredOut = 0; let matchedByText = 0; let matchedLivestreams = 0; if (compiledFilter) { for (const [id, e] of Object.entries(scanStore.entries)) { const verdict = classifyAgainstFilter(compiledFilter, e); if (verdict === "rejected") { filteredOut++; continue; } if (verdict === "livestream") matchedLivestreams++; else matchedByText++; matched.push({ id, uploadDate: e.uploadDate, title: e.title }); } // Newest first, and capped: the list is an eyeball check that the // regex caught what the operator meant, not an inventory. matched.sort((a, b) => b.uploadDate.localeCompare(a.uploadDate)); } return ( ); } case "download": return ( ); case "transcribe": return ( ); case "digest": { // From the operation registry (digestWorkOf) — the one definition of // the digest work list, the same one the runner dispatches from. A // channel with no entry reports unknown coverage, never "all digested". const digestWork = digestWorkOf(snapshot); return ( ); } case "speakers": return ( { const entry = snapshot.backfill?.[kind.id]; return { id: kind.id, label: kind.label, // What one video of this operation costs — its cost basis. Declared on the // registry so the card can print it beside the backlog without // knowing anything about diarization or model calls. costBasis: kind.costBasis, reachableIds: entry?.ids ?? [], missingInput: entry?.missingInput ?? 0, // ?? 0 is load-bearing, not defensive: snapshots written before // the cap existed have no `deferred` field, and // .toLocaleString() on undefined throws in the render path. deferred: entry?.deferred ?? 0, // From the registry, not the card: the card sums several kinds // and cannot know why any one of them deferred. deferredHint: kind.deferredHint, // Same reason: every snapshot on disk predates this field. blocked: entry?.blocked ?? 0, // Resolved here, on the server, because OPERATION_BY_ID // reads the filesystem and must never reach a client component. dependsOnLabels: (kind.dependsOn ?? []) .map((id) => getOperation(id)?.label) .filter((l): l is string => Boolean(l)), stale: entry?.stale ?? 0, }; })} allowRedownload={settings.backfill.allowRedownload} anyEnabled={laneOperations.length > 0} // The lane's name comes from what it HOLDS, not from its queue key. groupLabel={operationsGroupLabel(laneOperations.map((k) => k.id))} actionLabel={operationsActionLabel(laneOperations.map((k) => k.id))} /> ); case "cleanup": { // The saved-video summary below reads a pointer in every video dir, and // this stage's actions all act on the media: on a drive that is not // answering the stage says so instead (see MediaNotAnswering). const stall = channelMediaStall(config); if (stall) { return ( ); } // Saved-video store summary for this channel + whether backups are // configured, for the Retention & persistence section. Its reads go // through the watchdog (`notAnswering`): a drive that stops answering // on the way is named there, and the stage says so. const notAnswering: string[] = []; const savedTotals = await savedVideoTotals({ paths, channelSlug: slug, notAnswering, }); if (notAnswering.length > 0) { return ( ); } return ( ); } case "diagnostics": return ( ); case "storage": { // The free-space figure names the volume the media is on RIGHT NOW — // the platter for a relocated channel, the corpus disk otherwise — so // it answers "can this channel keep downloading", not "how full is // /home". getFreeBytes returns Infinity for a path it cannot statfs, // which formatBytes renders "∞"; that is the honest answer for an // unmounted target and is why the status line above it is the thing to // read first. const volumeDir = media.status === "ok" && media.target ? media.target : paths.channelsDir; // `media` is inspectChannelMedia's answer and it already CARRIES the // marker — reading the file again here was a second read of the same // bytes that could disagree with the status rendered beside it. const marker = media.marker ?? null; // A statfs of the channel's drive goes through the watchdog // (lib/storageHealth.ts): refused on a stalled location, given up on // after the budget (3 s by default), and read "—" either way. let freeBytes: number | null; try { freeBytes = volumeDir === media.target && media.target ? await onDrive(media.target, () => getFreeBytes(volumeDir)) : await getFreeBytes(volumeDir); } catch (err) { if (!isDriveNotAnswering(err)) throw err; freeBytes = null; } // The SAME two conditions storageActions.ts refuses on, stated here as // prose so the button is off with a reason rather than off and silent — // and stated in the action too, because a disabled button is a courtesy // and the server is the guard. "Busy" is jobs AND in-flight auto-queue // units, which make no job record — see lib/mediaBusy.ts. // The move holds the media writers only (release 17): a digest may // run during it. const busy = channelMediaBusyReason(slug, "moving its media", { mediaOnly: true, }); const blockedReason = busy ?? (marker ? `A relocation (${marker.direction}) to ${marker.target} is in flight, or was interrupted at phase "${marker.phase}". A channel in transition is not moved again from here — the running job finishes it, and an interrupted one is finished by "Resume move" or "Reconcile and resume" below.` : null); // THE DESTINATIONS, EACH WITH ITS DRIVE'S CURRENT STATE. One probe per // configured location, memoised for 10 s inside the controller — so a // re-render costs nothing and a stage the operator is not looking at // costs nothing either, because this branch only runs for the Storage // stage. The panel is `"use client"` and can never ask this itself: // `storageVolumes.ts` shells out. const locations = settings.storage.locations; const probes = await probeAllLocations(locations, paths); return ( { const probe = probes[loc.id]; return { id: loc.id, label: loc.label || loc.id, root: loc.root, statusLabel: STORAGE_STATUS_LABEL[probe?.status ?? "missing"], available: probe?.status === "available", }; })} defaultLocationId={settings.storage.defaultLocationId} // THE HOLD, in the rack's words: "held: its media is moving (…)" // while the marker stands (release 16 slice RM). mediaHold={mediaHoldText(media.status)} /> ); } case "danger": { // THE SAME SENTENCE THE ACTIONS REFUSE WITH, one click earlier. Both // actions ask this themselves — a disabled button is a courtesy and the // server is the guard — but a Danger-zone form that submits, moves // nothing and comes back with a paragraph is the worst place to learn // that a lane was mid-write. The verb differs per form so the reason // reads as an instruction in each. const renameBusy = channelMediaBusyReason(slug, "renaming it"); const deleteBusy = channelMediaBusyReason(slug, "deleting it"); return (
Promise } />
Promise } />
); } } } return (
{!existing && } {/* The shortcut exists so you do not have to dig into a stage to start the obvious thing. Once a stage IS open its own controls are right there, and a second button that queues the same job — under the same name — is a duplicate affordance rather than a shortcut. */} {selectedStage === null && }
{selectedStage ? (

{stages[selectedStage].title}

{stages[selectedStage].summary}

{panel}
) : ( )}
); }