video automations: working builder + New Automation button (own builder, not music's)

The video automations page had no way to CREATE automations and its card cog did
nothing — it called the music global showAutomationBuilder(), which swaps views
inside the (hidden-on-video) music page, so the builder 'opened' on the music tab
instead. Now the video side has its own builder.

- index.html: the video automations subpage gets its own list-view + builder-view
  (vauto- prefixed ids) and a '+ New Automation' button, swapping exactly like the
  music page. Save/Cancel/Back reuse the shared builder functions.
- stats-automations.js: the builder is now context-aware. A builder context holds
  the element ids + blocks endpoint + owned_by + reload callback. Music context is
  the default and byte-identical (all 17 id lookups go through _bEl() resolving the
  music ids). showVideoAutomationBuilder() sets a video context (vauto- ids,
  /api/video/automations/blocks, owned_by='video'); editAutomation() routes the
  card cog to the right builder by active side. Opening clears BOTH builders'
  canvases so cfg-* ids can't collide. Save tags owned_by from context and calls
  the context's reload. A generic config_fields renderer/reader (video-gated so
  music keeps its bespoke renderers) drives video block config like the mode select.
- video-automations.js: exposes window._reloadVideoAutomations so a save refreshes
  the video list.

11 wiring tests (test_video_automations_builder.py); node --check clean; music
builder path unchanged.
This commit is contained in:
BoulderBadgeDad 2026-06-19 23:02:52 -07:00
parent 5d488bceb4
commit 1fa3e1b599
4 changed files with 289 additions and 35 deletions

View file

@ -0,0 +1,101 @@
"""Video automation BUILDER wiring — the isolated video page must be able to
create/edit its own automations without hijacking the music page's builder.
These pin the wiring contract that makes that work:
- the video automations subpage hosts its OWN builder DOM (vauto- prefixed ids)
+ a "New Automation" button, swapping list/builder like the music page;
- the shared builder (stats-automations.js) is context-aware: a video context
points at the video ids + the video-scoped blocks endpoint + owned_by='video',
and the card cog routes to the video builder when the video side is active;
- a save tags the row owned_by='video' so it stays off the music page;
- a generic config_fields renderer drives video block config (so the video
action's fields show up) and is gated to the video context (music untouched).
String-contract level (like tests/test_video_side_shell.py) so a refactor that
silently breaks the coupling fails here.
"""
from __future__ import annotations
from pathlib import Path
_ROOT = Path(__file__).resolve().parent.parent
_INDEX = (_ROOT / "webui" / "index.html").read_text(encoding="utf-8", errors="replace")
_STATS = (_ROOT / "webui" / "static" / "stats-automations.js").read_text(encoding="utf-8")
_VAUTO = (_ROOT / "webui" / "static" / "video" / "video-automations.js").read_text(encoding="utf-8")
# --- the video subpage builder DOM ----------------------------------------
def test_video_subpage_has_new_automation_button():
# Header button + empty-state button both open the VIDEO builder.
assert _INDEX.count('onclick="showVideoAutomationBuilder()"') >= 1
assert 'auto-new-btn' in _INDEX
def test_video_subpage_has_list_and_builder_views():
for tok in ('id="vauto-list-view"', 'id="vauto-builder-view"'):
assert tok in _INDEX, tok
def test_video_builder_has_prefixed_ids():
for tok in (
'id="vauto-builder-name"', 'id="vauto-builder-group-name"',
'id="vauto-builder-group-list"', 'id="vauto-builder-sidebar"',
'id="vauto-builder-canvas"',
):
assert tok in _INDEX, tok
def test_video_builder_buttons_use_shared_handlers():
# Save / Cancel / Back reuse the SAME shared functions (ctx-driven).
assert 'onclick="saveAutomation()"' in _INDEX
assert 'onclick="hideAutomationBuilder()"' in _INDEX
# --- the shared builder is context-aware ----------------------------------
def test_shared_builder_has_video_entry_point():
assert 'function showVideoAutomationBuilder(' in _STATS
assert 'function showAutomationBuilder(' in _STATS
assert 'function _openAutomationBuilder(' in _STATS
def test_video_context_targets_video_ids_and_endpoint():
assert "'/api/video/automations/blocks'" in _STATS
assert "ownedBy: 'video'" in _STATS
# The video context must reference the vauto- prefixed element ids.
for tok in ('vauto-builder-name', 'vauto-builder-sidebar', 'vauto-builder-canvas',
'vauto-list-view', 'vauto-builder-view'):
assert tok in _STATS, tok
def test_card_cog_routes_by_active_side():
# Cards call editAutomation (not showAutomationBuilder directly) so a video
# card opens the video builder instead of the hidden music one.
assert 'editAutomation(${a.id})' in _STATS
assert 'function editAutomation(' in _STATS
assert "getAttribute('data-side') === 'video'" in _STATS
def test_save_tags_owned_by_from_context():
assert 'body.owned_by = _autoBuilderCtx.ownedBy' in _STATS
def test_open_clears_both_builders_to_avoid_id_collision():
# cfg-* ids exist in both builders; opening clears both canvases/sidebars.
assert "'vauto-builder-sidebar', 'vauto-builder-canvas'" in _STATS
def test_generic_config_renderer_is_video_gated():
# The generic config_fields renderer/reader must only run in the video
# context so the music side keeps its bespoke renderers (byte-identical).
assert 'function _renderGenericConfigField(' in _STATS
assert 'function _readGenericConfigField(' in _STATS
assert 'if (_autoBuilderCtx.ownedBy) {' in _STATS
# --- the video page exposes its reload hook -------------------------------
def test_video_page_exposes_reload_hook():
assert 'window._reloadVideoAutomations = load' in _VAUTO

View file

@ -1313,20 +1313,49 @@
(minus Refresh Beatport Cache + user/playlist ones). Reuses the
music .automation-* card look; built by video/video-automations.js. -->
<section class="video-subpage" data-video-subpage="video-automations" hidden>
<div class="page-shell automations-container">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/automation.png" class="page-header-icon" alt=""><span>Automations</span></h2>
<p class="header-subtitle">System tasks that keep your library fresh — shared with the music side</p>
<!-- List view (swaps with the builder view below, exactly like the
music page does inside #automations-page). -->
<div class="automations-list-view" id="vauto-list-view">
<div class="page-shell automations-container">
<div class="dashboard-header"><div class="dashboard-header-sweep" aria-hidden="true"><span></span></div>
<div class="header-text">
<h2 class="header-title"><img src="/static/automation.png" class="page-header-icon" alt=""><span>Automations</span></h2>
<p class="header-subtitle">Video tasks that keep your movie &amp; TV library fresh — separate from the music side</p>
</div>
<div class="header-spacer"></div>
<div class="header-actions">
<button class="auto-new-btn" onclick="showVideoAutomationBuilder()">+ New Automation</button>
</div>
</div>
<div class="automations-stats" data-vauto-stats></div>
<div class="automations-list" data-vauto-list></div>
<div class="automations-empty" data-vauto-empty style="display:none;">
<div class="automations-empty-icon">&#9889;</div>
<div class="automations-empty-title">No video automations yet</div>
<div class="automations-empty-text">The video side has its own automations (library scan, cleanup, wishlist…) — separate from the music ones. Create your first one.</div>
<button class="auto-new-btn" onclick="showVideoAutomationBuilder()">+ New Automation</button>
</div>
<div class="header-spacer"></div>
</div>
<div class="automations-stats" data-vauto-stats></div>
<div class="automations-list" data-vauto-list></div>
<div class="automations-empty" data-vauto-empty style="display:none;">
<div class="automations-empty-icon">&#9889;</div>
<div class="automations-empty-title">Video automations coming soon</div>
<div class="automations-empty-text">The video side gets its own automations (library scan, cleanup, wishlist…) — separate from the music ones.</div>
</div>
<!-- Builder view — mirrors the music builder markup with vauto- prefixed
ids so the SAME shared builder functions (showAutomationBuilder core,
saveAutomation, hideAutomationBuilder) drive it via a video context.
Its sidebar is fed by /api/video/automations/blocks (video scope). -->
<div class="automations-builder-view" id="vauto-builder-view" style="display:none;">
<div class="builder-header">
<button class="builder-back-btn" onclick="hideAutomationBuilder()" title="Back to list">&#8592;</button>
<input type="text" id="vauto-builder-name" class="builder-name-input" placeholder="Automation Name">
<input type="text" id="vauto-builder-group-name" class="builder-group-input" placeholder="Group (optional)" list="vauto-builder-group-list">
<datalist id="vauto-builder-group-list"></datalist>
<div class="builder-header-actions">
<button class="btn-cancel" onclick="hideAutomationBuilder()">Cancel</button>
<button class="btn-save" onclick="saveAutomation()">Save</button>
</div>
</div>
<div class="builder-content">
<div class="builder-sidebar" id="vauto-builder-sidebar"></div>
<div class="builder-canvas" id="vauto-builder-canvas"></div>
</div>
</div>
</section>

View file

@ -1703,6 +1703,48 @@ async function retryFailedMirroredDiscovery(urlHash) {
let _autoBlocks = null; // cached block definitions from /api/automations/blocks
let _autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
// Builder context — lets the SAME builder functions drive either the music
// automation page or the (isolated) video automation page. Default = music, so
// existing music behaviour is byte-identical. The video page swaps in its own
// element ids + the video-scoped blocks endpoint + owned_by tagging before
// opening, so a video automation is built with video triggers/actions and never
// leaks onto the music page. Only one builder is open at a time, so a single
// shared context is safe.
const _AUTO_CTX_MUSIC = {
ids: {
listView: 'automations-list-view', builderView: 'automations-builder-view',
name: 'builder-name', group: 'builder-group-name', groupList: 'builder-group-list',
sidebar: 'builder-sidebar', canvas: 'builder-canvas',
},
blocksUrl: '/api/automations/blocks',
ownedBy: null,
onSaved: function () { return loadAutomations(); },
};
const _AUTO_CTX_VIDEO = {
ids: {
listView: 'vauto-list-view', builderView: 'vauto-builder-view',
name: 'vauto-builder-name', group: 'vauto-builder-group-name', groupList: 'vauto-builder-group-list',
sidebar: 'vauto-builder-sidebar', canvas: 'vauto-builder-canvas',
},
blocksUrl: '/api/video/automations/blocks',
ownedBy: 'video',
// Reloads the video automations list after a save (video-automations.js
// exposes this; falls back to a no-op if the video page isn't loaded).
onSaved: function () { return window._reloadVideoAutomations ? window._reloadVideoAutomations() : null; },
};
let _autoBuilderCtx = _AUTO_CTX_MUSIC;
// Resolve a builder element through the active context (music vs video ids).
function _bEl(key) { return document.getElementById(_autoBuilderCtx.ids[key]); }
// Route a card's edit/cog to the right builder based on the active side, so a
// video card opens the VIDEO builder (on the video page) instead of hijacking
// the hidden music page's builder.
function editAutomation(id) {
if (document.body.getAttribute('data-side') === 'video') return showVideoAutomationBuilder(id);
return showAutomationBuilder(id);
}
let _autoMirroredPlaylists = null; // cached mirrored playlist list
let _autoSpotifyAuthenticated = false; // whether Spotify is authed (for refresh filtering)
@ -2911,7 +2953,7 @@ async function useHubRecipe(recipeId) {
const t = AUTO_HUB_RECIPES.find(r => r.id === recipeId);
if (!t) return;
await showAutomationBuilder();
document.getElementById('builder-name').value = t.name;
_bEl('name').value = t.name;
_autoBuilder.when = { type: t.when.type, config: JSON.parse(JSON.stringify(t.when.config)) };
_autoBuilder.do = { type: t.do.type, config: JSON.parse(JSON.stringify(t.do.config)) };
_autoBuilder.then = t.then.map(th => ({ type: th.type, config: JSON.parse(JSON.stringify(th.config)) }));
@ -3267,7 +3309,7 @@ function renderAutomationCard(a) {
<input type="checkbox" ${a.enabled ? 'checked' : ''} onchange="toggleAutomation(${a.id})">
<span class="toggle-slider"></span>
</label>
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); showAutomationBuilder(${a.id})">&#9881;</button>
<button class="automation-edit-btn" title="Edit" onclick="event.stopPropagation(); editAutomation(${a.id})">&#9881;</button>
${dupeBtn}
${groupBtn}
${deleteBtn}
@ -3677,7 +3719,7 @@ function _formatDuration(seconds) {
}
async function saveAutomation() {
const name = document.getElementById('builder-name').value.trim();
const name = _bEl('name').value.trim();
if (!name) { showToast('Name is required', 'error'); return; }
if (!_autoBuilder.when) { showToast('Add a trigger (WHEN)', 'error'); return; }
if (!_autoBuilder.do) { showToast('Add an action (DO)', 'error'); return; }
@ -3697,7 +3739,7 @@ async function saveAutomation() {
const delayVal = delayEl ? parseInt(delayEl.value) : 0;
if (delayVal > 0) actionConfig.delay = delayVal;
const groupInput = document.getElementById('builder-group-name');
const groupInput = _bEl('group');
const groupName = groupInput ? groupInput.value.trim() : '';
const body = {
@ -3707,6 +3749,9 @@ async function saveAutomation() {
then_actions: thenActions,
group_name: groupName || null,
};
// Tag the side that owns this automation (video) so it stays on the video
// page and off the music one. Music context leaves this null (unchanged).
if (_autoBuilderCtx.ownedBy) body.owned_by = _autoBuilderCtx.ownedBy;
try {
let res;
@ -3719,16 +3764,36 @@ async function saveAutomation() {
if (data.error) throw new Error(data.error);
showToast(_autoBuilder.editId ? 'Automation updated' : 'Automation created', 'success');
hideAutomationBuilder();
await loadAutomations();
if (_autoBuilderCtx.onSaved) await _autoBuilderCtx.onSaved();
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}
// --- Builder View ---
async function showAutomationBuilder(editId) {
// Load block definitions (always refresh)
// Music entry point — sets the music context, then opens the shared builder.
function showAutomationBuilder(editId) {
_autoBuilderCtx = _AUTO_CTX_MUSIC;
return _openAutomationBuilder(editId);
}
// Video entry point — sets the video context (video ids + video-scoped blocks +
// owned_by='video'), then opens the SAME shared builder on the video page.
function showVideoAutomationBuilder(editId) {
_autoBuilderCtx = _AUTO_CTX_VIDEO;
return _openAutomationBuilder(editId);
}
async function _openAutomationBuilder(editId) {
// Clear BOTH builders' sidebar/canvas first so stale cfg-* ids from a
// previously-open builder (music or video) can never be matched by
// getElementById while this one is open. Cheap and bulletproof.
['builder-sidebar', 'builder-canvas', 'vauto-builder-sidebar', 'vauto-builder-canvas'].forEach(function (id) {
const el = document.getElementById(id); if (el) el.innerHTML = '';
});
// Load block definitions for the active scope (always refresh).
try {
const res = await fetch('/api/automations/blocks');
const res = await fetch(_autoBuilderCtx.blocksUrl);
_autoBlocks = await res.json();
} catch (e) {
if (!_autoBlocks) { showToast('Failed to load blocks', 'error'); return; }
@ -3744,7 +3809,7 @@ async function showAutomationBuilder(editId) {
const allAutos = await allRes.json();
const groupSet = new Set();
if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); });
const datalist = document.getElementById('builder-group-list');
const datalist = _bEl('groupList');
if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `<option value="${_escAttr(g)}">`).join('');
} catch (e) { }
@ -3754,8 +3819,8 @@ async function showAutomationBuilder(editId) {
const res = await fetch('/api/automations/' + editId);
const a = await res.json();
if (a.error) throw new Error(a.error);
document.getElementById('builder-name').value = a.name || '';
const groupInput = document.getElementById('builder-group-name');
_bEl('name').value = a.name || '';
const groupInput = _bEl('group');
if (groupInput) groupInput.value = a.group_name || '';
_autoBuilder.when = { type: a.trigger_type, config: a.trigger_config || {} };
_autoBuilder.do = { type: a.action_type, config: a.action_config || {} };
@ -3770,34 +3835,34 @@ async function showAutomationBuilder(editId) {
_autoBuilder.isSystem = !!a.is_system;
} catch (err) { showToast('Failed to load automation', 'error'); return; }
} else {
document.getElementById('builder-name').value = '';
const groupInput = document.getElementById('builder-group-name');
_bEl('name').value = '';
const groupInput = _bEl('group');
if (groupInput) groupInput.value = '';
}
// System automations: lock the name field and hide group
document.getElementById('builder-name').readOnly = _autoBuilder.isSystem;
const groupEl = document.getElementById('builder-group-name');
_bEl('name').readOnly = _autoBuilder.isSystem;
const groupEl = _bEl('group');
if (groupEl) groupEl.style.display = _autoBuilder.isSystem ? 'none' : '';
_renderBuilderSidebar();
_renderBuilderCanvas();
document.getElementById('automations-list-view').style.display = 'none';
document.getElementById('automations-builder-view').style.display = '';
_bEl('listView').style.display = 'none';
_bEl('builderView').style.display = '';
}
function hideAutomationBuilder() {
document.getElementById('automations-builder-view').style.display = 'none';
document.getElementById('automations-list-view').style.display = '';
document.getElementById('builder-name').readOnly = false;
_bEl('builderView').style.display = 'none';
_bEl('listView').style.display = '';
const nameEl = _bEl('name'); if (nameEl) nameEl.readOnly = false;
_autoBuilder = { editId: null, when: null, do: null, then: [], isSystem: false };
}
// --- Sidebar ---
function _renderBuilderSidebar() {
const sidebar = document.getElementById('builder-sidebar');
const sidebar = _bEl('sidebar');
if (!sidebar || !_autoBlocks) return;
let html = '';
@ -3832,7 +3897,7 @@ function _renderBuilderSidebar() {
// --- Canvas ---
function _renderBuilderCanvas() {
const canvas = document.getElementById('builder-canvas');
const canvas = _bEl('canvas');
if (!canvas) return;
let html = '';
@ -4218,9 +4283,48 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</div>
${_notifyVarHtml(slotKey)}`;
}
// Generic config_fields renderer — drives the VIDEO builder only (gated on
// the video context) so the music side keeps its bespoke renderers above and
// is byte-identical. Any video block that declares config_fields in
// core/automation/blocks.py (select / text / number / checkbox) renders here.
if (_autoBuilderCtx.ownedBy) {
const def = _findBlockDef(blockType);
if (def && Array.isArray(def.config_fields) && def.config_fields.length) {
return def.config_fields.map(f => _renderGenericConfigField(slotKey, f, config)).join('');
}
}
return '';
}
// One generic config row from a block's config_fields definition.
function _renderGenericConfigField(slotKey, f, config) {
const id = 'cfg-' + slotKey + '-' + f.key;
const cur = (config && config[f.key] !== undefined && config[f.key] !== null) ? config[f.key] : f.default;
const label = _esc(f.label || f.key);
if (f.type === 'select') {
const opts = (f.options || []).map(o =>
`<option value="${_escAttr(o.value)}"${String(cur) === String(o.value) ? ' selected' : ''}>${_esc(o.label)}</option>`).join('');
return `<div class="config-row"><label>${label}</label><select id="${id}">${opts}</select></div>`;
}
if (f.type === 'checkbox') {
return `<div class="config-row"><label><input type="checkbox" id="${id}"${cur ? ' checked' : ''}> ${label}</label></div>`;
}
if (f.type === 'number') {
const min = (f.min !== undefined) ? ` min="${f.min}"` : '';
return `<div class="config-row"><label>${label}</label><input type="number" id="${id}" value="${_escAttr(cur != null ? cur : '')}"${min} style="width:90px;"></div>`;
}
return `<div class="config-row"><label>${label}</label><input type="text" id="${id}" value="${_escAttr(cur != null ? cur : '')}" placeholder="${_escAttr(f.placeholder || '')}"></div>`;
}
// Read one generic config field back from the DOM (mirror of the renderer).
function _readGenericConfigField(slotKey, f) {
const el = document.getElementById('cfg-' + slotKey + '-' + f.key);
if (!el) return (f.default !== undefined) ? f.default : '';
if (f.type === 'checkbox') return el.checked;
if (f.type === 'number') { const n = parseFloat(el.value); return isNaN(n) ? (f.default != null ? f.default : 0) : n; }
return el.value;
}
// --- Condition Builder ---
function _renderConditionBuilder(slotKey, blockDef, config) {
@ -4582,6 +4686,16 @@ function _readPlacedConfig(slotKey) {
message: document.getElementById('cfg-' + slotKey + '-message')?.value || '',
};
}
// Generic config_fields read — video builder only (mirror of the generic
// renderer above); music keeps its bespoke readers, untouched.
if (_autoBuilderCtx.ownedBy) {
const def = _findBlockDef(type);
if (def && Array.isArray(def.config_fields) && def.config_fields.length) {
const out = {};
def.config_fields.forEach(f => { out[f.key] = _readGenericConfigField(slotKey, f); });
return out;
}
}
return {};
}

View file

@ -69,7 +69,7 @@
}
function load() {
getJSON('/api/automations').then(function (d) {
return getJSON('/api/automations').then(function (d) {
var all = Array.isArray(d) ? d : (d && d.automations) || [];
var sys = all.filter(isVideoAutomation);
renderStats(sys);
@ -78,12 +78,22 @@
});
}
// Exposed so the shared automation builder (stats-automations.js) can refresh
// THIS list after a video automation is created/edited/saved. Lives on window
// because the builder is global and this module is an IIFE.
window._reloadVideoAutomations = load;
function onPage() {
return document.body.getAttribute('data-side') === 'video' &&
!!document.querySelector('[data-video-subpage="video-automations"]:not([hidden])');
}
function start() {
// Always enter on the list view — if the builder was left open from a
// previous visit, swap back so the page doesn't greet you mid-build.
var bv = document.getElementById('vauto-builder-view');
var lv = document.getElementById('vauto-list-view');
if (bv && lv && bv.style.display !== 'none') { bv.style.display = 'none'; lv.style.display = ''; }
load();
// Refresh the System section shortly after a run/toggle (the reused music card
// handlers fire on the music list, not ours) — keeps our cards in sync.