"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/
./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);
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(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/./): 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 (
);
}
function Section({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
);
}
function CollapsibleSection({
title,
children,
}: {
title: string;
children: React.ReactNode;
}) {
return (
{title}
{children}
);
}
// 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