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
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:
@@ -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,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);
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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 & 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>
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user