diff --git a/core/personalized/api.py b/core/personalized/api.py
index 1fc6fef8..bfa9de60 100644
--- a/core/personalized/api.py
+++ b/core/personalized/api.py
@@ -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}
diff --git a/tests/automation/test_automation_blocks.py b/tests/automation/test_automation_blocks.py
index 64d95cfa..151725bb 100644
--- a/tests/automation/test_automation_blocks.py
+++ b/tests/automation/test_automation_blocks.py
@@ -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',
}
diff --git a/web_server.py b/web_server.py
index 86269fa0..25bec6c7 100644
--- a/web_server.py
+++ b/web_server.py
@@ -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
diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js
index 7df4ab4b..922d6607 100644
--- a/webui/static/stats-automations.js
+++ b/webui/static/stats-automations.js
@@ -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) {
Runs 4 phases: Refresh → Discover → Sync → Download Missing
`;
}
+ 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 `
+
+
+
+
Loading personalized playlists…
+
+
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.
+
+
+
+
+
+
+
+ Runs 2 phases: Snapshot → Sync → Download Missing
`;
+ }
// 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 = 'No personalized playlists registered. Restart the server if you just upgraded.
';
+ 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 += ``;
+ } else {
+ const variants = Array.isArray(spec.variants) ? spec.variants : [];
+ html += `
+
${_escAttr(baseLabel)}
`;
+ if (variants.length === 0) {
+ html += `
(no variants available — populate via the discover page first)
`;
+ } else {
+ variants.forEach(variant => {
+ const key = selectedKey(spec.kind, variant);
+ const checked = selectedSet.has(key) ? ' checked' : '';
+ html += `
`;
+ });
+ }
+ html += '
';
+ }
+ });
+ 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() || '' };
}