Personalized pipeline: UI multi-select picker for kinds + variants

The action was registered + the block declared, but the automation
builder's per-action config renderer didn't have a case for
`personalized_pipeline` so users only saw the bare card with the
generic delay-minutes input — no way to select which playlists to
sync. This commit adds the multi-select picker.

Backend:
- `core/personalized/api.list_kinds(manager=...)` now optionally
  takes a manager and includes the resolved variant list per kind
  (calls each spec's variant_resolver(deps) when present). Singleton
  kinds get an empty `variants` list. Variant-bearing kinds
  (time_machine / genre_playlist / daily_mix / seasonal_mix) get
  their full enumerated set.
- `web_server.py` `/api/personalized/kinds` route now passes a built
  manager so the variants list lands in the response.

Frontend:
- `webui/static/stats-automations.js` `_renderBlockConfigFields`
  gains a `personalized_pipeline` branch that renders a scrollable
  multi-select picker:
  - Singletons (Hidden Gems, Discovery Shuffle, Popular Picks,
    Fresh Tape, The Archives) = one checkbox row per kind
  - Variant kinds = a section header + one checkbox row per variant
    (e.g. Time Machine: 1960s/1970s/.../2020s; Seasonal: halloween/
    christmas/valentines/summer/spring/autumn)
  - Pre-checks rows that match the existing `kinds` config on edit
- New `_autoLoadPersonalizedKinds(slotKey)` fetches `/api/personalized/kinds`
  (cached after first load), renders the picker DOM, and pre-checks
  saved selections via `data-kind` / `data-variant` attributes on
  the checkboxes.
- `_renderBuilderCanvas` calls the loader for any `cfg-*-kinds-picker`
  it finds in the freshly-rendered slots.
- The save-time `_collectActionConfig` walks the picker's checked
  inputs (matched by `data-kind` attribute) and emits
  `{kinds: [{kind, variant?}, ...], refresh_first, skip_wishlist}`
  in the same shape the handler expects.

Tests:
- `tests/automation/test_automation_blocks.py::_FIELD_TYPES` adds
  'personalized_playlist_select' so the block-shape regression test
  accepts the new field type. (Test was failing because it whitelists
  every field type used across all blocks.)
- 189 automation + personalized API tests pass; full suite intact.
This commit is contained in:
Broque Thomas 2026-05-15 19:33:34 -07:00
parent cc44254bf9
commit e1f0810df5
4 changed files with 153 additions and 8 deletions

View file

@ -62,23 +62,35 @@ def _track_to_dict(track: Track) -> Dict[str, Any]:
} }
def list_kinds(registry: Optional[PlaylistKindRegistry] = None) -> Dict[str, Any]: def list_kinds(
registry: Optional[PlaylistKindRegistry] = None,
manager: Optional[PersonalizedPlaylistManager] = None,
) -> Dict[str, Any]:
"""Return every registered playlist kind with metadata. """Return every registered playlist kind with metadata.
UI uses this to render the "available playlists" picker. Each UI uses this to render the "available playlists" picker. Each
kind reports whether it requires a variant and the resolved kind reports whether it requires a variant; when a manager is
variant set so the UI can render variant choices when relevant.""" supplied AND the kind has a variant_resolver, the resolved
variant list is also included so the UI can render variant
checkboxes without a second round-trip per kind."""
reg = registry or get_registry() reg = registry or get_registry()
out = [] out = []
for spec in reg.all(): for spec in reg.all():
out.append({ entry = {
'kind': spec.kind, 'kind': spec.kind,
'name_template': spec.name_template, 'name_template': spec.name_template,
'description': spec.description, 'description': spec.description,
'requires_variant': spec.requires_variant, 'requires_variant': spec.requires_variant,
'tags': list(spec.tags), 'tags': list(spec.tags),
'default_config': spec.default_config.to_json_dict(), 'default_config': spec.default_config.to_json_dict(),
}) 'variants': [],
}
if manager is not None and spec.variant_resolver is not None:
try:
entry['variants'] = list(spec.variant_resolver(manager.deps) or [])
except Exception:
entry['variants'] = []
out.append(entry)
return {'success': True, 'kinds': out} return {'success': True, 'kinds': out}

View file

@ -34,7 +34,8 @@ def _shape_check(items, allowed_types):
_FIELD_TYPES = { _FIELD_TYPES = {
'number', 'select', 'time', 'multi_select', 'checkbox', 'text', 'number', 'select', 'time', 'multi_select', 'checkbox', 'text',
'mirrored_playlist_select', 'signal_input', 'script_select', 'mirrored_playlist_select', 'personalized_playlist_select',
'signal_input', 'script_select',
} }

View file

@ -26856,9 +26856,12 @@ def _build_personalized_manager():
@app.route('/api/personalized/kinds', methods=['GET']) @app.route('/api/personalized/kinds', methods=['GET'])
def personalized_list_kinds(): def personalized_list_kinds():
"""List every registered personalized-playlist kind.""" """List every registered personalized-playlist kind. Includes the
resolved variant list per kind that supports variants so the UI
can render kind+variant checkboxes without per-kind round-trips."""
try: try:
return jsonify(_personalized_api.list_kinds()) manager = _build_personalized_manager()
return jsonify(_personalized_api.list_kinds(manager=manager))
except Exception as e: except Exception as e:
logger.error(f"Personalized kinds list error: {e}") logger.error(f"Personalized kinds list error: {e}")
return jsonify({"success": False, "error": str(e)}), 500 return jsonify({"success": False, "error": str(e)}), 500

View file

@ -5754,6 +5754,18 @@ function _renderBuilderCanvas() {
canvas.innerHTML = html; canvas.innerHTML = html;
// Load mirrored playlist selects if any are present // Load mirrored playlist selects if any are present
_autoLoadMirroredSelects(); _autoLoadMirroredSelects();
// Personalized-pipeline kind pickers (one per slot that uses it)
['when', 'do'].forEach(sk => {
if (document.getElementById('cfg-' + sk + '-kinds-picker')) {
_autoLoadPersonalizedKinds(sk);
}
});
_autoBuilder.then.forEach((item, i) => {
const sk = 'then-' + i;
if (document.getElementById('cfg-' + sk + '-kinds-picker')) {
_autoLoadPersonalizedKinds(sk);
}
});
// Set up checkbox state for refresh_mirrored // Set up checkbox state for refresh_mirrored
['when', 'do'].forEach(sk => { ['when', 'do'].forEach(sk => {
const allCb = document.getElementById('cfg-' + sk + '-all'); const allCb = document.getElementById('cfg-' + sk + '-all');
@ -5961,6 +5973,29 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</div> </div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Runs 4 phases: Refresh Discover Sync Download Missing</div>`; <div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Runs 4 phases: Refresh Discover Sync Download Missing</div>`;
} }
if (blockType === 'personalized_pipeline') {
const refreshFirstChecked = config.refresh_first ? ' checked' : '';
const skipWishlistChecked = config.skip_wishlist ? ' checked' : '';
// Stash existing selections on a hidden input as JSON so the
// async populator can mark them checked once kinds load. The
// visible picker container starts as a loading message.
const initialKinds = JSON.stringify(Array.isArray(config.kinds) ? config.kinds : []);
return `<div class="config-row">
<label>Personalized playlists to sync</label>
<input type="hidden" id="cfg-${slotKey}-kinds" data-initial='${_escAttr(initialKinds)}' value='${_escAttr(initialKinds)}'>
<div id="cfg-${slotKey}-kinds-picker" class="personalized-kinds-picker" style="max-height:280px;overflow-y:auto;border:1px solid rgba(255,255,255,0.08);border-radius:6px;padding:8px;background:rgba(0,0,0,0.2);">
<div style="color:rgba(255,255,255,0.4);font-size:12px;">Loading personalized playlists</div>
</div>
<div style="color:rgba(255,255,255,0.35);font-size:11px;margin-top:4px;">Pick which Discover-page playlists this automation will sync. Variant kinds (Time Machine, Genre, Daily Mix, Seasonal Mix) expose individual instances below their kind row.</div>
</div>
<div class="config-row">
<label><input type="checkbox" id="cfg-${slotKey}-refresh_first"${refreshFirstChecked}> Refresh playlists before sync (regenerate snapshots)</label>
</div>
<div class="config-row">
<label><input type="checkbox" id="cfg-${slotKey}-skip_wishlist"${skipWishlistChecked}> Skip wishlist processing</label>
</div>
<div class="config-row" style="color:rgba(255,255,255,0.35);font-size:11px;">Runs 2 phases: Snapshot Sync Download Missing</div>`;
}
// Shared variable tags builder for notification types // Shared variable tags builder for notification types
function _notifyVarHtml(slotKey) { function _notifyVarHtml(slotKey) {
let allVars = ['time', 'name', 'run_count', 'status']; let allVars = ['time', 'name', 'run_count', 'status'];
@ -6156,6 +6191,80 @@ function _autoTogglePlaylistSelect(slotKey) {
if (sel) sel.disabled = allCb && allCb.checked; if (sel) sel.disabled = allCb && allCb.checked;
} }
// --- Personalized Playlist Picker (multi-select kind+variant) ---
// Cached so the second action card renders instantly without re-fetching.
let _autoPersonalizedKinds = null;
async function _autoLoadPersonalizedKinds(slotKey) {
const picker = document.getElementById('cfg-' + slotKey + '-kinds-picker');
const hidden = document.getElementById('cfg-' + slotKey + '-kinds');
if (!picker || !hidden) return;
// Read existing selection so we can pre-check matching boxes after render.
let selected = [];
try {
const initial = hidden.dataset.initial || hidden.value || '[]';
selected = JSON.parse(initial);
if (!Array.isArray(selected)) selected = [];
} catch (_) { selected = []; }
const selectedKey = (kind, variant) => `${kind}::${variant || ''}`;
const selectedSet = new Set(selected.map(s => selectedKey(s.kind, s.variant)));
// Fetch + cache the kinds catalog.
if (!_autoPersonalizedKinds) {
try {
const res = await fetch('/api/personalized/kinds');
const data = await res.json();
_autoPersonalizedKinds = (data && data.kinds) || [];
} catch (e) {
_autoPersonalizedKinds = [];
}
}
if (!_autoPersonalizedKinds.length) {
picker.innerHTML = '<div style="color:rgba(255,255,255,0.4);font-size:12px;">No personalized playlists registered. Restart the server if you just upgraded.</div>';
return;
}
// Render: one row per (kind, variant). Singletons get one row;
// variant kinds get one row per resolved variant + an "all" toggle.
let html = '';
_autoPersonalizedKinds.forEach(spec => {
const baseLabel = spec.name_template
? spec.name_template.replace('{variant}', '').replace(/\s*[—-]\s*$/,'').trim()
: spec.kind;
if (!spec.requires_variant) {
// Singleton: one checkbox.
const key = selectedKey(spec.kind, '');
const checked = selectedSet.has(key) ? ' checked' : '';
html += `<label style="display:flex;align-items:center;gap:8px;padding:4px 0;cursor:pointer;">
<input type="checkbox" data-kind="${_escAttr(spec.kind)}"${checked}>
<span style="font-weight:600;">${_escAttr(baseLabel)}</span>
</label>`;
} else {
const variants = Array.isArray(spec.variants) ? spec.variants : [];
html += `<div style="margin:6px 0 4px;border-top:1px solid rgba(255,255,255,0.05);padding-top:6px;">
<div style="font-weight:600;margin-bottom:2px;">${_escAttr(baseLabel)}</div>`;
if (variants.length === 0) {
html += `<div style="font-size:11px;color:rgba(255,255,255,0.35);padding-left:18px;">(no variants available — populate via the discover page first)</div>`;
} else {
variants.forEach(variant => {
const key = selectedKey(spec.kind, variant);
const checked = selectedSet.has(key) ? ' checked' : '';
html += `<label style="display:flex;align-items:center;gap:8px;padding:2px 0 2px 18px;cursor:pointer;font-size:13px;">
<input type="checkbox" data-kind="${_escAttr(spec.kind)}" data-variant="${_escAttr(variant)}"${checked}>
<span>${_escAttr(variant)}</span>
</label>`;
});
}
html += '</div>';
}
});
picker.innerHTML = html;
}
async function _autoLoadMirroredSelects() { async function _autoLoadMirroredSelects() {
const selects = document.querySelectorAll('.mirrored-playlist-select'); const selects = document.querySelectorAll('.mirrored-playlist-select');
const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select'); const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select');
@ -6265,6 +6374,26 @@ function _readPlacedConfig(slotKey) {
skip_wishlist: skipWl ? skipWl.checked : false, skip_wishlist: skipWl ? skipWl.checked : false,
}; };
} }
if (type === 'personalized_pipeline') {
// Each checked box has data-kind / data-variant attributes;
// walk them to assemble the kinds list.
const picker = document.getElementById('cfg-' + slotKey + '-kinds-picker');
const kinds = [];
if (picker) {
picker.querySelectorAll('input[type="checkbox"][data-kind]:checked').forEach(cb => {
const entry = { kind: cb.dataset.kind };
if (cb.dataset.variant) entry.variant = cb.dataset.variant;
kinds.push(entry);
});
}
const refreshFirstCb = document.getElementById('cfg-' + slotKey + '-refresh_first');
const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist');
return {
kinds,
refresh_first: refreshFirstCb ? refreshFirstCb.checked : false,
skip_wishlist: skipWl ? skipWl.checked : false,
};
}
if (type === 'signal_received' || type === 'fire_signal') { if (type === 'signal_received' || type === 'fire_signal') {
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' }; return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
} }