commit 3b4c089bcc36af444084991943b299e65b80ede4
parent 4924b96b8a4307c1da00ea9513904d0a8e1902c2
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Sun, 20 Sep 2026 00:02:27 -0400
download filter: the form takes the two patterns, and the pages say who was settled
Two fields in Configure > Advanced, and the form is where an unparseable regex
is refused — a bad pattern saved to disk makes the filter inert at download
time, which the operator would experience as a channel that downloads
everything for no visible reason. The fields are controlled because the
preview panel (next commit) has to tell whether the last scan was run against
exactly what is typed now.
Diagnostics gets "Filtered out by download filter" with NO Retry button: these
are settled, and a retry there would offer to undo the configuration from the
wrong page. The bucket's text says where the undo actually lives. The video
page's outcome badge distinguishes the two skips in the only way that matters
to a reader — "(settled)" means it is not coming back on the next sync.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
5 files changed, 93 insertions(+), 1 deletion(-)
diff --git a/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx b/editor/app/channels/[slug]/components/stages/DiagnosticsStage.tsx
@@ -54,6 +54,7 @@ type Props = {
duplicateDirIds: string[];
nonStandardVttIds: string[];
skippedByFilterIds: string[];
+ skippedByTitleFilterIds: string[];
totals: { videos: number; transcribed: number; downloaded: number };
availability: AvailabilitySnapshot;
maybeMissing: { ids: string[]; checkedAt: string };
@@ -71,6 +72,7 @@ export function DiagnosticsStage({
duplicateDirIds,
nonStandardVttIds,
skippedByFilterIds,
+ skippedByTitleFilterIds,
totals,
availability,
maybeMissing,
@@ -118,6 +120,17 @@ export function DiagnosticsStage({
ariaLabel: "skipped live",
retry: true,
},
+ {
+ ids: skippedByTitleFilterIds,
+ label: "Filtered out by download filter",
+ // No Retry, deliberately. These are SETTLED: the operator's own
+ // include/exclude declined them, and a retry button would offer to undo
+ // the configuration from the wrong page. Editing the patterns in
+ // Configure re-evaluates every one of them on the next report.
+ description:
+ "Declined by this channel's download filter (Configure > Advanced) and settled, so nothing re-attempts them. Change the include/exclude patterns to bring them back — the signature they were settled under stops matching and they return as ordinary undownloaded videos.",
+ ariaLabel: "filtered out",
+ },
];
const populated = buckets.filter((b) => b.ids.length > 0);
const total = populated.reduce((acc, b) => acc + b.ids.length, 0);
diff --git a/editor/app/channels/[slug]/page.tsx b/editor/app/channels/[slug]/page.tsx
@@ -510,6 +510,7 @@ export default async function ChannelDetailPage({
duplicateDirIds={buckets.duplicateDirs}
nonStandardVttIds={buckets.nonStandardVtt}
skippedByFilterIds={buckets.skippedByFilter}
+ skippedByTitleFilterIds={buckets.skippedByTitleFilter}
totals={snapshot.totals}
availability={normalizeAvailability(snapshot.availability)}
maybeMissing={normalizeMaybeMissing(snapshot.maybeMissing)}
diff --git a/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx b/editor/app/channels/[slug]/videos/[id]/components/VideoPanel.tsx
@@ -1485,6 +1485,16 @@ function DownloadOutcomeBadge({
return "Corrupt full source — download completed but audio is malformed (file kept)";
case "failed-short-audio":
return "Truncated download — audio far shorter than the video (file kept)";
+ case "skipped-filtered": {
+ const f = outcome.filter;
+ const why = f?.reason ? `: ${f.reason}` : "";
+ // "(settled)" is the whole distinction a reader needs here. A skip-live
+ // skip comes back on the next sync by itself; a settled one does not,
+ // and will not, until the channel's download filter changes.
+ return f?.permanent
+ ? `Filtered out (settled)${why}`
+ : `Skipped by filter${why}`;
+ }
default:
return outcome.status;
}
diff --git a/editor/app/channels/components/ChannelForm.tsx b/editor/app/channels/components/ChannelForm.tsx
@@ -112,6 +112,16 @@ export function ChannelForm({
const isEdit = !!initial;
const c = initial?.config;
+ // 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
@@ -689,6 +699,22 @@ export function ChannelForm({
/>
</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. Non-matching videos are settled — not retried — until this filter changes; 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 this filter changes."
+ />
+ <Field
label="Cookies from browser"
name="cookiesFromBrowser"
defaultValue={c?.cookiesFromBrowser ?? ""}
@@ -972,10 +998,15 @@ function AudioCheckFields({ config }: { config?: ChannelConfig }) {
);
}
+// `value` + `onChange` make the field CONTROLLED (the download-filter patterns
+// are, so their live values can be read without touching the DOM). Omit both
+// for the ordinary uncontrolled field.
function Field({
label,
name,
defaultValue,
+ value,
+ onChange,
placeholder,
hint,
required,
@@ -984,6 +1015,8 @@ function Field({
label: string;
name: string;
defaultValue?: string;
+ value?: string;
+ onChange?: (v: string) => void;
placeholder?: string;
hint?: string;
required?: boolean;
@@ -995,7 +1028,9 @@ function Field({
<input
type="text"
name={name}
- defaultValue={defaultValue}
+ {...(value === undefined
+ ? { defaultValue }
+ : { value, onChange: (e) => onChange?.(e.target.value) })}
placeholder={placeholder}
required={required}
readOnly={readOnly}
diff --git a/editor/app/channels/components/parseChannelForm.ts b/editor/app/channels/components/parseChannelForm.ts
@@ -51,6 +51,7 @@ export const CHANNEL_FORM_FIELDS = [
"cookiesFromBrowser",
"cookieMode",
"audioCheck",
+ "downloadFilter",
] as const satisfies ReadonlyArray<keyof ChannelConfig>;
// Maps FormData -> ChannelConfig. Throws on invalid input so the server
@@ -206,6 +207,37 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
const cookieModeRaw = String(formData.get("cookieMode") ?? "").trim();
const cookieMode = isCookieMode(cookieModeRaw) ? cookieModeRaw : undefined;
+ // Download filter. Both patterns are regex SOURCES compiled with "i" at
+ // download time, so the form is the place that refuses an unparseable one:
+ // a bad pattern saved here would make the filter silently inert on every
+ // download, and the operator would see a channel that downloads everything
+ // with no explanation. Blank = omit, so clearing a field clears the stored
+ // pattern (see CHANNEL_FORM_FIELDS).
+ const downloadFilterInclude = stringOrUndef(formData, "downloadFilterInclude");
+ const downloadFilterExclude = stringOrUndef(formData, "downloadFilterExclude");
+ for (const [label, pattern] of [
+ ["include", downloadFilterInclude],
+ ["exclude", downloadFilterExclude],
+ ] as const) {
+ if (!pattern) continue;
+ try {
+ new RegExp(pattern, "i");
+ } catch (e) {
+ throw new Error(
+ `Download filter ${label} is not a valid regular expression: ${
+ (e as Error).message
+ }`,
+ );
+ }
+ }
+ const downloadFilter: ChannelConfig["downloadFilter"] | undefined =
+ downloadFilterInclude || downloadFilterExclude
+ ? {
+ ...(downloadFilterInclude ? { include: downloadFilterInclude } : {}),
+ ...(downloadFilterExclude ? { exclude: downloadFilterExclude } : {}),
+ }
+ : undefined;
+
const audioCheckEnabled = formData.get("audioCheckEnabled") != null;
let audioCheck: AudioCheckConfig | undefined;
if (audioCheckEnabled) {
@@ -293,6 +325,7 @@ export function parseChannelForm(formData: FormData): ParsedChannelForm {
if (cookiesFromBrowser) config.cookiesFromBrowser = cookiesFromBrowser;
if (cookieMode) config.cookieMode = cookieMode;
if (audioCheck) config.audioCheck = audioCheck;
+ if (downloadFilter) config.downloadFilter = downloadFilter;
return { name, slug, config };
}