# Channel config.json keys One channel of the corpus, persisted to `transcripts/channels//config.json`. The schema is `common/lib/channelConfigSchema.ts` over the coercions in `common/lib/channelConfig.ts`. Which sites expose a channel is `site.json`'s business — see [SITE.md](SITE.md); global settings are [SETTINGS.md](SETTINGS.md). `handling` is the one required key: a file without a valid one is not a channel. The smallest channel is `{ "handling": "youtube", "url": "https://www.youtube.com/@example" }`. Every other key is optional and has NO default of its own: an absent key means whatever its description says — for the per-channel overrides, inherit the global setting of the same name; for `name`, `url`, `mediaDir`, `subLangs` and the sync-state stamps, simply unset. So an ill-typed or out-of-range value is not coerced — it is DROPPED, as if the file did not spell it. Unknown keys (including the retired `excludeFromSync`, now a paused `sync` tier in the channel-priority document) are dropped by every read and every write. The three **sync state** keys are not configuration: the sync, sweep and download passes stamp them, the channel form never does, and they live in the same file on purpose. Writers after creation PATCH (`patchChannelConfig`): each re-reads the file at the moment it writes and changes only its own keys, so a stamp and a form save made at once in the editor both land. The two exceptions write a whole config, and only when there is no readable file to patch: a media move and a channel rename record `mediaDir` from their own copy of the config. Regenerate this file with `pnpm --filter yt-dlp-transcript-common exec tsx bin/file-schemas-docs.ts`. | Key | Kind | Description | |---|---|---| | `handling` | required | REQUIRED. `"youtube"` (fetch the platform's captions) or `"transcribe"` (download audio and transcribe it locally). A file without a valid `handling` is not a channel: it reads as null. | | `sourceKind` | config | What KIND of source this is: `"video"` (default — yt-dlp + transcription) or `"social"` (an account fetched into the posts corpus, skipped by the video scan). A separate axis from `handling`, so every binary handling branch stays binary. | | `postFetcher` | config | Social channels only: which social fetcher drives ingest (e.g. `"bluesky-atproto"`, `"x-gallery-dl"`). Absent = resolve by URL detection. Trimmed. | | `socialHandle` | config | Social channels only: the bare account handle (a leading "@" is stripped). Derived from `url` at creation but stored, so a later URL-format change upstream cannot silently re-point ingest at a different account. | | `postPagePauseSeconds` | config | Social channels that are read page by page (a forum thread) only: the pause between two page loads, in seconds; each pause is jittered to 0.85–1.65× of it. Absent = the fetcher's own (12 s, so 10–20 s); floored at 5, capped at 600. | | `platform` | config | The source platform: youtube, rumble, odysee, twitch, kick, archiveorg (archive.org items — imported, never listed; see README.md, "archive.org items"), bitchute (BitChute videos and channels; see README.md, "BitChute"), twitter, bluesky or xenforo (a forum thread). An unknown value is dropped. | | `name` | config | Display name. | | `url` | config | The channel / playlist / account URL syncs enumerate. Absent = the channel is never auto-synced. | | `audioFormat` | config | `"m4a"`, `"mp3"` or `"opus"`: the audio a transcribe-handling download keeps. | | `downloadFormat` | config | Per-channel override for the yt-dlp `-f` download format preset. Absent = inherit the global `downloadFormat`, which itself falls back to the per-source "auto" selector. Lets a channel whose source serves full-length audio only in its `original` format (e.g. Odysee) force it. | | `sourceVideoQuality` | config | Per-channel override for the quality of the source container a full persist keeps ("Persist source video", the whole-recording fetch, "Persist kept now"). `"original"` (best video + audio) or `"video_720"` (≤720p H.264, for clips/editing). Absent = inherit the global `sourceVideoQuality`. | | `keepSourceVideo` | config | Keep the downloaded source video beside the audio. | | `keepLatest` | config | Keep-latest window: the newest N videos (by upload date) are protected from the Clean-audio sweep AND have their source video persisted to the saved-video store. 0 or absent = disabled; positives clamp to [1, 100000]. A kept video later found deleted at the source is pinned permanently via the do-not-clean marker. | | `extractionMode` | config | `"ytdlp"` (default — yt-dlp's own `-x --audio-format` postprocessor, no source container kept) or `"app"` (yt-dlp downloads the source container and the app runs ffmpeg). The keep-latest persistence rule forces `"app"` for the videos it persists. | | `savedVideosDir` | config | Per-channel override for the saved-video store root: this channel's persisted source videos live under `///`. Trimmed; blank = the global store. | | `dataDir` | config | RETIRED (release 17). The whole-directory layout's record: the absolute path `channels//data` was a symlink to. Still parsed for one release so a write never erases it: a channel that carries it — or whose `data/` is a link — is `legacy`, and every media job, lane and build holds it until `archilyzer storage migrate-tier ` moves its text back and its media into `mediaDir`. Written only by the re-point of a storage location (which keeps it `//data` on the new root); removed by that migration. | | `mediaDir` | config | Where this channel's big files live when relocated: `channels//media` is a symlink to it, `//media`. Absent = in place. Written only by relocate / re-point / the tier migration — a record of what is on disk, never free text, because a value that disagrees with the link is an "inconsistent" channel every media guard refuses. The text (`data/`) never moves. | | `ytdlpExtraArgs` | config | Extra yt-dlp arguments, appended verbatim. Must be an array of strings or it is dropped. | | `subLangs` | config | yt-dlp `--sub-langs` value for caption downloads. | | `lastSyncedAt` | sync state | SYNC STATE. When the channel last synced (ISO time). Stamped by every sync, and by a social fetch; read by the scheduler's cadence gate. | | `lastFullDownloadAt` | sync state | SYNC STATE. When a full download pass last completed (ISO time). | | `lastFullSweepAt` | sync state | SYNC STATE. When this channel last paid for a sync FULL SWEEP — the deep pass that re-enumerates the whole listing to refresh `playlist` and flag videos that have left it. Stamped by the sweep; read by the cadence gate to decide whether the next sync sweeps or stays on the cheap newest-first paged walk. | | `excludeFromBuild` | config | Leave this channel out of every site build. | | `excludeFromCleanup` | config | Leave this channel's reclaimable bytes out of the aggregate "cleanable data" total on /cleanup and its badge. The per-channel cleanup sweeps stay available; only the running total changes. | | `syncIntervalMinutes` | config | Auto-sync cadence: the scheduler syncs this channel when `now - lastSyncedAt >= syncIntervalMinutes`. Absent = inherit the global default; 0 = auto-sync off (still manually syncable); positives clamp to [1, 44640] (~31 days). A missing `url`, or a `sync` tier of paused in the channel-priority document, also disables auto-sync. | | `fullSweepIntervalMinutes` | config | Full-sweep cadence: a sync upgrades itself to a full sweep when `now - lastFullSweepAt >= fullSweepIntervalMinutes`. Absent = inherit `syncScheduler.fullSweepIntervalMinutes`; 0 = never sweep (every sync is a paged walk); positives clamp to [1, 44640]. | | `skipLiveDownloads` | config | Per-channel override for the global `skipLiveDownloads`. Absent = inherit; false = allow downloading currently-live / upcoming videos. | | `downloadFilter` | config | Per-channel title/description download filter, matched against the per-video metadata prefetch. A declined video is SETTLED by a terminal download-outcome keyed on the filter's signature, not by an archive line, so changing either pattern re-evaluates every settled video on the next run. An object with no real rule is dropped (the filter is inert). See [`downloadFilter`](#downloadfilter). | | `cookiesFromBrowser` | config | Per-channel override of the global cookies-from-browser spec. Trimmed; blank = inherit. | | `cookieMode` | config | Per-channel override of the global cookie mode. Absent = inherit. | | `sleepBetweenDownloadsSeconds` | config | Per-channel override for the global pause between downloads. Absent = inherit; 0 = no sleep; floored and capped at 600. | | `audioCheck` | config | Opt-in audio-integrity checking for sources that intermittently serve corrupt audio mid-download (e.g. Odysee "original"): the managed downloader periodically validates the in-progress `.part` file and rolls back to the last known-good snapshot on corruption. transcribe-handling only. See [`audioCheck`](#audiocheck). | | `recordedDate` | config | For a channel that MIRRORS another's streams (a VOD archive): how to read the date a video was RECORDED from its title, since its `upload_date` is the date of the copy. The index stores the result as the record's `recordedDate` (`YYYYMMDD`), which coverage reads before the upload date. A title it does not match, a date that is not a real day, or one after the upload date gives none. Changing the rule re-derives the channel's records on the next index build. An object without a usable `titlePattern` is dropped. See [`recordedDate`](#recordeddate). | #### `downloadFilter` | Key | Default | Description | |---|---|---| | `include` | absent | Case-insensitive regex SOURCE (no delimiters, no flags) a video's `title + "\n" + description` must match to be downloaded. Trimmed; blank = no include rule. | | `exclude` | absent | Case-insensitive regex source that rejects a matching video. Wins over `include`. Trimmed; blank = no exclude rule. | | `includeLivestreams` | absent | Opt every livestream VOD in, whatever it is called. A SECOND positive selector beside `include`, not a modifier of it — so `{ includeLivestreams: true }` alone rejects plain uploads and passes livestreams. Stored only when `true`. | | `rejectedLivestreams` | absent | What to do with a livestream the filter REJECTED: `"skip"` (default, and what every channel predating the field did) or `"chat-only"` — the video is not downloaded, its live chat is, and it joins the corpus as a chat track with no captions. Stored only when not `"skip"`, and only beside a real filter: with nothing to reject it names a decision that can never be taken. | #### `audioCheck` | Key | Default | Description | |---|---|---| | `enabled` | required | Turn the check on. Required: an `audioCheck` object without a boolean `enabled` is dropped whole. | | `intervalSeconds` | absent | Seconds between integrity probes of the in-progress `.part` file; clamped to [10, 600]. Absent = 60. The live cadence adapts (AIMD): a malformed checkpoint halves it toward the 10 s floor, clean ones step it back up. | | `maxRollbacks` | absent | Rollbacks to the last known-good snapshot before the download is given up; clamped to [1, 20]. Absent = 5. | | `copyTimeoutSeconds` | absent | Seconds allowed for the snapshot copy; clamped to [5, 120]. Absent = 30. | | `resumeDuringProbe` | absent | When false (default), yt-dlp stays SIGSTOPped across each probe, so it never downloads bytes a malformed verdict would discard and force a re-fetch — minimising HTTP 429 risk. True = the legacy behaviour: resume right after the snapshot copy and probe while the download keeps running. | #### `recordedDate` | Key | Default | Description | |---|---|---| | `titlePattern` | required | Case-insensitive regex SOURCE (no delimiters, no flags) matched against each video's title, with three named groups: `year` (four digits, or two read as 20YY), `month` (1–12, or an English month name, whole or cut to three or more letters) and `day`. Trimmed; at most 200 characters, no nested quantifier (the download filter's rules). A pattern that breaks any of this drops the whole rule. |