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:
parent
cc44254bf9
commit
e1f0810df5
4 changed files with 153 additions and 8 deletions
|
|
@ -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}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
}
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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() || '' };
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue