diff --git a/design-system/design_handoff_auto_tune/AutoTune.jsx b/design-system/design_handoff_auto_tune/AutoTune.jsx
new file mode 100644
index 000000000..1e120c825
--- /dev/null
+++ b/design-system/design_handoff_auto_tune/AutoTune.jsx
@@ -0,0 +1,900 @@
+// Auto-Tune — generate a whole channel lineup from library metadata (#69).
+// The "automatic-first" creation mode alongside the manual Channel Builder.
+// One screen, three steps: Configure axes/defaults -> Preview proposed channels
+// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
+// Additive & non-destructive: never edits or deletes an existing channel; number
+// or name collisions are skipped, never overwritten. Each generated channel is
+// backed by a live SmartCollection query so it keeps tracking the library.
+(function () {
+ const NS = window.ChicoryTVDesignSystem_eb3b61;
+ const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
+ const Ico = window.Ico;
+ const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
+ const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
+
+ // ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
+ // nameOf() and order are display-only here; the real server owns query + name.
+ const AXES = [
+ { id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
+ { id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
+ { id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
+ ];
+ const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
+
+ // ---- Mock library metadata (EF distinct+count in the real app) ------------
+ const LIBRARY = {
+ TvShow: [
+ { value: "The Office", count: 201 },
+ { value: "Friends", count: 236 },
+ { value: "Breaking Bad", count: 62 },
+ { value: "Parks and Recreation", count: 125 },
+ { value: "The Twilight Zone", count: 156 },
+ { value: "Firefly", count: 3 }, // below default minItems -> filtered
+ ],
+ TvGenre: [
+ { value: "Comedy", count: 640 },
+ { value: "Drama", count: 512 },
+ { value: "Sci-Fi", count: 208 },
+ { value: "Crime", count: 174 },
+ ],
+ MovieGenre: [
+ { value: "Action", count: 42 },
+ { value: "Sci-Fi", count: 28 },
+ { value: "Horror", count: 35 },
+ { value: "Comedy", count: 51 },
+ { value: "Noir", count: 3 }, // below default minItems -> filtered
+ ],
+ };
+
+ // Coexistence demo: some names already exist (deduped, unchecked by default)
+ // and some numbers are already taken (allocation skips them).
+ const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
+ const TAKEN_NUMBERS = new Set(["500", "503"]);
+
+ const TEMPLATES = ["Standard", "Movie night", "Music videos"];
+
+ // ---- Preview computation (advisory numbers, re-validated at create) -------
+ function buildProposals(axisIds, minItems, startingNumber) {
+ const out = [];
+ let next = Math.max(1, startingNumber | 0);
+ const takenThisRun = new Set(TAKEN_NUMBERS);
+ const alloc = () => {
+ while (takenThisRun.has(String(next))) next++;
+ const n = String(next);
+ takenThisRun.add(n);
+ next++;
+ return n;
+ };
+ // Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
+ AXES.forEach((ax) => {
+ if (!axisIds.has(ax.id)) return;
+ LIBRARY[ax.id]
+ .filter((row) => row.count >= minItems)
+ .slice()
+ .sort((a, b) => a.value.localeCompare(b.value))
+ .forEach((row) => {
+ const name = ax.nameOf(row.value);
+ const exists = EXISTING_NAMES.has(name);
+ out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
+ });
+ });
+ return out;
+ }
+
+ // ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
+ // explicit initials + color (ChannelLogo only derives them from name). --
+ const BUG_PALETTE = [
+ ["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
+ ["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
+ ];
+ const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
+ const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
+ function Bug({ name, initials, ci, size = 32 }) {
+ const idx = ci != null ? ci : bugHash(name);
+ const [fg, bg] = BUG_PALETTE[idx];
+ return (
+
+ {bugInitials(name, initials)}
+
+ );
+ }
+
+ // ---- Example content sources (advisory sample of the smart-collection query)
+ const GENRE_SHOWS = {
+ Comedy: ["The Office", "Friends", "Parks and Recreation"],
+ Drama: ["Breaking Bad", "The Twilight Zone"],
+ "Sci-Fi": ["The Twilight Zone", "Firefly"],
+ Crime: ["Breaking Bad"],
+ };
+ const MOVIE_TITLES = {
+ Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
+ "Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
+ Horror: ["Hollow", "The Vigil", "Saltmarsh"],
+ Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
+ Noir: ["Rain on 5th", "The Long Con"],
+ };
+ const isGenreAxis = (axis) => axis !== "TvShow";
+ function baseSources(p) {
+ if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
+ if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
+ return []; // TvShow channels track a single show — no per-source editor
+ }
+ function effectiveSources(p, ov) {
+ const ex = ov.exclude || [];
+ return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
+ }
+ // Advisory item-count estimate as sources are added/removed (per-source share).
+ function estItems(p, ov) {
+ const base = baseSources(p);
+ if (!base.length) return p.itemCount;
+ const share = p.itemCount / base.length;
+ return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
+ }
+ // Sources with their rotation weight (episodes played per rotation).
+ function sourceList(p, ov) {
+ return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
+ }
+
+ // Compact rotation-weight stepper (− N +).
+ function Weight({ w, onChange }) {
+ const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
+ return (
+
+ onChange(w - 1)}>
+ {w}
+ onChange(w + 1)}>
+
+ );
+ }
+
+ // ---- Example schedule (rundown → EPG blocks), advisory only ---------------
+ // Effective order for a channel: the axis default, unless overridden per channel.
+ function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
+
+ const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
+ function buildSchedule(p, ov) {
+ const mins = RUNTIME[p.axis] || 30;
+ const shuffled = isShuffled(p, ov);
+ let seq;
+ if (p.axis === "TvShow") {
+ const n = Math.min(8, Math.max(3, p.itemCount));
+ let eps = Array.from({ length: n }, (_, i) => i + 1);
+ if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
+ seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
+ } else {
+ const list = sourceList(p, ov).filter((s) => s.weight > 0);
+ if (!list.length) return [];
+ if (shuffled) {
+ // Weighted round-robin interleave — honors the per-source rotation ratio.
+ const total = list.reduce((a, s) => a + s.weight, 0);
+ const st = list.map((s) => ({ ...s, acc: 0 }));
+ const count = Math.min(9, Math.max(5, total * 2));
+ seq = Array.from({ length: count }, () => {
+ let pick = null;
+ st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
+ pick.acc -= total;
+ return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
+ });
+ } else {
+ // Sequential — each source plays its rotation count in turn.
+ seq = [];
+ list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
+ seq = seq.slice(0, 9);
+ }
+ }
+ let t = 20 * 60;
+ return seq.map((b) => {
+ const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
+ t += mins;
+ return { ...b, start, mins };
+ });
+ }
+
+ function MiniEpg({ blocks }) {
+ if (!blocks.length) return (
+ No content matches — nothing to schedule.
+ );
+ const PPM = 2.0;
+ return (
+
+
+ {blocks.map((b, i) => (
+
+ {b.start}
+ {b.title}
+ {b.sub && {b.sub} }
+
+ ))}
+
+
+ );
+ }
+
+ function ConfigRow({ label, value, first }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+ }
+
+ // ---- Full channel settings (mirrors the manual Channel Builder) -----------
+ const CH_TEMPLATES = [
+ { id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
+ { id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
+ { id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
+ ];
+ const ADV_GROUPS = [
+ { group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
+ { group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
+ { group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
+ { group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
+ ];
+
+ // Friendly toggle row (label + description + Switch), with an override tag.
+ function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
+ return (
+ onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
+ {icon && }
+
+
+ {title}
+ {overrideOf != null && checked !== overrideOf && overrides template }
+
+
{desc}
+
+
+
+ );
+ }
+
+ // Channel Template picker (collapsed row + dropdown + spec chips).
+ function TemplatePicker({ value, onPick }) {
+ const [open, setOpen] = React.useState(false);
+ const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
+ return (
+
+
setOpen((o) => !o)} className="ctv-press"
+ style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
+
+
+
+
+ {open && (
+
+ {CH_TEMPLATES.map((x) => {
+ const on = x.id === value;
+ return (
+
{ onPick(x); setOpen(false); }}
+ style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
+
+
+
+ );
+ })}
+
+ )}
+
+ {t.sets.map((s) => (
+ {s}
+ ))}
+
+
+ );
+ }
+
+ // Advanced (~24-field) override disclosure: View defaults / Override.
+ function AdvancedSettings({ ov, patch, templateName }) {
+ const [mode, setMode] = React.useState("closed"); // closed | view | override
+ const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
+ const vals = { ...defaults, ...(ov.adv || {}) };
+ const override = mode === "override";
+ const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
+ const overridden = Object.keys(ov.adv || {}).length;
+ const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
+ return (
+
+
+
+ Advanced
+
+ {overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}
+
+
+ setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}> {override ? "Overriding" : "Override settings"}
+ setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}> View defaults
+
+ {mode !== "closed" && (
+
+
+
+ {override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
+
+ {ADV_GROUPS.map((g) => (
+
+
{g.group}
+
+ {g.fields.map(([k, v]) => override ? (
+
patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
+ ) : (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
+ function LogoDrop({ name, src, onSet }) {
+ const [over, setOver] = React.useState(false);
+ const fileRef = React.useRef(null);
+ const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
+ return (
+ { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
+ onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
+ onClick={() => fileRef.current && fileRef.current.click()}
+ style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
+
+
+
{src ? "Channel image set" : "Drop a channel image"}
+
Used as the guide logo and on-screen bug. Falls back to the initials below.
+
+ {src &&
{ e.stopPropagation(); onSet(null); }}> }
+
read(e.target.files && e.target.files[0])} />
+
+ );
+ }
+
+ // ---- Channel detail slide-over: full per-channel settings -----------------
+ function DetailPanel({ p, ov, patch, onClose }) {
+ const [addText, setAddText] = React.useState("");
+ const name = ov.customName != null ? ov.customName : p.name;
+ const bug = ov.bug || {};
+ const ax = AXIS[p.axis];
+ const number = ov.number != null ? ov.number : p.number;
+ const shuffled = isShuffled(p, ov);
+ const always = ov.always != null ? ov.always : true;
+ const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
+ const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
+ const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
+ const avatar = (size) => bug.src
+ ?
+ : ;
+ const genre = isGenreAxis(p.axis);
+ const base = baseSources(p);
+ const ex = ov.exclude || [];
+ const inc = ov.include || [];
+ const kept = base.filter((s) => !ex.includes(s));
+ const items = estItems(p, ov);
+ const schedule = buildSchedule(p, ov);
+ const ratios = ov.ratios || {};
+ const wOf = (s) => ratios[s] || 1;
+ const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
+ const list = [...kept, ...inc];
+ const multi = list.length > 1;
+
+ const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
+ const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
+ const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
+ const addInc = () => {
+ const v = addText.trim();
+ if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
+ patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
+ setAddText("");
+ };
+
+ return (
+
+
+
+
+ {avatar(34)}
+
+
{name || "Untitled channel"}
+
+ {number} · {shuffled ? "Shuffled" : "In order"}
+
+
+
+
+
+
+
+
+
+ Playback
+ patch({ shuffle: v })} overrideOf={tpl.shuffle} />
+ patch({ always: v })} overrideOf={tpl.always} />
+
+
+
+
+
+ Query & size
+
+ {shuffled ? "Shuffled" : "In order"}} />
+
+ {items.toLocaleString()}} />
+ {p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}} />
+
+
+
+ {genre && (
+
+
+
Content sources
+
Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.
+
+
+ {list.length === 0 && (
+
No sources — add one below.
+ )}
+ {[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
+
+
+ {row.s}
+ {multi && setW(row.s, w)} />}
+ (row.added ? removeInc(row.s) : excludeSrc(row.s))}>
+
+ ))}
+
+ {multi && (
+
+ Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
+
+ )}
+
+
+ setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={ } />
+
+
Add
+
+ {ex.length > 0 && (
+
+ Excluded:
+ {ex.map((s) => (
+ restoreSrc(s)} title="Add back"
+ style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
+ {s}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
Example schedule
+
A preview of tonight from 20:00 — the built playout may differ.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // =========================================================================
+ function AutoTune() {
+ const [step, setStep] = React.useState("configure"); // configure | preview | create
+ const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
+ const [minItems, setMinItems] = React.useState("5");
+ const [startingNumber, setStartingNumber] = React.useState("500");
+ const [group, setGroup] = React.useState("Auto-Tuned");
+ const [template, setTemplate] = React.useState("Standard");
+
+ const [proposals, setProposals] = React.useState([]);
+ const [selected, setSelected] = React.useState(() => new Set());
+ const [results, setResults] = React.useState(null);
+ const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
+ const [detail, setDetail] = React.useState(null); // proposal name open in the panel
+ const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
+
+ const patchOv = (name) => (patch) => setOverrides((o) => {
+ const cur = o[name] || {};
+ const delta = typeof patch === "function" ? patch(cur) : patch;
+ return { ...o, [name]: { ...cur, ...delta } };
+ });
+ const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
+
+ const toggleAxis = (id) =>
+ setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
+
+ const runPreview = () => {
+ const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
+ setProposals(p);
+ // Default selection: everything that isn't an already-existing name.
+ setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
+ setStep("preview");
+ };
+
+ const runCreate = () => {
+ // Demo the three outcome states across the selected set.
+ const chosen = proposals.filter((p) => selected.has(p.name));
+ const res = chosen.map((p, i) => {
+ const nm = dispName(p);
+ if (i === chosen.length - 1 && chosen.length > 2)
+ return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
+ if (p.name === "Sci-Fi Movies")
+ return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
+ return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
+ });
+ setResults(res);
+ setStep("create");
+ };
+
+ const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
+
+ return (
+
+
setStep("configure")}
+ onRestart={restart}
+ />
+
+ {step === "configure" && (
+
+ )}
+ {step === "preview" && (
+
+ )}
+ {step === "create" && }
+
+ {detail != null && (() => {
+ const p = proposals.find((x) => x.name === detail);
+ if (!p) return null;
+ return setDetail(null)} />;
+ })()}
+
+ );
+ }
+
+ // ---- Toolbar with step rail + contextual primary action -------------------
+ function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
+ return (
+
+
+
+
Auto-Tune
+
Generate channels from your library
+
+
+
+
+ {step === "configure" && (
+
+ } onClick={onPreview}>Preview channels
+
+ )}
+ {step === "preview" && (
+
+ } onClick={onBack}>Back
+
+ } onClick={onCreate}>
+ {selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
+
+
+
+ )}
+ {step === "create" && (
+
} onClick={onRestart}>Start over
+ )}
+
+ );
+ }
+
+ const STEPS = [
+ { id: "configure", label: "Configure" },
+ { id: "preview", label: "Preview" },
+ { id: "create", label: "Create" },
+ ];
+ function StepRail({ step }) {
+ const idx = STEPS.findIndex((s) => s.id === step);
+ return (
+
+ {STEPS.map((s, i) => {
+ const done = i < idx, active = i === idx;
+ const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
+ return (
+
+
+ {done ? : i + 1}
+ {s.label}
+
+ {i < STEPS.length - 1 && }
+
+ );
+ })}
+
+ );
+ }
+
+ // ---- Step 1: Configure ----------------------------------------------------
+ function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
+ return (
+
+
+ Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
+ preview the proposed channels, then create the ones you want. Existing channels are never touched.
+
+
+
+ Generate from
+
+ {AXES.map((ax) => {
+ const on = axisIds.has(ax.id);
+ return (
+
toggleAxis(ax.id)}
+ className="ctv-press"
+ style={{
+ textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
+ border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
+ background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
+ boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
+ }}>
+
+
+
+
+
+
{ax.title}
+
{ax.tagline}
+
+
+ {ax.order}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+ }
+
+ function Field({ label, hint, children }) {
+ return (
+
+ {label}
+ {children}
+ {hint}
+
+ );
+ }
+
+ // ---- Step 2: Preview ------------------------------------------------------
+ function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
+ const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
+ const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
+ const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
+ const selectable = proposals.filter((p) => !p.alreadyExists);
+ const existingCount = proposals.length - selectable.length;
+
+ if (!proposals.length) {
+ return (
+
+
+
No channels matched — try lowering the minimum items.
+
+ );
+ }
+
+ const setAll = (rows, on) => setSelected((s) => {
+ const n = new Set(s);
+ rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
+ return n;
+ });
+
+ return (
+
+
+
+ {selected.size} of {selectable.length} selected
+
+ {existingCount > 0 && (
+ } tone="neutral">{existingCount} already exist — deselected
+ )}
+
+ setAll(selectable, true)}>Select all
+ setAll(selectable, false)}>Clear
+
+
+ {groups.map(({ ax, rows }) => {
+ const groupSel = rows.filter((r) => !r.alreadyExists);
+ const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
+ const someOn = groupSel.some((r) => selected.has(r.name));
+ return (
+
+
+ setAll(rows, !allOn)} />
+
+ {ax.title}
+ {rows.length}
+
+
+ {ax.order}
+
+
+
+ {rows.map((r, i) => {
+ const on = selected.has(r.name);
+ const ov = overrides[r.name] || {};
+ const name = dispName(r);
+ const isExp = expanded.has(r.name);
+ return (
+
+
+
toggle(r.name)} />
+ {r.number}
+
+ !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
+ {name}
+ from “{r.value}”
+
+
+ {isShuffled(r, ov) ? "Shuffled" : "In order"}
+
+ {r.itemCount} items
+ {r.alreadyExists ? (
+ } tone="neutral">Exists
+ ) : (
+
+ } onClick={() => openDetail(r.name)}>Configure
+ toggleExp(r.name)}>
+
+
+
+ )}
+
+ {isExp && !r.alreadyExists && (
+
+
+ Example schedule
+
+ } onClick={() => openDetail(r.name)}>Open channel
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+ }
+
+ // ---- Step 3: Results ------------------------------------------------------
+ const RESULT_META = {
+ Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
+ Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
+ Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
+ };
+ function Results({ results, group }) {
+ const count = (s) => results.filter((r) => r.status === s).length;
+ return (
+
+
+ } />
+ } />
+ } />
+
+
+ Added to group {group}
+
+
+ {results.map((r, i) => {
+ const m = RESULT_META[r.status];
+ return (
+
+
+
+
{r.name}
+ {r.reason &&
{r.reason}
}
+
+ {r.channelId != null &&
#{r.channelId} }
+
{r.status}
+
+ );
+ })}
+
+
+ );
+ }
+
+ window.CTVAutoTune = AutoTune;
+})();
diff --git a/design-system/design_handoff_auto_tune/README.md b/design-system/design_handoff_auto_tune/README.md
new file mode 100644
index 000000000..e8e072855
--- /dev/null
+++ b/design-system/design_handoff_auto_tune/README.md
@@ -0,0 +1,167 @@
+# Handoff: ChicoryTV — Auto-Tune (generate channels from library metadata, #69)
+
+## Overview
+**Auto-Tune** is the *automatic-first* channel-creation mode — the counterpart to the manual
+**Channel Builder** ("New Channel"). Instead of building one channel by hand, it enumerates your
+library's metadata along one or more **axes** (TV Shows, TV Genres, Movie Genres), **previews** the
+whole set of proposed channels so you can pick which to keep, then **bulk-creates** the selected
+ones. It is **additive and non-destructive**: it never edits or deletes an existing channel; name
+and number collisions are *skipped*, never overwritten. Each generated channel is backed by a live
+**SmartCollection** query, so a "Comedy" channel keeps picking up new comedies as the library grows.
+
+Concept borrowed from **PseudoTV Live's** signature Auto-Tuning, deliberately fixing its two
+weaknesses: PseudoTV is all-or-nothing per category with no preview, and it wipes+rebuilds the whole
+lineup on every run. Ours adds a preview/select step and is non-destructive.
+
+The **backend already shipped** (PR1, ersatztv#379) — two endpoints under the frozen `/api/v1`. This
+handoff is **PR2: the SPA screen** that drives them.
+
+> **Scope note (2026-07-16).** The prototype (`AutoTune.jsx`) was iterated to add a per-channel
+> **DetailPanel** slide-over (opened from a "Configure" button on each Preview row — full per-channel
+> editor: identity/image, shuffle/always-playing, template + Advanced overrides, weighted content
+> sources, exclude/add-untagged, example schedule). That panel needs backend the PR1 endpoints don't
+> have, so **it is DEFERRED to a follow-up arc — ersatztv#383** (children #384 enumerate members,
+> #385 per-channel overrides + rotation weights in create, #386 the SPA panel). **PR2 (this handoff)
+> implements the 3-step wizard only — Configure → Preview → Create — on the existing PR1 endpoints;
+> no per-row Configure button.** The DetailPanel sections below the wizard spec are recorded for the
+> #383 arc, not PR2.
+
+## About the design files
+The files in this bundle are **design references built in HTML/React** (a prototype on the ChicoryTV
+UI kit) — **not production code**. Recreate the design in the target codebase (the ChicoryTV React
+SPA, `web/`) using its real components (`web/src/components/`) and CSS-custom-property tokens. The
+prototype (`AutoTune.jsx`) is the source of truth for **layout, spacing, motion, and interaction**;
+its mock data and mock outcome logic are illustrative only — the real screen calls the API.
+
+## Fidelity
+**High-fidelity.** Colors, typography, spacing, radii, and interactions are resolved and use tokens
+throughout, so it is fully theme-aware (verified in the warm + cool accent themes). All values below
+are exact.
+
+---
+
+## The screen — a 3-step wizard
+
+One full-height column: a fixed **toolbar** (`12px 20px` padding, `1px solid var(--border-hairline)`
+bottom border) over a scrolling ``. The toolbar is constant across steps; its content and
+primary action change per step.
+
+**Toolbar (all steps):**
+- Left: a 34×34 accent **icon tile** (`var(--radius-sm)`, `background: var(--ctv-accent-soft)`,
+ `color: var(--ctv-accent)`) with the `Sparkles` icon; then title **"Auto-Tune"** (`--text-md`,
+ semibold) + subtitle **"Generate channels from your library"** (`--text-xs`, `--text-secondary`).
+- Center: a **step rail** — three steps (`Configure · Preview · Create`) joined by 26px hairline
+ connectors. Each step is a 20px round chip + label. The **active** step: chip
+ `background var(--ctv-accent)` / `color var(--text-on-accent)`, label accent + semibold. A
+ **completed** step: chip `var(--ctv-accent-soft)` bg + accent `Check` icon, label
+ `--text-secondary`. A **future** step: chip `var(--ctv-surface-2)` + `--text-disabled`.
+- Right: the contextual primary action (below).
+
+### Step 1 — Configure
+Centered column, `max-width: 860px`, `padding: 26px 24px 40px`.
+1. **Intro paragraph** (`--text-sm`, `--text-secondary`, `max-width: 620px`): what Auto-Tune does +
+ the non-destructive promise.
+2. **"Generate from"** section (eyebrow label) — a **3-column grid** of selectable **axis cards**
+ (one per axis). Each card is a `` (`ctv-press` for the tactile scale-on-press):
+ - Selected: `1px solid var(--ctv-accent)` border, `var(--ctv-accent-soft)` bg, `var(--shadow-sm)`;
+ a filled accent `CheckCircle2` top-right; the icon tile is `var(--ctv-accent)` /
+ `var(--text-on-accent)`.
+ - Unselected: `1px solid var(--border-control)`, `var(--surface-card)` bg; a `Circle` outline
+ top-right (`--text-disabled`); icon tile `var(--ctv-surface-2)` / `--text-secondary`.
+ - Content: axis icon (Tv / Clapperboard / Film), **title**, one-line **tagline**, and a bottom
+ **order** chip (`ListOrdered` "Episode order" for TV Shows; `Shuffle` "Shuffled" for the genres).
+3. **"Defaults"** section — a 2-column grid of four labelled fields, each with a helper line
+ (`Field` component: label `--text-xs` medium, hint `--text-2xs` `--text-disabled`):
+ - **Minimum items** (`Input type=number`, `Hash` leading icon) — skip channels below this count.
+ - **Starting channel number** (`Input type=number`, `Tv` icon) — numbers count up from here.
+ - **Channel group** (`Input`, `FolderTree` icon) — the group every generated channel lands in.
+ - **Channel template** (`Select`) — the batch's streaming/playout/filler defaults.
+
+**Primary action:** `Preview channels` (primary, `Eye` icon). **Disabled** with a tooltip until ≥1
+axis is selected.
+
+### Step 2 — Preview
+Centered column, `max-width: 920px`.
+- **Summary bar:** `N of M selected` (mono N), an info **Tag** `"{k} already exist — deselected"`
+ when any proposal's name collides, a spacer, then ghost **Select all** / **Clear** buttons (they
+ only touch selectable — non-existing — rows).
+- **One section per axis** that produced rows (`var(--surface-card)`, hairline border,
+ `var(--radius-md)`), in axis order (TV Shows → TV Genres → Movie Genres). Section header
+ (`var(--ctv-bg-sunken)`): a **group checkbox** (tri-state: checked / indeterminate when partial),
+ the axis icon + title, a neutral **Badge** with the row count, and a right-aligned order eyebrow.
+- **Rows** (hairline-separated): per-row **Checkbox**, the allocated **number** (mono,
+ `min-width 42`), the channel **name** (semibold, ellipsis) over a `from "{value}"` sub-line, a
+ neutral **Badge** `{itemCount} items`, and — for an already-existing name — an `Exists` **Tag**.
+ Existing rows render at `opacity: 0.55` with a **disabled, unchecked** checkbox (dedup: you can't
+ re-create a channel that already exists by that name).
+- **Empty state** (no proposals matched): centered `SearchX` + "try lowering the minimum items".
+
+**Primary action:** `Create {N} channels` (primary, `Check` icon; label pluralizes; disabled until
+≥1 selected) preceded by a ghost `Back` (`ArrowLeft`) that returns to Configure.
+
+### Step 3 — Create (results)
+Centered column, `max-width: 860px`.
+- **Three `Stat` tiles** across the top: **Created / Skipped / Failed** counts (icons
+ `CheckCircle2` / `MinusCircle` / `XCircle`).
+- A **"Added to group {group}"** line (`FolderTree` icon).
+- A **results list** (card, hairline rows): per channel a status **glyph** in the status color
+ (`CheckCircle2` live-green / `MinusCircle` secondary / `XCircle` danger), the **name**, an optional
+ **reason** sub-line (skip/fail explanation), the new **`#{channelId}`** (mono) when created, and a
+ status **Badge** (`positive` / `neutral` / `danger`).
+
+**Primary action:** `Start over` (primary, `RotateCcw`) — resets the wizard to Configure.
+
+---
+
+## API mapping (the real screen)
+
+Both endpoints already exist (PR1). The client sends **only `axis` + `value`** back — never a Lucene
+query; the server regenerates it (query authorship is server-side only).
+
+| UI element | Endpoint / field |
+|---|---|
+| `Preview channels` | `POST /api/v1/channels/auto-tune/preview` |
+| — axis cards | request `axes: AutoTuneAxis[]` (`"TvShow" \| "TvGenre" \| "MovieGenre"`) |
+| — Minimum items | request `minItems: number` |
+| — Starting channel number | request `startingNumber: number` |
+| Preview rows | response `AutoTuneProposalResponseModel[]`: `{ axis, value, name, number (string), itemCount, alreadyExists }` |
+| — number chip | `number` (string — channel numbers can be `"500.1"`; render as-is) |
+| — `{itemCount} items` badge | `itemCount` |
+| — greyed + `Exists` | `alreadyExists === true` (deselect + disable) |
+| `Create {N} channels` | `POST /api/v1/channels/auto-tune` |
+| — Channel template field | request `templateId: number` (from `GET /api/v1/channel-templates` / `…/default`) |
+| — Channel group field | request `group: string` |
+| — selected rows | request `channels: { axis, value, name, number }[]` (echo the selected proposals) |
+| Results tiles + list | response `AutoTuneResultResponseModel`: `{ results: { name, status ("Created"\|"Skipped"\|"Failed"), channelId, reason }[], createdCount, skippedCount, failedCount }` |
+
+Notes:
+- **Numbers are advisory.** The preview allocates them (skipping taken numbers); the create handler
+ **re-validates** at create time — a number taken in between yields a per-channel `Skipped`, not a
+ batch failure. So a `Skipped` outcome with "number … already taken" is normal, not an error.
+- **Default selection = every proposal whose `alreadyExists` is false.**
+- **Grouping/ordering** is fixed: axis order (TvShow, TvGenre, MovieGenre) then by value; mirror the
+ server's ordering rather than re-sorting client-side.
+- **Template default:** preselect `GET /api/v1/channel-templates/default` (fall back to the first
+ template) so the field is never empty; the batch requires a `templateId`.
+
+## Real-SPA implementation notes (target = `web/`)
+- New screen `web/src/screens/AutoTuneScreen.tsx`; new route id `autoTune`, path `/app/auto-tune`,
+ label **"Auto-Tune"**, icon `Sparkles`, placed **right after `builder`** in the first sidebar nav
+ group (the two channel-creation modes sit together). No `primaryAction` label — the wizard's
+ action lives in-body (per `spa-conventions.md` §10: the "+" banner is for single unambiguous
+ *create* list screens; a multi-step wizard drives its own buttons), so the shell shows no banner
+ button for this screen.
+- New API module `web/src/api/autoTune.ts` (re-export the generated DTOs; `previewAutoTune(body)` +
+ `createAutoTunedChannels(body)` over the shared `request` helper; a `messageFrom…Error` narrower),
+ re-exported from `web/src/api/index.ts`.
+- Reuse `getChannelTemplates` / `getDefaultChannelTemplate` from `web/src/api/channelTemplates.ts`
+ for the template `Select`.
+- Follow `spa-conventions.md` §3 for the two async calls (discriminated-union state, `seqRef` +
+ `activeRef`, no synchronous set-state in an effect body). The wizard is transient (a create flow,
+ not an editor of persisted data), so it does **not** register the §8 unsaved-changes guard.
+- Map prototype primitives → real components: `Button`, `Input`, `Select`, `Checkbox`, `Badge`,
+ `Tag`, `Stat`, `Tooltip`, `Spinner` from `web/src/components/`. The axis cards and step rail are
+ small screen-local components built from `ctv-*` utility classes + tokens (no new shared primitive).
+- Docs to update in the same PR: `docs/domain-model.md` (add `/app/auto-tune` to the channel routes),
+ `docs/blazor-route-parity.md` (net-new SPA screen, no Blazor ancestor), `docs/spa-conventions.md`
+ (if the wizard/step-rail pattern is worth recording), and `design-system/` committed alongside.
diff --git a/design-system/templates/chicorytv-admin/AutoTune.jsx b/design-system/templates/chicorytv-admin/AutoTune.jsx
new file mode 100644
index 000000000..1e120c825
--- /dev/null
+++ b/design-system/templates/chicorytv-admin/AutoTune.jsx
@@ -0,0 +1,900 @@
+// Auto-Tune — generate a whole channel lineup from library metadata (#69).
+// The "automatic-first" creation mode alongside the manual Channel Builder.
+// One screen, three steps: Configure axes/defaults -> Preview proposed channels
+// (select which to keep) -> Create (per-channel Created/Skipped/Failed summary).
+// Additive & non-destructive: never edits or deletes an existing channel; number
+// or name collisions are skipped, never overwritten. Each generated channel is
+// backed by a live SmartCollection query so it keeps tracking the library.
+(function () {
+ const NS = window.ChicoryTVDesignSystem_eb3b61;
+ const { Button, IconButton, Input, Select, Switch, Checkbox, Badge, Tag, Stat, Tooltip, ChannelLogo } = NS;
+ const Ico = window.Ico;
+ const mono = { fontFamily: "var(--font-mono)", fontVariantNumeric: "tabular-nums" };
+ const eyebrow = { font: "var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)", letterSpacing: "0.06em", textTransform: "uppercase", color: "var(--text-disabled)" };
+
+ // ---- Axis catalogue (mirrors AutoTuneAxisMap.cs on the server) -------------
+ // nameOf() and order are display-only here; the real server owns query + name.
+ const AXES = [
+ { id: "TvShow", icon: "Tv", title: "TV Shows", tagline: "One 24/7 channel per show", detail: "Plays in season / episode order.", order: "Episode order", nameOf: (v) => v },
+ { id: "TvGenre", icon: "Clapperboard", title: "TV Genres", tagline: "A channel per episode genre", detail: "Shuffled across every matching episode.", order: "Shuffled", nameOf: (v) => v },
+ { id: "MovieGenre", icon: "Film", title: "Movie Genres", tagline: "A movie channel per genre", detail: "Shuffled across every matching movie.", order: "Shuffled", nameOf: (v) => v + " Movies" },
+ ];
+ const AXIS = Object.fromEntries(AXES.map((a) => [a.id, a]));
+
+ // ---- Mock library metadata (EF distinct+count in the real app) ------------
+ const LIBRARY = {
+ TvShow: [
+ { value: "The Office", count: 201 },
+ { value: "Friends", count: 236 },
+ { value: "Breaking Bad", count: 62 },
+ { value: "Parks and Recreation", count: 125 },
+ { value: "The Twilight Zone", count: 156 },
+ { value: "Firefly", count: 3 }, // below default minItems -> filtered
+ ],
+ TvGenre: [
+ { value: "Comedy", count: 640 },
+ { value: "Drama", count: 512 },
+ { value: "Sci-Fi", count: 208 },
+ { value: "Crime", count: 174 },
+ ],
+ MovieGenre: [
+ { value: "Action", count: 42 },
+ { value: "Sci-Fi", count: 28 },
+ { value: "Horror", count: 35 },
+ { value: "Comedy", count: 51 },
+ { value: "Noir", count: 3 }, // below default minItems -> filtered
+ ],
+ };
+
+ // Coexistence demo: some names already exist (deduped, unchecked by default)
+ // and some numbers are already taken (allocation skips them).
+ const EXISTING_NAMES = new Set(["Friends", "Sci-Fi"]);
+ const TAKEN_NUMBERS = new Set(["500", "503"]);
+
+ const TEMPLATES = ["Standard", "Movie night", "Music videos"];
+
+ // ---- Preview computation (advisory numbers, re-validated at create) -------
+ function buildProposals(axisIds, minItems, startingNumber) {
+ const out = [];
+ let next = Math.max(1, startingNumber | 0);
+ const takenThisRun = new Set(TAKEN_NUMBERS);
+ const alloc = () => {
+ while (takenThisRun.has(String(next))) next++;
+ const n = String(next);
+ takenThisRun.add(n);
+ next++;
+ return n;
+ };
+ // Grouped by axis order (TvShow, TvGenre, MovieGenre), then by value.
+ AXES.forEach((ax) => {
+ if (!axisIds.has(ax.id)) return;
+ LIBRARY[ax.id]
+ .filter((row) => row.count >= minItems)
+ .slice()
+ .sort((a, b) => a.value.localeCompare(b.value))
+ .forEach((row) => {
+ const name = ax.nameOf(row.value);
+ const exists = EXISTING_NAMES.has(name);
+ out.push({ axis: ax.id, value: row.value, name, number: alloc(), itemCount: row.count, alreadyExists: exists });
+ });
+ });
+ return out;
+ }
+
+ // ---- Bug (channel icon) — local variant of DS ChannelLogo that also takes
+ // explicit initials + color (ChannelLogo only derives them from name). --
+ const BUG_PALETTE = [
+ ["#5B7CFA", "#2E3A66"], ["#3FB984", "#1E4536"], ["#E0A83D", "#4A3818"],
+ ["#E5484D", "#4A1F21"], ["#B06CF0", "#38235A"], ["#48B0C8", "#193E47"],
+ ];
+ const bugHash = (s) => { let h = 0; s = s || ""; for (let i = 0; i < s.length; i++) h = (h * 31 + s.charCodeAt(i)) >>> 0; return h % BUG_PALETTE.length; };
+ const bugInitials = (name, custom) => (custom && custom.trim()) || (name || "?").split(/[\s\-|:]+/).filter(Boolean).slice(0, 2).map((w) => w[0]).join("").toUpperCase() || "?";
+ function Bug({ name, initials, ci, size = 32 }) {
+ const idx = ci != null ? ci : bugHash(name);
+ const [fg, bg] = BUG_PALETTE[idx];
+ return (
+
+ {bugInitials(name, initials)}
+
+ );
+ }
+
+ // ---- Example content sources (advisory sample of the smart-collection query)
+ const GENRE_SHOWS = {
+ Comedy: ["The Office", "Friends", "Parks and Recreation"],
+ Drama: ["Breaking Bad", "The Twilight Zone"],
+ "Sci-Fi": ["The Twilight Zone", "Firefly"],
+ Crime: ["Breaking Bad"],
+ };
+ const MOVIE_TITLES = {
+ Action: ["Afterburn", "Steel Horizon", "Nightfall Run"],
+ "Sci-Fi": ["Orbital Decay", "The Quiet Sky", "Vector"],
+ Horror: ["Hollow", "The Vigil", "Saltmarsh"],
+ Comedy: ["Office Party", "Two Left Feet", "The Understudy"],
+ Noir: ["Rain on 5th", "The Long Con"],
+ };
+ const isGenreAxis = (axis) => axis !== "TvShow";
+ function baseSources(p) {
+ if (p.axis === "TvGenre") return GENRE_SHOWS[p.value] || [];
+ if (p.axis === "MovieGenre") return MOVIE_TITLES[p.value] || [];
+ return []; // TvShow channels track a single show — no per-source editor
+ }
+ function effectiveSources(p, ov) {
+ const ex = ov.exclude || [];
+ return [...baseSources(p).filter((s) => !ex.includes(s)), ...(ov.include || [])];
+ }
+ // Advisory item-count estimate as sources are added/removed (per-source share).
+ function estItems(p, ov) {
+ const base = baseSources(p);
+ if (!base.length) return p.itemCount;
+ const share = p.itemCount / base.length;
+ return Math.max(0, Math.round(share * effectiveSources(p, ov).length));
+ }
+ // Sources with their rotation weight (episodes played per rotation).
+ function sourceList(p, ov) {
+ return effectiveSources(p, ov).map((name) => ({ name, weight: (ov.ratios && ov.ratios[name]) || 1 }));
+ }
+
+ // Compact rotation-weight stepper (− N +).
+ function Weight({ w, onChange }) {
+ const btn = { width: 24, height: 26, display: "inline-flex", alignItems: "center", justifyContent: "center", background: "transparent", border: "none", cursor: "pointer", color: "var(--text-secondary)", padding: 0 };
+ return (
+
+ onChange(w - 1)}>
+ {w}
+ onChange(w + 1)}>
+
+ );
+ }
+
+ // ---- Example schedule (rundown → EPG blocks), advisory only ---------------
+ // Effective order for a channel: the axis default, unless overridden per channel.
+ function isShuffled(p, ov) { return ov && ov.shuffle != null ? ov.shuffle : AXIS[p.axis].order === "Shuffled"; }
+
+ const RUNTIME = { TvShow: 24, TvGenre: 30, MovieGenre: 100 };
+ function buildSchedule(p, ov) {
+ const mins = RUNTIME[p.axis] || 30;
+ const shuffled = isShuffled(p, ov);
+ let seq;
+ if (p.axis === "TvShow") {
+ const n = Math.min(8, Math.max(3, p.itemCount));
+ let eps = Array.from({ length: n }, (_, i) => i + 1);
+ if (shuffled) eps = eps.map((e) => ({ e, k: (e * 7 + 3) % n })).sort((a, b) => a.k - b.k).map((x) => x.e);
+ seq = eps.map((e) => ({ title: `S01E${String(e).padStart(2, "0")}`, sub: p.value }));
+ } else {
+ const list = sourceList(p, ov).filter((s) => s.weight > 0);
+ if (!list.length) return [];
+ if (shuffled) {
+ // Weighted round-robin interleave — honors the per-source rotation ratio.
+ const total = list.reduce((a, s) => a + s.weight, 0);
+ const st = list.map((s) => ({ ...s, acc: 0 }));
+ const count = Math.min(9, Math.max(5, total * 2));
+ seq = Array.from({ length: count }, () => {
+ let pick = null;
+ st.forEach((s) => { s.acc += s.weight; if (!pick || s.acc > pick.acc) pick = s; });
+ pick.acc -= total;
+ return { title: pick.name, sub: p.axis === "TvGenre" ? "Episode" : "" };
+ });
+ } else {
+ // Sequential — each source plays its rotation count in turn.
+ seq = [];
+ list.forEach((s) => { for (let i = 0; i < s.weight; i++) seq.push({ title: s.name, sub: p.axis === "TvGenre" ? "Episode" : "" }); });
+ seq = seq.slice(0, 9);
+ }
+ }
+ let t = 20 * 60;
+ return seq.map((b) => {
+ const start = `${String(Math.floor(t / 60) % 24).padStart(2, "0")}:${String(t % 60).padStart(2, "0")}`;
+ t += mins;
+ return { ...b, start, mins };
+ });
+ }
+
+ function MiniEpg({ blocks }) {
+ if (!blocks.length) return (
+ No content matches — nothing to schedule.
+ );
+ const PPM = 2.0;
+ return (
+
+
+ {blocks.map((b, i) => (
+
+ {b.start}
+ {b.title}
+ {b.sub && {b.sub} }
+
+ ))}
+
+
+ );
+ }
+
+ function ConfigRow({ label, value, first }) {
+ return (
+
+ {label}
+ {value}
+
+ );
+ }
+
+ // ---- Full channel settings (mirrors the manual Channel Builder) -----------
+ const CH_TEMPLATES = [
+ { id: "Standard", builtin: true, desc: "General-purpose 1080p H.264, retro bumpers.", shuffle: false, always: true, sets: ["1080p H.264", "HLS Segmenter", "Sequential", "Always on", "Pre + post filler"] },
+ { id: "Music videos", builtin: true, desc: "Continuous rotation, no fillers, direct stream.", shuffle: true, always: true, sets: ["720p H.264", "HLS Direct", "Shuffle", "Always on", "No filler"] },
+ { id: "Movie night", builtin: true, desc: "Film-grain HEVC, mid-roll ad breaks.", shuffle: false, always: false, sets: ["1080p HEVC", "MPEG-TS", "Sequential", "On-demand", "Mid-roll ads"] },
+ ];
+ const ADV_GROUPS = [
+ { group: "Streaming", fields: [["Streaming mode", "HLS Segmenter"], ["FFmpeg profile", "1080p H.264"], ["Resolution", "1920\u00d71080"], ["Video bitrate", "8000 kbps"], ["Audio bitrate", "192 kbps"], ["Buffer size", "16000 kb"]] },
+ { group: "Filler", fields: [["Pre-roll", "Retro Bumpers"], ["Mid-roll", "Ad Break"], ["Post-roll", "Outro"], ["Tail filler", "None"], ["Fallback", "Test Pattern"], ["Filler kind", "Pad to :00"]] },
+ { group: "Playback", fields: [["Interleave", "On"], ["Keep multi-part together", "On"], ["Watermark", "Channel logo"], ["Subtitle mode", "Any"], ["Preferred audio", "English"], ["Preferred subtitle", "None"]] },
+ { group: "Behavior", fields: [["Guide mode default", "Normal"], ["Song video mode", "Off"], ["On-demand", "Off"], ["Idle behavior", "Offline image"], ["Transcode audio", "Normalize"], ["Number scheme", "Auto"]] },
+ ];
+
+ // Friendly toggle row (label + description + Switch), with an override tag.
+ function ToggleRow({ icon, iconColor, title, desc, checked, onChange, overrideOf, live }) {
+ return (
+ onChange(!checked)} style={{ display: "flex", alignItems: "flex-start", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", background: live && checked ? "var(--ctv-live-soft)" : "transparent", border: `1px solid ${live && checked ? "var(--ctv-live)" : "var(--border-hairline)"}` }}>
+ {icon && }
+
+
+ {title}
+ {overrideOf != null && checked !== overrideOf && overrides template }
+
+
{desc}
+
+
+
+ );
+ }
+
+ // Channel Template picker (collapsed row + dropdown + spec chips).
+ function TemplatePicker({ value, onPick }) {
+ const [open, setOpen] = React.useState(false);
+ const t = CH_TEMPLATES.find((x) => x.id === value) || CH_TEMPLATES[0];
+ return (
+
+
setOpen((o) => !o)} className="ctv-press"
+ style={{ width: "100%", display: "flex", alignItems: "center", gap: 10, padding: "10px 11px", borderRadius: "var(--radius-sm)", cursor: "pointer", textAlign: "left", background: "var(--ctv-bg-sunken)", border: "1px solid var(--border-control)" }}>
+
+
+
+
+ {open && (
+
+ {CH_TEMPLATES.map((x) => {
+ const on = x.id === value;
+ return (
+
{ onPick(x); setOpen(false); }}
+ style={{ display: "flex", alignItems: "center", gap: 9, padding: "8px 9px", borderRadius: "var(--radius-xs)", cursor: "pointer", textAlign: "left", background: on ? "var(--ctv-accent-soft)" : "transparent", border: "none" }}>
+
+
+
+ );
+ })}
+
+ )}
+
+ {t.sets.map((s) => (
+ {s}
+ ))}
+
+
+ );
+ }
+
+ // Advanced (~24-field) override disclosure: View defaults / Override.
+ function AdvancedSettings({ ov, patch, templateName }) {
+ const [mode, setMode] = React.useState("closed"); // closed | view | override
+ const defaults = React.useMemo(() => { const o = {}; ADV_GROUPS.forEach((g) => g.fields.forEach(([k, v]) => { o[k] = v; })); return o; }, []);
+ const vals = { ...defaults, ...(ov.adv || {}) };
+ const override = mode === "override";
+ const count = ADV_GROUPS.reduce((n, g) => n + g.fields.length, 0);
+ const overridden = Object.keys(ov.adv || {}).length;
+ const tabBtn = (active) => ({ display: "inline-flex", alignItems: "center", justifyContent: "center", gap: 6, padding: "8px 10px", borderRadius: "var(--radius-sm)", cursor: "pointer", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)", background: active ? "var(--ctv-accent-soft)" : "var(--ctv-surface-2)", color: active ? "var(--ctv-accent)" : "var(--text-secondary)", border: `1px solid ${active ? "rgba(224,138,60,.38)" : "var(--border-hairline)"}` });
+ return (
+
+
+
+ Advanced
+
+ {overridden ? `${overridden} overridden` : `${count} fields`} \u00b7 {templateName}
+
+
+ setMode((m) => (m === "override" ? "closed" : "override"))} style={tabBtn(override)}> {override ? "Overriding" : "Override settings"}
+ setMode((m) => (m === "view" ? "closed" : "view"))} style={tabBtn(mode === "view")}> View defaults
+
+ {mode !== "closed" && (
+
+
+
+ {override ? "Editing these overrides the template for this channel only." : `Read-only \u2014 inherited from the ${templateName} template. Turn on Override to edit.`}
+
+ {ADV_GROUPS.map((g) => (
+
+
{g.group}
+
+ {g.fields.map(([k, v]) => override ? (
+
patch((o) => ({ adv: { ...(o.adv || {}), [k]: e.target.value } }))} />
+ ) : (
+
+ ))}
+
+
+ ))}
+
+ )}
+
+ );
+ }
+
+ // Compact channel-image dropzone (sets a data-URL logo used as guide logo + bug).
+ function LogoDrop({ name, src, onSet }) {
+ const [over, setOver] = React.useState(false);
+ const fileRef = React.useRef(null);
+ const read = (file) => { if (!file) return; const r = new FileReader(); r.onload = () => onSet(r.result); r.readAsDataURL(file); };
+ return (
+ { e.preventDefault(); setOver(true); }} onDragLeave={() => setOver(false)}
+ onDrop={(e) => { e.preventDefault(); setOver(false); read(e.dataTransfer.files && e.dataTransfer.files[0]); }}
+ onClick={() => fileRef.current && fileRef.current.click()}
+ style={{ display: "flex", alignItems: "center", gap: 12, padding: 10, borderRadius: "var(--radius-sm)", cursor: "pointer", background: "var(--ctv-bg-sunken)", border: `1px dashed ${over ? "var(--ctv-accent)" : "var(--border-control)"}` }}>
+
+
+
{src ? "Channel image set" : "Drop a channel image"}
+
Used as the guide logo and on-screen bug. Falls back to the initials below.
+
+ {src &&
{ e.stopPropagation(); onSet(null); }}> }
+
read(e.target.files && e.target.files[0])} />
+
+ );
+ }
+
+ // ---- Channel detail slide-over: full per-channel settings -----------------
+ function DetailPanel({ p, ov, patch, onClose }) {
+ const [addText, setAddText] = React.useState("");
+ const name = ov.customName != null ? ov.customName : p.name;
+ const bug = ov.bug || {};
+ const ax = AXIS[p.axis];
+ const number = ov.number != null ? ov.number : p.number;
+ const shuffled = isShuffled(p, ov);
+ const always = ov.always != null ? ov.always : true;
+ const tpl = CH_TEMPLATES.find((t) => t.id === (ov.template || "Standard")) || CH_TEMPLATES[0];
+ const pickTemplate = (t) => patch({ template: t.id, shuffle: t.shuffle, always: t.always });
+ const setLogo = (v) => patch((o) => ({ bug: { ...(o.bug || {}), src: v } }));
+ const avatar = (size) => bug.src
+ ?
+ : ;
+ const genre = isGenreAxis(p.axis);
+ const base = baseSources(p);
+ const ex = ov.exclude || [];
+ const inc = ov.include || [];
+ const kept = base.filter((s) => !ex.includes(s));
+ const items = estItems(p, ov);
+ const schedule = buildSchedule(p, ov);
+ const ratios = ov.ratios || {};
+ const wOf = (s) => ratios[s] || 1;
+ const setW = (s, w) => patch((o) => ({ ratios: { ...(o.ratios || {}), [s]: Math.max(1, Math.min(9, w)) } }));
+ const list = [...kept, ...inc];
+ const multi = list.length > 1;
+
+ const excludeSrc = (s) => patch((o) => ({ exclude: [...(o.exclude || []), s] }));
+ const restoreSrc = (s) => patch((o) => ({ exclude: (o.exclude || []).filter((x) => x !== s) }));
+ const removeInc = (s) => patch((o) => ({ include: (o.include || []).filter((x) => x !== s) }));
+ const addInc = () => {
+ const v = addText.trim();
+ if (!v || kept.includes(v) || inc.includes(v)) { setAddText(""); return; }
+ patch((o) => ({ include: [...(o.include || []), v], exclude: (o.exclude || []).filter((x) => x !== v) }));
+ setAddText("");
+ };
+
+ return (
+
+
+
+
+ {avatar(34)}
+
+
{name || "Untitled channel"}
+
+ {number} · {shuffled ? "Shuffled" : "In order"}
+
+
+
+
+
+
+
+
+
+ Playback
+ patch({ shuffle: v })} overrideOf={tpl.shuffle} />
+ patch({ always: v })} overrideOf={tpl.always} />
+
+
+
+
+
+ Query & size
+
+ {shuffled ? "Shuffled" : "In order"}} />
+
+ {items.toLocaleString()}} />
+ {p.axis === "TvShow" ? `show="${p.value}"` : p.axis === "TvGenre" ? `genre="${p.value}"` : `genre="${p.value}" AND type=movie`}} />
+
+
+
+ {genre && (
+
+
+
Content sources
+
Everything tagged “{p.value}”. Exclude a title even though it matches, add one that isn’t tagged, or set how often each plays.
+
+
+ {list.length === 0 && (
+
No sources — add one below.
+ )}
+ {[...kept.map((s) => ({ s, added: false })), ...inc.map((s) => ({ s, added: true }))].map((row, idx) => (
+
+
+ {row.s}
+ {multi && setW(row.s, w)} />}
+ (row.added ? removeInc(row.s) : excludeSrc(row.s))}>
+
+ ))}
+
+ {multi && (
+
+ Rotation: {list.map((s) => `${wOf(s)}× ${s}`).join(" · ")}
+
+ )}
+
+
+ setAddText(e.target.value)} onKeyDown={(e) => { if (e.key === "Enter") addInc(); }} leadingIcon={ } />
+
+
Add
+
+ {ex.length > 0 && (
+
+ Excluded:
+ {ex.map((s) => (
+ restoreSrc(s)} title="Add back"
+ style={{ display: "inline-flex", alignItems: "center", gap: 5, cursor: "pointer", height: 22, padding: "0 8px", borderRadius: "var(--radius-xs)", background: "transparent", border: "1px solid var(--border-hairline)", color: "var(--text-disabled)", font: "var(--weight-medium) var(--text-xs)/1 var(--font-sans)" }}>
+ {s}
+
+ ))}
+
+ )}
+
+ )}
+
+
+
+
Example schedule
+
A preview of tonight from 20:00 — the built playout may differ.
+
+
+
+
+
+
+
+
+ );
+ }
+
+ // =========================================================================
+ function AutoTune() {
+ const [step, setStep] = React.useState("configure"); // configure | preview | create
+ const [axisIds, setAxisIds] = React.useState(() => new Set(["TvShow", "TvGenre", "MovieGenre"]));
+ const [minItems, setMinItems] = React.useState("5");
+ const [startingNumber, setStartingNumber] = React.useState("500");
+ const [group, setGroup] = React.useState("Auto-Tuned");
+ const [template, setTemplate] = React.useState("Standard");
+
+ const [proposals, setProposals] = React.useState([]);
+ const [selected, setSelected] = React.useState(() => new Set());
+ const [results, setResults] = React.useState(null);
+ const [expanded, setExpanded] = React.useState(() => new Set()); // rows with inline schedule open
+ const [detail, setDetail] = React.useState(null); // proposal name open in the panel
+ const [overrides, setOverrides] = React.useState({}); // name -> {customName, bug, exclude, include}
+
+ const patchOv = (name) => (patch) => setOverrides((o) => {
+ const cur = o[name] || {};
+ const delta = typeof patch === "function" ? patch(cur) : patch;
+ return { ...o, [name]: { ...cur, ...delta } };
+ });
+ const dispName = (p) => { const c = (overrides[p.name] || {}).customName; return c != null && c !== "" ? c : p.name; };
+
+ const toggleAxis = (id) =>
+ setAxisIds((s) => { const n = new Set(s); n.has(id) ? n.delete(id) : n.add(id); return n; });
+
+ const runPreview = () => {
+ const p = buildProposals(axisIds, Math.max(1, parseInt(minItems, 10) || 1), parseInt(startingNumber, 10) || 500);
+ setProposals(p);
+ // Default selection: everything that isn't an already-existing name.
+ setSelected(new Set(p.filter((x) => !x.alreadyExists).map((x) => x.name)));
+ setStep("preview");
+ };
+
+ const runCreate = () => {
+ // Demo the three outcome states across the selected set.
+ const chosen = proposals.filter((p) => selected.has(p.name));
+ const res = chosen.map((p, i) => {
+ const nm = dispName(p);
+ if (i === chosen.length - 1 && chosen.length > 2)
+ return { name: nm, status: "Skipped", channelId: null, reason: `number ${p.number} already taken` };
+ if (p.name === "Sci-Fi Movies")
+ return { name: nm, status: "Failed", channelId: null, reason: "smart collection query returned no items" };
+ return { name: nm, status: "Created", channelId: 80 + i, reason: "" };
+ });
+ setResults(res);
+ setStep("create");
+ };
+
+ const restart = () => { setProposals([]); setSelected(new Set()); setResults(null); setExpanded(new Set()); setDetail(null); setOverrides({}); setStep("configure"); };
+
+ return (
+
+
setStep("configure")}
+ onRestart={restart}
+ />
+
+ {step === "configure" && (
+
+ )}
+ {step === "preview" && (
+
+ )}
+ {step === "create" && }
+
+ {detail != null && (() => {
+ const p = proposals.find((x) => x.name === detail);
+ if (!p) return null;
+ return setDetail(null)} />;
+ })()}
+
+ );
+ }
+
+ // ---- Toolbar with step rail + contextual primary action -------------------
+ function Toolbar({ step, axisCount, selectedCount, onPreview, onCreate, onBack, onRestart }) {
+ return (
+
+
+
+
Auto-Tune
+
Generate channels from your library
+
+
+
+
+ {step === "configure" && (
+
+ } onClick={onPreview}>Preview channels
+
+ )}
+ {step === "preview" && (
+
+ } onClick={onBack}>Back
+
+ } onClick={onCreate}>
+ {selectedCount ? `Create ${selectedCount} channel${selectedCount === 1 ? "" : "s"}` : "Create channels"}
+
+
+
+ )}
+ {step === "create" && (
+
} onClick={onRestart}>Start over
+ )}
+
+ );
+ }
+
+ const STEPS = [
+ { id: "configure", label: "Configure" },
+ { id: "preview", label: "Preview" },
+ { id: "create", label: "Create" },
+ ];
+ function StepRail({ step }) {
+ const idx = STEPS.findIndex((s) => s.id === step);
+ return (
+
+ {STEPS.map((s, i) => {
+ const done = i < idx, active = i === idx;
+ const color = active ? "var(--ctv-accent)" : done ? "var(--text-secondary)" : "var(--text-disabled)";
+ return (
+
+
+ {done ? : i + 1}
+ {s.label}
+
+ {i < STEPS.length - 1 && }
+
+ );
+ })}
+
+ );
+ }
+
+ // ---- Step 1: Configure ----------------------------------------------------
+ function Configure({ axisIds, toggleAxis, minItems, setMinItems, startingNumber, setStartingNumber, group, setGroup, template, setTemplate }) {
+ return (
+
+
+ Turn your library into a full lineup in one pass. Pick which metadata axes to generate from,
+ preview the proposed channels, then create the ones you want. Existing channels are never touched.
+
+
+
+ Generate from
+
+ {AXES.map((ax) => {
+ const on = axisIds.has(ax.id);
+ return (
+
toggleAxis(ax.id)}
+ className="ctv-press"
+ style={{
+ textAlign: "left", cursor: "pointer", padding: "16px 16px 15px", borderRadius: "var(--radius-md)",
+ border: `1px solid ${on ? "var(--ctv-accent)" : "var(--border-control)"}`,
+ background: on ? "var(--ctv-accent-soft)" : "var(--surface-card)",
+ boxShadow: on ? "var(--shadow-sm)" : "none", position: "relative", display: "flex", flexDirection: "column", gap: 9,
+ }}>
+
+
+
+
+
+
{ax.title}
+
{ax.tagline}
+
+
+ {ax.order}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+ }
+
+ function Field({ label, hint, children }) {
+ return (
+
+ {label}
+ {children}
+ {hint}
+
+ );
+ }
+
+ // ---- Step 2: Preview ------------------------------------------------------
+ function Preview({ proposals, selected, setSelected, expanded, setExpanded, overrides, openDetail, dispName }) {
+ const toggle = (name) => setSelected((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
+ const toggleExp = (name) => setExpanded((s) => { const n = new Set(s); n.has(name) ? n.delete(name) : n.add(name); return n; });
+ const groups = AXES.map((ax) => ({ ax, rows: proposals.filter((p) => p.axis === ax.id) })).filter((g) => g.rows.length);
+ const selectable = proposals.filter((p) => !p.alreadyExists);
+ const existingCount = proposals.length - selectable.length;
+
+ if (!proposals.length) {
+ return (
+
+
+
No channels matched — try lowering the minimum items.
+
+ );
+ }
+
+ const setAll = (rows, on) => setSelected((s) => {
+ const n = new Set(s);
+ rows.forEach((r) => { if (!r.alreadyExists) (on ? n.add(r.name) : n.delete(r.name)); });
+ return n;
+ });
+
+ return (
+
+
+
+ {selected.size} of {selectable.length} selected
+
+ {existingCount > 0 && (
+ } tone="neutral">{existingCount} already exist — deselected
+ )}
+
+ setAll(selectable, true)}>Select all
+ setAll(selectable, false)}>Clear
+
+
+ {groups.map(({ ax, rows }) => {
+ const groupSel = rows.filter((r) => !r.alreadyExists);
+ const allOn = groupSel.length > 0 && groupSel.every((r) => selected.has(r.name));
+ const someOn = groupSel.some((r) => selected.has(r.name));
+ return (
+
+
+ setAll(rows, !allOn)} />
+
+ {ax.title}
+ {rows.length}
+
+
+ {ax.order}
+
+
+
+ {rows.map((r, i) => {
+ const on = selected.has(r.name);
+ const ov = overrides[r.name] || {};
+ const name = dispName(r);
+ const isExp = expanded.has(r.name);
+ return (
+
+
+
toggle(r.name)} />
+ {r.number}
+
+ !r.alreadyExists && toggleExp(r.name)} style={{ flex: 1, minWidth: 0, textAlign: "left", background: "transparent", border: "none", padding: 0, cursor: r.alreadyExists ? "default" : "pointer" }}>
+ {name}
+ from “{r.value}”
+
+
+ {isShuffled(r, ov) ? "Shuffled" : "In order"}
+
+ {r.itemCount} items
+ {r.alreadyExists ? (
+ } tone="neutral">Exists
+ ) : (
+
+ } onClick={() => openDetail(r.name)}>Configure
+ toggleExp(r.name)}>
+
+
+
+ )}
+
+ {isExp && !r.alreadyExists && (
+
+
+ Example schedule
+
+ } onClick={() => openDetail(r.name)}>Open channel
+
+
+
+ )}
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+ }
+
+ // ---- Step 3: Results ------------------------------------------------------
+ const RESULT_META = {
+ Created: { icon: "CheckCircle2", tone: "positive", color: "var(--ctv-live)" },
+ Skipped: { icon: "MinusCircle", tone: "neutral", color: "var(--text-secondary)" },
+ Failed: { icon: "XCircle", tone: "danger", color: "var(--ctv-danger, #e5484d)" },
+ };
+ function Results({ results, group }) {
+ const count = (s) => results.filter((r) => r.status === s).length;
+ return (
+
+
+ } />
+ } />
+ } />
+
+
+ Added to group {group}
+
+
+ {results.map((r, i) => {
+ const m = RESULT_META[r.status];
+ return (
+
+
+
+
{r.name}
+ {r.reason &&
{r.reason}
}
+
+ {r.channelId != null &&
#{r.channelId} }
+
{r.status}
+
+ );
+ })}
+
+
+ );
+ }
+
+ window.CTVAutoTune = AutoTune;
+})();
diff --git a/design-system/templates/chicorytv-admin/Shell.jsx b/design-system/templates/chicorytv-admin/Shell.jsx
index d50a56820..e93f6768b 100644
--- a/design-system/templates/chicorytv-admin/Shell.jsx
+++ b/design-system/templates/chicorytv-admin/Shell.jsx
@@ -8,6 +8,7 @@
{ id: "dashboard", label: "Dashboard", icon: "LayoutDashboard" },
{ id: "channels", label: "Channels", icon: "Tv" },
{ id: "builder", label: "New Channel", icon: "Plus" },
+ { id: "autoTune", label: "Auto-Tune", icon: "Sparkles" },
{ id: "guide", label: "Guide", icon: "LayoutGrid" },
{ id: "schedules", label: "Schedules", icon: "CalendarClock" },
{ id: "playouts", label: "Playouts", icon: "ListVideo", badge: 3 },
diff --git a/design-system/templates/chicorytv-admin/app.html b/design-system/templates/chicorytv-admin/app.html
index 8898ae0f7..5f4a98845 100644
--- a/design-system/templates/chicorytv-admin/app.html
+++ b/design-system/templates/chicorytv-admin/app.html
@@ -81,6 +81,7 @@
+
@@ -90,7 +91,7 @@
const { Sidebar, TopBar } = window.CTVShell;
const TITLES = {
- dashboard: "Dashboard", channels: "Channels", builder: "New Channel", guide: "Guide", schedules: "Schedules",
+ dashboard: "Dashboard", channels: "Channels", builder: "New Channel", autoTune: "Auto-Tune", guide: "Guide", schedules: "Schedules",
playouts: "Playouts", collections: "Collections", libraries: "Libraries", settings: "Settings",
};
@@ -110,6 +111,7 @@ function App() {
dashboard: window.CTVDashboard,
channels: window.CTVChannels,
builder: window.CTVChannelBuilder,
+ autoTune: window.CTVAutoTune,
guide: window.CTVEpg,
schedules: window.CTVScheduleEditor,
playouts: window.CTVPlayouts,
@@ -132,7 +134,7 @@ function App() {
function mount() {
if (!window.ChicoryTVDesignSystem_eb3b61 || !window.CTVShell || !window.CTVDashboard ||
- !window.CTVChannels || !window.CTVChannelBuilder || !window.CTVScheduleEditor || !window.CTVEpg ||
+ !window.CTVChannels || !window.CTVChannelBuilder || !window.CTVAutoTune || !window.CTVScheduleEditor || !window.CTVEpg ||
!window.CTVPlayouts || !window.CTVLibraries || !window.CTVSettings) {
return setTimeout(mount, 40);
}
diff --git a/docs/blazor-route-parity.md b/docs/blazor-route-parity.md
index 1bec75b4b..47a860975 100644
--- a/docs/blazor-route-parity.md
+++ b/docs/blazor-route-parity.md
@@ -185,6 +185,15 @@ section is now empty (mappings retained at the top of this section for the recor
| `/media/sources/emby/{Id:int}/libraries` | `EmbyLibrariesEditor.razor` | `/app/libraries/emby/{id}/sync` | `RemoteLibrariesEditScreen` (family=emby); **#202** |
| `/media/sources/emby/{Id:int}/paths` | `EmbyPathReplacementsEditor.razor` | `/app/libraries/emby/{id}/path-replacements` | `PathReplacementsEditScreen` (family=emby); **#202** |
+## Section 2b — SPA-NATIVE (no Blazor ancestor)
+
+Screens the SPA introduces that never existed in the Blazor UI, so they have no parity row and no
+redirect (nothing to redirect *from*). Recorded here so the parity tracker is complete.
+
+| SPA route | Screen | Notes |
+|---|---|---|
+| `/app/auto-tune` | `AutoTuneScreen` | **Net-new (#69).** Auto-Tune wizard: bulk-generate channels from library metadata (axes: TV Shows / TV Genres / Movie Genres) → preview & select → bulk-create, over the PR1 `POST /api/v1/channels/auto-tune/preview` + `POST /api/v1/channels/auto-tune` endpoints. Additive/non-destructive; no Blazor equivalent. Per-channel DetailPanel deferred to #383. |
+
## Section 3 — BLAZOR-ONLY (blocking issues)
### Playlist variant management — API gap #153 RESOLVED
diff --git a/docs/domain-model.md b/docs/domain-model.md
index 9c51427fb..7597f9e7f 100644
--- a/docs/domain-model.md
+++ b/docs/domain-model.md
@@ -75,7 +75,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
| **MediaItemState** | Health flag on a media item: Normal/FileNotFound/Unavailable/RemoteOnly. Drives the Trash screen. | `MediaItemState` | `/app/trash` |
| **PlayoutItem** | One materialized, built entry in a playout's timeline (the actual thing that will play at a given time). | `PlayoutItem` | (generated, not directly edited) |
| **PlayoutHistory** | Rotation/rerun bookkeeping per block (`BlockId`) + collection `Key`/`ChildKey`, used by block-playout schedulers to avoid repeats; inspectable via Troubleshooting. | `PlayoutHistory` | `/app/troubleshooting/blocks` |
-| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel` |
+| **Channel concepts** | `Number` (validated by `Channel.NumberValidator` regex), `Group`, `PlayoutSource` (Generated/Mirror; Mirror channels relay another channel via `MirrorSourceChannelId`+`PlayoutOffset`), `PlayoutMode` (Continuous/OnDemand), `TranscodeMode` (OnDemand only, today), `IdleBehavior` (StopOnDisconnect/KeepRunning), `StreamingMode` (TransportStream/HttpLiveStreamingDirect/HttpLiveStreamingSegmenter/TransportStreamHybrid). | `Channel` | `/app/channels`, `/app/edit-channel/{id}`, `/app/new-channel`, `/app/auto-tune` (bulk-generate from library metadata, #69) |
| **Guide / EPG (XMLTV)** | Per-channel programme guide generated from playout items; channels with `ShowInEpg=false` are excluded. | `GetChannelGuideHandler` | `/app/guide` (viewer); settings at `/app/settings/xmltv` |
| **M3U** | The channel lineup playlist Jellyfin/Dispatcharr consume. | `ChannelPlaylist.ToM3U()` | — |
| **IPTV base URL** | Optional advertised base URL for the IPTV surface (#340). Stored as a single `ConfigElement` (`ConfigElementKey.IptvBaseUrl`, key `iptv.base_url`, no EF migration); when set, `GetChannelPlaylistHandler` (M3U) and `GetChannelGuideHandler` (XMLTV) pin their absolute URLs to its scheme/host/base instead of the request `Host` (blank/invalid → request-derived). Not applied to HDHomeRun; distinct from `ETV_BASE_URL`. Parsed by `ErsatzTV.Core/Iptv/AdvertisedBaseUrl.cs`. | `ConfigElementKey.IptvBaseUrl` | `/app/settings` → IPTV (`GET`/`PUT /api/v1/settings/iptv`) |
@@ -83,7 +83,7 @@ Channel (1) ──< Playout (0..N per channel; ChannelPlayoutSource distinguishe
## Where things are edited (SPA routes)
-Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/guide`,
+Primary nav: `/app` (dashboard), `/app/channels`, `/app/new-channel`, `/app/auto-tune` (#69), `/app/guide`,
`/app/schedules`, `/app/blocks`, `/app/templates`, `/app/decos`, `/app/deco-templates`,
`/app/playouts` (+ sub-paths `/app/playouts/{id}/alternate-schedules`,
`/app/playouts/{id}/templates`).
diff --git a/web/src/App.test.tsx b/web/src/App.test.tsx
index 811866495..9bea879ea 100644
--- a/web/src/App.test.tsx
+++ b/web/src/App.test.tsx
@@ -99,6 +99,19 @@ describe('ChicoryTV SPA scaffold', () => {
expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
});
+ it('routes to the Auto-Tune wizard from its sidebar nav entry (#69)', async () => {
+ render( );
+
+ fireEvent.click(screen.getByRole('link', { name: 'Auto-Tune' }));
+
+ expect(window.location.pathname).toBe('/app/auto-tune');
+ // The wizard mounts on its Configure step with an in-body Preview action (no TopBar "+").
+ expect(await screen.findByRole('button', { name: /TV Shows/ })).toBeInTheDocument();
+ expect(screen.getByRole('button', { name: /Preview channels/ })).toBeInTheDocument();
+ // Loads channel templates on mount (its own endpoint dependency).
+ expect(window.fetch).toHaveBeenCalledWith('/api/v1/channel-templates', expect.any(Object));
+ });
+
it('registers the multi-collection and rerun-collection nav entries', async () => {
render( );
diff --git a/web/src/api/autoTune.test.ts b/web/src/api/autoTune.test.ts
new file mode 100644
index 000000000..2d23c1ac1
--- /dev/null
+++ b/web/src/api/autoTune.test.ts
@@ -0,0 +1,89 @@
+import { beforeEach, describe, expect, it, vi } from 'vitest';
+import { createAutoTunedChannels, previewAutoTune } from './autoTune';
+
+const sampleProposals = [
+ { axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
+ { axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: true }
+];
+
+const sampleResult = {
+ results: [{ name: 'The Office', status: 'Created', channelId: 80, reason: null }],
+ createdCount: 1,
+ skippedCount: 0,
+ failedCount: 0
+};
+
+describe('previewAutoTune', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it('POSTs /api/v1/channels/auto-tune/preview with the axes/minItems/startingNumber body', async () => {
+ const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify(sampleProposals), {
+ headers: { 'Content-Type': 'application/json' },
+ status: 200
+ })
+ );
+
+ await expect(
+ previewAutoTune({ axes: ['TvShow', 'TvGenre'], minItems: 5, startingNumber: 500 })
+ ).resolves.toHaveLength(2);
+
+ const [url, init] = fetchSpy.mock.calls[0];
+ expect(url).toBe('/api/v1/channels/auto-tune/preview');
+ expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
+ expect(JSON.parse(String(init?.body))).toEqual({
+ axes: ['TvShow', 'TvGenre'],
+ minItems: 5,
+ startingNumber: 500
+ });
+ });
+
+ it('rejects with the ApiError status on failure', async () => {
+ vi.spyOn(window, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify({ status: 500, title: 'Server Error' }), {
+ headers: { 'Content-Type': 'application/json' },
+ status: 500
+ })
+ );
+
+ await expect(
+ previewAutoTune({ axes: ['TvShow'], minItems: 1, startingNumber: 1 })
+ ).rejects.toMatchObject({ status: 500 });
+ });
+});
+
+describe('createAutoTunedChannels', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ it('POSTs /api/v1/channels/auto-tune with templateId, group and echoed channels', async () => {
+ const fetchSpy = vi.spyOn(window, 'fetch').mockResolvedValue(
+ new Response(JSON.stringify(sampleResult), {
+ headers: { 'Content-Type': 'application/json' },
+ status: 200
+ })
+ );
+
+ await expect(
+ createAutoTunedChannels({
+ templateId: 3,
+ group: 'Auto-Tuned',
+ channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
+ })
+ ).resolves.toMatchObject({ createdCount: 1 });
+
+ const [url, init] = fetchSpy.mock.calls[0];
+ expect(url).toBe('/api/v1/channels/auto-tune');
+ expect((init?.method ?? 'GET').toUpperCase()).toBe('POST');
+ expect(JSON.parse(String(init?.body))).toEqual({
+ templateId: 3,
+ group: 'Auto-Tuned',
+ channels: [{ axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' }]
+ });
+ });
+});
diff --git a/web/src/api/autoTune.ts b/web/src/api/autoTune.ts
new file mode 100644
index 000000000..cd4c93d38
--- /dev/null
+++ b/web/src/api/autoTune.ts
@@ -0,0 +1,36 @@
+import { ApiError, request } from './client';
+import type { components } from './generated/v1';
+
+export type AutoTuneAxis = components['schemas']['AutoTuneAxis'];
+export type AutoTuneProposal = components['schemas']['AutoTuneProposalResponseModel'];
+export type PreviewAutoTuneChannelsRequest = components['schemas']['PreviewAutoTuneChannelsRequest'];
+export type AutoTunedChannelRequest = components['schemas']['AutoTunedChannelRequest'];
+export type CreateAutoTunedChannelsRequest = components['schemas']['CreateAutoTunedChannelsRequest'];
+export type AutoTuneChannelResult = components['schemas']['AutoTuneChannelResultModel'];
+export type AutoTuneResult = components['schemas']['AutoTuneResultResponseModel'];
+
+export function previewAutoTune(body: PreviewAutoTuneChannelsRequest): Promise {
+ return request('/api/v1/channels/auto-tune/preview', {
+ body,
+ method: 'POST'
+ });
+}
+
+export function createAutoTunedChannels(body: CreateAutoTunedChannelsRequest): Promise {
+ return request('/api/v1/channels/auto-tune', {
+ body,
+ method: 'POST'
+ });
+}
+
+export function messageFromAutoTuneError(error: unknown, fallback = 'Unable to auto-tune channels'): string {
+ if (error instanceof ApiError) {
+ return error.detail ?? error.message;
+ }
+
+ if (error instanceof Error) {
+ return error.message;
+ }
+
+ return fallback;
+}
diff --git a/web/src/api/index.ts b/web/src/api/index.ts
index ed40e97cd..567b02c0e 100644
--- a/web/src/api/index.ts
+++ b/web/src/api/index.ts
@@ -1,5 +1,6 @@
export * from './artwork';
export * from './auth';
+export * from './autoTune';
export * from './blocks';
export * from './channels';
export * from './channelTemplates';
diff --git a/web/src/app/ScreenContent.tsx b/web/src/app/ScreenContent.tsx
index 983539c08..5a05acc8b 100644
--- a/web/src/app/ScreenContent.tsx
+++ b/web/src/app/ScreenContent.tsx
@@ -8,6 +8,7 @@ import {
parseMediaSubRoute
} from '../routing';
import { ApiKeyScreen } from '../screens/ApiKeyScreen';
+import { AutoTuneScreen } from '../screens/AutoTuneScreen';
import { BlockPlayoutTroubleshootingScreen } from '../screens/BlockPlayoutTroubleshootingScreen';
import { BlocksScreen } from '../screens/BlocksScreen';
import { ChannelEditScreen } from '../screens/ChannelEditScreen';
@@ -165,6 +166,8 @@ export function ScreenContent({
return ;
case 'builder':
return ;
+ case 'autoTune':
+ return ;
case 'editChannel':
return ;
case 'guide':
diff --git a/web/src/app/routes.tsx b/web/src/app/routes.tsx
index 16a8c6494..c0d56a49c 100644
--- a/web/src/app/routes.tsx
+++ b/web/src/app/routes.tsx
@@ -23,6 +23,7 @@ import {
Search,
Settings,
SlidersHorizontal,
+ Sparkles,
Stamp,
Stethoscope,
Trash2,
@@ -33,6 +34,7 @@ export type ScreenId =
| 'dashboard'
| 'channels'
| 'builder'
+ | 'autoTune'
| 'editChannel'
| 'guide'
| 'schedules'
@@ -113,6 +115,19 @@ export const routes: ScreenRoute[] = [
primaryAction: '',
placeholder: 'Channel builder workspace'
},
+ {
+ // Net-new SPA screen with no Blazor ancestor (#69). The 3-step wizard drives its own in-body
+ // buttons, so it registers no TopBar primaryAction (spa-conventions §10).
+ id: 'autoTune',
+ path: '/app/auto-tune',
+ label: 'Auto-Tune',
+ title: 'Auto-Tune',
+ kicker: 'Builder',
+ description: 'Generate a whole channel lineup from your library metadata, preview it, then bulk-create.',
+ icon: ,
+ primaryAction: '',
+ placeholder: 'Auto-tune workspace'
+ },
{
// Not in the sidebar nav; reached via the edit pencil on the channel table. The
// screen owns parsing the {id} suffix (see ChannelEditScreen), so it opts into
@@ -458,6 +473,7 @@ export const sidebarNavGroups: SidebarNavGroupDefinition[] = [
'dashboard',
'channels',
'builder',
+ 'autoTune',
'guide',
'schedules',
'blocks',
diff --git a/web/src/screens/AutoTuneScreen.test.tsx b/web/src/screens/AutoTuneScreen.test.tsx
new file mode 100644
index 000000000..2d718e0f9
--- /dev/null
+++ b/web/src/screens/AutoTuneScreen.test.tsx
@@ -0,0 +1,203 @@
+import { cleanup, fireEvent, render, screen } from '@testing-library/react';
+import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { AutoTuneScreen } from './AutoTuneScreen';
+
+function jsonResponse(body: unknown, status = 200): Response {
+ return new Response(JSON.stringify(body), {
+ headers: { 'Content-Type': 'application/json' },
+ status
+ });
+}
+
+const templates = [
+ { id: 1, name: 'Standard' },
+ { id: 2, name: 'Movie night' }
+];
+
+const proposals = [
+ { axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500', itemCount: 201, alreadyExists: false },
+ { axis: 'TvShow', value: 'Friends', name: 'Friends', number: '501', itemCount: 236, alreadyExists: true },
+ { axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502', itemCount: 640, alreadyExists: false }
+];
+
+const createResult = {
+ results: [
+ { name: 'The Office', status: 'Created', channelId: 80, reason: null },
+ { name: 'Comedy', status: 'Skipped', channelId: null, reason: 'number 502 already taken' }
+ ],
+ createdCount: 1,
+ skippedCount: 1,
+ failedCount: 0
+};
+
+function mockAutoTuneApi(): ReturnType {
+ return vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const path = input.toString();
+ const method = (init?.method ?? 'GET').toUpperCase();
+
+ if (path === '/api/v1/channel-templates') {
+ return Promise.resolve(jsonResponse(templates));
+ }
+ if (path === '/api/v1/channel-templates/default') {
+ return Promise.resolve(jsonResponse(templates[0]));
+ }
+ if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') {
+ return Promise.resolve(jsonResponse(proposals));
+ }
+ if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
+ return Promise.resolve(jsonResponse(createResult));
+ }
+
+ return Promise.resolve(jsonResponse(null, 404));
+ });
+}
+
+describe('AutoTuneScreen', () => {
+ beforeEach(() => {
+ window.localStorage.clear();
+ vi.restoreAllMocks();
+ });
+
+ afterEach(() => {
+ cleanup();
+ });
+
+ it('gates the Preview action on at least one selected axis', async () => {
+ mockAutoTuneApi();
+ render( );
+
+ // All three axes start selected, so Preview is enabled.
+ const previewButton = screen.getByRole('button', { name: /Preview channels/ });
+ expect(previewButton).toBeEnabled();
+
+ // Deselect every axis → Preview disabled.
+ fireEvent.click(screen.getByRole('button', { name: /TV Shows/ }));
+ fireEvent.click(screen.getByRole('button', { name: /TV Genres/ }));
+ fireEvent.click(screen.getByRole('button', { name: /Movie Genres/ }));
+ expect(screen.getByRole('button', { name: /Preview channels/ })).toBeDisabled();
+
+ // Re-select one → enabled again.
+ fireEvent.click(screen.getByRole('button', { name: /TV Shows/ }));
+ expect(screen.getByRole('button', { name: /Preview channels/ })).toBeEnabled();
+ });
+
+ it('renders preview groups and deselects already-existing proposals', async () => {
+ mockAutoTuneApi();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
+
+ // Group headers appear in axis order.
+ expect(await screen.findByText('TV Shows')).toBeInTheDocument();
+ expect(screen.getByText('TV Genres')).toBeInTheDocument();
+
+ // Non-existing proposals are selected by default.
+ expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('checkbox', { name: 'Comedy' })).toHaveAttribute('aria-checked', 'true');
+
+ // The already-existing proposal is disabled + unchecked.
+ const existing = screen.getByRole('checkbox', { name: 'Friends' });
+ expect(existing).toHaveAttribute('aria-checked', 'false');
+ expect(existing).toHaveAttribute('aria-disabled', 'true');
+ expect(screen.getByText('Exists')).toBeInTheDocument();
+ });
+
+ it('supports select-all / clear over the selectable proposals', async () => {
+ mockAutoTuneApi();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
+ await screen.findByText('TV Shows');
+
+ fireEvent.click(screen.getByRole('button', { name: 'Clear' }));
+ expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'false');
+ expect(screen.getByRole('button', { name: /Create channels/ })).toBeDisabled();
+
+ fireEvent.click(screen.getByRole('button', { name: 'Select all' }));
+ expect(screen.getByRole('checkbox', { name: 'The Office' })).toHaveAttribute('aria-checked', 'true');
+ expect(screen.getByRole('button', { name: /Create 2 channels/ })).toBeEnabled();
+ });
+
+ it('posts the selected proposals and renders the result tiles', async () => {
+ const fetchSpy = mockAutoTuneApi();
+ render( );
+
+ fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
+ await screen.findByText('TV Shows');
+
+ fireEvent.click(screen.getByRole('button', { name: /Create 2 channels/ }));
+
+ // Result view renders on success (reason + group line are unique to it).
+ expect(await screen.findByText('number 502 already taken')).toBeInTheDocument();
+ expect(screen.getByText(/Added to group/)).toBeInTheDocument();
+ expect(screen.getByText('#80')).toBeInTheDocument();
+ // "Created" / "Skipped" appear both as Stat tile labels and per-row status badges.
+ expect(screen.getAllByText('Created').length).toBeGreaterThanOrEqual(2);
+ expect(screen.getAllByText('Skipped').length).toBeGreaterThanOrEqual(2);
+ expect(screen.getByText('Failed')).toBeInTheDocument();
+
+ // The create request echoes only the selected (non-existing) proposals.
+ const createCall = fetchSpy.mock.calls.find(
+ ([url, init]: [RequestInfo | URL, RequestInit | undefined]) =>
+ url === '/api/v1/channels/auto-tune' && (init?.method ?? '').toUpperCase() === 'POST'
+ );
+ expect(createCall).toBeTruthy();
+ const body = JSON.parse(String(createCall?.[1]?.body));
+ expect(body.templateId).toBe(1);
+ expect(body.group).toBe('Auto-Tuned');
+ expect(body.channels).toEqual([
+ { axis: 'TvShow', value: 'The Office', name: 'The Office', number: '500' },
+ { axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '502' }
+ ]);
+ });
+
+ it('keys selection by axis+value so same-named proposals stay independent', async () => {
+ // A TV show literally titled "Comedy" and the "Comedy" TV genre — identical names, different
+ // axes. Keying selection on the name would couple them (toggling one flips both).
+ const collision = [
+ { axis: 'TvShow', value: 'Comedy', name: 'Comedy', number: '500', itemCount: 30, alreadyExists: false },
+ { axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501', itemCount: 640, alreadyExists: false }
+ ];
+ const fetchSpy = vi.spyOn(window, 'fetch').mockImplementation((input: RequestInfo | URL, init?: RequestInit) => {
+ const path = input.toString();
+ const method = (init?.method ?? 'GET').toUpperCase();
+ if (path === '/api/v1/channel-templates') {
+ return Promise.resolve(jsonResponse(templates));
+ }
+ if (path === '/api/v1/channel-templates/default') {
+ return Promise.resolve(jsonResponse(templates[0]));
+ }
+ if (path === '/api/v1/channels/auto-tune/preview' && method === 'POST') {
+ return Promise.resolve(jsonResponse(collision));
+ }
+ if (path === '/api/v1/channels/auto-tune' && method === 'POST') {
+ return Promise.resolve(jsonResponse({ results: [], createdCount: 0, skippedCount: 0, failedCount: 0 }));
+ }
+ return Promise.resolve(jsonResponse(null, 404));
+ });
+
+ render( );
+ fireEvent.click(screen.getByRole('button', { name: /Preview channels/ }));
+ await screen.findByText('TV Shows');
+
+ // Two distinct row checkboxes both named "Comedy" (one per axis), both selected by default.
+ const boxes = screen.getAllByRole('checkbox', { name: 'Comedy' });
+ expect(boxes).toHaveLength(2);
+ expect(boxes[0]).toHaveAttribute('aria-checked', 'true');
+ expect(boxes[1]).toHaveAttribute('aria-checked', 'true');
+
+ // Deselecting the TV-show "Comedy" must NOT deselect the genre "Comedy".
+ fireEvent.click(boxes[0]);
+ const after = screen.getAllByRole('checkbox', { name: 'Comedy' });
+ expect(after[0]).toHaveAttribute('aria-checked', 'false');
+ expect(after[1]).toHaveAttribute('aria-checked', 'true');
+
+ // Exactly one remains selected → create posts only the still-selected genre proposal.
+ fireEvent.click(screen.getByRole('button', { name: /Create 1 channel/ }));
+ const createCall = fetchSpy.mock.calls.find(
+ (call) => call[0] === '/api/v1/channels/auto-tune' && (call[1]?.method ?? '').toUpperCase() === 'POST'
+ );
+ const body = JSON.parse(String(createCall?.[1]?.body));
+ expect(body.channels).toEqual([{ axis: 'TvGenre', value: 'Comedy', name: 'Comedy', number: '501' }]);
+ });
+});
diff --git a/web/src/screens/AutoTuneScreen.tsx b/web/src/screens/AutoTuneScreen.tsx
new file mode 100644
index 000000000..0dc7f9c61
--- /dev/null
+++ b/web/src/screens/AutoTuneScreen.tsx
@@ -0,0 +1,876 @@
+import { useCallback, useEffect, useRef, useState, type CSSProperties, type ReactNode } from 'react';
+import {
+ ArrowLeft,
+ Check,
+ CheckCircle2,
+ Circle,
+ Clapperboard,
+ Eye,
+ Film,
+ FolderTree,
+ Hash,
+ ListOrdered,
+ MinusCircle,
+ RotateCcw,
+ SearchX,
+ Shuffle,
+ Sparkles,
+ TriangleAlert,
+ Tv,
+ XCircle
+} from 'lucide-react';
+import { Badge, Button, Checkbox, Input, Select, Spinner, Stat, Tag, Tooltip } from '../components';
+import {
+ createAutoTunedChannels,
+ getChannelTemplates,
+ getDefaultChannelTemplate,
+ messageFromAutoTuneError,
+ messageFromChannelTemplateError,
+ previewAutoTune,
+ type AutoTuneAxis,
+ type AutoTuneProposal,
+ type AutoTuneResult,
+ type ChannelTemplate
+} from '../api';
+
+type WizardStep = 'configure' | 'preview' | 'create';
+
+interface AxisMeta {
+ id: AutoTuneAxis;
+ title: string;
+ tagline: string;
+ icon: typeof Tv;
+ order: string;
+ shuffled: boolean;
+}
+
+// Display catalogue only — the server owns the query and channel name. Order here mirrors the
+// server grouping (TvShow → TvGenre → MovieGenre).
+const AXES: AxisMeta[] = [
+ { id: 'TvShow', title: 'TV Shows', tagline: 'One 24/7 channel per show', icon: Tv, order: 'Episode order', shuffled: false },
+ { id: 'TvGenre', title: 'TV Genres', tagline: 'A channel per episode genre', icon: Clapperboard, order: 'Shuffled', shuffled: true },
+ { id: 'MovieGenre', title: 'Movie Genres', tagline: 'A movie channel per genre', icon: Film, order: 'Shuffled', shuffled: true }
+];
+
+const eyebrowStyle: CSSProperties = {
+ font: 'var(--weight-semibold) var(--text-2xs)/1 var(--font-sans)',
+ letterSpacing: '0.06em',
+ textTransform: 'uppercase',
+ color: 'var(--text-disabled)'
+};
+
+const monoStyle: CSSProperties = { fontFamily: 'var(--font-mono)', fontVariantNumeric: 'tabular-nums' };
+
+// Selection identity — axis+value uniquely identifies a proposal. Keying on the display
+// name would couple two same-named proposals (e.g. a TV show titled "Comedy" and the
+// "Comedy" genre), flipping/creating both. An axis id never contains a space, so a single
+// space separates it unambiguously from the metadata value.
+const proposalKey = (proposal: { axis: string; value: string }) => `${proposal.axis} ${proposal.value}`;
+
+type TemplatesState =
+ | { status: 'loading' }
+ | { status: 'error'; message: string }
+ | { status: 'success'; templates: ChannelTemplate[] };
+
+type PreviewState =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'error'; message: string }
+ | { status: 'success'; proposals: AutoTuneProposal[] };
+
+type CreateState =
+ | { status: 'idle' }
+ | { status: 'loading' }
+ | { status: 'error'; message: string }
+ | { status: 'success'; result: AutoTuneResult };
+
+const RESULT_META: Record = {
+ Created: { icon: CheckCircle2, tone: 'ok', color: 'var(--status-ok)' },
+ Skipped: { icon: MinusCircle, tone: 'neutral', color: 'var(--text-secondary)' },
+ Failed: { icon: XCircle, tone: 'error', color: 'var(--status-error)' }
+};
+
+export function AutoTuneScreen() {
+ const [step, setStep] = useState('configure');
+ const [axisIds, setAxisIds] = useState>(() => new Set(['TvShow', 'TvGenre', 'MovieGenre']));
+ const [minItems, setMinItems] = useState('5');
+ const [startingNumber, setStartingNumber] = useState('500');
+ const [group, setGroup] = useState('Auto-Tuned');
+ const [templateId, setTemplateId] = useState('');
+
+ const [templatesState, setTemplatesState] = useState({ status: 'loading' });
+ const [previewState, setPreviewState] = useState({ status: 'idle' });
+ const [createState, setCreateState] = useState({ status: 'idle' });
+ const [selected, setSelected] = useState>(() => new Set());
+
+ const activeRef = useRef(true);
+ const templatesSeqRef = useRef(0);
+ const previewSeqRef = useRef(0);
+ const createSeqRef = useRef(0);
+
+ useEffect(() => {
+ activeRef.current = true;
+ return () => {
+ activeRef.current = false;
+ };
+ }, []);
+
+ const loadTemplates = useCallback(() => {
+ const id = ++templatesSeqRef.current;
+ Promise.all([getChannelTemplates(), getDefaultChannelTemplate()])
+ .then(([templates, preferred]) => {
+ if (!activeRef.current || id !== templatesSeqRef.current) {
+ return;
+ }
+ setTemplatesState({ status: 'success', templates });
+ const fallback = preferred ?? templates[0] ?? null;
+ if (fallback) {
+ setTemplateId(String(fallback.id));
+ }
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current && id === templatesSeqRef.current) {
+ setTemplatesState({ status: 'error', message: messageFromChannelTemplateError(error) });
+ }
+ });
+ }, []);
+
+ useEffect(() => {
+ loadTemplates();
+ }, [loadTemplates]);
+
+ const toggleAxis = (id: AutoTuneAxis) => {
+ setAxisIds((current) => {
+ const next = new Set(current);
+ if (next.has(id)) {
+ next.delete(id);
+ } else {
+ next.add(id);
+ }
+ return next;
+ });
+ };
+
+ const runPreview = () => {
+ if (axisIds.size === 0) {
+ return;
+ }
+ const id = ++previewSeqRef.current;
+ setStep('preview');
+ setPreviewState({ status: 'loading' });
+ previewAutoTune({
+ axes: AXES.filter((axis) => axisIds.has(axis.id)).map((axis) => axis.id),
+ minItems: Math.max(1, Number.parseInt(minItems, 10) || 1),
+ startingNumber: Number.parseInt(startingNumber, 10) || 1
+ })
+ .then((proposals) => {
+ if (!activeRef.current || id !== previewSeqRef.current) {
+ return;
+ }
+ setPreviewState({ status: 'success', proposals });
+ // Default selection: every proposal that doesn't already exist.
+ setSelected(new Set(proposals.filter((proposal) => !proposal.alreadyExists).map(proposalKey)));
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current && id === previewSeqRef.current) {
+ setPreviewState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to preview channels') });
+ }
+ });
+ };
+
+ const proposals = previewState.status === 'success' ? previewState.proposals : [];
+ const selectedProposals = proposals.filter(
+ (proposal) => !proposal.alreadyExists && selected.has(proposalKey(proposal))
+ );
+
+ const runCreate = () => {
+ if (selectedProposals.length === 0 || templateId === '') {
+ return;
+ }
+ const id = ++createSeqRef.current;
+ setStep('create');
+ setCreateState({ status: 'loading' });
+ createAutoTunedChannels({
+ templateId: Number(templateId),
+ group,
+ channels: selectedProposals.map((proposal) => ({
+ axis: proposal.axis as AutoTuneAxis,
+ value: proposal.value,
+ name: proposal.name,
+ number: proposal.number
+ }))
+ })
+ .then((result) => {
+ if (activeRef.current && id === createSeqRef.current) {
+ setCreateState({ status: 'success', result });
+ }
+ })
+ .catch((error: unknown) => {
+ if (activeRef.current && id === createSeqRef.current) {
+ setCreateState({ status: 'error', message: messageFromAutoTuneError(error, 'Unable to create channels') });
+ }
+ });
+ };
+
+ const restart = () => {
+ setPreviewState({ status: 'idle' });
+ setCreateState({ status: 'idle' });
+ setSelected(new Set());
+ setStep('configure');
+ };
+
+ const templateOptions =
+ templatesState.status === 'success'
+ ? templatesState.templates.map((template) => ({ value: String(template.id), label: template.name }))
+ : [];
+
+ return (
+
+
+
+
+
+
+
+ Auto-Tune
+
+
+ Generate channels from your library
+
+
+
+
+
+ {step === 'configure' && (
+
+ }
+ onClick={runPreview}
+ >
+ Preview channels
+
+
+ )}
+ {step === 'preview' && (
+ <>
+
} onClick={() => setStep('configure')}>
+ Back
+
+
+ }
+ onClick={runCreate}
+ >
+ {selectedProposals.length
+ ? `Create ${selectedProposals.length} channel${selectedProposals.length === 1 ? '' : 's'}`
+ : 'Create channels'}
+
+
+ >
+ )}
+ {step === 'create' && (
+
} onClick={restart}>
+ Start over
+
+ )}
+
+
+
+ {step === 'configure' && (
+
+ )}
+ {step === 'preview' && (
+
+ )}
+ {step === 'create' && }
+
+
+ );
+}
+
+const STEPS: { id: WizardStep; label: string }[] = [
+ { id: 'configure', label: 'Configure' },
+ { id: 'preview', label: 'Preview' },
+ { id: 'create', label: 'Create' }
+];
+
+function StepRail({ step }: { step: WizardStep }) {
+ const index = STEPS.findIndex((entry) => entry.id === step);
+ return (
+
+ {STEPS.map((entry, i) => {
+ const done = i < index;
+ const active = i === index;
+ const color = active ? 'var(--ctv-accent)' : done ? 'var(--text-secondary)' : 'var(--text-disabled)';
+ return (
+
+
+
+ {done ? : i + 1}
+
+
+ {entry.label}
+
+
+ {i < STEPS.length - 1 && (
+
+ )}
+
+ );
+ })}
+
+ );
+}
+
+function Field({ label, hint, children }: { label: string; hint: string; children: ReactNode }) {
+ return (
+
+ {label}
+ {children}
+ {hint}
+
+ );
+}
+
+function ConfigureStep({
+ axisIds,
+ toggleAxis,
+ minItems,
+ setMinItems,
+ startingNumber,
+ setStartingNumber,
+ group,
+ setGroup,
+ templateId,
+ setTemplateId,
+ templatesState,
+ templateOptions,
+ retryTemplates
+}: {
+ axisIds: Set;
+ toggleAxis: (id: AutoTuneAxis) => void;
+ minItems: string;
+ setMinItems: (value: string) => void;
+ startingNumber: string;
+ setStartingNumber: (value: string) => void;
+ group: string;
+ setGroup: (value: string) => void;
+ templateId: string;
+ setTemplateId: (value: string) => void;
+ templatesState: TemplatesState;
+ templateOptions: { value: string; label: string }[];
+ retryTemplates: () => void;
+}) {
+ return (
+
+
+ Turn your library into a full lineup in one pass. Pick which metadata axes to generate from, preview the proposed
+ channels, then create the ones you want. Existing channels are never touched.
+
+
+
+ Generate from
+
+ {AXES.map((axis) => {
+ const on = axisIds.has(axis.id);
+ const AxisIcon = axis.icon;
+ const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
+ return (
+
toggleAxis(axis.id)}
+ className="ctv-press"
+ style={{
+ textAlign: 'left',
+ cursor: 'pointer',
+ padding: '16px 16px 15px',
+ borderRadius: 'var(--radius-md)',
+ border: `1px solid ${on ? 'var(--ctv-accent)' : 'var(--border-control)'}`,
+ background: on ? 'var(--ctv-accent-soft)' : 'var(--surface-card)',
+ boxShadow: on ? 'var(--shadow-sm)' : 'none',
+ position: 'relative',
+ display: 'flex',
+ flexDirection: 'column',
+ gap: 9
+ }}
+ >
+
+ {on ? : }
+
+
+
+
+
+
+ {axis.title}
+
+
+ {axis.tagline}
+
+
+
+
+ {axis.order}
+
+
+ );
+ })}
+
+
+
+
+
+ );
+}
+
+function PreviewStep({
+ state,
+ selected,
+ setSelected,
+ onRetry
+}: {
+ state: PreviewState;
+ selected: Set;
+ setSelected: (updater: (current: Set) => Set) => void;
+ onRetry: () => void;
+}) {
+ if (state.status === 'loading' || state.status === 'idle') {
+ return (
+
+
+
Enumerating your library…
+
+ );
+ }
+
+ if (state.status === 'error') {
+ return (
+
+
+
{state.message}
+
+ Retry
+
+
+ );
+ }
+
+ const { proposals } = state;
+
+ if (proposals.length === 0) {
+ return (
+
+
+
+ No channels matched — try lowering the minimum items.
+
+
+ );
+ }
+
+ const selectable = proposals.filter((proposal) => !proposal.alreadyExists);
+ const existingCount = proposals.length - selectable.length;
+ const selectedCount = selectable.filter((proposal) => selected.has(proposalKey(proposal))).length;
+
+ const setAll = (rows: AutoTuneProposal[], on: boolean) => {
+ setSelected((current) => {
+ const next = new Set(current);
+ rows.forEach((row) => {
+ if (!row.alreadyExists) {
+ if (on) {
+ next.add(proposalKey(row));
+ } else {
+ next.delete(proposalKey(row));
+ }
+ }
+ });
+ return next;
+ });
+ };
+
+ const toggle = (key: string) => {
+ setSelected((current) => {
+ const next = new Set(current);
+ if (next.has(key)) {
+ next.delete(key);
+ } else {
+ next.add(key);
+ }
+ return next;
+ });
+ };
+
+ const groups = AXES.map((axis) => ({ axis, rows: proposals.filter((proposal) => proposal.axis === axis.id) })).filter(
+ (entry) => entry.rows.length > 0
+ );
+
+ return (
+
+
+
+ {selectedCount} of{' '}
+ {selectable.length} selected
+
+ {existingCount > 0 && (
+ } tone="neutral">
+ {existingCount} already exist — deselected
+
+ )}
+
+ setAll(selectable, true)}>
+ Select all
+
+ setAll(selectable, false)}>
+ Clear
+
+
+
+ {groups.map(({ axis, rows }) => {
+ const groupSelectable = rows.filter((row) => !row.alreadyExists);
+ const allOn = groupSelectable.length > 0 && groupSelectable.every((row) => selected.has(proposalKey(row)));
+ const someOn = groupSelectable.some((row) => selected.has(proposalKey(row)));
+ const AxisIcon = axis.icon;
+ const OrderIcon = axis.shuffled ? Shuffle : ListOrdered;
+ return (
+
+
+
setAll(rows, !allOn)}
+ />
+
+
+ {axis.title}
+
+ {rows.length}
+
+
+
+ {axis.order}
+
+
+
+ {rows.map((row, i) => {
+ const on = selected.has(proposalKey(row));
+ return (
+
+
+
toggle(proposalKey(row))}
+ />
+
+ {row.number}
+
+
+
+ {row.name}
+
+
+ from “{row.value}”
+
+
+
+ {row.itemCount} items
+
+ {row.alreadyExists && Exists }
+
+
+ );
+ })}
+
+
+ );
+ })}
+
+ );
+}
+
+function CreateStep({ state, group, onRetry }: { state: CreateState; group: string; onRetry: () => void }) {
+ if (state.status === 'loading' || state.status === 'idle') {
+ return (
+
+ );
+ }
+
+ if (state.status === 'error') {
+ return (
+
+
+
{state.message}
+
+ Retry
+
+
+ );
+ }
+
+ const { result } = state;
+
+ return (
+
+
+ } />
+ } />
+ } />
+
+
+ Added to group{' '}
+ {group}
+
+
+ {result.results.map((entry, i) => {
+ const meta = RESULT_META[entry.status] ?? RESULT_META.Skipped;
+ const StatusIcon = meta.icon;
+ return (
+
+
+
+
+
+
+ {entry.name}
+
+ {entry.reason && (
+
+ {entry.reason}
+
+ )}
+
+ {entry.channelId != null && (
+
+ #{entry.channelId}
+
+ )}
+
{entry.status}
+
+ );
+ })}
+
+
+ );
+}
+
+const centeredMessageStyle: CSSProperties = {
+ display: 'flex',
+ flexDirection: 'column',
+ alignItems: 'center',
+ justifyContent: 'center',
+ height: '100%',
+ gap: 12,
+ padding: '40px 24px'
+};