From 1fa3e1b599cb72386e43ce52727af5cf2ed96b00 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 19 Jun 2026 23:02:52 -0700 Subject: [PATCH] video automations: working builder + New Automation button (own builder, not music's) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- tests/test_video_automations_builder.py | 101 +++++++++++++++ webui/index.html | 53 ++++++-- webui/static/stats-automations.js | 158 ++++++++++++++++++++---- webui/static/video/video-automations.js | 12 +- 4 files changed, 289 insertions(+), 35 deletions(-) create mode 100644 tests/test_video_automations_builder.py diff --git a/tests/test_video_automations_builder.py b/tests/test_video_automations_builder.py new file mode 100644 index 00000000..1e2e874f --- /dev/null +++ b/tests/test_video_automations_builder.py @@ -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 diff --git a/webui/index.html b/webui/index.html index 50e2e1a0..dafca2e7 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1313,20 +1313,49 @@ (minus Refresh Beatport Cache + user/playlist ones). Reuses the music .automation-* card look; built by video/video-automations.js. --> diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index d829a632..472d5d8b 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -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) { - + ${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 => ``).join(''); + return `
`; + } + if (f.type === 'checkbox') { + return `
`; + } + if (f.type === 'number') { + const min = (f.min !== undefined) ? ` min="${f.min}"` : ''; + return `
`; + } + return `
`; +} + +// 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 {}; } diff --git a/webui/static/video/video-automations.js b/webui/static/video/video-automations.js index 8fa1b038..299e7d0f 100644 --- a/webui/static/video/video-automations.js +++ b/webui/static/video/video-automations.js @@ -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.