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:
parent
c3a3510c75
commit
9f7fe27e7c
3 changed files with 399 additions and 45 deletions
283
web_server.py
283
web_server.py
|
|
@ -984,9 +984,223 @@ def _register_automation_handlers():
|
||||||
return {'status': 'started', 'playlist_count': str(len(playlists)), 'playlists': names,
|
return {'status': 'started', 'playlist_count': str(len(playlists)), 'playlists': names,
|
||||||
'_manages_own_progress': True}
|
'_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('refresh_mirrored', _auto_refresh_mirrored)
|
||||||
automation_engine.register_action_handler('sync_playlist', _auto_sync_playlist)
|
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('discover_playlist', _auto_discover_playlist)
|
||||||
|
automation_engine.register_action_handler('playlist_pipeline', _auto_playlist_pipeline, _pipeline_guard)
|
||||||
|
|
||||||
# --- Phase 3 action handlers ---
|
# --- Phase 3 action handlers ---
|
||||||
|
|
||||||
|
|
@ -5633,6 +5847,14 @@ def get_automation_blocks():
|
||||||
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
{"key": "playlist_id", "type": "mirrored_playlist_select", "label": "Playlist"},
|
||||||
{"key": "all", "type": "checkbox", "label": "Discover all mirrored playlists", "default": False}
|
{"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},
|
{"type": "notify_only", "label": "Notify Only", "icon": "bell", "description": "No action — just send notification", "available": True},
|
||||||
# Phase 3 actions
|
# Phase 3 actions
|
||||||
{"type": "start_database_update", "label": "Update Database", "icon": "database",
|
{"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
|
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]}
|
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
|
# O(1) lookups per item
|
||||||
album_results = []
|
album_results = []
|
||||||
for a in albums:
|
for a in albums:
|
||||||
|
|
@ -7691,6 +7951,7 @@ def enhanced_search_library_check():
|
||||||
track_results = []
|
track_results = []
|
||||||
for t in tracks:
|
for t in tracks:
|
||||||
key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower())
|
key = (t.get('name', '').lower() + '|||' + t.get('artist', '').split(',')[0].strip().lower())
|
||||||
|
in_wishlist = key in wishlist_keys
|
||||||
match = owned_tracks.get(key)
|
match = owned_tracks.get(key)
|
||||||
if match:
|
if match:
|
||||||
# Resolve thumb URL
|
# 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('/'):
|
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}"
|
thumb = f"{_plex_base}{thumb}?X-Plex-Token={_plex_token}" if _plex_token else f"{_plex_base}{thumb}"
|
||||||
match['album_thumb_url'] = 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:
|
else:
|
||||||
track_results.append({'in_library': False})
|
track_results.append({'in_library': False, 'in_wishlist': in_wishlist})
|
||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
|
@ -13074,6 +13335,24 @@ def _search_service(service, entity_type, query):
|
||||||
'image': None, 'extra': artist_name}]
|
'image': None, 'extra': artist_name}]
|
||||||
return []
|
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':
|
elif service == 'audiodb':
|
||||||
if not audiodb_worker or not audiodb_worker.client:
|
if not audiodb_worker or not audiodb_worker.client:
|
||||||
raise ValueError("AudioDB worker not initialized")
|
raise ValueError("AudioDB worker not initialized")
|
||||||
|
|
|
||||||
|
|
@ -8392,6 +8392,16 @@ function initializeSearchModeToggle() {
|
||||||
}
|
}
|
||||||
}, delay);
|
}, delay);
|
||||||
delay += 30;
|
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) {
|
} catch (e) {
|
||||||
|
|
@ -17509,6 +17519,14 @@ async function _gsLibraryCheck() {
|
||||||
playBtn.replaceWith(newBtn);
|
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) {
|
} catch (e) {
|
||||||
|
|
@ -23028,6 +23046,31 @@ const TOOL_HELP_CONTENT = {
|
||||||
</ol>
|
</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': {
|
'auto-notify_only': {
|
||||||
title: 'Notify Only',
|
title: 'Notify Only',
|
||||||
content: `
|
content: `
|
||||||
|
|
@ -43506,6 +43549,7 @@ function renderArtistMetaPanel(artist) {
|
||||||
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
|
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
|
||||||
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
|
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
|
||||||
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
||||||
|
{ id: 'discogs', label: 'Discogs', icon: '🟤' },
|
||||||
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
||||||
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
||||||
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
||||||
|
|
@ -43985,9 +44029,11 @@ function renderExpandedAlbumHeader(album) {
|
||||||
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
|
{ id: 'spotify', label: 'Spotify', icon: '🟢' },
|
||||||
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
|
{ id: 'musicbrainz', label: 'MusicBrainz', icon: '🟠' },
|
||||||
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
{ id: 'deezer', label: 'Deezer', icon: '🟣' },
|
||||||
|
{ id: 'discogs', label: 'Discogs', icon: '🟤' },
|
||||||
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
{ id: 'audiodb', label: 'AudioDB', icon: '🔵' },
|
||||||
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
{ id: 'itunes', label: 'iTunes', icon: '🔴' },
|
||||||
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
{ id: 'lastfm', label: 'Last.fm', icon: '⚪' },
|
||||||
|
{ id: 'genius', label: 'Genius', icon: '🟡' },
|
||||||
].forEach(svc => {
|
].forEach(svc => {
|
||||||
const item = document.createElement('div');
|
const item = document.createElement('div');
|
||||||
item.className = 'enhanced-enrich-menu-item';
|
item.className = 'enhanced-enrich-menu-item';
|
||||||
|
|
@ -65574,6 +65620,7 @@ const _autoIcons = {
|
||||||
clean_search_history: '\uD83D\uDDD1\uFE0F',
|
clean_search_history: '\uD83D\uDDD1\uFE0F',
|
||||||
clean_completed_downloads: '\u2705',
|
clean_completed_downloads: '\u2705',
|
||||||
full_cleanup: '\uD83E\uDDF9',
|
full_cleanup: '\uD83E\uDDF9',
|
||||||
|
playlist_pipeline: '\uD83D\uDE80',
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- Inspiration Templates ---
|
// --- Inspiration Templates ---
|
||||||
|
|
@ -65582,54 +65629,17 @@ const _autoIcons = {
|
||||||
// ── Automation Hub: One-Click Pipeline Groups ──
|
// ── Automation Hub: One-Click Pipeline Groups ──
|
||||||
const AUTO_HUB_GROUPS = [
|
const AUTO_HUB_GROUPS = [
|
||||||
{
|
{
|
||||||
id: 'release-radar', icon: '📡', name: 'Release Radar Sync',
|
id: 'playlist-pipeline', icon: '🚀', name: 'Playlist Pipeline (All-in-One)',
|
||||||
desc: 'Auto-sync your Release Radar playlist every Friday. Refreshes from Spotify, discovers metadata, syncs to your media server, and downloads missing tracks.',
|
desc: 'Single automation that runs the full playlist lifecycle: refresh → discover → sync → download missing. No signal wiring needed.',
|
||||||
category: 'Sync', badge: '4 automations', color: '#1db954',
|
category: 'Sync', badge: '1 automation', color: '#8b5cf6',
|
||||||
steps: [
|
steps: [
|
||||||
{ label: 'Refresh', icon: '🔄', type: 'action' },
|
{ label: 'Refresh', icon: '🔄', type: 'action' },
|
||||||
{ label: 'Discover', icon: '🔍', type: 'action' },
|
{ label: 'Discover', icon: '🔍', type: 'action' },
|
||||||
{ label: 'Sync', icon: '📋', type: 'action' },
|
{ label: 'Sync', icon: '🔗', type: 'action' },
|
||||||
{ label: 'Download', icon: '📥', type: 'action' },
|
{ label: 'Download', icon: '📥', type: 'action' },
|
||||||
],
|
],
|
||||||
automations: [
|
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: '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' },
|
||||||
{ 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 },
|
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|
@ -65910,6 +65920,7 @@ const AUTO_HUB_REFERENCE = {
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
{ group: 'Downloads & Sync', items: [
|
{ 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: '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: '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' },
|
{ 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',
|
backup_database: 'Backup Database',
|
||||||
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
|
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
|
||||||
clean_completed_downloads: 'Clean Completed Downloads',
|
clean_completed_downloads: 'Clean Completed Downloads',
|
||||||
full_cleanup: 'Full Cleanup' };
|
full_cleanup: 'Full Cleanup',
|
||||||
|
playlist_pipeline: 'Playlist Pipeline' };
|
||||||
return labels[type] || type || 'Unknown';
|
return labels[type] || type || 'Unknown';
|
||||||
}
|
}
|
||||||
function _autoFormatNotify(type) {
|
function _autoFormatNotify(type) {
|
||||||
|
|
@ -67084,6 +67096,14 @@ const _RESULT_DISPLAY_MAP = {
|
||||||
{ key: 'staging_removed', label: 'Staging Dirs Removed' },
|
{ key: 'staging_removed', label: 'Staging Dirs Removed' },
|
||||||
{ key: 'total_removed', label: 'Total Items 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) {
|
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>
|
<label><input type="checkbox" id="cfg-${slotKey}-all"${allChecked} onchange="_autoTogglePlaylistSelect('${slotKey}')"> Discover all mirrored playlists</label>
|
||||||
</div>`;
|
</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
|
// Shared variable tags builder for notification types
|
||||||
function _notifyVarHtml(slotKey) {
|
function _notifyVarHtml(slotKey) {
|
||||||
let allVars = ['time', 'name', 'run_count', 'status'];
|
let allVars = ['time', 'name', 'run_count', 'status'];
|
||||||
|
|
@ -67858,6 +67895,15 @@ function _readPlacedConfig(slotKey) {
|
||||||
all: allCb ? allCb.checked : false,
|
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') {
|
if (type === 'signal_received' || type === 'fire_signal') {
|
||||||
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
|
return { signal_name: document.getElementById('cfg-' + slotKey + '-signal_name')?.value?.trim() || '' };
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5390,6 +5390,10 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
||||||
flex-shrink: 0;
|
flex-shrink: 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.gsearch-wishlist-badge {
|
||||||
|
background: rgba(251,191,36,0.12); color: #fbbf24;
|
||||||
|
}
|
||||||
|
|
||||||
.gsearch-loading {
|
.gsearch-loading {
|
||||||
text-align: center; padding: 24px; font-size: 12px; color: rgba(255,255,255,0.2);
|
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;
|
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) */
|
/* Hydrabase badge (dev mode) */
|
||||||
.enh-badge-hydrabase {
|
.enh-badge-hydrabase {
|
||||||
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3));
|
background: linear-gradient(135deg, rgba(99, 102, 241, 0.3), rgba(139, 92, 246, 0.3));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue