Archilyzer · Source

archilyzer

Archilyzer
git clone https://archilyzer.pages.dev/source/archilyzer.git
Log | Files | Refs | README | LICENSE

commit b233569bd71e431e61eba0478ecd07601169b77b
parent 5d873080210090faba165e7434b4f98618c5ad06
Author: I Mean I'm Just Saying <imeanimjustsaying@kiwifarms.st>
Date:   Tue,  6 Oct 2026 20:36:33 -0400

Merge tags/site-scope (a curated tag can exist on some sites only: the Eva tags on Anilyzer alone)

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>

Diffstat:
Mcommon/controller/buildIndex.ts | 12+++++++++++-
Mcommon/controller/curatedTagsBuild.test.ts | 65+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mcommon/controller/curatedTagsIndex.test.ts | 36++++++++++++++++++++++++++++++++++++
Mcommon/controller/curatedTagsIndex.ts | 59++++++++++++++++++++++++++++++++++++++++++++++++++---------
Mcommon/lib/curatedTags.ts | 76++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Meditor/CHANGELOG.md | 1+
Meditor/app/tags/components/EditorTagsClient.tsx | 32++++++++++++++++++++++++++++++++
Meditor/app/tags/page.tsx | 2++
Meditor/e2e/tags.spec.ts | 11+++++++++++
Mplans/FACTS.md | 22++++++++++++++++++++++
Mplans/STATE.md | 5+++++
Mscripts/archilyzer-ops.mjs | 2++
12 files changed, 313 insertions(+), 10 deletions(-)

diff --git a/common/controller/buildIndex.ts b/common/controller/buildIndex.ts @@ -172,6 +172,7 @@ import { loadCuratedTagsRuntime, reapplyCuratedTags, } from "./curatedTagsIndex"; +import { tagsForSite } from "../lib/curatedTags"; // v10: multi-site build. Shared per-channel transcript/subs pages are written // once; per-site summaries + subs manifests are filtered selections. Bumped to @@ -2342,7 +2343,16 @@ export async function buildIndex({ for (const { key, value } of sums.getRange({ reverse: true })) { const ik = key as IndexKey; if (!slugSet.has(ik[1])) continue; - const s = value as TranscriptSummary; + const stored = value as TranscriptSummary; + // A tag scoped to other sites (curated `sites`) is not this site's: + // dropped from its summaries and its counts. A copy only when it moves — + // the stored value is the LMDB cursor's. + const siteTags = tagsForSite(stored.curatedTags, site.siteId, curated.defs); + let s: TranscriptSummary = stored; + if (siteTags !== stored.curatedTags) { + s = { ...stored, curatedTags: siteTags }; + if (!siteTags || siteTags.length === 0) delete s.curatedTags; + } const acc = chan.get(ik[1]); if (acc) acc.count++; tagCounter.add(ik[1], s.curatedTags); diff --git a/common/controller/curatedTagsBuild.test.ts b/common/controller/curatedTagsBuild.test.ts @@ -329,3 +329,68 @@ test("an interrupted build's page debt is paid on the next build", async () => { assert.equal(meta2.get(META_PAGES_PENDING), false, "the debt is settled"); await after.close(); }); + +test("a site-scoped tag: derived only on its sites' channels, and dropped from another site's summaries and counts", async () => { + // A second channel whose only site is OTHER, with a video the rule matches, + // and OTHER also carrying the first channel (a channel on both sites). + const OTHER = "othersite"; + const OTHER_CHANNEL = "other-channel"; + const OTHER_HIT = "vid-other"; + writeJson(path.join(paths.channelsDir, OTHER_CHANNEL, "config.json"), { + handling: "youtube", + name: "Other Channel", + url: "https://www.youtube.com/@other/videos", + }); + const dir = path.join(paths.channelsDir, OTHER_CHANNEL, "data", OTHER_HIT); + writeJson(path.join(dir, "metadata.info.json"), { + id: OTHER_HIT, + title: "Elfpire mentioned on another channel", + channel: "Other Channel", + upload_date: "20260103", + duration: 120, + description: "fixture", + webpage_url: `https://www.youtube.com/watch?v=${OTHER_HIT}`, + extractor_key: "Youtube", + }); + writeFileSync(path.join(dir, "transcript.en.vtt"), "WEBVTT\n\n00:00:00.000 --> 00:00:05.000\nAnother line.\n"); + writeJson(path.join(paths.sitesDir, OTHER, "site.json"), { + siteId: OTHER, + siteTitle: "Other Site", + siteDescription: "fixture", + headerTitle: "Other Site", + homeTagline: "", + socialLinks: [], + groups: [{ id: "default", name: "All channels", selectedByDefault: true }], + defaultGroupId: "default", + channels: [ + { slug: CHANNEL, groupId: "default" }, + { slug: OTHER_CHANNEL, groupId: "default" }, + ], + }); + const read = (file: string) => JSON.parse(readFileSync(file, "utf8")); + const summaries = (site: string): { id: string; curatedTags?: string[] }[] => + read(path.join(paths.exportSitesIndexDir, site, "summaries", "page-0000.json")); + const counts = (site: string): { tags: Record<string, { count: number }> } => + read(path.join(paths.exportSitesIndexDir, site, "tag-counts.json")); + const shard = (slug: string): { id: string; curatedTags?: string[] }[] => + read(path.join(paths.exportSharedTranscriptsDir, slug, "page-0000.json")); + + // Scoped to SITE: it exists there only. + writeGlobalTags(paths, { ...EVA, tags: [{ ...EVA.tags[0], sites: [SITE] }] }); + await build(); + assert.equal("curatedTags" in recordIn(shard(OTHER_CHANNEL), OTHER_HIT), false, "not derived off SITE's channels"); + assert.deepEqual(recordIn(summaries(SITE), HIT).curatedTags, ["eva-collab"]); + assert.equal(counts(SITE).tags["eva-collab"]?.count, 1); + // OTHER carries the shared channel too: the tag is dropped from its own + // summaries and counts (so compose publishes no /tags.json entry for it). + assert.equal("curatedTags" in recordIn(summaries(OTHER), HIT), false); + assert.equal("curatedTags" in recordIn(summaries(OTHER), OTHER_HIT), false); + assert.equal(counts(OTHER).tags["eva-collab"], undefined); + + // Unscoped again: the same rule now reaches the other channel and the other + // site — the scope, not the pattern, is what kept it off. + writeGlobalTags(paths, EVA); + await build(); + assert.deepEqual(recordIn(shard(OTHER_CHANNEL), OTHER_HIT).curatedTags, ["eva-collab"]); + assert.equal(counts(OTHER).tags["eva-collab"]?.count, 2); +}); diff --git a/common/controller/curatedTagsIndex.test.ts b/common/controller/curatedTagsIndex.test.ts @@ -570,3 +570,39 @@ test("publishedTagsFrom drops hidden and zero-count tags and sorts by order", () // No counts at all → nothing to publish (compose then writes no file). assert.deepEqual(publishedTagsFrom(defs, null).tags, []); }); + +// ─── site-scoped tags (`sites`) ─── + +test("a site-scoped tag: rule hits and pins only on its sites' channels; no scope, no hash change", async () => { + const { sanitizeTagsConfig, tagsForSite } = await import("../lib/curatedTags"); + // The sanitizer keeps `sites` (valid ids, deduplicated, sorted) at the corpus + // layer and drops it at the site layer. + const [def] = sanitizeTagsConfig({ tags: [{ ...COLLAB_RULE, sites: ["anilyzer", "Anilyzer", "bad id", "zz"] }] }).tags; + assert.deepEqual(def.sites, ["anilyzer", "zz"]); + assert.equal("sites" in sanitizeTagsConfig({ tags: [{ id: "x", sites: ["anilyzer"] }] }, { layer: "site" }).tags[0], false); + + const scoped: CuratedTagDef = { ...COLLAB_RULE, sites: ["anilyzer"] }; + const members = { anilyzer: ["vtuber"], hasanalyzer: ["hasanabi"] }; + const runtime = curatedTagsRuntime( + cfg([scoped], { "hasanabi/pinned": { manual: ["eva-collab"] }, "vtuber/pinned": { manual: ["eva-collab"] } }), + members, + ); + const video = (channelSlug: string, id = "v1") => ({ channelSlug, id, title: "with elfpire" }); + assert.deepEqual(deriveCuratedTags(runtime, video("vtuber")), ["eva-collab"]); + assert.deepEqual(deriveCuratedTags(runtime, video("hasanabi")), [], "a rule hit outside the scope"); + assert.deepEqual(deriveCuratedTags(runtime, video("hasanabi", "pinned")), [], "a pin outside the scope"); + assert.deepEqual(deriveCuratedTags(runtime, video("vtuber", "pinned")), ["eva-collab"]); + + // The hash: unchanged by the field's existence when nothing is scoped; moved + // by a scope, and by a change in the scoped sites' membership. + assert.equal(curatedTagsRuntime(cfg([COLLAB_RULE]), members).rulesHash, hashCuratedRules([COLLAB_RULE])); + const moved = curatedTagsRuntime(cfg([scoped]), { ...members, anilyzer: ["vtuber", "another"] }); + assert.notEqual(runtime.rulesHash, hashCuratedRules([COLLAB_RULE])); + assert.notEqual(moved.rulesHash, runtime.rulesHash); + + // A site's view of a record: a tag scoped elsewhere is dropped; the same + // array comes back when nothing is. + const tags = ["eva-collab", "other"]; + assert.deepEqual(tagsForSite(tags, "hasanalyzer", [scoped]), ["other"]); + assert.equal(tagsForSite(tags, "anilyzer", [scoped]), tags); +}); diff --git a/common/controller/curatedTagsIndex.ts b/common/controller/curatedTagsIndex.ts @@ -32,6 +32,8 @@ import { effectiveTagsFor, evaluateCompiledRules, ruleApplies, + scopeTagsToChannel, + tagChannelScope, type CompiledTagRules, type CuratedTagAssignment, type CuratedTagDef, @@ -40,6 +42,7 @@ import { type TagRuleInput, } from "../lib/curatedTags"; import { readGlobalTags } from "../lib/curatedTagsStore"; +import { listSites } from "../lib/site"; export const META_RULES_HASH = "curatedRulesHash"; export const META_ASSIGN_HASH = "curatedAssignHash"; @@ -72,7 +75,24 @@ function sha1(s: string): string { // 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 { +// +// A SITE-SCOPED tag (`sites`, release 18) also changes records when the +// membership of its sites changes, so its resolved channel set is part of the +// hash — but only when some tag is scoped (`scope` non-empty): a corpus with +// none keeps the hash it had, and pays no re-derivation for this field. +export function hashCuratedRules( + defs: CuratedTagDef[], + scope: ReadonlyMap<string, ReadonlySet<string>> = new Map(), +): string { + const base = hashCuratedRulesShape(defs); + if (scope.size === 0) return base; + const scoped = [...scope.entries()] + .map(([id, slugs]) => [id, [...slugs].sort()] as const) + .sort((a, b) => (a[0] < b[0] ? -1 : a[0] > b[0] ? 1 : 0)); + return sha1(JSON.stringify([base, scoped])); +} + +function hashCuratedRulesShape(defs: CuratedTagDef[]): string { const shape = defs.map((def, i) => [ def.id, def.order ?? i, @@ -121,22 +141,35 @@ export type CuratedTagsRuntime = { rulesHash: string; assignHash: string; sigs: Record<string, string>; + // Site-scoped tags (`sites`): tag id -> the channels it may be derived for. + // Empty when no tag is scoped. + scope: Map<string, Set<string>>; // True when the corpus has no rules and no assignments at all — the state of // every untagged install, and the cue to do no work whatsoever. isNoop: boolean; }; export function loadCuratedTagsRuntime(paths: Paths): CuratedTagsRuntime { - return curatedTagsRuntime(readGlobalTags(paths)); + const members: Record<string, string[]> = {}; + for (const site of listSites(paths)) members[site.siteId] = site.channels.map((c) => c.slug); + return curatedTagsRuntime(readGlobalTags(paths), members); } -export function curatedTagsRuntime(config: CuratedTagsConfig): CuratedTagsRuntime { +// `members` (site id -> its channel slugs, site.json `channels`) resolves the +// site-scoped tags; without it a scoped tag's sites have no members and the +// tag is derived for nothing. +export function curatedTagsRuntime( + config: CuratedTagsConfig, + members: Readonly<Record<string, readonly string[]>> = {}, +): CuratedTagsRuntime { const compiled = compileTagRules(config.tags); + const scope = tagChannelScope(config.tags, members); return { config, defs: config.tags, compiled, - rulesHash: hashCuratedRules(config.tags), + scope, + rulesHash: hashCuratedRules(config.tags, scope), assignHash: hashCuratedAssignments(config.assignments), sigs: assignmentSignatures(config.assignments), isNoop: @@ -153,18 +186,24 @@ export function needsCaptionCues( channelSlug: string, uploadDate?: string, ): boolean { - return runtime.compiled.caption.some((r) => - ruleApplies(r, { channelSlug, uploadDate }), + return runtime.compiled.caption.some( + (r) => inScope(runtime, r.tagId, channelSlug) && ruleApplies(r, { channelSlug, uploadDate }), ); } +// A rule of a site-scoped tag never fires outside its sites' channels: no cue +// read is paid for it there either. +function inScope(runtime: CuratedTagsRuntime, tagId: string, channelSlug: string): boolean { + return runtime.scope.get(tagId)?.has(channelSlug) ?? true; +} + export function needsChatCues( runtime: CuratedTagsRuntime, channelSlug: string, uploadDate?: string, ): boolean { - return runtime.compiled.chatAuthor.some((r) => - ruleApplies(r, { channelSlug, uploadDate }), + return runtime.compiled.chatAuthor.some( + (r) => inScope(runtime, r.tagId, channelSlug) && ruleApplies(r, { channelSlug, uploadDate }), ); } @@ -177,7 +216,9 @@ export function deriveCuratedTags( const hits = evaluateCompiledRules(input, runtime.compiled); const assignment = runtime.config.assignments[`${input.channelSlug}/${input.id}`]; if (hits.length === 0 && !assignment) return []; - return effectiveTagsFor(hits, assignment, runtime.defs); + // A site-scoped tag exists only on its sites' channels: neither a rule hit + // nor a pin carries it to a video on any other channel. + return scopeTagsToChannel(effectiveTagsFor(hits, assignment, runtime.defs), input.channelSlug, runtime.scope); } // Set/clear `curatedTags` on a summary in place. Returns true when the stored diff --git a/common/lib/curatedTags.ts b/common/lib/curatedTags.ts @@ -80,6 +80,14 @@ export type CuratedTagDef = { // Presentation only: hidden tags are dropped from a published /tags.json but // still ride on records (a site hides a chip; it does not erase a fact). hidden?: boolean; + // The sites this tag EXISTS on (site ids). Absent or empty = every site. + // Unlike `hidden`, this is not presentation: a scoped tag is never derived for + // a video whose channel is not a member of one of these sites (its rules do + // not fire there and its pins do not apply), and a site outside the list + // drops it from its summaries and its /tags.json — so it is on no record, + // chip or count of another site (the Eva tags: Anilyzer's only). Corpus layer + // only: a site's own tags.json cannot widen or narrow it. + sites?: string[]; // Optional — a purely manual tag is legal. rules?: CuratedTagRule[]; }; @@ -241,6 +249,8 @@ function coerceTagDef(raw: unknown, layer: TagLayer): CuratedTagDef | null { typeof r.order === "number" && Number.isFinite(r.order) ? r.order : undefined; + // Corpus layer only (see the field): a site row's `sites` is dropped. + const sites = layer === "site" ? [] : siteIdList(r.sites); return { id, ...(label !== undefined ? { label } : {}), @@ -254,10 +264,76 @@ function coerceTagDef(raw: unknown, layer: TagLayer): CuratedTagDef | null { ...(trimmedString(r.color) ? { color: trimmedString(r.color) } : {}), ...(order !== undefined ? { order } : {}), ...(r.hidden === true ? { hidden: true } : {}), + ...(sites.length > 0 ? { sites } : {}), ...(rules.length > 0 ? { rules } : {}), }; } +// lib/siteSchema.ts SITE_ID_RE, repeated: this module imports nothing. +const SITE_ID_PATTERN = /^[a-z0-9][a-z0-9-]*$/; + +// A list of site ids: trimmed, lowercased, valid, deduplicated, sorted. +function siteIdList(v: unknown): string[] { + if (!Array.isArray(v)) return []; + const out = new Set<string>(); + for (const item of v) { + if (typeof item !== "string") continue; + const id = item.trim().toLowerCase(); + if (SITE_ID_PATTERN.test(id)) out.add(id); + } + return [...out].sort(); +} + +// ─── Site scope (a tag that exists on some sites only) ─── + +// tag id -> the channel slugs it may be derived for: the members of the sites +// its def names. Only scoped tags have an entry; a tag with no entry exists +// everywhere. A scoped tag whose sites carry no channel gets an EMPTY set — it +// is derived for nothing — never "no entry". +export function tagChannelScope( + defs: readonly CuratedTagDef[], + members: Readonly<Record<string, readonly string[]>>, +): Map<string, Set<string>> { + const out = new Map<string, Set<string>>(); + for (const def of defs) { + if (!def.sites || def.sites.length === 0) continue; + const slugs = new Set<string>(); + for (const siteId of def.sites) for (const slug of members[siteId] ?? []) slugs.add(slug); + out.set(def.id, slugs); + } + return out; +} + +// A video's tags with every scoped tag its channel is outside of removed. +export function scopeTagsToChannel( + tags: string[], + channelSlug: string, + scope: ReadonlyMap<string, ReadonlySet<string>>, +): string[] { + if (scope.size === 0 || tags.length === 0) return tags; + return tags.filter((id) => scope.get(id)?.has(channelSlug) ?? true); +} + +// A record's tags as one SITE publishes them: a tag scoped to other sites is +// dropped. (Derivation already keeps a scoped tag off every channel outside +// its sites; this covers a channel that is a member of an allowed site AND of +// this one.) Returns the same array when nothing is dropped. +export function tagsForSite( + tags: string[] | undefined, + siteId: string, + defs: readonly CuratedTagDef[], +): string[] | undefined { + if (!tags || tags.length === 0) return tags; + let out: string[] | null = null; + for (const [i, id] of tags.entries()) { + const sites = defs.find((d) => d.id === id)?.sites; + const keep = !sites || sites.length === 0 || sites.includes(siteId); + if (!keep && out === null) out = tags.slice(0, i); + else if (keep && out !== null) out.push(id); + } + return out ?? tags; +} + function coerceAssignment(raw: unknown): CuratedTagAssignment | null { if (!raw || typeof raw !== "object") return null; const r = raw as Record<string, unknown>; diff --git a/editor/CHANGELOG.md b/editor/CHANGELOG.md @@ -1,6 +1,7 @@ # Changelog ## [Unreleased] +- **A curated tag can exist on some sites only.** A tag's new **Sites** field on /tags (`sites` in `transcripts/tags.json`; `pnpm ops tags` takes it in a define) names the sites it exists on. Its rules then fire, and its pins apply, only to videos on those sites' channels, and every other site drops it from its records, its counts and its `/tags.json` — where **Hidden** only hid the chip. Empty is every site, as before. Setting it, or changing the channels of those sites, re-derives the corpus's tags once at the next index update. The Eva tags are what this is for: they belong on Anilyzer alone. - **The publish lane.** Publishing can run itself: turn it on at **/operations/publish** (the runner's Start, Drain and Stop, the hold, and the lane's settings; or `publish.enabled` in settings) and the lane checks every `checkEveryMinutes` (10) whether the index is stale; when it is — and its last update is at least `refreshEveryMinutes` (360) old — it updates it, then builds every site whose channels changed or whose data the new index moved, one stage at a time on the `publish` queue. What it may do with a site is the site's own — the **Publish policy** on the site's settings form, `site.json` `publish.auto` —: `off` (the default: left alone), `build`, `preview` (built and deployed to the preview branch `publish.previewBranch`) or `production`; the hub and the homepage have `publish.hub` and `publish.homepage`. A private site is only ever built, and a site needs its Cloudflare Pages project before it may deploy. Hold the lane and the stage running finishes and no next one starts; quiet hours (`publish.quietHours`) do the same; Drain finishes the stage and ends the runner. The lane never forces a stage: a stage that finds its target current does nothing. On /jobs every stage of one run reads `run <id> · <target>`, and a stage still queued when the editor restarts is cancelled, never re-queued — the lane works out again what is stale from what is on disk. `archilyzer publish now` runs the same plan from the command line, one stage after another in its own process. - **One index for every site.** The index is updated once and every site, the hub and the homepage are built from it; `archilyzer publish status` says, per site, whether its build is current — "stale: 3 channels changed (a, b, c)" as soon as a download, transcription or digest on one of its channels finishes, before any index runs; "stale: data changed" once the index has run and the site's data moved; "stale: config changed" after its site.json, tags or aliases changed — and whether what is deployed is that build, with a build made by older code marked "code newer" but not stale. - **Deploys are pinned and checked live.** wrangler is an exact dependency of the workspace (4.147.0), so a deploy runs the version installed with the code instead of whatever `pnpm dlx` fetched that day, and every deploy names its branch: production is `--branch main`, never taken from the checkout it ran in (where a "production" deploy from a feature branch used to land as a preview). The publish stages' deploy (release 18) refuses before wrangler runs when there is no Cloudflare credential at all — "set CLOUDFLARE_API_TOKEN in .env" — and says "REFUSED by Cloudflare — the API token was not accepted" when Cloudflare rejects one; it refuses a production deploy of a build made from a branch other than `main`. After each deploy it reads `corpus.json` at the site's address twice, as a visitor would and cache-busted, and records the verdict: ok, stale-edge (the deployment is right, Cloudflare's edge still serves an older copy), mismatch, or unreachable. A verdict short of ok is a warning in the log; the deploy itself succeeded. What each target last shipped, where, and how it read is kept in `deployed.json` beside its build. diff --git a/editor/app/tags/components/EditorTagsClient.tsx b/editor/app/tags/components/EditorTagsClient.tsx @@ -54,12 +54,19 @@ type TagRow = { color: string; order: string; hidden: boolean; + // `sites`, comma-separated: the sites this tag exists on (empty = every site). + sitesText: string; rules: RuleRow[]; }; let keySeq = 0; const mkKey = () => `k${keySeq++}`; +// The Sites box as site ids (the store's sanitizer validates them again). +function siteList(text: string): string[] { + return [...new Set(text.split(",").map((s) => s.trim().toLowerCase()).filter(Boolean))]; +} + function toRow(def: CuratedTagDef): TagRow { return { key: mkKey(), @@ -71,6 +78,7 @@ function toRow(def: CuratedTagDef): TagRow { color: def.color ?? "", order: def.order === undefined ? "" : String(def.order), hidden: def.hidden === true, + sitesText: (def.sites ?? []).join(", "), rules: (def.rules ?? []).map((r) => ({ ...r, key: mkKey(), @@ -91,6 +99,7 @@ function toDef(row: TagRow): CuratedTagDef { ...(row.color.trim() ? { color: row.color.trim() } : {}), ...(row.order.trim() !== "" && Number.isFinite(order) ? { order } : {}), ...(row.hidden ? { hidden: true } : {}), + ...(siteList(row.sitesText).length > 0 ? { sites: siteList(row.sitesText) } : {}), ...(row.rules.length > 0 ? { rules: row.rules.map((r) => { @@ -140,11 +149,14 @@ export function EditorTagsClient({ initialTags, counts, channels, + sites = [], orphans, }: { initialTags: CuratedTagDef[]; counts: TagCounts; channels: string[]; + // Configured site ids, offered by the Sites box. + sites?: string[]; // Tag ids carrying pins or suppressions that no definition claims — a def // deleted after the facts were made. They keep riding on records and appear // in no published /tags.json, so the only place they can be seen is here. @@ -190,6 +202,7 @@ export function EditorTagsClient({ color: "", order: "", hidden: false, + sitesText: "", rules: [], }, ]); @@ -355,6 +368,11 @@ export function EditorTagsClient({ </p> )} + <datalist id="tag-sites"> + {sites.map((id) => ( + <option key={id} value={id} /> + ))} + </datalist> {rows.length === 0 ? ( <p className="rounded-md border border-dashed px-3 py-6 text-center text-sm text-muted-foreground"> No tags yet. Add one, give it an id like <code>eva-collab</code>, and @@ -528,6 +546,20 @@ function TagCard({ className="rounded border border-border bg-card px-2 py-1 text-sm text-foreground" /> </label> + {/* Not presentation, unlike Hidden: a tag scoped to sites is derived only + for videos on those sites' channels, and every other site drops it + from its records and its /tags.json. */} + <label className="flex flex-col gap-1 text-xs text-muted-foreground"> + Sites (comma-separated site ids; empty = every site — the tag exists only on these) + <input + value={row.sitesText} + onChange={(e) => onPatch({ sitesText: e.target.value })} + placeholder="every site" + list="tag-sites" + aria-label="tag sites" + className="rounded border border-border bg-card px-2 py-1 font-mono text-xs text-foreground" + /> + </label> <p className="text-xs text-muted-foreground" data-testid="tag-counts"> {counts diff --git a/editor/app/tags/page.tsx b/editor/app/tags/page.tsx @@ -1,5 +1,6 @@ import type { Metadata } from "next"; import { getPaths } from "yt-dlp-transcript-common/lib/paths"; +import { listSiteIds } from "yt-dlp-transcript-common/lib/site"; import { listChannelConfigs } from "yt-dlp-transcript-common/controller/channels"; import { readGlobalTags } from "../../lib/tagsStore"; import { EditorTagsClient, type TagCounts } from "./components/EditorTagsClient"; @@ -62,6 +63,7 @@ export default async function TagsPage() { initialTags={config.tags} counts={counts} channels={channels} + sites={listSiteIds(paths)} orphans={orphans} /> </section> diff --git a/editor/e2e/tags.spec.ts b/editor/e2e/tags.spec.ts @@ -36,6 +36,7 @@ type TagsFile = { label: string; color?: string; hidden?: boolean; + sites?: string[]; rules?: { id: string; kind: string; channelsExclude?: string[] }[]; }[]; assignments: Record< @@ -107,6 +108,16 @@ test("a tag is defined, edited and removed, and the file holds what the form sai file = await tagsFile(); expect(file.tags[0]).toMatchObject({ label: "On mic", group: "eva" }); + // Sites: the tag exists on these only (curated `sites`), saved sorted and + // deduplicated, and read back into the box. + await page.goto("/tags"); + await page.getByTestId("tag-card").first().getByLabel("tag sites").fill("testsite, Testsite"); + await page.getByRole("button", { name: "save tags" }).click(); + await expect(page.getByTestId("tags-saved")).toBeVisible(); + expect((await tagsFile()).tags[0]).toMatchObject({ sites: ["testsite"] }); + await page.goto("/tags"); + await expect(page.getByTestId("tag-card").first().getByLabel("tag sites")).toHaveValue("testsite"); + await page.goto("/tags"); await page.getByRole("button", { name: "remove tag eva-collab" }).click(); await page.getByRole("button", { name: "save tags" }).click(); diff --git a/plans/FACTS.md b/plans/FACTS.md @@ -5297,6 +5297,28 @@ or an explicit clips total, a count on the storage page, and an eviction rule"). Three branches off `4ac8ceda`, merged in order: `1a011d96` alone, then `tags/rules-and-ops` (`9837f066`), `export/tags-followups` (`cb254919`), `editor/debts` (`766e0873`). +### A tag can exist on some sites only (`sites`, 2026-10-06) + +- `CuratedTagDef.sites?: string[]` (`common/lib/curatedTags.ts`): the site ids a tag EXISTS on; + absent or empty = every site. Corpus layer only — `coerceTagDef` drops it from a site's + tags.json, so a site cannot widen or narrow it. Not presentation (that is `hidden`). +- **Derivation:** `curatedTagsRuntime(config, members)` resolves `scope` = tag id -> the channel + slugs of its sites' members (`tagChannelScope`; `loadCuratedTagsRuntime` reads `listSites`). + `deriveCuratedTags` ends with `scopeTagsToChannel`: neither a rule hit nor a PIN carries a + scoped tag to a video on a channel outside its sites. `needsCaptionCues` / `needsChatCues` + skip a scoped tag's rules there too (no cue read is paid for them). +- **Per site:** `buildIndex` filters each site's summaries through `tagsForSite`, so a channel + that is a member of an allowed site AND of another publishes the tag on the allowed one only + — summaries and `tag-counts.json` (compose then writes no `/tags.json` entry). The SHARED + per-channel transcript pages of such a dual-member channel still carry it (one byte-stream + for every site); the viewer renders an id its site does not publish as the raw id. +- **The hash:** `hashCuratedRules(defs, scope)` folds the resolved scope in ONLY when some tag + is scoped, so shipping the field re-derives nothing; scoping a tag, or changing the membership + of a scoped tag's sites, changes `curatedRulesHash` and the next `build-index` re-derives the + corpus once (the `rulesChanged` branch below). +- Written like every def: the /tags page's **Sites** box (`aria-label="tag sites"`), or + `pnpm ops tags --json '{"op":"define","tag":{…, "sites":["anilyzer"]}}'` (the whole def). + ### A rule can exclude channels, and the exclusion wins — and it re-derives the corpus once - `CuratedTagRule.channelsExclude?: string[]` (`common/lib/curatedTags.ts:54`), compiled to a diff --git a/plans/STATE.md b/plans/STATE.md @@ -16,6 +16,11 @@ the status, the queue and the publish lane; S4 the surfaces; S5 the image half; then policies (`build` for all six; `preview` where wanted; `production` for none until a week of lane runs reads clean) and the lane on (`checkEveryMinutes` 10, `refreshEveryMinutes` 360); `archilyzer doctor`; build `runtime-vulkan` and `runtime-cuda` once. +- **The Eva tags leak (found 2026-10-06):** `eva-topic` is published on hasanalyzer (1, hasanabi) and bonnellyzer + (3, omnibased) — the three Eva rules fire on every channel but Eva's own. Ruled: they exist on Anilyzer only (and + a future VTuber site). Fixed in code on `tags/site-scope` (a tag's `sites`, merged here); the DATA change is + owed once the new code is live: `~/reports/release-18/scripts/r18-eva-sites.sh` (scopes the three to anilyzer, + updates the index, rebuilds the three sites), then production deploys of hasanalyzer and bonnellyzer. - **What changes for the operator:** /sites → **Publish** replaces "Build all sites" and the hub/homepage sections (Publish now, Build all stale, a row per target with index | built | deployed | live chips); **Build stats dataset is gone** (the index update builds the stats); `/operations/publish` is the lane; a site's **Publish policy** is on its diff --git a/scripts/archilyzer-ops.mjs b/scripts/archilyzer-ops.mjs @@ -46,6 +46,8 @@ // pnpm ops reports-export --json '{"siteId":"demo-site","formats":["html","md"]}' --wait // pnpm ops get channel the-quartering // pnpm ops tags --json '{"op":"define","tag":{"id":"eva-collab","label":"Collab"}}' +// (a define is the WHOLE def: rules, and `sites` — the site ids the +// tag exists on, absent = every site — included) // pnpm ops tag-videos --file ids.json // pnpm ops persist-videos --file list.json --wait // pnpm ops get tags eva-collab