Add Your Albums — multi-source liked albums pool (Spotify, Tidal, Deezer)

Builds a new Your Albums section on the Discover page that aggregates
saved/liked albums from all connected services, mirroring the Your Artists
pattern. Deezer works via both OAuth and ARL.

- tidal_client: add get_favorite_albums() with V2/V1 API fallback
- deezer_client: add get_user_favorite_albums() via OAuth (user/me/albums)
- deezer_download_client: add get_user_favorite_albums() via ARL session
- music_database: add liked_albums_pool table (deduped by artist::album
  normalized key), upsert_liked_album, get_liked_albums,
  get_liked_albums_last_fetch, clear_liked_albums
- web_server: GET /api/discover/your-albums (ownership-checked, paginated),
  GET /api/discover/your-albums/sources, POST /api/discover/your-albums/refresh,
  _fetch_liked_albums background worker (Spotify + Tidal + Deezer OAuth/ARL)
- frontend: Your Albums section with source selector cog, album grid reusing
  spotify-library-card styles, search/filter/sort/pagination, download missing
  button, auto-refresh poll on first load

Also fix: Deezer greyed out in Your Artists sources when using ARL — connection
check now accepts ARL auth (deezer_dl.is_authenticated()) in addition to OAuth,
and _fetch_and_match_liked_artists falls back to ARL client for artist fetching.
This commit is contained in:
Broque Thomas 2026-04-13 23:02:37 -07:00
parent 453eb90f19
commit 3b8b369492
8 changed files with 1726 additions and 540 deletions

View file

@ -738,6 +738,49 @@ class DeezerClient:
logger.error(f"Error fetching Deezer favorite artists: {e}")
return []
@rate_limited
def get_user_favorite_albums(self, limit: int = 200) -> list:
"""Fetch user's favorite albums from Deezer. Requires OAuth access token.
Returns list of dicts with deezer_id, album_name, artist_name, image_url, release_date, total_tracks."""
if not self._access_token:
logger.debug("Deezer not user-authenticated — cannot fetch favorite albums")
return []
try:
albums = []
index = 0
while len(albums) < limit:
data = self._api_get('user/me/albums', params={
'limit': min(100, limit - len(albums)),
'index': index
})
if not data or 'data' not in data:
break
items = data['data']
if not items:
break
for a in items:
artist_name = ''
if isinstance(a.get('artist'), dict):
artist_name = a['artist'].get('name', '')
albums.append({
'deezer_id': str(a.get('id', '')),
'album_name': a.get('title', ''),
'artist_name': artist_name,
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
'release_date': a.get('release_date', ''),
'total_tracks': a.get('nb_tracks', 0),
})
if not data.get('next'):
break
index += len(items)
time.sleep(0.3)
logger.info(f"Retrieved {len(albums)} favorite albums from Deezer")
return albums
except Exception as e:
logger.error(f"Error fetching Deezer favorite albums: {e}")
return []
# ==================== Stub Methods (match iTunesClient interface) ====================
def get_user_playlists(self) -> List[Playlist]:

View file

@ -323,6 +323,53 @@ class DeezerDownloadClient:
logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
return artists
def get_user_favorite_albums(self, limit: int = 200) -> list:
"""Fetch the authenticated user's favorite albums via public API with ARL cookies."""
if not self._authenticated or not self._user_data:
return []
user_id = self._user_data.get('USER_ID')
if not user_id:
return []
albums = []
index = 0
while len(albums) < limit:
try:
resp = self._session.get(
f'https://api.deezer.com/user/{user_id}/albums',
params={'index': index, 'limit': min(100, limit - len(albums))},
timeout=15
)
resp.raise_for_status()
data = resp.json()
if 'error' in data:
logger.warning(f"Deezer albums error: {data['error']}")
break
items = data.get('data', [])
if not items:
break
for a in items:
artist_name = ''
if isinstance(a.get('artist'), dict):
artist_name = a['artist'].get('name', '')
albums.append({
'deezer_id': str(a.get('id', '')),
'album_name': a.get('title', ''),
'artist_name': artist_name,
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
'release_date': a.get('release_date', ''),
'total_tracks': a.get('nb_tracks', 0),
})
if not data.get('next'):
break
index += len(items)
except Exception as e:
logger.error(f"Error fetching favorite albums at index {index}: {e}")
break
logger.info(f"Fetched {len(albums)} favorite albums from Deezer (ARL)")
return albums
def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
try:

View file

@ -1497,6 +1497,128 @@ class TidalClient:
logger.error(f"Error fetching Tidal favorite artists: {e}")
return []
def get_favorite_albums(self, limit: int = 200) -> list:
"""Fetch user's favorite albums from Tidal.
Returns list of dicts with tidal_id, album_name, artist_name, image_url, release_date, total_tracks."""
try:
if not self._ensure_valid_token():
logger.debug("Tidal not authenticated — cannot fetch favorite albums")
return []
user_id, api_version = self._get_user_id()
if not user_id:
logger.warning("Could not get Tidal user ID for favorite albums")
return []
albums = []
if api_version == 'v2':
offset = 0
while len(albums) < limit:
try:
headers = self.session.headers.copy()
headers['accept'] = 'application/vnd.api+json'
resp = requests.get(
f"{self.base_url}/favorites",
params={
'countryCode': 'US',
'filter[user.id]': user_id,
'filter[type]': 'ALBUMS',
'include': 'albums',
'page[limit]': min(50, limit - len(albums)),
'page[offset]': offset
},
headers=headers, timeout=15
)
if resp.status_code != 200:
logger.debug(f"Tidal V2 favorite albums returned {resp.status_code}, trying V1")
break
data = resp.json()
included = data.get('included', [])
items = included if included else data.get('data', [])
if not items:
break
for item in items:
if included and item.get('type') not in ('albums', 'album'):
continue
attrs = item.get('attributes', {})
title = attrs.get('title', '')
if not title:
continue
img = None
img_rel = item.get('relationships', {}).get('image', {}).get('data', {})
if isinstance(img_rel, dict) and img_rel.get('id'):
img = f"https://resources.tidal.com/images/{img_rel['id'].replace('-', '/')}/750x750.jpg"
artist_name = ''
artist_rel = attrs.get('artists', [{}])
if artist_rel and isinstance(artist_rel, list):
artist_name = artist_rel[0].get('name', '') if isinstance(artist_rel[0], dict) else ''
albums.append({
'tidal_id': str(item.get('id', '')),
'album_name': title,
'artist_name': artist_name,
'image_url': img,
'release_date': attrs.get('releaseDate', ''),
'total_tracks': attrs.get('numberOfTracks', 0),
})
if not data.get('links', {}).get('next'):
break
offset += 50
import time
time.sleep(0.5)
except Exception as e:
logger.debug(f"Tidal V2 favorite albums error: {e}")
break
# Fallback to V1 API
if not albums:
try:
offset = 0
while len(albums) < limit:
resp = self.session.get(
f"{self.alt_base_url}/users/{user_id}/favorites/albums",
params={'countryCode': 'US', 'limit': min(50, limit - len(albums)), 'offset': offset},
timeout=15
)
if resp.status_code != 200:
logger.debug(f"Tidal V1 favorite albums returned {resp.status_code}")
break
data = resp.json()
items = data.get('items', [])
if not items:
break
for item in items:
a = item.get('item', item)
img_id = (a.get('cover') or '').replace('-', '/')
img = f"https://resources.tidal.com/images/{img_id}/750x750.jpg" if img_id else None
artist_name = ''
if isinstance(a.get('artist'), dict):
artist_name = a['artist'].get('name', '')
elif isinstance(a.get('artists'), list) and a['artists']:
artist_name = a['artists'][0].get('name', '')
albums.append({
'tidal_id': str(a.get('id', '')),
'album_name': a.get('title', ''),
'artist_name': artist_name,
'image_url': img,
'release_date': a.get('releaseDate', ''),
'total_tracks': a.get('numberOfTracks', 0),
})
total = data.get('totalNumberOfItems', 0)
offset += len(items)
if offset >= total:
break
import time
time.sleep(0.5)
except Exception as e:
logger.debug(f"Tidal V1 favorite albums error: {e}")
logger.info(f"Retrieved {len(albums)} favorite albums from Tidal")
return albums
except Exception as e:
logger.error(f"Error fetching Tidal favorite albums: {e}")
return []
# Global instance
_tidal_client = None

View file

@ -1358,6 +1358,30 @@ class MusicDatabase:
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
# Liked albums pool — aggregated saved/liked albums from connected services
cursor.execute("""
CREATE TABLE IF NOT EXISTS liked_albums_pool (
id INTEGER PRIMARY KEY AUTOINCREMENT,
album_name TEXT NOT NULL,
artist_name TEXT NOT NULL,
normalized_key TEXT NOT NULL,
spotify_album_id TEXT,
tidal_album_id TEXT,
deezer_album_id TEXT,
image_url TEXT,
release_date TEXT,
total_tracks INTEGER DEFAULT 0,
source_services TEXT DEFAULT '[]',
profile_id INTEGER DEFAULT 1,
last_fetched_at TIMESTAMP,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, normalized_key)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_profile ON liked_albums_pool (profile_id)")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_spotify ON liked_albums_pool (spotify_album_id)")
logger.info("Discovery tables added/verified successfully")
except Exception as e:
@ -9298,6 +9322,181 @@ class MusicDatabase:
logger.error(f"Error clearing liked artists: {e}")
return 0
# ==================== Liked Albums Pool Methods ====================
@staticmethod
def _normalize_album_key(artist_name: str, album_name: str) -> str:
"""Normalize artist+album into a dedup key."""
import unicodedata
def _norm(s):
if not s:
return ''
n = unicodedata.normalize('NFKD', s)
n = ''.join(c for c in n if not unicodedata.combining(c))
n = n.lower().strip()
if n.startswith('the '):
n = n[4:]
return ' '.join(n.split())
return f"{_norm(artist_name)}::{_norm(album_name)}"
def upsert_liked_album(self, album_name: str, artist_name: str, source_service: str,
source_id: str = None, source_id_type: str = None,
image_url: str = None, release_date: str = None,
total_tracks: int = 0, profile_id: int = 1) -> bool:
"""Insert or merge a liked album into the pool. Deduplicates by normalized artist+album key."""
try:
import json
if self._is_placeholder_image(image_url):
image_url = None
normalized = self._normalize_album_key(artist_name, album_name)
if not normalized or '::' not in normalized:
return False
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT id, source_services FROM liked_albums_pool WHERE profile_id = ? AND normalized_key = ?",
(profile_id, normalized)
)
existing = cursor.fetchone()
if existing:
current_sources = json.loads(existing['source_services'] or '[]')
if source_service not in current_sources:
current_sources.append(source_service)
set_parts = [
"source_services = ?",
"updated_at = CURRENT_TIMESTAMP",
"last_fetched_at = CURRENT_TIMESTAMP",
]
params = [json.dumps(current_sources)]
if source_id and source_id_type:
col = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
'deezer': 'deezer_album_id'}.get(source_id_type)
if col:
set_parts.append(f"{col} = COALESCE({col}, ?)")
params.append(source_id)
if image_url:
set_parts.append("image_url = COALESCE(image_url, ?)")
params.append(image_url)
if release_date:
set_parts.append("release_date = COALESCE(release_date, ?)")
params.append(release_date)
if total_tracks:
set_parts.append("total_tracks = COALESCE(NULLIF(total_tracks, 0), ?)")
params.append(total_tracks)
params.extend([profile_id, normalized])
cursor.execute(
f"UPDATE liked_albums_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_key = ?",
params
)
else:
sources_json = json.dumps([source_service])
id_cols = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
'deezer': 'deezer_album_id'}
col_values = {v: None for v in id_cols.values()}
if source_id and source_id_type and source_id_type in id_cols:
col_values[id_cols[source_id_type]] = source_id
cursor.execute("""
INSERT INTO liked_albums_pool
(album_name, artist_name, normalized_key, spotify_album_id, tidal_album_id,
deezer_album_id, image_url, release_date, total_tracks, source_services,
profile_id, last_fetched_at)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (
album_name, artist_name, normalized,
col_values['spotify_album_id'], col_values['tidal_album_id'],
col_values['deezer_album_id'],
image_url, release_date, total_tracks or 0,
sources_json, profile_id
))
conn.commit()
return True
except Exception as e:
logger.error(f"Error upserting liked album '{album_name}' by '{artist_name}': {e}")
return False
def get_liked_albums(self, profile_id: int = 1, page: int = 1, per_page: int = 50,
search: str = None, source_filter: str = None,
sort: str = 'artist_name') -> dict:
"""Get liked albums from the pool. Returns {albums: [...], total: N}."""
try:
conn = self._get_connection()
cursor = conn.cursor()
where = ["profile_id = ?"]
params = [profile_id]
if search:
where.append("(album_name LIKE ? COLLATE NOCASE OR artist_name LIKE ? COLLATE NOCASE)")
params.extend([f"%{search}%", f"%{search}%"])
if source_filter:
where.append("source_services LIKE ?")
params.append(f'%"{source_filter}"%')
where_clause = " AND ".join(where)
cursor.execute(f"SELECT COUNT(*) FROM liked_albums_pool WHERE {where_clause}", params)
total = cursor.fetchone()[0]
order = {
'artist_name': 'artist_name COLLATE NOCASE, album_name COLLATE NOCASE',
'album_name': 'album_name COLLATE NOCASE',
'recent': 'created_at DESC',
'release_date': 'release_date DESC',
}.get(sort, 'artist_name COLLATE NOCASE')
offset = (page - 1) * per_page
cursor.execute(f"""
SELECT * FROM liked_albums_pool
WHERE {where_clause}
ORDER BY {order}
LIMIT ? OFFSET ?
""", params + [per_page, offset])
import json
albums = []
for r in cursor.fetchall():
d = dict(r)
d['source_services'] = json.loads(d['source_services'] or '[]')
albums.append(d)
return {'albums': albums, 'total': total}
except Exception as e:
logger.error(f"Error getting liked albums: {e}")
return {'albums': [], 'total': 0}
def get_liked_albums_last_fetch(self, profile_id: int = 1):
"""Get the most recent fetch timestamp."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute(
"SELECT MAX(last_fetched_at) FROM liked_albums_pool WHERE profile_id = ?",
(profile_id,)
)
row = cursor.fetchone()
return row[0] if row and row[0] else None
except Exception:
return None
def clear_liked_albums(self, profile_id: int = 1) -> int:
"""Clear all liked albums for a profile."""
try:
conn = self._get_connection()
cursor = conn.cursor()
cursor.execute("DELETE FROM liked_albums_pool WHERE profile_id = ?", (profile_id,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error(f"Error clearing liked albums: {e}")
return 0
# ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str,

View file

@ -42815,6 +42815,248 @@ def _backfill_liked_artist_images(database, profile_id: int, search_clients: dic
logger.debug(f"[Your Artists] Image backfill error: {e}")
# ── Your Albums (Liked Albums Pool) ──
@app.route('/api/discover/your-albums', methods=['GET'])
def get_your_albums():
"""Get liked albums with library ownership status, paginated."""
try:
database = get_database()
profile_id = get_current_profile_id()
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 48, type=int)
search = request.args.get('search', '', type=str).strip()
status_filter = request.args.get('status', 'all', type=str)
source_filter = request.args.get('source', '', type=str).strip()
sort = request.args.get('sort', 'artist_name', type=str)
# Auto-trigger refresh if stale (>24h or empty)
last_fetch = database.get_liked_albums_last_fetch(profile_id)
stale = True
if last_fetch:
from datetime import datetime, timedelta
try:
if isinstance(last_fetch, str):
last_dt = datetime.fromisoformat(last_fetch.replace('Z', '+00:00'))
else:
last_dt = last_fetch
stale = (datetime.now() - last_dt.replace(tzinfo=None)) > timedelta(hours=24)
except Exception:
stale = True
if stale:
_trigger_your_albums_refresh(profile_id)
# Fetch all (ownership check requires full set)
all_result = database.get_liked_albums(
profile_id=profile_id, page=1, per_page=100000,
search=search, source_filter=source_filter or None, sort=sort
)
all_albums = all_result['albums']
if not all_albums:
return jsonify({
"success": True, "albums": [], "total": 0,
"page": page, "per_page": per_page, "stale": stale,
"stats": {"total": 0, "owned": 0, "missing": 0}
})
# Ownership check — same strategy as Spotify library endpoint
library_spotify_ids = database.get_library_spotify_album_ids(profile_id)
library_album_names = database.get_library_album_names()
owned_count = 0
for album in all_albums:
if album.get('spotify_album_id') and album['spotify_album_id'] in library_spotify_ids:
album['in_library'] = True
elif (album['artist_name'].lower(), album['album_name'].lower()) in library_album_names:
album['in_library'] = True
else:
album['in_library'] = False
if album['in_library']:
owned_count += 1
# Apply status filter
if status_filter == 'missing':
filtered = [a for a in all_albums if not a['in_library']]
elif status_filter == 'owned':
filtered = [a for a in all_albums if a['in_library']]
else:
filtered = all_albums
filtered_total = len(filtered)
offset = (page - 1) * per_page
albums = filtered[offset:offset + per_page]
stats = {
'total': all_result['total'],
'owned': owned_count,
'missing': all_result['total'] - owned_count,
}
return jsonify({
"success": True, "albums": albums,
"total": filtered_total, "page": page, "per_page": per_page,
"stale": stale, "stats": stats,
})
except Exception as e:
logger.error(f"Error getting your albums: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/your-albums/refresh', methods=['POST'])
def refresh_your_albums():
"""Force-trigger a fetch cycle for liked albums. ?clear=true wipes pool first."""
try:
profile_id = get_current_profile_id()
if request.args.get('clear', '').lower() == 'true':
database = get_database()
cleared = database.clear_liked_albums(profile_id)
print(f"[Your Albums] Cleared {cleared} entries before refresh")
_trigger_your_albums_refresh(profile_id)
return jsonify({"success": True, "message": "Refresh started"})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/discover/your-albums/sources', methods=['GET'])
def get_your_albums_sources():
"""Return current source config + which services are connected (albums)."""
try:
enabled_raw = config_manager.get('discover.your_albums_sources', 'spotify,tidal,deezer')
enabled = [s.strip() for s in enabled_raw.split(',') if s.strip()]
connected = []
if spotify_client and spotify_client.is_spotify_authenticated():
connected.append('spotify')
try:
if tidal_client and hasattr(tidal_client, '_ensure_valid_token') and tidal_client._ensure_valid_token():
connected.append('tidal')
except Exception:
pass
try:
deezer_cl = _get_deezer_client()
deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated()
deezer_arl = (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl
and soulseek_client.deezer_dl.is_authenticated())
if deezer_oauth or deezer_arl:
connected.append('deezer')
except Exception:
pass
return jsonify({"success": True, "enabled": enabled, "connected": connected})
except Exception as e:
return jsonify({"success": False, "error": str(e)}), 500
_your_albums_refresh_lock = threading.Lock()
_your_albums_refreshing = False
def _trigger_your_albums_refresh(profile_id: int):
"""Start background album fetch if not already running."""
global _your_albums_refreshing
if _your_albums_refreshing:
return
with _your_albums_refresh_lock:
if _your_albums_refreshing:
return
_your_albums_refreshing = True
def _run():
global _your_albums_refreshing
try:
_fetch_liked_albums(profile_id)
except Exception as e:
logger.error(f"Your albums refresh failed: {e}")
import traceback
traceback.print_exc()
finally:
_your_albums_refreshing = False
threading.Thread(target=_run, daemon=True, name="YourAlbumsRefresh").start()
def _fetch_liked_albums(profile_id: int):
"""Background worker: fetch liked/saved albums from all connected services."""
database = get_database()
fetched = 0
enabled_raw = config_manager.get('discover.your_albums_sources', 'spotify,tidal,deezer')
enabled_sources = {s.strip() for s in enabled_raw.split(',') if s.strip()}
# 1. Fetch from Spotify (saved albums)
try:
if 'spotify' not in enabled_sources:
print("[Your Albums] Spotify skipped (disabled in sources config)")
elif spotify_client and spotify_client.is_spotify_authenticated():
print("[Your Albums] Fetching saved albums from Spotify...")
albums = spotify_client.get_saved_albums()
for a in albums:
database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'],
source_service='spotify',
source_id=a['spotify_album_id'], source_id_type='spotify',
image_url=a.get('image_url'), release_date=a.get('release_date'),
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
)
fetched += len(albums)
print(f"[Your Albums] Fetched {len(albums)} from Spotify")
except Exception as e:
logger.error(f"[Your Albums] Spotify fetch error: {e}")
# 2. Fetch from Tidal (favorite albums)
try:
if 'tidal' not in enabled_sources:
print("[Your Albums] Tidal skipped (disabled in sources config)")
elif tidal_client and hasattr(tidal_client, 'get_favorite_albums'):
tidal_auth = tidal_client._ensure_valid_token() if hasattr(tidal_client, '_ensure_valid_token') else False
if tidal_auth:
print("[Your Albums] Fetching favorite albums from Tidal...")
albums = tidal_client.get_favorite_albums(limit=500)
for a in albums:
database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'],
source_service='tidal',
source_id=a.get('tidal_id'), source_id_type='tidal',
image_url=a.get('image_url'), release_date=a.get('release_date'),
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
)
fetched += len(albums)
print(f"[Your Albums] Fetched {len(albums)} from Tidal")
except Exception as e:
logger.error(f"[Your Albums] Tidal fetch error: {e}")
# 3. Fetch from Deezer (favorite albums — OAuth or ARL)
try:
if 'deezer' not in enabled_sources:
print("[Your Albums] Deezer skipped (disabled in sources config)")
else:
deezer_cl = _get_deezer_client()
albums = []
if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated():
print("[Your Albums] Fetching favorite albums from Deezer (OAuth)...")
albums = deezer_cl.get_user_favorite_albums(limit=500)
elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl
and soulseek_client.deezer_dl.is_authenticated()):
print("[Your Albums] Fetching favorite albums from Deezer (ARL)...")
albums = soulseek_client.deezer_dl.get_user_favorite_albums(limit=500)
for a in albums:
database.upsert_liked_album(
album_name=a['album_name'], artist_name=a['artist_name'],
source_service='deezer',
source_id=a.get('deezer_id'), source_id_type='deezer',
image_url=a.get('image_url'), release_date=a.get('release_date'),
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
)
fetched += len(albums)
if albums:
print(f"[Your Albums] Fetched {len(albums)} from Deezer")
except Exception as e:
logger.error(f"[Your Albums] Deezer fetch error: {e}")
print(f"[Your Albums] Total fetched: {fetched}")
@app.route('/api/discover/your-artists/info/<artist_id>', methods=['GET'])
def get_your_artist_info(artist_id):
"""Get artist info for the Your Artists info modal. Checks library, cache, then API."""

View file

@ -3298,6 +3298,49 @@
</div>
</div>
<!-- Your Albums Section -->
<div class="discover-section" id="your-albums-section" style="display: none;">
<div class="discover-section-header">
<div>
<h2 class="discover-section-title">Your Albums</h2>
<p class="discover-section-subtitle" id="your-albums-subtitle">Albums you've saved across your music services</p>
</div>
<div class="discover-section-actions">
<button class="ya-header-btn ya-refresh-btn" id="your-albums-refresh-btn" onclick="refreshYourAlbums()" title="Refresh from services">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
</button>
<button class="ya-header-btn ya-settings-btn" onclick="openYourAlbumsSourcesModal()" title="Configure sources">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
</button>
<button class="ya-header-btn" id="your-albums-download-btn" onclick="downloadMissingYourAlbums()" style="display:none;" title="Download missing albums">
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
</button>
</div>
</div>
<div class="spotify-library-filters" id="your-albums-filters" style="display: none;">
<input type="text" class="spotify-library-search" id="your-albums-search"
placeholder="Search by artist or album..." oninput="debouncedYourAlbumsSearch()">
<select id="your-albums-status-filter" class="spotify-library-select" onchange="loadYourAlbumsGrid()">
<option value="all">All Albums</option>
<option value="missing">Missing</option>
<option value="owned">Owned</option>
</select>
<select id="your-albums-sort" class="spotify-library-select" onchange="loadYourAlbumsGrid()">
<option value="artist_name">Artist</option>
<option value="album_name">Album</option>
<option value="release_date">Release Date</option>
<option value="recent">Date Added</option>
</select>
</div>
<div class="spotify-library-grid" id="your-albums-grid">
<div class="discover-loading">
<div class="loading-spinner"></div>
<p>Loading your albums...</p>
</div>
</div>
<div class="spotify-library-pagination" id="your-albums-pagination" style="display: none;"></div>
</div>
<!-- Spotify Library Section -->
<div class="discover-section" id="spotify-library-section" style="display: none;">
<div class="discover-section-header">

View file

@ -2290,6 +2290,11 @@ const HELPER_CONTENT = {
description: 'Carousel of artists from your watchlist. Quick access to view their latest releases, discography, or manage watchlist settings.',
},
'#your-albums-section': {
title: 'Your Albums',
description: 'Albums you\'ve saved or liked across connected services (Spotify, Tidal, Deezer). Shows which are already in your library and lets you download missing ones.',
},
// ─── PERSONAL SETTINGS ─────────────────────────────────────────
'#personal-settings-btn': {

View file

@ -45605,12 +45605,16 @@ function _showMobileTrackActions(track, album) {
const actions = [];
if (track.file_path) {
actions.push({ icon: '▶', label: 'Play', action: () => {
actions.push({
icon: '▶', label: 'Play', action: () => {
playLibraryTrack({ id: track.id, title: track.title, file_path: track.file_path, bitrate: track.bitrate, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id }, album.title || '', artistName);
}});
actions.push({ icon: '+', label: 'Add to Queue', action: () => {
}
});
actions.push({
icon: '+', label: 'Add to Queue', action: () => {
addToQueue({ title: track.title || 'Unknown', artist: artistName, album: album.title || '', file_path: track.file_path, filename: track.file_path, is_library: true, image_url: albumArt, id: track.id, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id, bitrate: track.bitrate });
}});
}
});
}
if (admin && track.file_path) {
actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) });
@ -52221,6 +52225,7 @@ async function loadDiscoverPage() {
await Promise.all([
loadDiscoverHero(),
loadYourArtists(),
loadYourAlbums(),
loadSpotifyLibrarySection(),
loadDiscoverRecentReleases(),
loadSeasonalContent(), // Seasonal discovery
@ -53096,6 +53101,406 @@ async function loadDiscoverRecentReleases() {
}
}
// ===============================
// ===============================
// YOUR ALBUMS SECTION
// ===============================
let yourAlbums = [];
let yourAlbumsPage = 1;
let yourAlbumsTotal = 0;
const YOUR_ALBUMS_PAGE_SIZE = 48;
let _yourAlbumsSearchTimeout = null;
function debouncedYourAlbumsSearch() {
clearTimeout(_yourAlbumsSearchTimeout);
_yourAlbumsSearchTimeout = setTimeout(() => {
yourAlbumsPage = 1;
loadYourAlbumsGrid();
}, 400);
}
async function loadYourAlbums() {
const section = document.getElementById('your-albums-section');
if (!section) return;
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
if (!resp.ok) return;
const data = await resp.json();
if (!data.success) return;
const totalCount = (data.stats && data.stats.total) || 0;
if (totalCount === 0 && !data.stale) return; // Nothing to show yet
section.style.display = '';
yourAlbums = data.albums || [];
yourAlbumsTotal = data.total || 0;
yourAlbumsPage = 1;
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
const filters = document.getElementById('your-albums-filters');
if (filters && totalCount > 0) filters.style.display = '';
const downloadBtn = document.getElementById('your-albums-download-btn');
if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = '';
_renderYourAlbumsGrid(yourAlbums);
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
if (data.stale && totalCount === 0) {
const grid = document.getElementById('your-albums-grid');
if (grid) grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Fetching your albums from connected services...</p></div>';
_pollYourAlbums();
}
} catch (e) {
console.error('Error loading your albums:', e);
}
}
function _pollYourAlbums() {
let attempts = 0;
const poll = setInterval(async () => {
attempts++;
if (attempts > 12) { clearInterval(poll); return; }
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
if (!resp.ok) return;
const data = await resp.json();
if (!data.success) return;
const total = (data.stats && data.stats.total) || 0;
if (total > 0) {
clearInterval(poll);
loadYourAlbums();
}
} catch (e) { }
}, 5000);
}
async function loadYourAlbumsGrid() {
const grid = document.getElementById('your-albums-grid');
if (!grid) return;
grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading...</p></div>';
try {
const search = (document.getElementById('your-albums-search')?.value || '').trim();
const status = document.getElementById('your-albums-status-filter')?.value || 'all';
const sort = document.getElementById('your-albums-sort')?.value || 'artist_name';
const params = new URLSearchParams({ page: yourAlbumsPage, per_page: YOUR_ALBUMS_PAGE_SIZE, sort, status });
if (search) params.set('search', search);
const resp = await fetch(`/api/discover/your-albums?${params}`);
const data = await resp.json();
if (!data.success) throw new Error(data.error);
yourAlbums = data.albums || [];
yourAlbumsTotal = data.total || 0;
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle && data.stats) {
const s = data.stats;
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
}
_renderYourAlbumsGrid(yourAlbums);
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
} catch (e) {
console.error('Error loading your albums grid:', e);
grid.innerHTML = '<div class="spotify-library-empty"><p>Failed to load albums</p></div>';
}
}
function _renderYourAlbumsGrid(albums) {
const grid = document.getElementById('your-albums-grid');
if (!grid) return;
if (!albums || albums.length === 0) {
grid.innerHTML = '<div class="spotify-library-empty"><p>No albums found</p></div>';
return;
}
let html = '';
albums.forEach((album, index) => {
const coverUrl = album.image_url || '/static/placeholder-album.png';
const year = album.release_date ? album.release_date.substring(0, 4) : '';
const badgeClass = album.in_library ? 'owned' : 'missing';
const badgeIcon = album.in_library ? '\u2713' : '\u2193';
const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : '';
const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 ');
const sources = (album.source_services || []).join(', ');
html += `
<div class="spotify-library-card" onclick="openYourAlbumDownload(${index})" title="${escapeHtml(album.album_name)} \u2014 ${escapeHtml(album.artist_name)}">
<div class="spotify-library-card-img">
<img src="${coverUrl}" alt="${escapeHtml(album.album_name)}" loading="lazy">
<div class="spotify-library-card-badge ${badgeClass}">${badgeIcon}</div>
</div>
<div class="spotify-library-card-info">
<p class="spotify-library-card-title">${escapeHtml(album.album_name)}</p>
<p class="spotify-library-card-artist">${escapeHtml(album.artist_name)}</p>
<p class="spotify-library-card-meta">${escapeHtml(meta)}</p>
</div>
</div>`;
});
grid.innerHTML = html;
}
function _renderYourAlbumsPagination(total, page) {
const container = document.getElementById('your-albums-pagination');
if (!container) return;
if (total <= YOUR_ALBUMS_PAGE_SIZE) { container.style.display = 'none'; return; }
container.style.display = '';
const totalPages = Math.ceil(total / YOUR_ALBUMS_PAGE_SIZE);
const start = (page - 1) * YOUR_ALBUMS_PAGE_SIZE + 1;
const end = Math.min(page * YOUR_ALBUMS_PAGE_SIZE, total);
container.innerHTML = `
<button class="spotify-library-page-btn" onclick="_yourAlbumsPrevPage()" ${page <= 1 ? 'disabled' : ''}>&larr; Previous</button>
<span class="spotify-library-page-info">${start}\u2013${end} of ${total}</span>
<button class="spotify-library-page-btn" onclick="_yourAlbumsNextPage()" ${page >= totalPages ? 'disabled' : ''}>Next &rarr;</button>
`;
}
function _yourAlbumsPrevPage() {
if (yourAlbumsPage > 1) { yourAlbumsPage--; loadYourAlbumsGrid(); }
}
function _yourAlbumsNextPage() {
const totalPages = Math.ceil(yourAlbumsTotal / YOUR_ALBUMS_PAGE_SIZE);
if (yourAlbumsPage < totalPages) { yourAlbumsPage++; loadYourAlbumsGrid(); }
}
async function openYourAlbumDownload(index) {
const album = yourAlbums[index];
if (!album) { showToast('Album data not found', 'error'); return; }
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
try {
// Prefer Spotify ID, fall back to Deezer, then search by name
let albumData = null;
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
if (album.spotify_album_id) {
const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData && album.deezer_album_id) {
const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData) {
// Last resort — search by name
const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
throw new Error('No tracks found for this album');
}
const tracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id, name: track.name, artists,
album: {
id: albumData.id, name: albumData.name,
album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0,
release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0,
track_number: track.track_number || 0
};
});
const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || album.tidal_album_id || index}`;
await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks,
{ name: album.artist_name, source: albumData.source || 'spotify' },
{
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
}
);
hideLoadingOverlay();
} catch (e) {
console.error('Error opening your album download:', e);
showToast(`Failed to load album: ${e.message}`, 'error');
hideLoadingOverlay();
}
}
async function refreshYourAlbums() {
const btn = document.getElementById('your-albums-refresh-btn');
if (btn) btn.disabled = true;
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle) subtitle.textContent = 'Refreshing from connected services...';
try {
await fetch('/api/discover/your-albums/refresh?clear=true', { method: 'POST' });
showToast('Refresh started — checking for new albums...', 'info');
const poll = setInterval(async () => {
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48');
const data = await resp.json();
if (data.success && data.stats && data.stats.total > 0) {
clearInterval(poll);
loadYourAlbums();
if (btn) btn.disabled = false;
}
} catch (e) { }
}, 4000);
setTimeout(() => { clearInterval(poll); if (btn) btn.disabled = false; }, 60000);
} catch (e) {
showToast('Failed to start refresh', 'error');
if (btn) btn.disabled = false;
}
}
async function openYourAlbumsSourcesModal() {
const existing = document.getElementById('ya-albums-sources-modal-overlay');
if (existing) existing.remove();
let enabled = ['spotify', 'tidal', 'deezer'];
let connected = [];
try {
const resp = await fetch('/api/discover/your-albums/sources');
if (resp.ok) {
const data = await resp.json();
if (data.enabled) enabled = data.enabled;
if (data.connected) connected = data.connected;
}
} catch (e) { }
const sourceInfo = [
{ id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' },
{ id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' },
{ id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' },
];
const state = {};
sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); });
const overlay = document.createElement('div');
overlay.id = 'ya-albums-sources-modal-overlay';
overlay.className = 'modal-overlay';
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
const rows = sourceInfo.map(s => {
const isConnected = connected.includes(s.id);
const isOn = state[s.id];
return `
<div class="ya-source-row${isConnected ? '' : ' disconnected'}" data-yaa-source="${s.id}" onclick="_yaaSourceRowClick('${s.id}')">
<div class="ya-source-row-left">
<span style="font-size:18px">${s.icon}</span>
<div>
<div class="ya-source-name">${s.label}</div>
<div class="ya-source-status">${isConnected ? 'Connected' : 'Not connected'}</div>
</div>
</div>
<button class="ya-source-toggle${isOn ? ' on' : ''}" id="yaa-toggle-${s.id}" onclick="event.stopPropagation();_yaaSourceToggle('${s.id}')"></button>
</div>`;
}).join('');
overlay.innerHTML = `
<div class="ya-sources-modal">
<h2>Your Albums Sources</h2>
<p class="ya-sources-desc">Choose which connected services contribute albums to this section.</p>
<div class="ya-sources-list">${rows}</div>
<div class="ya-sources-footer">
<button class="ya-sources-cancel-btn" onclick="document.getElementById('ya-albums-sources-modal-overlay').remove()">Cancel</button>
<button class="ya-sources-save-btn" onclick="_yaaSourcesSave()">Save</button>
</div>
</div>
`;
document.body.appendChild(overlay);
window._yaaSourcesState = state;
}
function _yaaSourceRowClick(id) {
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
if (row && row.classList.contains('disconnected')) return;
_yaaSourceToggle(id);
}
function _yaaSourceToggle(id) {
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
if (row && row.classList.contains('disconnected')) return;
window._yaaSourcesState[id] = !window._yaaSourcesState[id];
const btn = document.getElementById(`yaa-toggle-${id}`);
if (btn) btn.classList.toggle('on', window._yaaSourcesState[id]);
}
async function _yaaSourcesSave() {
const enabledArr = Object.entries(window._yaaSourcesState).filter(([, v]) => v).map(([k]) => k);
if (enabledArr.length === 0) { showToast('Select at least one source', 'error'); return; }
try {
const resp = await fetch('/api/settings', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ discover: { your_albums_sources: enabledArr.join(',') } })
});
if (resp.ok) {
document.getElementById('ya-albums-sources-modal-overlay')?.remove();
showToast('Sources saved — refresh to apply', 'success');
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer' };
const subtitle = document.getElementById('your-albums-subtitle');
if (subtitle) {
const names = enabledArr.map(s => sourceNames[s] || s).join(' and ');
subtitle.textContent = `Albums you\u2019ve saved on ${names}`;
}
} else {
showToast('Failed to save sources', 'error');
}
} catch (e) {
showToast('Failed to save sources', 'error');
}
}
async function downloadMissingYourAlbums() {
try {
const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing');
const data = await resp.json();
if (!data.success || !data.albums || data.albums.length === 0) {
showToast('No missing albums to download', 'info');
return;
}
const missing = data.albums.filter(a => !a.in_library);
if (missing.length === 0) { showToast('All albums are already in your library!', 'success'); return; }
if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your saved albums?`)) return;
showToast(`Starting download for ${missing.length} albums...`, 'info');
for (let i = 0; i < missing.length; i++) {
const album = missing[i];
try {
showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info');
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
let albumData = null;
if (album.spotify_album_id) {
const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData && album.deezer_album_id) {
const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`);
if (r.ok) albumData = await r.json();
}
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) continue;
const tracks = albumData.tracks.map(track => {
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
return {
id: track.id, name: track.name, artists,
album: {
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
},
duration_ms: track.duration_ms || 0, track_number: track.track_number || 0
};
});
const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || i}`;
await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks,
{ name: album.artist_name, source: albumData.source || 'spotify' },
{
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
images: albumData.images || []
}
);
} catch (err) { console.error(`Error queuing ${album.album_name}:`, err); }
}
} catch (e) {
console.error('Error downloading missing your albums:', e);
showToast(`Error: ${e.message}`, 'error');
}
}
// ===============================
// SPOTIFY LIBRARY SECTION
// ===============================
@ -63990,8 +64395,10 @@ async function loadStatsData() {
if (!data.success) {
// Cache not available — show empty state, user should hit Sync
data = { overview: {}, top_artists: [], top_albums: [], top_tracks: [],
timeline: [], genres: [], recent: [], health: {} };
data = {
overview: {}, top_artists: [], top_albums: [], top_tracks: [],
timeline: [], genres: [], recent: [], health: {}
};
}
const overview = data.overview || {};
@ -67059,61 +67466,102 @@ const AUTO_HUB_GROUPS = [
const AUTO_HUB_RECIPES = [
// Sync & Playlists
{ id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.',
category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] },
{ id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.',
{
id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.',
category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
},
{
id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.',
category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [],
chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.' },
{ id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.',
category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] },
{ id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.',
category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] },
{ id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.',
category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: [] },
chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.'
},
{
id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.',
category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
},
{
id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.',
category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
},
{
id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.',
category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: []
},
// New Music Discovery
{ id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.',
{
id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.',
category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }],
chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'],
note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.' },
{ id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [] },
{ id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] },
{ id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: [] },
{ id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] },
note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.'
},
{
id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: []
},
{
id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }]
},
{
id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: []
},
{
id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.',
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: []
},
// Library Maintenance
{ id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.',
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: [] },
{ id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: [] },
{ id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.',
category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: [] },
{ id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.',
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }] },
{ id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.',
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: [] },
{
id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.',
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: []
},
{
id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: []
},
{
id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.',
category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: []
},
{
id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.',
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }]
},
{
id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.',
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: []
},
// Notifications & Alerts
{ id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] },
{ id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.',
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }] },
{ id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.',
category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] },
{
id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
},
{
id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.',
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }]
},
{
id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.',
category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }]
},
// Power User Chains
{ id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.',
{
id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.',
category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }],
chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'],
note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.' },
{ id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.',
note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.'
},
{
id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.',
category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [],
chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'],
note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.' },
note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.'
},
];
const AUTO_HUB_GUIDES = [
{ id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner',
{
id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner',
steps: [
'Go to the <strong>Playlists</strong> page and find a Spotify playlist you want to track.',
'Click <strong>Mirror Playlist</strong> to create a local copy.',
@ -67121,16 +67569,20 @@ const AUTO_HUB_GUIDES = [
'Set WHEN to <strong>Schedule \u2192 Every 6 hours</strong>.',
'Set DO to <strong>Refresh Mirrored Playlists</strong>.',
'Save and enable \u2014 your playlist will now stay in sync automatically.'
], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab'] },
{ id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner',
], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab']
},
{
id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner',
steps: [
'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.',
'Create a webhook and copy the URL.',
'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.',
'Go to <strong>Automations \u2192 New Automation</strong>.',
'Set WHEN to <strong>Download Failed</strong> (or any event), DO to <strong>Notify Only</strong>, THEN to <strong>Discord</strong>.'
], relatedRecipes: ['download-failure-alert', 'batch-complete-notify'] },
{ id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate',
], relatedRecipes: ['download-failure-alert', 'batch-complete-notify']
},
{
id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate',
steps: [
'Create Automation 1: <strong>Schedule (12h) \u2192 Scan Watchlist</strong>, THEN fire signal <code>scan_done</code>.',
'Create Automation 2: <strong>Signal scan_done \u2192 Process Wishlist</strong>, THEN fire signal <code>process_done</code>.',
@ -67139,8 +67591,10 @@ const AUTO_HUB_GUIDES = [
'Test by manually running Automation 1 \u2014 watch the chain execute.',
'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.',
'Adjust the schedule interval based on how often you want new music checked.'
], relatedRecipes: ['complete-new-release', 'full-hands-free'] },
{ id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced',
], relatedRecipes: ['complete-new-release', 'full-hands-free']
},
{
id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced',
steps: [
'Understand the concept: <strong>fire_signal</strong> is a THEN action that emits a named signal. <strong>signal_received</strong> is a WHEN trigger that listens for it.',
'In your first automation, add a THEN action \u2192 <strong>Fire Signal</strong> and name it (e.g., <code>step1_done</code>).',
@ -67148,15 +67602,18 @@ const AUTO_HUB_GUIDES = [
'The second automation will fire automatically when the first one completes.',
'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.',
'Use descriptive signal names like <code>watchlist_scanned</code> or <code>cleanup_finished</code>.'
], relatedRecipes: ['quality-assurance', 'complete-new-release'] },
{ id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate',
], relatedRecipes: ['quality-assurance', 'complete-new-release']
},
{
id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate',
steps: [
'Create a <strong>Daily Time (04:00) \u2192 Backup Database</strong> automation.',
'Create a <strong>Weekly Time (Saturday, 05:00) \u2192 Full Cleanup</strong> automation.',
'Create a <strong>Daily Time (02:00) \u2192 Update Discovery Pool</strong> automation.',
'Stagger times by at least 1 hour to avoid resource contention.',
'Add Discord/Telegram notifications to any you want alerts for.'
], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly'] },
], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly']
},
];
const AUTO_HUB_TIPS = [
@ -67172,45 +67629,58 @@ const AUTO_HUB_TIPS = [
const AUTO_HUB_REFERENCE = {
triggers: [
{ group: 'Time-Based', items: [
{
group: 'Time-Based', items: [
{ type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' },
{ type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' },
{ type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' },
]},
{ group: 'Download Events', items: [
]
},
{
group: 'Download Events', items: [
{ type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' },
{ type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' },
{ type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' },
{ type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' },
]},
{ group: 'Watchlist & Wishlist', items: [
]
},
{
group: 'Watchlist & Wishlist', items: [
{ type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' },
{ type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' },
{ type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' },
{ type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' },
{ type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' },
{ type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' },
]},
{ group: 'Playlists', items: [
]
},
{
group: 'Playlists', items: [
{ type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' },
{ type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' },
{ type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' },
{ type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' },
]},
{ group: 'Library & System', items: [
]
},
{
group: 'Library & System', items: [
{ type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' },
{ type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' },
{ type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' },
{ type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' },
{ type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' },
{ type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' },
]},
{ group: 'Signals', items: [
]
},
{
group: 'Signals', items: [
{ type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' },
]},
]
},
],
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: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' },
@ -67218,34 +67688,45 @@ const AUTO_HUB_REFERENCE = {
{ type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' },
{ type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' },
{ type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' },
]},
{ group: 'Library Tools', items: [
]
},
{
group: 'Library Tools', items: [
{ type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' },
{ type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' },
{ type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' },
{ type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' },
]},
{ group: 'Cleanup', items: [
]
},
{
group: 'Cleanup', items: [
{ type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' },
{ type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' },
{ type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' },
{ type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' },
{ type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' },
{ type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' },
]},
{ group: 'Other', items: [
]
},
{
group: 'Other', items: [
{ type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' },
]},
]
},
],
thenActions: [
{ group: 'Notifications', items: [
{
group: 'Notifications', items: [
{ type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' },
{ type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' },
{ type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' },
]},
{ group: 'Chaining', items: [
]
},
{
group: 'Chaining', items: [
{ type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' },
]},
]
},
],
};
@ -68121,7 +68602,8 @@ function _autoFormatTrigger(type, config) {
const sig = config.signal_name || 'unknown';
return 'Signal: ' + sig;
}
const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
const labels = {
app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
@ -68130,7 +68612,8 @@ function _autoFormatTrigger(type, config) {
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done',
library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' };
library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received'
};
let label = labels[type] || type || 'Unknown';
if (config && config.conditions && config.conditions.length) {
const first = config.conditions[0];
@ -68140,7 +68623,8 @@ function _autoFormatTrigger(type, config) {
return label;
}
function _autoFormatAction(type) {
const labels = { process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
const labels = {
process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist',
notify_only: 'Notify Only',
@ -68151,7 +68635,8 @@ function _autoFormatAction(type) {
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
clean_completed_downloads: 'Clean Completed Downloads',
full_cleanup: 'Full Cleanup',
playlist_pipeline: 'Playlist Pipeline' };
playlist_pipeline: 'Playlist Pipeline'
};
return labels[type] || type || 'Unknown';
}
function _autoFormatNotify(type) {
@ -68816,7 +69301,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
return `<div class="config-row">
<label>Signal Name</label>
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
list="known-signals-list-${slotKey}" placeholder="e.g. library_ready"
list="known-signals-list-${slotKey}" placeholder="e.g. libraryReady"
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
style="font-family:monospace;">
<datalist id="known-signals-list-${slotKey}">
@ -68831,7 +69316,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
return `<div class="config-row">
<label>Signal Name</label>
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
list="known-signals-fire-${slotKey}" placeholder="e.g. library_ready"
list="known-signals-fire-${slotKey}" placeholder="e.g. libraryReady"
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
style="font-family:monospace;">
<datalist id="known-signals-fire-${slotKey}">