"use client"; import { useEffect, useState } from "react"; import slugify from "@sindresorhus/slugify"; import type { ChannelConfig } from "yt-dlp-transcript-common/lib/channelConfig"; import { detectPlatform, isSocialPlatform, queueKeyForUrl, type Platform, } from "yt-dlp-transcript-common/lib/platform"; import { DOWNLOAD_FORMAT_LABELS, DOWNLOAD_FORMAT_PRESETS, SOURCE_VIDEO_QUALITIES, SOURCE_VIDEO_QUALITY_LABELS, } from "yt-dlp-transcript-common/ytdlp/downloadFormat"; import { AUDIO_CHECK_COPY_TIMEOUT_DEFAULT_SECONDS, AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS, AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS, AUDIO_CHECK_INTERVAL_DEFAULT_SECONDS, AUDIO_CHECK_INTERVAL_MAX_SECONDS, AUDIO_CHECK_INTERVAL_MIN_SECONDS, AUDIO_CHECK_MAX_ROLLBACKS_DEFAULT, AUDIO_CHECK_MAX_ROLLBACKS_MAX, AUDIO_CHECK_MAX_ROLLBACKS_MIN, SYNC_INTERVAL_MAX_MINUTES, SYNC_INTERVAL_MIN_MINUTES, } from "yt-dlp-transcript-common/lib/channelConfig"; import { FULL_SWEEP_PRESETS, SYNC_INTERVAL_PRESETS, } from "../../scheduler/intervalPresets"; import { DurationField } from "../../components/DurationField"; import { handleFromAccountUrl } from "yt-dlp-transcript-common/social/fetchers"; import { probeChannelUrlAction } from "../actions"; import { Field, SeededInput, SeededSelect, } from "../../components/forms/Field"; import { ControlledCheck, ControlledSelect, } from "../../components/forms/Controlled"; import { failedValues, seedChecked, seedValue, type SeedSource, } from "../../lib/formState"; import { SiteMembershipsSection, type InitialMembership, type SiteMembershipOption, } from "./SiteMembershipsSection"; // Path segments that never make a good slug (feed/tab names, container labels), // so slugCandidateFromUrl skips them when picking the channel name from a URL. const SLUG_NOISE_SEGMENTS = new Set([ "videos", "featured", "streams", "playlists", "community", "channel", "channels", "c", "user", "about", "shorts", "live", ]); // A long thread title cut at a word (hyphen) boundary, so its slug stays short. function shortSlugBase(title: string, max = 48): string { if (title.length <= max) return title; const cut = title.slice(0, max); const at = cut.lastIndexOf("-"); return at > 12 ? cut.slice(0, at) : cut; } // Offline slug candidate from a channel/playlist URL, no network. Prefers an // @handle (@Veritasium -> veritasium); otherwise the last non-noise path segment // (/c/Some Name -> some-name). Returns "" when nothing usable is found, so the // caller falls back to the name-derived slug. function slugCandidateFromUrl(url: string): string { const trimmed = url.trim(); if (!trimmed) return ""; try { const u = new URL(trimmed); const segs = u.pathname .split("/") .map((s) => { try { return decodeURIComponent(s); } catch { return s; } }) .filter(Boolean); const handle = segs.find((s) => s.startsWith("@")); // A forum thread (…/threads/.<id>/page-N): the title. const threadAt = segs.indexOf("threads"); const threadTitle = threadAt >= 0 && /\.\d+$/.test(segs[threadAt + 1] ?? "") ? shortSlugBase(segs[threadAt + 1].replace(/\.\d+$/, "")) : undefined; const base = handle ?? threadTitle ?? [...segs].reverse().find((s) => !SLUG_NOISE_SEGMENTS.has(s.toLowerCase())) ?? segs[segs.length - 1] ?? ""; return slugify(base.replace(/^@/, "")); } catch { return ""; } } type Props = { action: string | ((formData: FormData) => void | Promise<void>); initial?: { slug: string; config: ChannelConfig }; submitLabel: string; errorMessage?: string; // The form's action state (ChannelFormClient's useActionState): after a // refused submit, every uncontrolled field seeds from what was submitted // (lib/formState.ts). state?: SeedSource; // All configured sites, for the Sites membership section. sites: SiteMembershipOption[]; // Edit mode: the channel's current site memberships. initialMemberships?: InitialMembership[]; // Create mode: the active site, which starts checked (ChannelFormClient: a // `?site=` link's, else the stored selection). activeSiteId?: string | null; }; export function ChannelForm({ action, initial, submitLabel, errorMessage, state, sites, initialMemberships, activeSiteId, }: Props) { const isEdit = !!initial; const c = initial?.config; // Edit mode's handling radios are uncontrolled: a radio group seeds from the // VALUE it posted, not from whether its name was there. const editHandling = seedValue( state, "handling", c?.handling === "transcribe" ? "transcribe" : "youtube", ); // Create mode's "On create" boxes are drawn per KIND of source (Fetch posts // now for a social account; Fetch playlist now and Add to top for a video // channel), so each kind seeds only from a refused submit of its own kind. // A refused video submit carries no `fetchPostsNow`, and switching the URL // to a social account afterwards must show that box at its default // (ticked), not "absent, so unticked". The social branch always posts the // hidden `sourceKind=social`. const postedSocial = failedValues(state)?.sourceKind === "social"; const socialSeed = postedSocial ? state : undefined; const videoSeed = postedSocial ? undefined : state; // Controlled, so a surface that has to compare what is TYPED against what a // stored scan was run under can read them. (Nothing does yet; the metadata // scan that will is a catalogued operation, not part of this form.) const [filterInclude, setFilterInclude] = useState( c?.downloadFilter?.include ?? "", ); const [filterExclude, setFilterExclude] = useState( c?.downloadFilter?.exclude ?? "", ); // URL-first onboarding state (create mode only — edit mode keeps the plain // uncontrolled fields). We track "touched" flags so the offline autofill and // the Fetch-details probe only ever fill a field the operator hasn't set by // hand. Hooks run unconditionally (rules of hooks); they're simply unused in // edit mode. const [name, setName] = useState(c?.name ?? ""); const [slug, setSlug] = useState(""); const [slugTouched, setSlugTouched] = useState(false); const [url, setUrl] = useState(c?.url ?? ""); const [handling, setHandling] = useState<"youtube" | "transcribe">( c?.handling === "transcribe" ? "transcribe" : "youtube", ); const [handlingTouched, setHandlingTouched] = useState(false); const [platform, setPlatform] = useState<string>(c?.platform ?? ""); const [platformTouched, setPlatformTouched] = useState(false); // Social sources are ingested into the posts corpus, not the video pipeline, // so every video-only control below is hidden for them. In edit mode this is // fixed by the stored config; in create mode it follows the URL/platform. const [postFetcher, setPostFetcher] = useState(c?.postFetcher ?? ""); const [socialHandle, setSocialHandle] = useState(c?.socialHandle ?? ""); const [handleTouched, setHandleTouched] = useState(false); const editSourceKind = c?.sourceKind ?? "video"; const isSocial = isEdit ? editSourceKind === "social" : isSocialPlatform(platform as Platform) || isSocialPlatform(detectPlatform(url)); const [probe, setProbe] = useState<{ state: "idle" | "loading" | "done" | "error"; message?: string; queueKey?: string; queueKnown?: boolean; }>({ state: "idle" }); // Offline autofill: derive platform / handling / slug from the URL alone, with // NO network call. Only fills untouched fields, so a hand-set value sticks. useEffect(() => { if (isEdit) return; const detected = detectPlatform(url); if (!platformTouched) setPlatform(detected ?? ""); if (!handlingTouched && url.trim()) { setHandling(detected === "youtube" ? "youtube" : "transcribe"); } if (!slugTouched) { const cand = slugCandidateFromUrl(url); if (cand) setSlug(cand); } // Social sources get their handle + fetcher offline too, from the URL // alone — same no-network rule as platform/handling/slug. "Fetch details" // only refines them (it can return the canonical-cased handle). if (!handleTouched) { setSocialHandle(handleFromAccountUrl(url) ?? ""); } if (detected === "twitter") setPostFetcher("x-gallery-dl"); else if (detected === "bluesky") setPostFetcher("bluesky-atproto"); // A forum thread URL (…/threads/<title>.<id>/): the thread walker. else if (detected === "xenforo") setPostFetcher("xenforo-thread"); else setPostFetcher(""); }, [url, isEdit, platformTouched, handlingTouched, slugTouched, handleTouched]); // The derived download queue this channel will use. Offline from the URL; the // probe result (which may prove an unknown host) overrides it once fetched. const offlineQueueKey = url.trim() ? queueKeyForUrl(url) : null; const shownQueueKey = probe.state === "done" && probe.queueKey ? probe.queueKey : offlineQueueKey; const queueKnown = probe.state === "done" ? probe.queueKnown === true : detectPlatform(url) !== null; async function fetchDetails() { if (!url.trim()) return; setProbe({ state: "loading" }); try { const res = await probeChannelUrlAction(url); if (!res.ok) { setProbe({ state: "error", message: res.error }); return; } if (res.name) { setName(res.name); if (!slugTouched) setSlug(slugify(res.name)); } if (!platformTouched) setPlatform(res.platformDetected ?? ""); // A social URL resolves through the fetcher's own probe, which also // reports which fetcher claimed it and the canonical handle. if (res.postFetcher) setPostFetcher(res.postFetcher); if (res.socialHandle) setSocialHandle(res.socialHandle); setProbe({ state: "done", message: res.name ?? undefined, queueKey: res.queueKey, queueKnown: res.queueKnown, }); } catch (e) { setProbe({ state: "error", message: (e as Error).message }); } } return ( <form action={action} className="flex flex-col gap-4 max-w-xl"> {errorMessage && ( <div className="rounded border border-destructive/30 bg-destructive-soft px-3 py-2 text-sm text-destructive"> {errorMessage} </div> )} <Section title="Identity"> {isEdit ? ( <Field label="Name" name="name" state={state} required defaultValue={c?.name ?? ""} placeholder="Display name (e.g., TheQuartering)" /> ) : ( <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Name</span> <input type="text" name="name" required value={name} onChange={(e) => setName(e.target.value)} placeholder="Display name (e.g., TheQuartering)" className="rounded border border-border bg-card px-2 py-1 text-sm" /> </label> )} {isEdit ? ( <Field label="Slug" name="slug" state={state} readOnly defaultValue={initial!.slug} hint="To change the slug, use Rename in the Danger zone below." /> ) : ( <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Slug</span> <input type="text" name="slug" value={slug} onChange={(e) => { setSlug(e.target.value); setSlugTouched(true); }} placeholder="kebab-case (auto-derived from URL or name if blank)" className="rounded border border-border bg-card px-2 py-1 text-sm" /> </label> )} </Section> <Section title="Sites"> <SiteMembershipsSection sites={sites} initialMemberships={initialMemberships} activeSiteId={activeSiteId} /> </Section> <Section title="Source"> {isEdit ? ( <> <fieldset className="flex flex-col gap-2"> <legend className="text-sm font-medium">Handling</legend> <label className="flex items-center gap-2 text-sm"> <input type="radio" name="handling" value="youtube" defaultChecked={editHandling === "youtube"} /> YouTube (auto-subtitles via yt-dlp) </label> <label className="flex items-center gap-2 text-sm"> <input type="radio" name="handling" value="transcribe" defaultChecked={editHandling === "transcribe"} /> Transcribe (download audio, run whisper-cpp) </label> </fieldset> <Field label="URL" name="url" state={state} defaultValue={c?.url ?? ""} placeholder="https://www.youtube.com/@channel/videos" hint="Channel or playlist URL passed to yt-dlp." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Platform</span> <SeededSelect state={state} name="platform" initial={c?.platform ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Auto (detect from URL)</option> <option value="youtube">YouTube</option> <option value="rumble">Rumble</option> <option value="odysee">Odysee</option> <option value="twitch">Twitch</option> <option value="kick">Kick</option> <option value="archiveorg">archive.org</option> <option value="bitchute">BitChute</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> <option value="xenforo">Forum thread — XenForo (posts)</option> </SeededSelect> <span className="text-xs text-muted-foreground"> Used as the default job queue, so all channels on the same platform run sequentially. </span> </label> </> ) : ( <> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">URL</span> <div className="flex items-stretch gap-2"> <input type="text" name="url" value={url} onChange={(e) => setUrl(e.target.value)} placeholder="https://www.youtube.com/@channel/videos" aria-label="URL" className="flex-1 rounded border border-border bg-card px-2 py-1 text-sm" /> <button type="button" onClick={fetchDetails} disabled={!url.trim() || probe.state === "loading"} aria-label="fetch details" className="shrink-0 rounded-md border border-border px-3 py-1 text-sm hover:bg-muted disabled:opacity-50" > {probe.state === "loading" ? "Fetching…" : "Fetch details"} </button> </div> <span className="text-xs text-muted-foreground"> Channel or playlist URL passed to yt-dlp. Platform, handling and a slug are derived from the URL automatically; Fetch details runs a one-entry yt-dlp probe to fill the name (and prove a new platform works). </span> {shownQueueKey && ( <span aria-label="derived queue" className="text-xs text-muted-foreground font-mono" > Queue: {shownQueueKey} {queueKnown ? "" : " (new)"} </span> )} {probe.state === "done" && ( <span role="status" aria-label="probe result" className="text-xs text-success" > {probe.message ? `yt-dlp found "${probe.message}".` : "yt-dlp reached the URL."} </span> )} {probe.state === "error" && ( <span role="alert" className="text-xs text-destructive"> {probe.message} </span> )} </label> {isSocial ? ( // Handling is a video-pipeline axis; a social source has no audio // to transcribe. Emit a fixed value so the field stays present // for parseChannelForm without offering a meaningless choice. <input type="hidden" name="handling" value="transcribe" /> ) : ( <fieldset className="flex flex-col gap-2"> <legend className="text-sm font-medium">Handling</legend> <label className="flex items-center gap-2 text-sm"> <ControlledCheck type="radio" name="handling" value="youtube" checked={handling === "youtube"} onChange={() => { setHandling("youtube"); setHandlingTouched(true); }} /> YouTube (auto-subtitles via yt-dlp) </label> <label className="flex items-center gap-2 text-sm"> <ControlledCheck type="radio" name="handling" value="transcribe" checked={handling === "transcribe"} onChange={() => { setHandling("transcribe"); setHandlingTouched(true); }} /> Transcribe (download audio, run whisper-cpp) </label> </fieldset> )} <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Platform</span> <ControlledSelect name="platform" value={platform} onChange={(e) => { setPlatform(e.target.value); setPlatformTouched(true); }} aria-label="platform" className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Auto (detect from URL)</option> <option value="youtube">YouTube</option> <option value="rumble">Rumble</option> <option value="odysee">Odysee</option> <option value="twitch">Twitch</option> <option value="kick">Kick</option> <option value="archiveorg">archive.org</option> <option value="bitchute">BitChute</option> <option value="twitter">X / Twitter (posts)</option> <option value="bluesky">Bluesky (posts)</option> <option value="xenforo">Forum thread — XenForo (posts)</option> </ControlledSelect> <span className="text-xs text-muted-foreground"> Used as the default job queue, so all channels on the same platform run sequentially. Leave on Auto for a host yt-dlp knows but the app doesn't — it gets its own per-domain queue. </span> </label> <fieldset className="flex flex-col gap-2 rounded-md border border-border px-3 py-2"> <legend className="px-1 text-xs font-medium uppercase tracking-wide text-muted-foreground"> On create </legend> {isSocial ? ( <label className="flex items-start gap-2 text-sm"> <input type="checkbox" name="fetchPostsNow" defaultChecked={seedChecked( socialSeed, "fetchPostsNow", true, )} className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium">Fetch posts now</span> <span className="text-xs text-muted-foreground"> Start a fetch-posts job immediately so the account's history begins archiving. Requires a URL. </span> </span> </label> ) : ( <> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" name="fetchPlaylist" defaultChecked={seedChecked(videoSeed, "fetchPlaylist", true)} className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium">Fetch playlist now</span> <span className="text-xs text-muted-foreground"> Store the channel's video list immediately (a store-playlist job) so its pending downloads populate right away. Requires a URL. </span> </span> </label> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" name="prioritizeDownload" defaultChecked={seedChecked( videoSeed, "prioritizeDownload", false, )} className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium">Add to top of auto-queue</span> <span className="text-xs text-muted-foreground"> Prepend this channel to the auto-download queue and start the runner, so its videos download first. Requires a URL. </span> </span> </label> </> )} </fieldset> </> )} {isSocial && ( <> {/* A social source is ingested into the posts corpus, so it carries its fetcher + handle instead of any of the video controls. */} <input type="hidden" name="sourceKind" value="social" /> <input type="hidden" name="postFetcher" value={postFetcher} /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Account handle</span> <input type="text" name="socialHandle" value={socialHandle} onChange={(e) => { setSocialHandle(e.target.value); setHandleTouched(true); }} readOnly={isEdit} aria-label="account handle" placeholder="example.bsky.social" className="rounded border border-border bg-card px-2 py-1 text-sm" /> <span className="text-xs text-muted-foreground"> The account whose posts are archived. Derived from the URL; stored so a later URL-format change upstream can't silently re-point ingest at a different account. {postFetcher ? ` Fetcher: ${postFetcher}.` : ""} </span> </label> </> )} <SyncIntervalField value={c?.syncIntervalMinutes} fullSweepValue={c?.fullSweepIntervalMinutes} /> </Section> {/* Video-only sections. A social account has no audio, no download format and no source video to retain, so they are hidden entirely rather than shown as inert controls. */} {!isSocial && ( <> <Section title="Transcription"> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium"> Audio format{" "} <span className="text-muted-foreground font-normal">(transcribe only)</span> </span> <SeededSelect state={state} name="audioFormat" initial={c?.audioFormat ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Default (mp3)</option> <option value="m4a">m4a</option> <option value="mp3">mp3</option> <option value="opus">opus</option> </SeededSelect> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Download format</span> <SeededSelect state={state} name="downloadFormat" initial={c?.downloadFormat ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Inherit (global default)</option> {DOWNLOAD_FORMAT_PRESETS.map((p) => ( <option key={p} value={p}> {DOWNLOAD_FORMAT_LABELS[p]} </option> ))} </SeededSelect> <span className="text-xs text-muted-foreground"> The yt-dlp <code>-f</code> selector for this channel's downloads. Leave on Inherit to use the global default; choose{" "} <strong>Original</strong> for sources that only serve full-length audio in their original file (e.g. Odysee). </span> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Source video quality</span> <SeededSelect state={state} name="sourceVideoQuality" initial={c?.sourceVideoQuality ?? ""} className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Inherit (global default)</option> {SOURCE_VIDEO_QUALITIES.map((q) => ( <option key={q} value={q}> {SOURCE_VIDEO_QUALITY_LABELS[q]} </option> ))} </SeededSelect> <span className="text-xs text-muted-foreground"> What a full persist of this channel's videos keeps (“Persist source video”, a whole-recording fetch, “Persist kept now”). <strong>720p</strong> saves a smaller H.264 file that is quick to cut for clips and editing. </span> </label> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" name="keepSourceVideo" defaultChecked={seedChecked( state, "keepSourceVideo", c?.keepSourceVideo === true, )} className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium"> Keep source video after extraction{" "} <span className="text-muted-foreground font-normal"> (transcribe only) </span> </span> <span className="text-xs text-muted-foreground"> Adds <code>-k</code> to yt-dlp so the original container is kept alongside <code>audio.<format></code>. Useful for debugging corrupt-audio extraction issues. </span> </span> </label> <AudioCheckFields config={c} state={state} /> </Section> <Section title="Retention & persistence"> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Keep latest (source videos)</span> <SeededInput state={state} type="number" name="keepLatest" initial={c?.keepLatest != null ? String(c.keepLatest) : ""} min={0} max={100000} placeholder="0 (off)" aria-label="keep latest" className="rounded border border-border bg-card px-2 py-1 text-sm w-32" /> <span className="text-xs text-muted-foreground"> Protect the newest N videos from the Clean-audio sweep and persist their source containers to the saved-video store. A kept video later found deleted-from-source is pinned permanently. Blank or 0 disables. </span> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Extraction mode</span> <SeededSelect state={state} name="extractionMode" initial={c?.extractionMode ?? ""} aria-label="extraction mode" className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Default (yt-dlp extracts)</option> <option value="ytdlp">yt-dlp (-x postprocessor)</option> <option value="app">App (download container, ffmpeg extracts)</option> </SeededSelect> <span className="text-xs text-muted-foreground"> Who extracts audio for transcribe-handling downloads. App mode keeps the source container in hand so it can be persisted; the keep-latest rule forces app mode for the videos it persists regardless. </span> </label> <Field label="Saved-video store dir" name="savedVideosDir" state={state} defaultValue={c?.savedVideosDir ?? ""} placeholder="(global default)" hint="Per-channel override for where this channel's persisted source videos live (e.g. a larger disk). Blank uses the global SAVED_VIDEOS_DIR default." /> {/* READ-ONLY, AND NOT AN INPUT — the one field on this page that is a record of the disk rather than an instruction to it. `config.mediaDir` is written ONLY by the relocate job, on success, after the bytes are copied, verified and the symlink is in place. An editable text box here would let the two disagree with a keystroke: type a path nothing was moved to and every reader follows a `media` link that still points somewhere else, which inspectChannelMedia reports as `inconsistent` and refuses to guess past. So the move is the only writer, and this line just says what it wrote. (It is also not in CHANNEL_FORM_FIELDS, so saving this form preserves it rather than clearing it.) */} <div className="flex flex-col gap-1 text-sm"> <span className="font-medium">Media location</span> <span aria-label="media location" className="font-mono text-xs break-all rounded border border-border bg-muted px-2 py-1" > {c?.mediaDir?.trim() ? c.mediaDir.trim() : c?.dataDir?.trim() ? `${c.dataDir.trim()} (the retired whole-directory layout — run archilyzer storage migrate-tier)` : "In the channel directory (media/)"} </span> <span className="text-xs text-muted-foreground"> Where this channel’s big files — the audio and the raw live chat — actually live; its text is always in the channel directory. Change it from the Storage panel, which copies and verifies the bytes before recording anything here. </span> </div> </Section> </> )} <CollapsibleSection title="Advanced"> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium"> Extra yt-dlp args <span className="text-muted-foreground font-normal"> (one per line)</span> </span> <textarea name="ytdlpExtraArgs" defaultValue={seedValue( state, "ytdlpExtraArgs", (c?.ytdlpExtraArgs ?? []).join("\n"), )} rows={4} className="rounded border border-border bg-card px-2 py-1 text-sm font-mono" /> </label> <Field label="Download filter: include (regex)" name="downloadFilterInclude" value={filterInclude} onChange={setFilterInclude} placeholder="(download everything)" hint="Case-insensitive regex. Only videos whose title + description match are downloaded. Run Metadata scan on the Playlist stage to read the titles first — non-matching videos are then settled (never downloaded, never retried) until you change this. The listing and the roster stay complete either way." /> <Field label="Download filter: exclude (regex)" name="downloadFilterExclude" value={filterExclude} onChange={setFilterExclude} placeholder="(exclude nothing)" hint="Case-insensitive regex, applied to title + description and winning over include. Matching videos are settled until you change this. Patterns are capped at 200 characters and must not nest quantifiers — this runs on the server for every stored title." /> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" name="downloadFilterIncludeLivestreams" defaultChecked={seedChecked( state, "downloadFilterIncludeLivestreams", c?.downloadFilter?.includeLivestreams ?? false, )} className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium">Include every livestream</span> <span className="text-xs text-muted-foreground"> A second way in, beside the include pattern: any livestream VOD is downloaded whatever it is called. On its own — with no include pattern — this means <em>livestreams only</em>: ordinary uploads are then filtered out. An exclude pattern still wins over it. </span> </span> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Filtered-out livestreams</span> <SeededSelect state={state} name="downloadFilterRejectedLivestreams" initial={c?.downloadFilter?.rejectedLivestreams ?? "skip"} aria-label="filtered-out livestreams" className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="skip">Skip them — download nothing</option> <option value="chat-only"> Keep the live chat — no audio, no transcript </option> </SeededSelect> <span className="text-xs text-muted-foreground"> What to do with a livestream the filter above rejected. A multi-hour stream whose title says nothing is rarely worth its audio, but its live chat is text, it is small, and it is the only record of what the room said. Chat-only videos join the corpus as a chat track with no captions; they are never downloaded and never transcribed. </span> </label> <Field label="Recorded date from the title (regex)" name="recordedDateTitlePattern" state={state} defaultValue={c?.recordedDate?.titlePattern ?? ""} placeholder="(the upload date)" hint="For a channel that mirrors another's streams, whose upload dates are the copies'. Case-insensitive regex naming three groups — year, month (a number or an English month name) and day — e.g. (?<year>\d{4})-(?<month>\d{2})-(?<day>\d{2}). The index reads each video's recorded date from its title, and coverage uses it before the upload date. A title it does not match keeps the upload date." /> <Field label="Cookies from browser" name="cookiesFromBrowser" state={state} defaultValue={c?.cookiesFromBrowser ?? ""} placeholder="(inherit global)" hint="Per-channel override of the global yt-dlp --cookies-from-browser browser spec (e.g. firefox, chrome:Default). Blank inherits the global value from /settings." /> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium">Cookie mode</span> <SeededSelect state={state} name="cookieMode" initial={c?.cookieMode ?? ""} aria-label="cookie mode" className="rounded border border-border bg-card px-2 py-1 text-sm" > <option value="">Inherit global</option> <option value="when-required"> When required — retry auth/age failures with cookies </option> <option value="always"> Always — pass cookies on every yt-dlp invocation </option> <option value="defer"> Defer — never in normal runs; collect into "Needs cookies" </option> </SeededSelect> <span className="text-xs text-muted-foreground"> When this channel's downloads use browser cookies. Leave on Inherit to use the global mode from /settings. Defer excludes auth-gated videos from batch runs and gathers them into the “Needs cookies” bucket on the Download stage. </span> </label> <label className="flex flex-col gap-1 text-sm"> <span className="font-medium"> Sleep between managed downloads <span className="text-muted-foreground font-normal"> {" "} (seconds, optional) </span> </span> <SeededInput state={state} type="number" name="sleepBetweenDownloadsSeconds" initial={ c?.sleepBetweenDownloadsSeconds != null ? String(c.sleepBetweenDownloadsSeconds) : "" } min={0} max={600} className="rounded border border-border bg-card px-2 py-1 text-sm" /> <span className="text-xs text-muted-foreground"> Pause inserted between per-video yt-dlp invocations for this channel's managed downloads. Leave blank to use the global default from /settings. 0 disables. </span> </label> </CollapsibleSection> <div className="flex gap-2"> <button type="submit" className="px-3 py-2 rounded-md bg-primary text-primary-foreground text-sm font-medium hover:opacity-90" > {submitLabel} </button> </div> </form> ); } function Section({ title, children, }: { title: string; children: React.ReactNode; }) { return ( <fieldset className="flex flex-col gap-3 border border-border rounded-md px-4 py-3"> <legend className="px-1 text-sm font-semibold">{title}</legend> {children} </fieldset> ); } function CollapsibleSection({ title, children, }: { title: string; children: React.ReactNode; }) { return ( <details className="group border border-border rounded-md"> <summary className="cursor-pointer list-none flex items-center justify-between gap-2 px-4 py-2 text-sm font-semibold select-none rounded-md hover:bg-muted"> <span>{title}</span> <svg aria-hidden="true" className="w-4 h-4 text-muted-foreground transition-transform group-open:rotate-90" viewBox="0 0 20 20" fill="currentColor" > <path fillRule="evenodd" d="M7.21 14.77a.75.75 0 0 1 .02-1.06L11.168 10 7.23 6.29a.75.75 0 0 1 1.04-1.08l4.5 4.25a.75.75 0 0 1 0 1.08l-4.5 4.25a.75.75 0 0 1-1.06-.02z" clipRule="evenodd" /> </svg> </summary> <div className="px-4 pt-2 pb-4 flex flex-col gap-3 border-t border-border"> {children} </div> </details> ); } // The two per-channel cadences. Both are DurationFields, so an arbitrary value // can now be *typed* here rather than only preserved — the old control was a // bare <select> whose only concession to a hand-edited config.json was a // synthetic extra <option>. function SyncIntervalField({ value, fullSweepValue, }: { value?: number; fullSweepValue?: number; }) { return ( <> <DurationField name="syncIntervalMinutes" label="Auto-sync (newest)" baseUnit="minutes" units={["minutes", "hours", "days"]} value={value} presets={SYNC_INTERVAL_PRESETS} allowInherit allowOff offLabel="Off (never auto-sync)" min={SYNC_INTERVAL_MIN_MINUTES} max={SYNC_INTERVAL_MAX_MINUTES} rate={{ noun: "check", per: "channel" }} hint={ <> Checks the newest videos and downloads what's new, when the global scheduler is enabled in{" "} <a href="/settings" className="underline"> Settings </a> . Requires a URL; excluded-from-sync channels never auto-sync. </> } /> <DurationField name="fullSweepIntervalMinutes" label="Full sweep" baseUnit="minutes" units={["hours", "days"]} value={fullSweepValue} presets={FULL_SWEEP_PRESETS} allowInherit allowOff offLabel="Off (never sweep)" min={SYNC_INTERVAL_MIN_MINUTES} max={SYNC_INTERVAL_MAX_MINUTES} rate={{ noun: "full listing fetch", pluralNoun: "full listing fetches", per: "channel", }} hint="How often a sync upgrades itself to re-read the whole channel listing: refreshes the stored video list and flags videos that have gone missing upstream. Costs one full enumeration, so it runs far less often than auto-sync." /> </> ); } function AudioCheckFields({ config, state, }: { config?: ChannelConfig; state?: SeedSource; }) { const ac = config?.audioCheck; const enabled = ac?.enabled === true; return ( <div className="flex flex-col gap-3 mt-1"> <label className="flex items-start gap-2 text-sm"> <input type="checkbox" name="audioCheckEnabled" defaultChecked={seedChecked(state, "audioCheckEnabled", enabled)} aria-label="audio-check enabled" className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium"> Audio-integrity checking{" "} <span className="text-muted-foreground font-normal"> (transcribe only, slow) </span> </span> <span className="text-xs text-muted-foreground"> For sources that intermittently serve corrupt audio (e.g. Odysee "original" format). Periodically probes the in-progress download via ffmpeg and rolls back to the last known-good snapshot on corruption. Disk usage peaks at ~3× file size; final probe decodes the whole file. </span> </span> </label> <label className="flex flex-col gap-1 text-sm pl-6"> <span className="font-medium"> Check interval (seconds) </span> <SeededInput state={state} type="number" name="audioCheckIntervalSeconds" initial={String(ac?.intervalSeconds ?? "")} placeholder={String(AUDIO_CHECK_INTERVAL_DEFAULT_SECONDS)} min={AUDIO_CHECK_INTERVAL_MIN_SECONDS} max={AUDIO_CHECK_INTERVAL_MAX_SECONDS} aria-label="audio-check interval seconds" className="rounded border border-border bg-card px-2 py-1 text-sm w-32" /> </label> <label className="flex flex-col gap-1 text-sm pl-6"> <span className="font-medium"> Max consecutive rollbacks </span> <SeededInput state={state} type="number" name="audioCheckMaxRollbacks" initial={String(ac?.maxRollbacks ?? "")} placeholder={String(AUDIO_CHECK_MAX_ROLLBACKS_DEFAULT)} min={AUDIO_CHECK_MAX_ROLLBACKS_MIN} max={AUDIO_CHECK_MAX_ROLLBACKS_MAX} aria-label="audio-check max rollbacks" className="rounded border border-border bg-card px-2 py-1 text-sm w-32" /> <span className="text-xs text-muted-foreground"> After this many rollbacks against the same checkpoint, the download is marked <code>failed-corrupt-source</code>. </span> </label> <label className="flex flex-col gap-1 text-sm pl-6"> <span className="font-medium"> Copy timeout (seconds) </span> <SeededInput state={state} type="number" name="audioCheckCopyTimeoutSeconds" initial={String(ac?.copyTimeoutSeconds ?? "")} placeholder={String(AUDIO_CHECK_COPY_TIMEOUT_DEFAULT_SECONDS)} min={AUDIO_CHECK_COPY_TIMEOUT_MIN_SECONDS} max={AUDIO_CHECK_COPY_TIMEOUT_MAX_SECONDS} aria-label="audio-check copy timeout seconds" className="rounded border border-border bg-card px-2 py-1 text-sm w-32" /> <span className="text-xs text-muted-foreground"> Max time SIGSTOP can hold yt-dlp while we snapshot the <code>.part</code>{" "} file. Without reflink (btrfs/xfs), large snapshots may exceed CDN keepalive; the check skips itself rather than holding longer. </span> </label> <label className="flex items-start gap-2 text-sm pl-6"> <input type="checkbox" name="audioCheckResumeDuringProbe" defaultChecked={seedChecked( state, "audioCheckResumeDuringProbe", ac?.resumeDuringProbe ?? false, )} aria-label="audio-check resume during probe" className="mt-1" /> <span className="flex flex-col gap-0.5"> <span className="font-medium">Resume during probe (legacy)</span> <span className="text-xs text-muted-foreground"> Keep downloading while each integrity probe runs. Leave off (default) to pause yt-dlp until the probe finishes — it then never downloads bytes a corrupt verdict would discard and re-fetch, lowering HTTP 429 risk. The trade-off is a briefly idle connection during each probe. </span> </span> </label> </div> ); }