feat(440): per-source weight steppers + exclude/add-untagged in Auto-Tune DetailPanel
PR Gates / CI image pin matches docker/ci (pull_request) Successful in 11s
PR Gates / Docs update reminder (pull_request) Successful in 12s
PR Gates / decisions lifecycle (pull_request) Successful in 21s
Build ErsatzTV Image / API docs in sync (OpenAPI + endpoint index) (pull_request) Successful in 21s
Build ErsatzTV Image / Formatting (changed .cs conform to .editorconfig) (pull_request) Successful in 13s
Build ErsatzTV Image / EF migration integrity (SQLite + MySql) (pull_request) Successful in 5m48s
Build ErsatzTV Image / Functional E2E (curl contracts) (pull_request) Successful in 15m29s
Build ErsatzTV Image / Build & test (.NET) (pull_request) Successful in 22m6s
Build ErsatzTV Image / Build & push image (amd64) (pull_request) Has been skipped

Wires the Auto-Tune DetailPanel's Content-sources pane to #425's backend: each
member row gains a 1..1000 weight stepper and an include/exclude toggle, and a
library typeahead adds a source that isn't in the axis's base set. Edits
accumulate in the screen's per-channel draft (the existing §8/§11 guard covers
them) and are flushed as the create request's `sources` array.

- Only genuinely customised rows are sent, mirroring the server's own
  `customized` predicate — an all-default array is a backend no-op, so the field
  is omitted entirely and the channel keeps the cheaper fair-share shape.
- Weights are clamped to 1..1000 on blur and again at save, so an out-of-range
  value never reaches the server as a raw 400 (spa-conventions §4a).
- The add-untagged picker compiles typed text to `title:*…*` rather than
  forwarding raw Lucene: the index's default field does not match bare title
  words, so a raw forward would silently find nothing.
- Removes the read-only #425 hint.

Docs: spa-conventions.md §11 records the per-source correction-row convention.

fixes #440
This commit is contained in:
2026-07-25 11:15:20 +02:00
parent be1070de51
commit d3c89d87aa
4 changed files with 584 additions and 84 deletions
+22
View File
@@ -457,6 +457,28 @@ string-keyed dispatcher, or generic screen-action framework participates in norm
bulk-create), closing the panel does **not** lose data (it's still in screen state) — so the
`window.confirm` guard belongs on *screen navigation / unload* while any uncommitted edit exists,
not on panel close. Show an "Edited" badge on customised rows so the pending edits are visible.
- **Per-source correction rows: weight stepper + include/exclude toggle, keyed by the server's source id**
(#440 wiring #425's backend into the Auto-Tune DetailPanel's Content-sources pane). The row model is
reusable wherever an item list carries per-item rotation weights:
- The row's draft (`SourceDraft` in `AutoTuneScreen.tsx`) holds `weight` as a **string** per §4a, plus a
boolean `excluded`; the shared bound `WEIGHT_MIN`/`WEIGHT_MAX` = 1..1000 mirrors the server's
`MultiCollectionItemWeight` validator (the same const pair the multi-collection editor uses, #404) and is
clamped on `onBlur` **and** again in the draft→request mapping.
- **Only genuinely customised rows go on the wire.** The draft→request mapper (`sourcesRequest`) drops any
row that is still fair-share (weight 1, not excluded, not added) and returns `undefined` when nothing is
left, so the field is **omitted** rather than sent as an all-default array. That predicate mirrors the
server's own `customized` check exactly — an all-default array is a documented no-op there, and omitting
it keeps the channel on the cheaper single-SmartCollection shape. The same predicate drives the row's
"Edited" badge and the §8 dirty guard, so a value touched and set back to the default is correctly *not*
an edit (same rule as a name cleared back to empty).
- **A library picker compiles typed text; it never forwards raw Lucene.** The "add a source not in the
base set" typeahead sends `title:*<escaped>*` (`titleContainsQuery`), not the user's literal text: the
search index's default field does **not** match bare title words (`Alpha` finds nothing for "Show Alpha"
— see `e2e-local.md`), so a raw forward like `SearchScreen`'s (where the box is explicitly a query box)
would look broken in a *name* picker. Escape every Lucene special + whitespace so the boundary stars are
the only live wildcards — the same shape `builder/rules/compile.ts` emits for `contains`.
- Rows are read-keyed by the **server's** id (`GET /api/v1/channels/auto-tune/members`), and an added id
that turns out to already be a base member patches the existing row instead of appending a duplicate.
- **Advanced channel-options overrides are shared, not duplicated** (`web/src/builder/advancedOptions.tsx`).
The `CreateChannelFromLineupAdvancedOptionsRequest` override model — the enum catalogs,
`ADVANCED_KEYS`, `effectiveValue`, and the field adapters (`useAdvancedOverrides`) — is one module
+7 -2
View File
@@ -7,6 +7,11 @@ export type PreviewAutoTuneChannelsRequest = components['schemas']['PreviewAutoT
export type AutoTunedChannelRequest = components['schemas']['AutoTunedChannelRequest'];
export type CreateAutoTunedChannelsRequest = components['schemas']['CreateAutoTunedChannelsRequest'];
export type AutoTuneChannelResult = components['schemas']['AutoTuneChannelResultModel'];
// Per-source rotation weight + query correction on a create request (#425). `sourceId` is a show
// (TV axes) or movie (MovieGenre axis) id — the ids the members endpoint below returns; `weight` is
// 1..1000 with 1 = fair share; `excluded` drops the source from the channel entirely. An entry whose
// id is NOT in the axis's base set is an "add-untagged" source and simply airs alongside the rest.
export type AutoTuneSourceWeightRequest = components['schemas']['AutoTuneSourceWeightRequest'];
export type AutoTuneResult = components['schemas']['AutoTuneResultResponseModel'];
export type PagedAutoTuneMembers = components['schemas']['PagedLibraryBrowseItemsResponseModel'];
export type AutoTuneMember = components['schemas']['LibraryBrowseItemResponseModel'];
@@ -33,8 +38,8 @@ export function createAutoTunedChannels(body: CreateAutoTunedChannelsRequest): P
}
// #384 read endpoint: the distinct members a proposed channel's smart collection
// resolves to (axis+value identify the proposal). Read-only source list for the
// DetailPanel; per-source rotation weights + corrections are #425, not here.
// resolves to (axis+value identify the proposal). Backs the DetailPanel's content-source
// rows — its ids are the `sourceId`s a per-source weight/exclusion is keyed by (#425/#440).
export function getAutoTuneChannelMembers(params: GetAutoTuneMembersParams): Promise<PagedAutoTuneMembers> {
const searchParams = new URLSearchParams();
searchParams.set('axis', params.axis);
+141 -1
View File
@@ -47,6 +47,13 @@ function autoTuneAuxRoute(path: string, method: string): Response | null {
if (path.startsWith('/api/v1/channels/auto-tune/members') && method === 'GET') {
return jsonResponse(members);
}
// Backs the #440 add-untagged typeahead: a show that is NOT in the axis's base member set.
if (path.startsWith('/api/v1/search?') && method === 'GET') {
return jsonResponse({
movies: { totalCount: 0, items: [] },
shows: { totalCount: 1, items: [{ id: 99, mediaType: 'TelevisionShow', title: 'Dark Matter', artwork: '' }] }
});
}
return null;
}
@@ -266,7 +273,9 @@ describe('AutoTuneScreen', () => {
// Panel opens as a dialog titled with the proposal name; members load from GET /members.
expect(await screen.findByRole('dialog', { name: 'The Office' })).toBeInTheDocument();
expect(await screen.findByText('Parks and Rec')).toBeInTheDocument();
expect(screen.getByText(/rotation weights/i)).toBeInTheDocument(); // #425 hint
// Each member row is now writable (#440): a weight stepper + an exclude toggle.
expect(screen.getByRole('spinbutton', { name: 'Weight for Parks and Rec' })).toHaveValue(1);
expect(screen.getByRole('button', { name: 'Exclude Parks and Rec' })).toBeInTheDocument();
});
it('threads per-channel edits (name, shuffle) into the create payload and flags the row Edited', async () => {
@@ -339,4 +348,135 @@ describe('AutoTuneScreen', () => {
expect(screen.queryByText('Edited')).not.toBeInTheDocument();
expect(screen.getByRole('checkbox', { name: 'The Office' })).toBeInTheDocument();
});
// ---- per-source weights / exclusions / add-untagged (#440 over #425's backend) ----
async function openSources() {
await openPreview();
fireEvent.click(screen.getAllByRole('button', { name: 'Configure' })[0]);
await screen.findByRole('dialog', { name: 'The Office' });
await screen.findByText('Parks and Rec');
}
function postedChannels(fetchSpy: ReturnType<typeof mockAutoTuneApi>) {
const createCall = fetchSpy.mock.calls.find(
([url, init]: [RequestInfo | URL, RequestInit | undefined]) =>
url === '/api/v1/channels/auto-tune' && (init?.method ?? '').toUpperCase() === 'POST'
);
return JSON.parse(String(createCall?.[1]?.body)).channels as { value: string; sources?: unknown }[];
}
it('threads per-source weights and exclusions into the create payload as `sources`', async () => {
const fetchSpy = mockAutoTuneApi();
await openSources();
// Weight The Office 3× and drop Parks and Rec entirely.
fireEvent.change(screen.getByRole('spinbutton', { name: 'Weight for The Office' }), { target: { value: '3' } });
fireEvent.click(screen.getByRole('button', { name: 'Exclude Parks and Rec' }));
// The toggle flips to the inverse action once excluded.
expect(screen.getByRole('button', { name: 'Include Parks and Rec' })).toBeInTheDocument();
// The pending corrections are visible on the row (screen-level draft, §11 "Edited" badge).
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.getByText('Edited')).toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
await screen.findByText(/Added to group/);
const office = postedChannels(fetchSpy).find((c) => c.value === 'The Office');
expect(office?.sources).toEqual([
{ sourceId: 11, weight: 3, excluded: false },
{ sourceId: 12, weight: 1, excluded: true }
]);
// An untouched proposal still sends no `sources` at all.
expect(postedChannels(fetchSpy).find((c) => c.value === 'Comedy')).toEqual({
axis: 'TvGenre',
value: 'Comedy',
name: 'Comedy',
number: '502'
});
});
it('omits `sources` when every weight is left at (or returned to) the fair-share default', async () => {
const fetchSpy = mockAutoTuneApi();
await openSources();
const weight = screen.getByRole('spinbutton', { name: 'Weight for The Office' });
fireEvent.change(weight, { target: { value: '4' } });
fireEvent.change(weight, { target: { value: '1' } });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
// Back at fair-share, so the row is not "Edited" and the request stays clean.
expect(screen.queryByText('Edited')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
await screen.findByText(/Added to group/);
expect(postedChannels(fetchSpy).every((channel) => !('sources' in channel))).toBe(true);
});
it('clamps an out-of-range weight on blur and again at save', async () => {
const fetchSpy = mockAutoTuneApi();
await openSources();
const weight = screen.getByRole('spinbutton', { name: 'Weight for The Office' });
// Blur clamp: immediate feedback in the field itself.
fireEvent.change(weight, { target: { value: '5000' } });
fireEvent.blur(weight);
expect(screen.getByRole('spinbutton', { name: 'Weight for The Office' })).toHaveValue(1000);
// Save clamp: a value that never got blurred is still clamped before it reaches the server.
fireEvent.change(screen.getByRole('spinbutton', { name: 'Weight for The Office' }), { target: { value: '9999' } });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
await screen.findByText(/Added to group/);
const office = postedChannels(fetchSpy).find((c) => c.value === 'The Office');
expect(office?.sources).toEqual([{ sourceId: 11, weight: 1000, excluded: false }]);
});
it('adds an untagged source from the library search and sends its id', async () => {
const fetchSpy = mockAutoTuneApi();
await openSources();
fireEvent.change(screen.getByRole('textbox', { name: 'Add a show not in this axis' }), { target: { value: 'Dark' } });
// Debounced search resolves to a show outside the axis; picking it adds a row.
fireEvent.click(await screen.findByRole('button', { name: /Dark Matter/ }));
// Typed text is compiled to a substring title match — the index's default field does not match
// bare title words, so forwarding the raw text would silently return nothing.
const searchCall = fetchSpy.mock.calls.find(([url]: [RequestInfo | URL, RequestInit | undefined]) =>
String(url).startsWith('/api/v1/search?')
);
expect(String(searchCall?.[0])).toContain(`query=${encodeURIComponent('title:*Dark*')}`);
expect(screen.getByRole('spinbutton', { name: 'Weight for Dark Matter' })).toBeInTheDocument();
expect(screen.getByText('Added')).toBeInTheDocument();
fireEvent.change(screen.getByRole('spinbutton', { name: 'Weight for Dark Matter' }), { target: { value: '2' } });
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
await screen.findByText(/Added to group/);
const office = postedChannels(fetchSpy).find((c) => c.value === 'The Office');
expect(office?.sources).toEqual([{ sourceId: 99, weight: 2, excluded: false }]);
});
it('removes an added source again, leaving the base members untouched', async () => {
const fetchSpy = mockAutoTuneApi();
await openSources();
fireEvent.change(screen.getByRole('textbox', { name: 'Add a show not in this axis' }), { target: { value: 'Dark' } });
fireEvent.click(await screen.findByRole('button', { name: /Dark Matter/ }));
fireEvent.click(screen.getByRole('button', { name: 'Remove Dark Matter' }));
expect(screen.queryByRole('spinbutton', { name: 'Weight for Dark Matter' })).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: 'Close' }));
expect(screen.queryByText('Edited')).not.toBeInTheDocument();
fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
await screen.findByText(/Added to group/);
expect(postedChannels(fetchSpy).every((channel) => !('sources' in channel))).toBe(true);
});
});
+414 -81
View File
@@ -6,6 +6,7 @@ import {
Circle,
Clapperboard,
Eye,
EyeOff,
Film,
FolderTree,
Hash,
@@ -14,16 +15,33 @@ import {
ListOrdered,
MinusCircle,
Pencil,
Plus,
RotateCcw,
Search,
SearchX,
Settings2,
Shuffle,
Sparkles,
TriangleAlert,
Trash2,
Tv,
XCircle
} from 'lucide-react';
import { Badge, Button, ChannelLogo, Checkbox, Input, Select, SlideOver, Spinner, Stat, Switch, Tag, Tooltip } from '../components';
import {
Badge,
Button,
ChannelLogo,
Checkbox,
IconButton,
Input,
Select,
SlideOver,
Spinner,
Stat,
Switch,
Tag,
Tooltip
} from '../components';
import {
createAutoTunedChannels,
getAutoTuneChannelMembers,
@@ -31,9 +49,11 @@ import {
getDefaultChannelTemplate,
getFFmpegProfiles,
getFillerPresets,
getSearchResults,
getWatermarks,
messageFromAutoTuneError,
messageFromChannelTemplateError,
messageFromSearchError,
uploadArtwork,
previewAutoTune,
type AutoTuneAxis,
@@ -41,6 +61,7 @@ import {
type AutoTuneMember,
type AutoTuneProposal,
type AutoTuneResult,
type AutoTuneSourceWeightRequest,
type ChannelTemplate,
type FFmpegProfile,
type FillerPreset,
@@ -102,12 +123,50 @@ const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNu
// space separates it unambiguously from the metadata value.
const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`;
// Per-source weight bounds. Mirrors the server's MultiCollectionItemWeight validator (1..1000) —
// the same bound the multi-collection editor enforces (#404) and the one #425's auto-tune handler
// clamps to. Clamp client-side on blur AND again at save so an out-of-range value never reaches the
// server as a raw 400 (spa-conventions §4a).
const WEIGHT_MIN = 1;
const WEIGHT_MAX = 1000;
// Coerce a free-text weight to a valid integer share: empty / non-numeric / out-of-range all snap
// into [1,1000], with 1 (the floor, = fair share) for a blank or invalid field.
function clampWeight(raw: string): number {
const parsed = Math.trunc(Number(raw));
if (!Number.isFinite(parsed)) {
return WEIGHT_MIN;
}
return Math.min(WEIGHT_MAX, Math.max(WEIGHT_MIN, parsed));
}
// One per-source correction in the draft, keyed by the member's source id (a show id on the TV axes,
// a movie id on MovieGenre — the ids GET /channels/auto-tune/members returns). `weight` is held as a
// string so the number field edits smoothly (transient empty/partial values), clamped on blur and at
// save. `addedTitle` is set only for an add-untagged source — one that is NOT in the axis's base set,
// so the members list can't supply its title/artwork for the row.
interface SourceDraft {
weight: string;
excluded: boolean;
addedTitle?: string;
addedArtwork?: string;
}
const defaultSourceDraft = (): SourceDraft => ({ weight: String(WEIGHT_MIN), excluded: false });
// A source only counts as customized when it actually diverges from fair-share: a non-default weight,
// an exclusion, or an added out-of-axis id. This is exactly the server's `customized` predicate in
// CreateAutoTunedChannelsHandler.BuildWeightedPlan — an all-default list is a no-op there, so we keep
// it off the wire entirely rather than send noise.
const sourceCustomized = (draft: SourceDraft): boolean =>
draft.excluded || draft.addedTitle != null || clampWeight(draft.weight) !== WEIGHT_MIN;
// A per-channel edit accumulated in the Preview step's DetailPanel, keyed by proposalKey.
// Everything is optional/inherited: an unset field falls back to the proposal default or the
// (per-channel-or-batch) template. playbackOrder + playoutMode mirror the Channel Builder's
// dedicated-state model (see builder/advancedOptions ADVANCED_KEYS comment) — the Shuffle and
// Always-playing toggles — and are merged into `advanced` only when the user diverges from the
// template default. Per-source rotation weights + query corrections are #425, not modelled here.
// template default. `sources` carries the #425 per-source weights / corrections, keyed by source id.
interface ChannelOverride {
name?: string;
number?: string;
@@ -117,10 +176,28 @@ interface ChannelOverride {
playbackOrder?: PlaybackOrder;
playoutMode?: PlayoutMode;
advanced: Overrides;
sources?: Record<number, SourceDraft>;
}
const emptyOverride = (): ChannelOverride => ({ advanced: {} });
// Draft → request mapping for the #425 `sources` array. Only genuinely customized sources are sent
// (an all-default array is a backend no-op, so omitting it keeps the request clean and keeps the
// channel on the cheaper single-SmartCollection fair-share shape). Returns undefined when nothing
// was customized. Weights are clamped here too — the blur clamp is feedback, this one is
// authoritative (a value can reach save unblurred).
function sourcesRequest(override: ChannelOverride | undefined): AutoTuneSourceWeightRequest[] | undefined {
const entries = Object.entries(override?.sources ?? {}).filter(([, draft]) => sourceCustomized(draft));
if (entries.length === 0) {
return undefined;
}
return entries.map(([id, draft]) => ({
sourceId: Number(id),
weight: clampWeight(draft.weight),
excluded: draft.excluded
}));
}
// True when the user actually diverged this proposal from its defaults (drives the "Edited"
// row badge + the unsaved-changes guard). A name/number equal to the proposal default does
// not count as edited.
@@ -138,7 +215,10 @@ function overrideEdited(override: ChannelOverride | undefined, proposal: AutoTun
override.logoFile ||
override.playbackOrder != null ||
override.playoutMode != null ||
Object.keys(override.advanced).length > 0
Object.keys(override.advanced).length > 0 ||
// A weight/exclusion/added source only counts once it diverges from fair-share, mirroring the
// cleared-name rule above: a source row touched and set back to weight 1 is not an edit.
sourcesRequest(override) != null
);
}
@@ -340,6 +420,12 @@ export function AutoTuneScreen() {
if (Object.keys(advanced).length > 0) {
channel.advanced = advanced as AutoTunedChannelRequest['advanced'];
}
// Per-source weights / exclusions / added sources (#425). Omitted entirely when nothing was
// customized so the server keeps the fair-share single-SmartCollection shape.
const sources = sourcesRequest(override);
if (sources) {
channel.sources = sources;
}
return channel;
})
);
@@ -1183,35 +1269,22 @@ function DetailPanel({
onPatchAdvanced: (updater: (prev: Overrides) => Overrides) => void;
onClose: () => void;
}) {
const [membersState, setMembersState] = useState<MembersState>({ status: 'loading' });
const [advancedOpen, setAdvancedOpen] = useState(false);
const fileInputRef = useRef<HTMLInputElement>(null);
const activeRef = useRef(true);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Per-source draft writers (#440). Each lazily materializes a fair-share draft for the row, so a
// source only enters `sources` once it is actually touched.
const patchSource = (id: number, delta: Partial<SourceDraft>) =>
onPatch((current) => ({
sources: { ...current.sources, [id]: { ...defaultSourceDraft(), ...current.sources?.[id], ...delta } }
}));
// Load the distinct members this proposal's smart collection resolves to (#384). The parent
// keys the panel by proposalKey, so it remounts per proposal and `membersState` starts at
// 'loading' each time — the effect never sets state synchronously (spa-conventions §3), only in
// the fetch callbacks.
useEffect(() => {
getAutoTuneChannelMembers({ axis: proposal.axis as AutoTuneAxis, value: proposal.value, pageNum: 0, pageSize: MEMBER_PREVIEW_SIZE })
.then((page) => {
if (activeRef.current) {
setMembersState({ status: 'success', totalCount: page.totalCount, members: page.page ?? [] });
}
})
.catch((error: unknown) => {
if (activeRef.current) {
setMembersState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to load content sources') });
}
});
}, [proposal.axis, proposal.value]);
const removeSource = (id: number) =>
onPatch((current) => {
const next = { ...current.sources };
delete next[id];
return { sources: next };
});
const readImage = (file: File | null | undefined) => {
if (!file) {
@@ -1378,62 +1451,219 @@ function DetailPanel({
<ReadRow label="Streaming mode" value={streamingLabel} />
</DetailSection>
<DetailSection
label="Content sources"
hint={
<span style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Info aria-hidden="true" size={12} /> Per-source rotation weights &amp; query corrections are coming (#425).
</span>
<ContentSourcesSection
proposal={proposal}
sources={override.sources}
patchSource={patchSource}
removeSource={removeSource}
/>
</SlideOver>
);
}
// Add-untagged typeahead: how many library matches to offer, and the shortest query worth searching.
const ADD_SOURCE_RESULTS = 8;
const ADD_SOURCE_MIN_QUERY = 2;
// The picker takes plain typed text, not Lucene: the index's default field does NOT match bare title
// words (`Alpha` finds nothing for "Show Alpha" — docs/e2e-local.md), so a raw forward like the search
// box does would look broken here. Compile a substring title match instead, escaping every Lucene
// special (and whitespace) so the boundary stars are the only live wildcards — the same shape the rule
// builder's `contains` operator emits (builder/rules/compile.ts).
const LUCENE_WILD_SPECIAL = /([\s+\-!(){}[\]^"~*?:\\/])/g;
function titleContainsQuery(text: string): string {
return `title:*${text.replace(LUCENE_WILD_SPECIAL, '\\$1')}*`;
}
type AddSearchState =
| { status: 'idle' }
| { status: 'error'; query: string; message: string }
| { status: 'success'; query: string; items: AutoTuneMember[] };
// One row in the Content-sources pane: either a live member of the axis's base set, or an
// add-untagged source the user pulled in from the library search.
interface SourceRow {
id: number;
title: string;
artwork?: string | null;
added: boolean;
}
// The Content-sources pane (#384 read model, #425 write model, wired here in #440). Each row carries a
// 1..1000 weight stepper and an include/exclude toggle; below them a library typeahead adds a source
// that isn't in the axis's base set. Everything writes into the parent screen's per-channel draft
// (spa-conventions §11: draft-until-commit, so there is no panel-level save) — the bulk create flushes
// it as the request's `sources` array.
function ContentSourcesSection({
proposal,
sources,
patchSource,
removeSource
}: {
proposal: AutoTuneProposal;
sources: Record<number, SourceDraft> | undefined;
patchSource: (id: number, delta: Partial<SourceDraft>) => void;
removeSource: (id: number) => void;
}) {
const [membersState, setMembersState] = useState<MembersState>({ status: 'loading' });
const [addQuery, setAddQuery] = useState('');
const [addState, setAddState] = useState<AddSearchState>({ status: 'idle' });
const activeRef = useRef(true);
const searchSeqRef = useRef(0);
useEffect(() => {
activeRef.current = true;
return () => {
activeRef.current = false;
};
}, []);
// Load the distinct members this proposal's smart collection resolves to (#384). The parent keys
// the panel by proposalKey, so this remounts per proposal and `membersState` starts at 'loading'
// each time — the effect never sets state synchronously (spa-conventions §3), only in the fetch
// callbacks.
useEffect(() => {
getAutoTuneChannelMembers({ axis: proposal.axis as AutoTuneAxis, value: proposal.value, pageNum: 0, pageSize: MEMBER_PREVIEW_SIZE })
.then((page) => {
if (activeRef.current) {
setMembersState({ status: 'success', totalCount: page.totalCount, members: page.page ?? [] });
}
>
{membersState.status === 'loading' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="status">
<Spinner size={14} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading content sources…</span>
</div>
)}
{membersState.status === 'error' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
<TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
<span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--status-error)' }}>{membersState.message}</span>
</div>
)}
{membersState.status === 'success' && (
<>
<div style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
})
.catch((error: unknown) => {
if (activeRef.current) {
setMembersState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to load content sources') });
}
});
}, [proposal.axis, proposal.value]);
// The axis decides what an added source can be: the TV axes group episodes by parent show, the
// movie axis groups movies (GetAutoTuneChannelMembersHandler), so the id space must match.
const wantsMovies = proposal.axis === 'MovieGenre';
const trimmedQuery = addQuery.trim();
const searchable = trimmedQuery.length >= ADD_SOURCE_MIN_QUERY;
// Debounced library search for the add-untagged picker. The timer fires on mount too, so the
// length gate above (not a setState in the effect body) is what keeps a blank field quiet; results
// are keyed to the query that produced them and read back through `resultsForQuery`, so a stale
// response can never render under a newer query (spa-conventions §3a/§9).
useEffect(() => {
if (!searchable) {
return;
}
const id = ++searchSeqRef.current;
const timer = window.setTimeout(() => {
getSearchResults({ query: titleContainsQuery(trimmedQuery), pageSize: ADD_SOURCE_RESULTS })
.then((results) => {
if (activeRef.current && id === searchSeqRef.current) {
const group = wantsMovies ? results.movies : results.shows;
setAddState({ status: 'success', query: trimmedQuery, items: group?.items ?? [] });
}
})
.catch((error: unknown) => {
if (activeRef.current && id === searchSeqRef.current) {
setAddState({ status: 'error', query: trimmedQuery, message: messageFromSearchError(error) });
}
});
}, 250);
return () => window.clearTimeout(timer);
}, [searchable, trimmedQuery, wantsMovies]);
const baseMembers = membersState.status === 'success' ? membersState.members : [];
const baseIds = new Set(baseMembers.map((member) => member.id));
const rows: SourceRow[] = [
...baseMembers.map((member) => ({ id: member.id, title: member.title, artwork: member.artwork, added: false })),
...Object.entries(sources ?? {})
.filter(([id, draft]) => draft.addedTitle != null && !baseIds.has(Number(id)))
.map(([id, draft]) => ({ id: Number(id), title: draft.addedTitle ?? `#${id}`, artwork: draft.addedArtwork, added: true }))
];
const customizedCount = Object.values(sources ?? {}).filter(sourceCustomized).length;
const addSource = (item: AutoTuneMember) => {
setAddQuery('');
// A hit that is already a base member is not an "added" source — just mark it touched so the
// existing row reads as customised rather than sprouting a duplicate row.
patchSource(item.id, baseIds.has(item.id) ? {} : { addedTitle: item.title, addedArtwork: item.artwork ?? undefined });
};
const resultsForQuery = addState.status === 'success' && addState.query === trimmedQuery ? addState.items : null;
const errorForQuery = addState.status === 'error' && addState.query === trimmedQuery ? addState.message : null;
return (
<DetailSection
label="Content sources"
hint={
<span style={{ display: 'inline-flex', alignItems: 'flex-start', gap: 6 }}>
<Info aria-hidden="true" size={12} style={{ flex: '0 0 auto', marginTop: 1 }} />
<span>
Weight {WEIGHT_MIN}–{WEIGHT_MAX}: a source at weight <strong>N</strong> airs N× as often as each other source.
Excluded sources are dropped from the channel entirely. Leave every source at {WEIGHT_MIN} and the channel keeps
the simpler fair-share collection.
</span>
</span>
}
>
{membersState.status === 'loading' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="status">
<Spinner size={14} />
<span style={{ font: 'var(--text-xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Loading content sources…</span>
</div>
)}
{membersState.status === 'error' && (
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }} role="alert">
<TriangleAlert aria-hidden="true" size={14} style={{ color: 'var(--status-error)' }} />
<span style={{ font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--status-error)' }}>{membersState.message}</span>
</div>
)}
{membersState.status === 'success' && (
<>
<div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
<span style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-disabled)' }}>
{membersState.totalCount} {membersState.totalCount === 1 ? 'source' : 'sources'}
{membersState.totalCount > membersState.members.length ? ` · showing first ${membersState.members.length}` : ''}
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
maxHeight: 220,
overflowY: 'auto'
}}
>
{membersState.members.length === 0 ? (
<div style={{ padding: '12px 14px', font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
No members resolved.
</div>
) : (
membersState.members.map((member, i) => (
</span>
<span style={{ flex: 1 }} />
{customizedCount > 0 && (
<Tag icon={<Pencil aria-hidden="true" size={11} />} tone="accent">
{customizedCount} customised
</Tag>
)}
</div>
<div
style={{
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden',
maxHeight: 260,
overflowY: 'auto'
}}
>
{rows.length === 0 ? (
<div style={{ padding: '12px 14px', font: 'var(--text-xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
No members resolved.
</div>
) : (
rows.map((row, i) => {
const draft = sources?.[row.id] ?? defaultSourceDraft();
return (
<div
key={member.id}
key={row.id}
style={{
display: 'flex',
alignItems: 'center',
gap: 10,
padding: '7px 12px',
borderTop: i ? '1px solid var(--border-hairline)' : 'none'
borderTop: i ? '1px solid var(--border-hairline)' : 'none',
opacity: draft.excluded ? 0.5 : 1
}}
>
{member.artwork ? (
{row.artwork ? (
<img
src={member.artwork}
src={row.artwork}
alt=""
loading="lazy"
style={{ width: 28, height: 42, objectFit: 'cover', borderRadius: 3, flex: '0 0 auto' }}
@@ -1441,6 +1671,108 @@ function DetailPanel({
) : (
<div style={{ width: 28, height: 42, borderRadius: 3, background: 'var(--ctv-surface-2)', flex: '0 0 auto' }} />
)}
<span
style={{
flex: 1,
minWidth: 0,
font: 'var(--text-xs)/1.3 var(--font-sans)',
color: 'var(--text-primary)',
whiteSpace: 'nowrap',
overflow: 'hidden',
textOverflow: 'ellipsis',
textDecoration: draft.excluded ? 'line-through' : 'none'
}}
>
{row.title}
</span>
{row.added && <Tag tone="accent">Added</Tag>}
<Input
ariaLabel={`Weight for ${row.title}`}
disabled={draft.excluded}
fullWidth={false}
inputMode="numeric"
max={WEIGHT_MAX}
min={WEIGHT_MIN}
onBlur={() => patchSource(row.id, { weight: String(clampWeight(draft.weight)) })}
onChange={(event) => patchSource(row.id, { weight: event.target.value })}
size="sm"
style={{ width: 64 }}
type="number"
value={draft.weight}
/>
<IconButton
size="sm"
active={draft.excluded}
title={draft.excluded ? `Include ${row.title}` : `Exclude ${row.title}`}
onClick={() => patchSource(row.id, { excluded: !draft.excluded })}
>
{draft.excluded ? <EyeOff aria-hidden="true" size={14} /> : <Eye aria-hidden="true" size={14} />}
</IconButton>
{row.added && (
<IconButton size="sm" title={`Remove ${row.title}`} onClick={() => removeSource(row.id)}>
<Trash2 aria-hidden="true" size={14} />
</IconButton>
)}
</div>
);
})
)}
</div>
<div style={{ display: 'flex', flexDirection: 'column', gap: 6 }}>
<Input
size="sm"
value={addQuery}
onChange={(event) => setAddQuery(event.target.value)}
ariaLabel={wantsMovies ? 'Add a movie not in this genre' : 'Add a show not in this axis'}
placeholder={wantsMovies ? 'Add a movie from your library…' : 'Add a show from your library…'}
leadingIcon={<Search aria-hidden="true" size={14} />}
/>
{searchable && errorForQuery && (
<div role="alert" style={{ font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--status-error)' }}>
{errorForQuery}
</div>
)}
{searchable && !errorForQuery && resultsForQuery == null && (
<div role="status" style={{ display: 'inline-flex', alignItems: 'center', gap: 6 }}>
<Spinner size={12} />
<span style={{ font: 'var(--text-2xs)/1 var(--font-sans)', color: 'var(--text-secondary)' }}>Searching…</span>
</div>
)}
{searchable && resultsForQuery != null && resultsForQuery.length === 0 && (
<div style={{ font: 'var(--text-2xs)/1.3 var(--font-sans)', color: 'var(--text-disabled)' }}>
No {wantsMovies ? 'movies' : 'shows'} matched “{trimmedQuery}”.
</div>
)}
{searchable && resultsForQuery != null && resultsForQuery.length > 0 && (
<div
style={{
display: 'flex',
flexDirection: 'column',
border: '1px solid var(--border-hairline)',
borderRadius: 'var(--radius-md)',
overflow: 'hidden'
}}
>
{resultsForQuery.map((item, i) => (
<button
key={item.id}
type="button"
className="ctv-press"
onClick={() => addSource(item)}
style={{
display: 'flex',
alignItems: 'center',
gap: 8,
padding: '7px 10px',
border: 'none',
borderTop: i ? '1px solid var(--border-hairline)' : 'none',
background: 'var(--surface-card)',
cursor: 'pointer',
textAlign: 'left'
}}
>
<Plus aria-hidden="true" size={13} style={{ color: 'var(--ctv-accent)', flex: '0 0 auto' }} />
<span
style={{
flex: 1,
@@ -1452,16 +1784,17 @@ function DetailPanel({
textOverflow: 'ellipsis'
}}
>
{member.title}
{item.title}
</span>
</div>
))
)}
</div>
</>
)}
</DetailSection>
</SlideOver>
{baseIds.has(item.id) && <Tag tone="neutral">Already a source</Tag>}
</button>
))}
</div>
)}
</div>
</>
)}
</DetailSection>
);
}