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.
UI uses this to render the "available playlists" picker. Each
kind reports whether it requires a variant and the resolved
variant set so the UI can render variant choices when relevant."""
kind reports whether it requires a variant; when a manager is
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()
out = []
for spec in reg.all():
out.append({
entry = {
'kind': spec.kind,
'name_template': spec.name_template,
'description': spec.description,
'requires_variant': spec.requires_variant,
'tags': list(spec.tags),
'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}

View file

@ -34,7 +34,8 @@ def _shape_check(items, allowed_types):
_FIELD_TYPES = {
'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'])
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:
return jsonify(_personalized_api.list_kinds())
manager = _build_personalized_manager()
return jsonify(_personalized_api.list_kinds(manager=manager))
except Exception as e:
logger.error(f"Personalized kinds list error: {e}")
return jsonify({"success": False, "error": str(e)}), 500

View file

@ -5754,6 +5754,18 @@ function _renderBuilderCanvas() {
canvas.innerHTML = html;
// Load mirrored playlist selects if any are present
_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
['when', 'do'].forEach(sk => {
const allCb = document.getElementById('cfg-' + sk + '-all');
@ -5961,6 +5973,29 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
</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
function _notifyVarHtml(slotKey) {
let allVars = ['time', 'name', 'run_count', 'status'];
@ -6156,6 +6191,80 @@ function _autoTogglePlaylistSelect(slotKey) {
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() {
const selects = document.querySelectorAll('.mirrored-playlist-select');
const nameSelects = document.querySelectorAll('.mirrored-playlist-name-select');
@ -6265,6 +6374,26 @@ function _readPlacedConfig(slotKey) {
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') {
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
}