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,
|
||||
'_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")
|
||||
|
|
|
|||
|
|
@ -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() || '' };
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue