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() || '' }; }