---
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.
