--- key: sched.autotune-per-source-weights title: '2026-07-18 — Auto-Tune per-source weights ride #70''s MultiCollection machinery; created at tune time, not a post-hoc PUT (#425)' status: active since: '2026-07-18' supersedes: none superseded-by: none rule: 'Auto-Tune per-source rotation weights and query corrections are supplied at bulk-create time via #70''s MultiCollection/SmartCollection machinery, not a post-hoc PUT.' signals: 'Auto-Tune, per-source weights, MultiCollection, WeightedShuffle · paths: `AutoTunedChannelRequest`, `OwnedByChannelId` migration · issues: #425, #70, #383, #386, #385' mechanics: '`AddCollectionOwnedByChannelId` migration; `WeightedShuffleCollectionEnumerator`' --- Per-source rotation weights (`3× Show A, 1× Show B`) and query corrections (exclude / add-untagged) for an auto-tune channel are supplied **at bulk-create time** — an optional `sources: [{sourceId, weight, excluded}]` list on each `AutoTunedChannelRequest` (the same DetailPanel-wizard surface #385 added its per-channel `advanced`/`templateId`/`logo` to). There is **no** post-hoc `PUT .../weights`, no lazy upgrade/downgrade, and no idempotent desired-state handler: the auto-tune DetailPanel (#383/#386) is a create-wizard, and #425's Done-when is "create → built playout". (An earlier plan draft assumed a PUT on an existing channel; the shipped flow is create-time, matching #385.) **Structure — Option A (reuse #70), not a new fake-collection weight path.** When any source is customized (a non-default weight, an exclusion, or an added out-of-axis id), the channel is backed by a system-owned `MultiCollection` of per-source `SmartCollection`s, weights on `MultiCollectionSmartItem.Weight`, and its single-item lineup points at the `MultiCollectionId` with `PlaybackOrder.WeightedShuffle` — the exact path `WeightedShuffleCollectionEnumerator` already consumes (`GetMultiCollectionCollections` forwards the per-row weight). Rejected: threading a weight map through `GroupIntoFakeCollections` (the fake-collection path a lone SmartCollection takes, which hardcodes weight 1) — it derives its group keys at runtime, is shared with the unrelated `FillWithGroupMode`, and would need a parallel weight-persistence home. When **no** source is customized (all weights 1, nothing excluded/added) the channel keeps the #69 single-SmartCollection fair-share shape — cheaper, and identical output for TV since the fake path already groups per show. **Discriminators.** A source's member query is discriminator-only (membership is fixed at tune time): TV uses `type:episode AND show_title:"X"` (episode docs carry no parent-show id in the index — `show_title` is the only per-show field, the same one #69's TvShow axis already uses; a post-create rename empties the member until re-tuned), and movies use the stable, rename-proof `type:movie AND id:{mediaItemId}` (a movie is the played item). The remainder is `({base}) AND NOT ({d1} OR {d2} …)` over every materialized ∪ excluded discriminator — a partition of the base set, so no item is counted twice or dropped. Exclude = omit the member **and** keep it in the NOT-list (else its items leak back through the remainder); add-untagged = an ordinary member whose id isn't in the base set (no genre clause, so it just airs). **Materialization bound is axis-dependent — "weight N" means N× *each* other source.** TV materializes **every** base show as its own member (un-weighted shows keep per-show fair-share; a single merged remainder would regress them to item-proportional, so a 200-episode show would swamp a 20-episode one) plus one live remainder at weight 1 for shows/episodes added after tune-in (empty at create → harmlessly skipped by `WeightedShuffleCollectionEnumerator`'s `ActiveSources` filter). MovieGenre materializes **only** the touched movies (weighted or added) and leaves every un-touched base movie in ONE remainder whose weight = its member count — exactly equivalent to materializing each movie individually, because the fake path already pools movies uniformly, without hundreds of rows. Cost note: a large TV genre materializes one SmartCollection per show (dozens–hundreds), each a cheap stored term query; a future optimization could cap/warn. **Ownership + lifecycle.** The MultiCollection and its member/remainder SmartCollections carry a nullable `OwnedByChannelId` (dual-provider migration `AddCollectionOwnedByChannelId`, indexed). Owned rows are hidden from the user-facing collection lists and cascade-cleaned when the channel is deleted (the `ProgramScheduleItem → MultiCollection` cascade removes the dangling flood item). Names embed a per-create token (`at-mc:{token}`, `at:{token}:{n}`) because both names are unique `varchar(50)` and the channel id isn't known until `CreateChannelFromLineup` runs; ownership is stamped immediately after. Non-weighted (#69) channels set no ownership, so their pre-existing orphan-on-delete behavior is unchanged. The create is non-atomic across the two handlers (mirrors #69) with best-effort rollback of the artifacts on channel-create failure.