import path from "node:path"; import type { Metadata } from "next"; import { notFound } from "next/navigation"; import { isSocialChannel } from "yt-dlp-transcript-common/lib/channelConfig"; import type { ChannelSnapshot } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { excludedDownloadIdSet, readChannelSnapshot, } from "yt-dlp-transcript-common/controller/channelSnapshot"; import { loadFailedTranscriptions } from "yt-dlp-transcript-common/controller/failedTranscriptions"; import { loadDownloadOutcome } from "yt-dlp-transcript-common/lib/downloadOutcome-server"; import { loadAvailability } from "yt-dlp-transcript-common/lib/availability-server"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; import { resolvePrimaryVtt } from "yt-dlp-transcript-common/lib/videoStatus"; import { readTranscriptCoverage } from "yt-dlp-transcript-common/controller/normalizeTranscript"; import { isIncompleteTranscript } from "yt-dlp-transcript-common/lib/transcriptCoverage"; import { isExcludedFromTruncatedCheck } from "yt-dlp-transcript-common/lib/excludeTruncatedCheck-server"; import { platformQueueKey, queueKeyForUrl, TRANSCRIPTION_QUEUE, } from "yt-dlp-transcript-common/lib/platform"; import { getRegistry } from "yt-dlp-transcript-common/jobs/registry"; import { getSettings } from "yt-dlp-transcript-common/lib/settings"; import { resolveSourceVideoQuality } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { VideoPanel, type VideoFile } from "../videos/[id]/components/VideoPanel"; import { VideoWorkspace } from "./components/VideoWorkspace"; import { readChannelConfigCached } from "../lib/channelConfigCache"; import { parseFilters, filterRows, matchesVideoQuery, serializeFilters, } from "../lib/videoRows"; import { computeVideoRows, readDataDirVideoIds } from "../lib/videoRowsServer"; import { normalizeBuckets } from "yt-dlp-transcript-common/views/pipeline/stageStatus"; import { attachCuratedTags } from "../lib/videoTagRows"; import { readChannelVideoTitles } from "yt-dlp-transcript-common/controller/videoTitles"; import { channelTextStall } from "yt-dlp-transcript-common/lib/channelMedia"; import { loadVideoDirFiles } from "../lib/videoDirFiles"; import { isDriveNotAnswering, onDrive, } from "yt-dlp-transcript-common/lib/storageHealth"; import { MediaNotAnswering } from "../components/MediaNotAnswering"; export const dynamic = "force-dynamic"; // The selected video's files, tiered links included (release 17): a dirent // `isFile()` filter hides them. See lib/videoDirFiles.ts. async function loadVideoDir( channelDataDir: string, videoId: string, mediaDir: string | undefined, ): Promise<{ files: VideoFile[] }> { return loadVideoDirFiles(path.join(channelDataDir, videoId), mediaDir); } export async function generateMetadata({ params, }: { params: Promise<{ slug: string }>; }): Promise { const { slug } = await params; const config = await readChannelConfigCached(slug); return { title: `${config?.name ?? slug} — Videos` }; } export default async function ChannelVideosPage({ params, searchParams, }: { params: Promise<{ slug: string }>; searchParams?: Promise>; }) { const { slug } = await params; const sp = (await searchParams) ?? {}; const rawVideo = sp.video; const selectedVideoId = typeof rawVideo === "string" && rawVideo.length > 0 ? rawVideo : null; const rawFilter = typeof sp.filter === "string" ? sp.filter : undefined; const filters = parseFilters(rawFilter); const queryRaw = typeof sp.q === "string" ? sp.q : ""; // Tag chips ride in the URL as repeated `tg` params — the export viewer's // spelling for the same question, and an OR across them. const tagFilter = Array.isArray(sp.tg) ? sp.tg.filter((t): t is string => typeof t === "string") : typeof sp.tg === "string" ? [sp.tg] : []; const paths = getPaths(); const config = await readChannelConfigCached(slug); if (!config) notFound(); // A social channel has posts, not videos — there is no data directory to list // and nothing here would render. 404 rather than an empty workspace. if (isSocialChannel(config)) notFound(); // THE LIST IS TEXT (a readdir of data/, a head read per title, the selected // video's files), on the corpus disk since release 17: a stalled MEDIA drive // does not hold it. Only a legacy channel's text is on another drive, and a // drive that is not answering is not read: the page says so instead. See // MediaNotAnswering. const stall = channelTextStall(config); if (stall) { return ; } const registry = getRegistry(); const existingQueues = registry.activeQueueNames(); const runningJobs = registry .list() .filter( (j) => j.channelSlug === slug && (j.status === "running" || j.status === "queued"), ); const platformDefaultQueueKey = config.platform ? platformQueueKey(config.platform) : queueKeyForUrl(config.url); // Read what's there; never generate. Same rule as the overview page — a // snapshot regen walks every video directory and must not run inside a GET. // The list itself comes from the DATA DIRECTORY, not the snapshot, so a // channel that has never been analysed still shows all of its videos. const existing = await readChannelSnapshot(paths, slug); const snapshot: ChannelSnapshot = existing ?? { generatedAt: "", totals: { videos: 0, transcribed: 0, downloaded: 0 }, buckets: normalizeBuckets(undefined), undownloadedIds: [], }; const excludedDownloadIds = excludedDownloadIdSet(snapshot); const failedVideoIds = (await loadFailedTranscriptions(paths, slug)).filter( (id) => !excludedDownloadIds.has(id), ); const channelDataDir = path.join(paths.channelsDir, slug, "data"); // THE READS OF A LEGACY CHANNEL'S DRIVE go through the watchdog: a drive // that has not answered them within the budget (`storage.health.budgetMs`, // 3 s by default) is marked stalled and the page says so instead (see // MediaNotAnswering). Every other channel's text is on the corpus disk. const drive = config.dataDir?.trim(); const mediaDir = config.mediaDir?.trim() || undefined; const onMedia = (call: () => Promise): Promise => drive ? onDrive(drive, call) : call(); let channelDataDirIds: string[]; let titles: Awaited>; try { [channelDataDirIds, titles] = await onMedia(async () => { const ids = await readDataDirVideoIds(channelDataDir); // What each video is called: one key range over the transcript index, // one read of the channel's metadata-scan.json, and a head read of // metadata.info.json only for what those two did not name. Measured at // ~80 ms for a synthetic 5,000-id channel (plans/release-8.md, slice V). const named = await readChannelVideoTitles(paths, slug, [ ...new Set([...ids, ...(snapshot.undownloadedIds ?? [])]), ]); return [ids, named] as const; }); } catch (err) { if (!isDriveNotAnswering(err)) throw err; return ; } const rows = computeVideoRows({ channelDataDirIds, snapshot, failedTranscriptionIds: failedVideoIds, runningJobs, excludedIds: excludedDownloadIds, titles, }); // Curated-tag pins, folded onto the rows so the list can offer Tag chips and // a bulk Tag action. Free for an untagged channel: attachCuratedTags reads // tags.json (usually absent) and returns the rows untouched when nothing in // it names this channel. const { rows: taggedRows, defs: tagDefs } = attachCuratedTags( paths, slug, rows, ); // The server's view of the ordering, used only for the detail pane's prev/next // and position readout. The list itself re-filters on the client, which is why // chip toggles don't navigate. const filteredRows = filterRows(taggedRows, filters).filter( (r) => tagFilter.length === 0 || (r.curatedTags ?? []).some((t) => tagFilter.includes(t)), ); const orderedRows = queryRaw.trim() ? filteredRows.filter((r) => matchesVideoQuery(r, queryRaw)) : filteredRows; const selectedIndex = selectedVideoId ? orderedRows.findIndex((r) => r.id === selectedVideoId) : -1; const buildVideoHref = (videoId: string): string => { const params = new URLSearchParams(); const filterParam = serializeFilters(filters); if (filterParam) params.set("filter", filterParam); if (queryRaw) params.set("q", queryRaw); for (const t of tagFilter) params.append("tg", t); params.set("video", videoId); return `/channels/${slug}/videos?${params.toString()}`; }; let detail = ( ); if (selectedVideoId) { const videoDir = path.join(channelDataDir, selectedVideoId); let loadedVideo; try { loadedVideo = await onMedia(() => Promise.all([ loadVideoDir(channelDataDir, selectedVideoId, mediaDir), // Already read for the list — the title map covers every row. Promise.resolve(titles.get(selectedVideoId)?.title ?? null), loadDownloadOutcome(videoDir), loadAvailability(videoDir), readTranscriptCoverage(videoDir), isExcludedFromTruncatedCheck(videoDir), ])); } catch (err) { if (!isDriveNotAnswering(err)) throw err; return ; } const [dirData, title, outcome, availabilityRecord, cov, excludedTrunc] = loadedVideo; const coverage = cov ? { lastCueEnd: cov.cov.lastCueEnd, duration: cov.cov.duration, coverage: cov.cov.coverage, incomplete: !excludedTrunc && isIncompleteTranscript(cov.cov, { isLivestream: cov.isLivestream }), } : null; const prevRow = selectedIndex > 0 ? orderedRows[selectedIndex - 1] : undefined; const nextRow = selectedIndex >= 0 && selectedIndex < orderedRows.length - 1 ? orderedRows[selectedIndex + 1] : undefined; detail = ( <> f.name))} handling={config.handling} defaultQueueKey={platformDefaultQueueKey} existingQueues={existingQueues} downloadOutcome={outcome} availabilityHistory={availabilityRecord?.history ?? []} channelAudioFormat={config.audioFormat} excludedFromTruncatedCheck={excludedTrunc} sourceVideoQuality={resolveSourceVideoQuality({ channel: config.sourceVideoQuality, global: getSettings().sourceVideoQuality, })} coverage={coverage} // The standalone /videos/[id] page has no list to walk, which is why // the embedded pane keeps prev/next and the position readout. prevHref={prevRow ? buildVideoHref(prevRow.id) : undefined} nextHref={nextRow ? buildVideoHref(nextRow.id) : undefined} position={ selectedIndex >= 0 ? { index: selectedIndex, total: orderedRows.length } : undefined } /> {title && (

{title}

)} ); } return ( ); } function EmptyVideoPanel({ total, filtered, }: { total: number; filtered: number; }) { return (

Select a video from the list to view actions.

{filtered} matching · {total} total

); }