commit 0241df1b65171770f5c1d1bfe198a04e74b9e69e
parent 002d49d8be4effcc1823ab8f0c2f02939db3380b
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date: Tue, 22 Sep 2026 16:02:15 -0400
tags: a rule can exclude channels, and the exclusion wins
"Every channel except the one that floods the tag" was only expressible as an
allow-list of every other channel, which the operator then had to keep in sync
by hand as channels were added. `channelsExclude` on a rule is applied after
`channels` and beats it: a slug in both is excluded, because naming a channel
to skip is the more specific statement. It reaches the compiler and the one
`ruleApplies` every kind already routes through, so needsCaptionCues and
needsChatCues predict it for free.
curatedTagsPreview.ts had a second copy of that predicate, and the copy stopped
predicting the evaluation it guards the moment this field existed; it now calls
the exported one instead of restating it. The /tags preview drops a channel
from its scan set only when EVERY rule excludes it — one rule's exclusion is
not the tag's.
TRAP: the exclude list is part of hashCuratedRules, so every stored
curatedRulesHash is now stale and the FIRST build-index after this re-derives
the whole corpus once (~80 s, out of LMDB, no cue re-reads). The build log's
"curated tags: rules <hash8> (changed)" line is the operator's signal that the
pass was this migration and not a rule they edited.
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
Diffstat:
8 files changed, 233 insertions(+), 13 deletions(-)
diff --git a/common/controller/curatedTagsIndex.test.ts b/common/controller/curatedTagsIndex.test.ts
@@ -144,6 +144,27 @@ test("hashCuratedRules ignores presentation, reacts to derivation", () => {
]),
base,
);
+ // An exclusion changes which records the rule derives, so it is in the hash
+ // — and the empty list it replaces must not be, or the field's arrival would
+ // re-derive the corpus a second time.
+ assert.notEqual(
+ hashCuratedRules([
+ {
+ ...COLLAB_RULE,
+ rules: [{ ...COLLAB_RULE.rules![0], channelsExclude: ["mommaocco"] }],
+ },
+ ]),
+ base,
+ );
+ assert.equal(
+ hashCuratedRules([
+ {
+ ...COLLAB_RULE,
+ rules: [{ ...COLLAB_RULE.rules![0], channelsExclude: [] }],
+ },
+ ]),
+ base,
+ );
// A disabled rule is not part of the derivation at all.
assert.equal(
hashCuratedRules([
diff --git a/common/controller/curatedTagsIndex.ts b/common/controller/curatedTagsIndex.ts
@@ -64,6 +64,14 @@ function sha1(s: string): string {
// The shape of the vocabulary that can change a record. Presentation fields
// (label, colour, group, hidden) are deliberately absent: relabelling a tag
// must not re-page 30,000 videos.
+//
+// ONE-TIME COST WHEN THIS SHAPE CHANGES: the hash is stored per build, so
+// adding a field to it (channelsExclude, 2026-09-22) makes every stored
+// `curatedRulesHash` stale and the first build-index after the change
+// re-derives the whole corpus once — ~80 s, read back out of LMDB with no cue
+// re-read. The log line below says `rules <hash8> (changed)` when that happens;
+// that sentence is the operator's signal that the pass was the migration and
+// not a rule they edited.
export function hashCuratedRules(defs: CuratedTagDef[]): string {
const shape = defs.map((def, i) => [
def.id,
@@ -75,6 +83,7 @@ export function hashCuratedRules(defs: CuratedTagDef[]): string {
r.kind,
r.pattern,
[...(r.channels ?? [])].sort(),
+ [...(r.channelsExclude ?? [])].sort(),
r.dateFrom ?? null,
r.dateTo ?? null,
]),
diff --git a/common/controller/curatedTagsPreview.ts b/common/controller/curatedTagsPreview.ts
Binary files differ.
diff --git a/common/lib/curatedTags.test.ts b/common/lib/curatedTags.test.ts
@@ -432,6 +432,92 @@ test("channel scope and date range are applied before any regex", () => {
assert.deepEqual(evaluateTagRules({ ...hit, uploadDate: undefined }, [scoped]), []);
});
+test("an excluded channel never fires, and beats the allow-list", () => {
+ // The shape this exists for: every channel EXCEPT the one that floods the
+ // tag, with no allow-list to keep in sync as channels are added.
+ const wide: CuratedTagDef = {
+ id: "wide",
+ label: "Wide",
+ rules: [
+ {
+ id: "r1",
+ kind: "metadata",
+ pattern: "elfpire",
+ channelsExclude: ["mommaocco"],
+ enabled: true,
+ },
+ ],
+ };
+ const hit = { ...base, title: "elfpire" };
+ assert.deepEqual(evaluateTagRules(hit, [wide]), ["wide"]);
+ assert.deepEqual(evaluateTagRules({ ...hit, channelSlug: "mommaocco" }, [wide]), []);
+
+ // A slug in BOTH lists is excluded — the exclusion is the more specific
+ // statement, so it wins rather than the allow-list re-admitting the channel.
+ const both: CuratedTagDef = {
+ ...wide,
+ rules: [
+ {
+ ...wide.rules![0],
+ channels: [base.channelSlug, "mommaocco"],
+ channelsExclude: ["mommaocco"],
+ },
+ ],
+ };
+ assert.deepEqual(evaluateTagRules(hit, [both]), ["wide"]);
+ assert.deepEqual(evaluateTagRules({ ...hit, channelSlug: "mommaocco" }, [both]), []);
+ // And the allow-list still bounds the rule on its own.
+ assert.deepEqual(evaluateTagRules({ ...hit, channelSlug: "other" }, [both]), []);
+
+ // The exclusion applies to the cue kinds too — they route through the same
+ // ruleApplies, which is exactly what needsCaptionCues/needsChatCues predict.
+ const compiled = compileTagRules([
+ {
+ id: "cap",
+ label: "Cap",
+ rules: [
+ {
+ id: "r1",
+ kind: "caption",
+ pattern: "elfpire",
+ channelsExclude: ["mommaocco"],
+ enabled: true,
+ },
+ ],
+ },
+ ]);
+ const captionCues = [{ text: "and then elfpire said" }];
+ assert.deepEqual(evaluateCompiledRules({ ...base, captionCues }, compiled), ["cap"]);
+ assert.deepEqual(
+ evaluateCompiledRules(
+ { ...base, channelSlug: "mommaocco", captionCues },
+ compiled,
+ ),
+ [],
+ );
+});
+
+test("sanitizeTagsConfig cleans channelsExclude like channels, and omits it when empty", () => {
+ const read = (raw: object) =>
+ (sanitizeTagsConfig({
+ tags: [{ id: "t", rules: [{ kind: "metadata", pattern: "x", ...raw }] }],
+ }).tags[0].rules ?? [])[0];
+
+ const rule = read({ channelsExclude: ["a", " ", 3, " b "] });
+ assert.deepEqual(rule.channelsExclude, ["a", "b"]);
+ // Idempotent: sanitizing an already-sanitized rule changes nothing.
+ assert.deepEqual(read(rule), rule);
+
+ // An empty (or all-junk, or absent) exclude emits no key at all, so a rule
+ // nobody touched round-trips through the store byte-identically.
+ for (const raw of [{}, { channelsExclude: [] }, { channelsExclude: [" ", 7] }]) {
+ assert.equal(
+ Object.prototype.hasOwnProperty.call(read(raw), "channelsExclude"),
+ false,
+ );
+ }
+});
+
test("a disabled rule never fires, and neither does a broken one", () => {
const defs = sanitizeTagsConfig({
tags: [
diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts
@@ -47,6 +47,11 @@ export type CuratedTagRule = {
pattern: string;
// Channel slugs this rule may fire on. Absent or empty = every channel.
channels?: string[];
+ // Channel slugs this rule may NEVER fire on, applied AFTER `channels`. A slug
+ // in both lists is excluded: naming a channel to skip is the more specific
+ // statement, and the shape this exists for — "every channel except that one"
+ // — needs no allow-list at all. Absent or empty = exclude nothing.
+ channelsExclude?: string[];
// Inclusive upload-date bounds, compared digit-wise so both "20260101" and
// "2026-01-01" work. null/absent = unbounded.
dateFrom?: string | null;
@@ -164,6 +169,16 @@ function tryCompile(pattern: string): { re: RegExp } | { error: string } {
}
}
+// A rule's channel scope, either side of it. Shared so the allow-list and the
+// exclude list can never drift into sanitizing differently.
+function channelList(v: unknown): string[] {
+ return Array.isArray(v)
+ ? v
+ .filter((c): c is string => typeof c === "string" && c.trim() !== "")
+ .map((c) => c.trim())
+ : [];
+}
+
function coerceRule(raw: unknown, index: number): CuratedTagRule | null {
if (!raw || typeof raw !== "object") return null;
const r = raw as Record<string, unknown>;
@@ -173,11 +188,8 @@ function coerceRule(raw: unknown, index: number): CuratedTagRule | null {
// A rule with no pattern cannot mean anything; drop it.
if (pattern.trim() === "") return null;
const id = trimmedString(r.id) ?? `r${index + 1}`;
- const channels = Array.isArray(r.channels)
- ? r.channels
- .filter((c): c is string => typeof c === "string" && c.trim() !== "")
- .map((c) => c.trim())
- : [];
+ const channels = channelList(r.channels);
+ const channelsExclude = channelList(r.channelsExclude);
const dateFrom = dateBound(r.dateFrom);
const dateTo = dateBound(r.dateTo);
const rule: CuratedTagRule = {
@@ -186,6 +198,7 @@ function coerceRule(raw: unknown, index: number): CuratedTagRule | null {
pattern,
enabled: r.enabled !== false, // default true
...(channels.length > 0 ? { channels } : {}),
+ ...(channelsExclude.length > 0 ? { channelsExclude } : {}),
...(dateFrom ? { dateFrom } : {}),
...(dateTo ? { dateTo } : {}),
};
@@ -430,6 +443,8 @@ export type CompiledTagRule = {
re: RegExp;
// null = every channel.
channels: Set<string> | null;
+ // null = exclude nothing. Applied after `channels` — see ruleApplies.
+ channelsExclude: Set<string> | null;
dateFrom: string | null;
dateTo: string | null;
};
@@ -473,6 +488,10 @@ export function compileTagRules(defs: CuratedTagDef[]): CompiledTagRules {
rule.channels && rule.channels.length > 0
? new Set(rule.channels)
: null,
+ channelsExclude:
+ rule.channelsExclude && rule.channelsExclude.length > 0
+ ? new Set(rule.channelsExclude)
+ : null,
dateFrom: dateBound(rule.dateFrom),
dateTo: dateBound(rule.dateTo),
};
@@ -502,6 +521,12 @@ export function ruleApplies(
input: { channelSlug: string; uploadDate?: string },
): boolean {
if (rule.channels && !rule.channels.has(input.channelSlug)) return false;
+ // The exclude wins over the allow-list on purpose: a slug in both is
+ // excluded, so "this whole group, minus the one that floods it" is one rule
+ // rather than an allow-list the operator has to keep in sync by hand.
+ if (rule.channelsExclude && rule.channelsExclude.has(input.channelSlug)) {
+ return false;
+ }
if (rule.dateFrom || rule.dateTo) {
const date = (input.uploadDate ?? "").replace(/\D/g, "");
if (!date) return false;
diff --git a/editor/app/tags/actions.ts b/editor/app/tags/actions.ts
@@ -160,9 +160,23 @@ export async function previewTagRuleAction(input: {
// "every channel", and then so does the tag.
const rules = input.def.rules ?? [];
const unscoped = rules.some((r) => !r.channels || r.channels.length === 0);
- const channels = unscoped
- ? []
+ const scoped = unscoped
+ ? allSlugs
: Array.from(new Set(rules.flatMap((r) => r.channels ?? [])));
+ // A channel leaves the scan set only when EVERY rule excludes it — one rule's
+ // exclusion is not the tag's, and the preview has to scan what the build
+ // would. (ruleApplies gates each rule per video regardless, so this is about
+ // not reading channels nothing can match, never about correctness.)
+ const excluded = new Set(
+ (rules[0]?.channelsExclude ?? []).filter((slug) =>
+ rules.every((r) => (r.channelsExclude ?? []).includes(slug)),
+ ),
+ );
+ const kept = scoped.filter((s) => !excluded.has(s));
+ // An empty list means "every slug" to previewTagRule, so a tag that excluded
+ // everything must not fall through to a full scan — but it also cannot scan
+ // nothing, so hand it the scoped set and let ruleApplies reject each video.
+ const channels = kept.length > 0 ? kept : scoped;
const result = previewTagRule(paths, {
def: input.def,
channels,
diff --git a/editor/app/tags/components/EditorTagsClient.tsx b/editor/app/tags/components/EditorTagsClient.tsx
@@ -38,7 +38,11 @@ const KINDS: { value: CuratedTagRuleKind; label: string; hint: string }[] = [
{ value: "caption", label: "Caption", hint: "the transcript's cue text" },
];
-type RuleRow = CuratedTagRule & { key: string; channelsText: string };
+type RuleRow = CuratedTagRule & {
+ key: string;
+ channelsText: string;
+ channelsExcludeText: string;
+};
type TagRow = {
key: string;
@@ -71,6 +75,7 @@ function toRow(def: CuratedTagDef): TagRow {
...r,
key: mkKey(),
channelsText: (r.channels ?? []).join(", "),
+ channelsExcludeText: (r.channelsExclude ?? []).join(", "),
})),
};
}
@@ -89,16 +94,20 @@ function toDef(row: TagRow): CuratedTagDef {
...(row.rules.length > 0
? {
rules: row.rules.map((r) => {
- const channels = r.channelsText
- .split(",")
- .map((c) => c.trim())
- .filter(Boolean);
+ const slugs = (text: string) =>
+ text
+ .split(",")
+ .map((c) => c.trim())
+ .filter(Boolean);
+ const channels = slugs(r.channelsText);
+ const channelsExclude = slugs(r.channelsExcludeText);
return {
id: r.id.trim() || "rule",
kind: r.kind,
pattern: r.pattern,
enabled: r.enabled !== false,
...(channels.length > 0 ? { channels } : {}),
+ ...(channelsExclude.length > 0 ? { channelsExclude } : {}),
...(r.dateFrom ? { dateFrom: r.dateFrom } : {}),
...(r.dateTo ? { dateTo: r.dateTo } : {}),
} satisfies CuratedTagRule;
@@ -615,6 +624,21 @@ function TagCard({
className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
/>
</label>
+ {/* Applied AFTER the allow-list, and it wins: a slug in both is
+ excluded. "Every channel except that one" is then one rule
+ with an empty Channels box. */}
+ <label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[11px] text-muted-foreground">
+ Except (comma-separated; wins over Channels)
+ <input
+ value={rule.channelsExcludeText}
+ onChange={(e) =>
+ patchRule(rule.key, { channelsExcludeText: e.target.value })
+ }
+ list="tag-rule-channels"
+ aria-label="rule exclude channels"
+ className="rounded border border-border bg-card px-1.5 py-1 font-mono text-xs text-foreground"
+ />
+ </label>
<label className="flex w-28 flex-col gap-1 text-[11px] text-muted-foreground">
From
<input
@@ -670,6 +694,7 @@ function TagCard({
pattern: "",
enabled: true,
channelsText: "",
+ channelsExcludeText: "",
},
],
})
diff --git a/editor/e2e/tags.spec.ts b/editor/e2e/tags.spec.ts
@@ -36,7 +36,7 @@ type TagsFile = {
label: string;
color?: string;
hidden?: boolean;
- rules?: { id: string; kind: string }[];
+ rules?: { id: string; kind: string; channelsExclude?: string[] }[];
}[];
assignments: Record<
string,
@@ -170,6 +170,46 @@ test("a rule previews against the index, and Pin writes the operator's provenanc
);
});
+test("an excluded channel is saved on the rule and drops out of the preview", async ({
+ page,
+}) => {
+ await resetData("curated-tags-channel");
+ await withIndex(page);
+ await addTag(page, "synthetic", "Synthetic", { pattern: "small synthetic" });
+
+ await page.goto("/tags");
+ const card = page.getByTestId("tag-card").first();
+ await card.getByRole("button", { name: "preview synthetic" }).click();
+ await expect(page.getByTestId("preview-row")).toHaveCount(1);
+
+ // Excluding the only channel in the fixture is the whole assertion: the
+ // preview has to predict the build, and the build will not fire this rule
+ // there any more.
+ const rule = card.getByTestId("tag-rule").first();
+ await rule.getByLabel("rule exclude channels").fill(SLUG);
+ await card.getByRole("button", { name: "preview synthetic" }).click();
+ await expect(page.getByTestId("preview-row")).toHaveCount(0);
+ await expect(page.getByTestId("tag-preview")).toContainText("0 matches");
+
+ await page.getByRole("button", { name: "save tags" }).click();
+ await expect(page.getByTestId("tags-saved")).toBeVisible();
+ expect((await tagsFile()).tags[0].rules?.[0].channelsExclude).toEqual([SLUG]);
+
+ // Cleared, it leaves no key behind — a rule nobody scoped round-trips as it
+ // was written.
+ await page.goto("/tags");
+ await page
+ .getByTestId("tag-rule")
+ .first()
+ .getByLabel("rule exclude channels")
+ .fill("");
+ await page.getByRole("button", { name: "save tags" }).click();
+ await expect(page.getByTestId("tags-saved")).toBeVisible();
+ expect((await tagsFile()).tags[0].rules?.[0]).not.toHaveProperty(
+ "channelsExclude",
+ );
+});
+
// THE COMPRESSION REGRESSION, from both ends.
//
// buildIndex writes the index compressed; a reader that opens it without