Add Playlist Pipeline automation + wishlist badges + Discogs enrichment modal + hub cleanup

Playlist Pipeline — single automation that runs the full playlist lifecycle:
refresh → discover → sync → download missing. Replaces 4-automation signal
chains. Phase-aware progress display (Phase 1/4, 2/4, etc.), guard function
prevents concurrent runs, fire-and-forget wishlist at end. Re-sync loop
catches newly downloaded tracks on next scheduled run.

- New action type 'playlist_pipeline' with handler, blocks endpoint config,
  builder UI (playlist select, process all, skip wishlist checkboxes),
  help modal, result display map, and Hub template
- Removed 3 redundant Hub templates (Release Radar, Discovery Weekly,
  Playlist Auto-Sync) — all replaced by the pipeline
- Fixed sync completion polling (status is 'finished' not 'complete')
- Fixed refresh handler progress hijack (null out _automation_id)
- Fixed matched_tracks field access from sync_states result

Also in this commit:
- Wishlist badges on enhanced search and global search tracks (amber)
- Discogs added to manual enrichment modal search + artist/album dropdowns
- Profile PIN forgot recovery on profile selection dialog
This commit is contained in:
Broque Thomas 2026-04-05 11:34:50 -07:00
parent c3a3510c75
commit 9f7fe27e7c
3 changed files with 399 additions and 45 deletions

View file

@ -984,9 +984,223 @@ def _register_automation_handlers():
return {'status': 'started', 'playlist_count': str(len(playlists)), 'playlists': names,
'_manages_own_progress': True}
# --- Playlist Pipeline: single automation for full lifecycle ---
_pipeline_running = False
def _pipeline_guard():
return _pipeline_running
def _auto_playlist_pipeline(config):
"""Full playlist lifecycle: refresh → discover → sync → wishlist.
Runs all 4 phases sequentially in one automation, reporting progress throughout."""
nonlocal _pipeline_running
_pipeline_running = True
automation_id = config.get('_automation_id')
pipeline_start = time.time()
try:
db = get_database()
playlist_id = config.get('playlist_id')
process_all = config.get('all', False)
skip_wishlist = config.get('skip_wishlist', False)
# Resolve playlists
if process_all:
playlists = db.get_mirrored_playlists()
elif playlist_id:
p = db.get_mirrored_playlist(int(playlist_id))
playlists = [p] if p else []
else:
_pipeline_running = False
return {'status': 'error', 'error': 'No playlist specified'}
playlists = [pl for pl in playlists if pl.get('source', '') not in ('file', 'beatport')]
if not playlists:
_pipeline_running = False
return {'status': 'error', 'error': 'No refreshable playlists found'}
pl_names = ', '.join(p.get('name', '?') for p in playlists[:3])
if len(playlists) > 3:
pl_names += f' (+{len(playlists) - 3} more)'
_update_automation_progress(automation_id, progress=2,
phase=f'Pipeline: {len(playlists)} playlist(s)',
log_line=f'Starting pipeline for: {pl_names}', log_type='info')
# ── PHASE 1: REFRESH ──────────────────────────────────────────
_update_automation_progress(automation_id, progress=3,
phase='Phase 1/4: Refreshing playlists...',
log_line='Phase 1: Refresh', log_type='info')
refresh_config = dict(config)
refresh_config['_automation_id'] = None # Don't let sub-handler hijack pipeline progress
refresh_result = _auto_refresh_mirrored(refresh_config)
refreshed = int(refresh_result.get('refreshed', 0))
refresh_errors = int(refresh_result.get('errors', 0))
_update_automation_progress(automation_id, progress=25,
phase='Phase 1/4: Refresh complete',
log_line=f'Phase 1 done: {refreshed} refreshed, {refresh_errors} errors',
log_type='success' if refresh_errors == 0 else 'warning')
# ── PHASE 2: DISCOVER ─────────────────────────────────────────
_update_automation_progress(automation_id, progress=26,
phase='Phase 2/4: Discovering metadata...',
log_line='Phase 2: Discover', log_type='info')
# Reload playlists (refresh may have updated them)
if process_all:
disc_playlists = db.get_mirrored_playlists()
else:
disc_playlists = [db.get_mirrored_playlist(int(playlist_id))]
disc_playlists = [p for p in disc_playlists if p]
# Run discovery in a thread and wait for it
disc_done = threading.Event()
disc_result = {'discovered': 0, 'failed': 0, 'skipped': 0, 'total': 0}
def _disc_wrapper(pls):
try:
# The worker updates automation_progress internally,
# but we pass None so it doesn't conflict with our pipeline progress
_run_playlist_discovery_worker(pls, automation_id=None)
except Exception as e:
print(f"[Pipeline] Discovery error: {e}")
finally:
disc_done.set()
threading.Thread(target=_disc_wrapper, args=(disc_playlists,), daemon=True,
name='pipeline-discover').start()
# Poll for completion with progress updates
poll_start = time.time()
while not disc_done.wait(timeout=3):
elapsed = int(time.time() - poll_start)
_update_automation_progress(automation_id, progress=min(26 + elapsed // 4, 54),
phase=f'Phase 2/4: Discovering... ({elapsed}s)')
if elapsed > 3600: # 1hr safety timeout
_update_automation_progress(automation_id,
log_line='Discovery timed out after 1 hour', log_type='warning')
break
_update_automation_progress(automation_id, progress=55,
phase='Phase 2/4: Discovery complete',
log_line='Phase 2 done: discovery complete', log_type='success')
# ── PHASE 3: SYNC ─────────────────────────────────────────────
_update_automation_progress(automation_id, progress=56,
phase='Phase 3/4: Syncing to server...',
log_line='Phase 3: Sync', log_type='info')
total_synced = 0
total_skipped = 0
sync_errors = 0
for pl_idx, pl in enumerate(playlists):
pl_id = pl.get('id')
if not pl_id:
continue
# Build sync config for this playlist (reuse existing sync handler)
sync_config = {
'playlist_id': str(pl_id),
'_automation_id': None, # Don't let sync handler hijack our progress
}
sync_result = _auto_sync_playlist(sync_config)
sync_status = sync_result.get('status', '')
if sync_status == 'started':
# Sync launched a background thread — wait for it
sync_id = f"auto_mirror_{pl_id}"
sync_poll_start = time.time()
while time.time() - sync_poll_start < 600: # 10 min per playlist max
if sync_id in sync_states and sync_states[sync_id].get('status') in ('finished', 'complete', 'error', 'failed'):
break
time.sleep(2)
elapsed = int(time.time() - sync_poll_start)
sub_progress = 56 + ((pl_idx + 1) / max(1, len(playlists))) * 29
_update_automation_progress(automation_id, progress=min(int(sub_progress), 84),
phase=f'Phase 3/4: Syncing "{pl.get("name", "")}" ({elapsed}s)')
# Check result
ss = sync_states.get(sync_id, {})
ss_result = ss.get('result', ss.get('progress', {}))
matched = ss_result.get('matched_tracks', 0) if isinstance(ss_result, dict) else 0
total_synced += int(matched) if matched else 0
_update_automation_progress(automation_id,
log_line=f'Synced "{pl.get("name", "")}": {matched} tracks matched',
log_type='success')
elif sync_status == 'skipped':
total_skipped += 1
reason = sync_result.get('reason', 'unchanged')
_update_automation_progress(automation_id,
log_line=f'Skipped "{pl.get("name", "")}": {reason}',
log_type='skip')
elif sync_status == 'error':
sync_errors += 1
_update_automation_progress(automation_id,
log_line=f'Sync error "{pl.get("name", "")}": {sync_result.get("reason", "unknown")}',
log_type='error')
_update_automation_progress(automation_id, progress=85,
phase='Phase 3/4: Sync complete',
log_line=f'Phase 3 done: {total_synced} matched, {total_skipped} skipped, {sync_errors} errors',
log_type='success' if sync_errors == 0 else 'warning')
# ── PHASE 4: WISHLIST ─────────────────────────────────────────
wishlist_queued = 0
if not skip_wishlist:
_update_automation_progress(automation_id, progress=86,
phase='Phase 4/4: Processing wishlist...',
log_line='Phase 4: Wishlist', log_type='info')
try:
if not is_wishlist_actually_processing() and not is_watchlist_actually_scanning():
_process_wishlist_automatically(automation_id=None)
_update_automation_progress(automation_id,
log_line='Wishlist processing triggered', log_type='success')
wishlist_queued = 1
else:
_update_automation_progress(automation_id,
log_line='Wishlist/watchlist already running — skipped', log_type='skip')
except Exception as e:
_update_automation_progress(automation_id,
log_line=f'Wishlist error: {e}', log_type='warning')
else:
_update_automation_progress(automation_id, progress=86,
log_line='Phase 4: Wishlist skipped (disabled)', log_type='skip')
# ── COMPLETE ──────────────────────────────────────────────────
duration = int(time.time() - pipeline_start)
_update_automation_progress(automation_id, status='finished', progress=100,
phase='Pipeline complete',
log_line=f'Pipeline finished in {duration // 60}m {duration % 60}s',
log_type='success')
_pipeline_running = False
return {
'status': 'completed',
'_manages_own_progress': True,
'playlists_refreshed': str(refreshed),
'tracks_discovered': 'completed',
'tracks_synced': str(total_synced),
'sync_skipped': str(total_skipped),
'wishlist_queued': str(wishlist_queued),
'duration_seconds': str(duration),
}
except Exception as e:
_pipeline_running = False
_update_automation_progress(automation_id, status='error', progress=100,
phase='Pipeline error',
log_line=f'Pipeline failed: {e}', log_type='error')
return {'status': 'error', 'error': str(e), '_manages_own_progress': True}
automation_engine.register_action_handler('refresh_mirrored', _auto_refresh_mirrored)
automation_engine.register_action_handler('sync_playlist', _auto_sync_playlist)
automation_engine.register_action_handler('discover_playlist', _auto_discover_playlist)
automation_engine.register_action_handler('playlist_pipeline', _auto_playlist_pipeline, _pipeline_guard)
# --- Phase 3 action handlers ---
@ -5633,6 +5847,14 @@ def get_automation_blocks():
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
{"key": "all", "type": "checkbox", "label": "Discover all mirrored playlists", "default": False}
]},
{"type": "playlist_pipeline", "label": "Playlist Pipeline", "icon": "rocket",
"description": "Full lifecycle: refresh → discover → sync → download missing. One automation for the entire flow.",
"available": True,
"config_fields": [
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
{"key": "all", "type": "checkbox", "label": "Process all mirrored playlists", "default": False},
{"key": "skip_wishlist", "type": "checkbox", "label": "Skip wishlist processing", "default": False},
]},
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
# Phase 3 actions
{"type": "start_database_update", "label": "Update Database", "icon": "database",
@ -7671,6 +7893,44 @@ def enhanced_search_library_check():
if r[0] not in owned_tracks: # Keep first match only
owned_tracks[r[0]] = {'track_id': r[1], 'file_path': r[2], 'title': r[3], 'artist_name': r[4], 'album_title': r[5], 'album_thumb_url': r[6]}
# Build wishlist lookup set — track name|||artist
wishlist_keys = set()
try:
profile_id = get_current_profile_id()
cursor.execute("SELECT spotify_data FROM wishlist_tracks WHERE profile_id = ?", (profile_id,))
for wr in cursor.fetchall():
try:
wd = json.loads(wr[0]) if isinstance(wr[0], str) else {}
wname = (wd.get('name') or '').lower()
wartists = wd.get('artists', [])
if wartists:
wa = wartists[0].get('name', '') if isinstance(wartists[0], dict) else str(wartists[0])
else:
wa = ''
if wname:
wishlist_keys.add(wname + '|||' + wa.lower().strip())
except Exception:
pass
except Exception:
# profile_id column may not exist on older DBs — try without it
try:
cursor.execute("SELECT spotify_data FROM wishlist_tracks")
for wr in cursor.fetchall():
try:
wd = json.loads(wr[0]) if isinstance(wr[0], str) else {}
wname = (wd.get('name') or '').lower()
wartists = wd.get('artists', [])
if wartists:
wa = wartists[0].get('name', '') if isinstance(wartists[0], dict) else str(wartists[0])
else:
wa = ''
if wname:
wishlist_keys.add(wname + '|||' + wa.lower().strip())
except Exception:
pass
except Exception:
pass
# O(1) lookups per item
album_results = []
for a in albums:
@ -7691,6 +7951,7 @@ def enhanced_search_library_check():
track_results = []
for t in tracks:
key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower())
in_wishlist = key in wishlist_keys
match = owned_tracks.get(key)
if match:
# Resolve thumb URL
@ -7698,9 +7959,9 @@ def enhanced_search_library_check():
if thumb and not thumb.startswith('http') and _plex_base and thumb.startswith('/'):
thumb = f"{_plex_base}{thumb}?X-Plex-Token={_plex_token}" if _plex_token else f"{_plex_base}{thumb}"
match['album_thumb_url'] = thumb
track_results.append({'in_library': True, **match})
track_results.append({'in_library': True, 'in_wishlist': in_wishlist, **match})
else:
track_results.append({'in_library': False})
track_results.append({'in_library': False, 'in_wishlist': in_wishlist})
finally:
conn.close()
@ -13074,6 +13335,24 @@ def _search_service(service, entity_type, query):
'image': None, 'extra': artist_name}]
return []
elif service == 'discogs':
if not discogs_worker or not discogs_worker.client:
raise ValueError("Discogs worker not initialized")
client = discogs_worker.client
if entity_type == 'artist':
items = client.search_artists(query, limit=8)
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
'extra': ', '.join(a.genres[:3]) if a.genres else ''} for a in items]
elif entity_type == 'album':
items = client.search_albums(query, limit=8)
return [{'id': str(a.id), 'name': a.name, 'image': a.image_url,
'extra': f"{', '.join(a.artists)} · {a.release_date or ''}"} for a in items]
elif entity_type == 'track':
items = client.search_tracks(query, limit=8)
return [{'id': str(t.id), 'name': t.name, 'image': t.image_url,
'extra': f"{', '.join(t.artists)} · {t.album or ''}"} for t in items]
return []
elif service == 'audiodb':
if not audiodb_worker or not audiodb_worker.client:
raise ValueError("AudioDB worker not initialized")

View file

@ -8392,6 +8392,16 @@ function initializeSearchModeToggle() {
}
}, delay);
delay += 30;
} else if (tr && tr.in_wishlist) {
setTimeout(() => {
if (!card.querySelector('.enh-item-wishlist-badge')) {
const badge = document.createElement('div');
badge.className = 'enh-item-wishlist-badge';
badge.textContent = 'In Wishlist';
card.appendChild(badge);
}
}, delay);
delay += 30;
}
});
} catch (e) {
@ -17509,6 +17519,14 @@ async function _gsLibraryCheck() {
playBtn.replaceWith(newBtn);
}
}
} else if (tr && tr.in_wishlist) {
if (!el.querySelector('.gsearch-item-badge')) {
const badge = document.createElement('span');
badge.className = 'gsearch-item-badge gsearch-wishlist-badge';
badge.textContent = 'In Wishlist';
badge.style.marginRight = '4px';
el.querySelector('.gsearch-track-dur')?.before(badge);
}
}
});
} catch (e) {
@ -23028,6 +23046,31 @@ const TOOL_HELP_CONTENT = {
</ol>
`
},
'auto-playlist_pipeline': {
title: 'Playlist Pipeline',
content: `
<h4>What does this action do?</h4>
<p>Runs the full playlist lifecycle in one automation no signal wiring needed. Executes four phases sequentially:</p>
<ol>
<li><strong>Refresh</strong> Re-fetches playlist tracks from the source platform (Spotify, Tidal, YouTube, Deezer)</li>
<li><strong>Discover</strong> Matches each track to official metadata (Spotify/iTunes/Deezer IDs)</li>
<li><strong>Sync</strong> Pushes the playlist to your media server (Plex, Jellyfin, Navidrome)</li>
<li><strong>Download Missing</strong> Queues unmatched tracks to the wishlist for automatic download</li>
</ol>
<h4>Configuration</h4>
<ul>
<li><strong>Playlist:</strong> Select a specific mirrored playlist, or check "Process all" to run the pipeline for every mirrored playlist</li>
<li><strong>Skip wishlist:</strong> Check this to skip the download phase (useful if you only want to sync, not download)</li>
</ul>
<h4>How the re-sync loop works</h4>
<p>Set this on a schedule (e.g., every 6 hours). Between runs, the wishlist processor downloads missing tracks in the background. On the next pipeline run, those newly downloaded tracks will match during the sync phase so your server playlist gets more complete with each cycle until fully synced.</p>
<h4>Replaces</h4>
<p>This single automation replaces the 4-automation signal chain pattern (Refresh signal Discover signal Sync signal Download). No signals, no chaining, no room for misconfiguration.</p>
`
},
'auto-notify_only': {
title: 'Notify Only',
content: `
@ -43506,6 +43549,7 @@ function renderArtistMetaPanel(artist) {
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
{ id: 'discogs', label: 'Discogs', icon: '🟤' },
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
@ -43985,9 +44029,11 @@ function renderExpandedAlbumHeader(album) {
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
{ id: 'discogs', label: 'Discogs', icon: '🟤' },
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
{ id: 'genius', label: 'Genius', icon: '🟡' },
].forEach(svc => {
const item = document.createElement('div');
item.className = 'enhanced-enrich-menu-item';
@ -65574,6 +65620,7 @@ const _autoIcons = {
clean_search_history: '\uD83D\uDDD1\uFE0F',
clean_completed_downloads: '\u2705',
full_cleanup: '\uD83E\uDDF9',
playlist_pipeline: '\uD83D\uDE80',
};
// --- Inspiration Templates ---
@ -65582,54 +65629,17 @@ const _autoIcons = {
// ── Automation Hub: One-Click Pipeline Groups ──
const AUTO_HUB_GROUPS = [
{
id: 'release-radar', icon: '📡', name: 'Release Radar Sync',
desc: 'Auto-sync your Release Radar playlist every Friday. Refreshes from Spotify, discovers metadata, syncs to your media server, and downloads missing tracks.',
category: 'Sync', badge: '4 automations', color: '#1db954',
id: 'playlist-pipeline', icon: '🚀', name: 'Playlist Pipeline (All-in-One)',
desc: 'Single automation that runs the full playlist lifecycle: refresh → discover → sync → download missing. No signal wiring needed.',
category: 'Sync', badge: '1 automation', color: '#8b5cf6',
steps: [
{ label: 'Refresh', icon: '🔄', type: 'action' },
{ label: 'Discover', icon: '🔍', type: 'action' },
{ label: 'Sync', icon: '📋', type: 'action' },
{ label: 'Sync', icon: '🔗', type: 'action' },
{ label: 'Download', icon: '📥', type: 'action' },
],
automations: [
{ name: 'Release Radar — Refresh', trigger_type: 'weekly_time', trigger_config: { days: ['friday'], time: '18:00' }, action_type: 'refresh_mirrored', action_config: { all: true }, then_actions: [{ type: 'fire_signal', config: { signal_name: 'rr_refreshed' } }], group_name: 'Release Radar' },
{ name: 'Release Radar — Discover', trigger_type: 'signal_received', trigger_config: { signal_name: 'rr_refreshed' }, action_type: 'discover_playlist', action_config: { all: true }, then_actions: [{ type: 'fire_signal', config: { signal_name: 'rr_discovered' } }], group_name: 'Release Radar' },
{ name: 'Release Radar — Sync', trigger_type: 'signal_received', trigger_config: { signal_name: 'rr_discovered' }, action_type: 'sync_playlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'rr_synced' } }], group_name: 'Release Radar' },
{ name: 'Release Radar — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'rr_synced' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Release Radar', needs_notify: true },
]
},
{
id: 'discovery-weekly', icon: '🎵', name: 'Discovery Weekly Sync',
desc: 'Capture your Discover Weekly every Monday before Spotify replaces it. Refreshes, discovers, syncs, and downloads the full playlist.',
category: 'Sync', badge: '4 automations', color: '#6366f1',
steps: [
{ label: 'Refresh', icon: '🔄', type: 'action' },
{ label: 'Discover', icon: '🔍', type: 'action' },
{ label: 'Sync', icon: '📋', type: 'action' },
{ label: 'Download', icon: '📥', type: 'action' },
],
automations: [
{ name: 'Discovery Weekly — Refresh', trigger_type: 'weekly_time', trigger_config: { days: ['monday'], time: '06:00' }, action_type: 'refresh_mirrored', action_config: { all: true }, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dw_refreshed' } }], group_name: 'Discovery Weekly' },
{ name: 'Discovery Weekly — Discover', trigger_type: 'signal_received', trigger_config: { signal_name: 'dw_refreshed' }, action_type: 'discover_playlist', action_config: { all: true }, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dw_discovered' } }], group_name: 'Discovery Weekly' },
{ name: 'Discovery Weekly — Sync', trigger_type: 'signal_received', trigger_config: { signal_name: 'dw_discovered' }, action_type: 'sync_playlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'dw_synced' } }], group_name: 'Discovery Weekly' },
{ name: 'Discovery Weekly — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'dw_synced' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Discovery Weekly', needs_notify: true },
]
},
{
id: 'playlist-auto-sync', icon: '🔄', name: 'Playlist Auto-Sync',
desc: 'Keep all mirrored playlists in sync. Refreshes every 6 hours, then discovers, syncs, and downloads any new tracks.',
category: 'Sync', badge: '4 automations', color: '#06b6d4',
steps: [
{ label: 'Refresh', icon: '🔄', type: 'action' },
{ label: 'Discover', icon: '🔍', type: 'action' },
{ label: 'Sync', icon: '📋', type: 'action' },
{ label: 'Download', icon: '📥', type: 'action' },
],
automations: [
{ name: 'Playlist Sync — Refresh', trigger_type: 'schedule', trigger_config: { interval: 6, unit: 'hours' }, action_type: 'refresh_mirrored', action_config: { all: true }, then_actions: [{ type: 'fire_signal', config: { signal_name: 'ps_refreshed' } }], group_name: 'Playlist Auto-Sync' },
{ name: 'Playlist Sync — Discover', trigger_type: 'signal_received', trigger_config: { signal_name: 'ps_refreshed' }, action_type: 'discover_playlist', action_config: { all: true }, then_actions: [{ type: 'fire_signal', config: { signal_name: 'ps_discovered' } }], group_name: 'Playlist Auto-Sync' },
{ name: 'Playlist Sync — Sync', trigger_type: 'signal_received', trigger_config: { signal_name: 'ps_discovered' }, action_type: 'sync_playlist', action_config: {}, then_actions: [{ type: 'fire_signal', config: { signal_name: 'ps_synced' } }], group_name: 'Playlist Auto-Sync' },
{ name: 'Playlist Sync — Download', trigger_type: 'signal_received', trigger_config: { signal_name: 'ps_synced' }, action_type: 'process_wishlist', action_config: {}, then_actions: [], group_name: 'Playlist Auto-Sync', needs_notify: true },
{ name: 'Playlist Pipeline', trigger_type: 'schedule', trigger_config: { interval: 6, unit: 'hours' }, action_type: 'playlist_pipeline', action_config: { all: true }, then_actions: [], group_name: 'Playlist Pipeline' },
]
},
{
@ -65910,6 +65920,7 @@ const AUTO_HUB_REFERENCE = {
],
actions: [
{ group: 'Downloads & Sync', items: [
{ type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' },
{ type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' },
{ type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' },
{ type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' },
@ -66848,7 +66859,8 @@ function _autoFormatAction(type) {
backup_database: 'Backup Database',
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
clean_completed_downloads: 'Clean Completed Downloads',
full_cleanup: 'Full Cleanup' };
full_cleanup: 'Full Cleanup',
playlist_pipeline: 'Playlist Pipeline' };
return labels[type] || type || 'Unknown';
}
function _autoFormatNotify(type) {
@ -67084,6 +67096,14 @@ const _RESULT_DISPLAY_MAP = {
{ key: 'staging_removed', label: 'Staging Dirs Removed' },
{ key: 'total_removed', label: 'Total Items Removed' },
],
'playlist_pipeline': [
{ key: 'playlists_refreshed', label: 'Refreshed' },
{ key: 'tracks_discovered', label: 'Discovered' },
{ key: 'tracks_synced', label: 'Synced' },
{ key: 'sync_skipped', label: 'Skipped', hideZero: true },
{ key: 'wishlist_queued', label: 'Wishlist Queued' },
{ key: 'duration_seconds', label: 'Duration (s)' },
],
};
function _renderResultStats(resultJson, actionType) {
@ -67563,6 +67583,23 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Discover all mirrored playlists</label>
</div>`;
}
if (blockType === 'playlist_pipeline') {
const allChecked = config.all ? ' checked' : '';
const skipWishlistChecked = config.skip_wishlist ? ' checked' : '';
return `<div class="config-row">
<label>Playlist</label>
<select id="cfg-${slotKey}-playlist_id" class="mirrored-playlist-select" data-value="${_escAttr(config.playlist_id || '')}">
<option value="">Loading...</option>
</select>
</div>
<div class="config-row">
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Process all mirrored playlists</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 4 phases: Refresh Discover Sync Download Missing</div>`;
}
// Shared variable tags builder for notification types
function _notifyVarHtml(slotKey) {
let allVars = ['time', 'name', 'run_count', 'status'];
@ -67858,6 +67895,15 @@ function _readPlacedConfig(slotKey) {
all: allCb ? allCb.checked : false,
};
}
if (type === 'playlist_pipeline') {
const allCb = document.getElementById('cfg-' + slotKey + '-all');
const skipWl = document.getElementById('cfg-' + slotKey + '-skip_wishlist');
return {
playlist_id: document.getElementById('cfg-' + slotKey + '-playlist_id')?.value || '',
all: allCb ? allCb.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() || '' };
}

View file

@ -5390,6 +5390,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
flex-shrink: 0;
}
.gsearch-wishlist-badge {
background: rgba(251,191,36,0.12); color: #fbbf24;
}
.gsearch-loading {
text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2);
}
@ -33039,6 +33043,31 @@ body.helper-mode-active #dashboard-activity-feed:hover {
font-size: 9px;
}
.enh-item-wishlist-badge {
position: absolute;
top: 6px;
left: 6px;
font-size: 8px;
font-weight: 700;
text-transform: uppercase;
letter-spacing: 0.04em;
background: rgba(251, 191, 36, 0.85);
color: #1a1a2e;
padding: 3px 7px;
z-index: 5;
animation: libBadgeFadeIn 0.3s ease;
border-radius: 6px;
backdrop-filter: blur(4px);
box-shadow: 0 2px 6px rgba(0, 0, 0, 0.3);
}
.enh-compact-item.track-item .enh-item-wishlist-badge {
position: static;
flex-shrink: 0;
margin-left: auto;
font-size: 9px;
}
/* Hydrabase badge (dev mode) */
.enh-badge-hydrabase {
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3));