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}") logger.error(f"Error fetching Deezer favorite artists: {e}")
return [] 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) ==================== # ==================== Stub Methods (match iTunesClient interface) ====================
def get_user_playlists(self) -> List[Playlist]: 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)") logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
return artists 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]: def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access).""" """Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
try: try:

View file

@ -1497,6 +1497,128 @@ class TidalClient:
logger.error(f"Error fetching Tidal favorite artists: {e}") logger.error(f"Error fetching Tidal favorite artists: {e}")
return [] 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 # Global instance
_tidal_client = None _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_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)") 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") logger.info("Discovery tables added/verified successfully")
except Exception as e: except Exception as e:
@ -9298,6 +9322,181 @@ class MusicDatabase:
logger.error(f"Error clearing liked artists: {e}") logger.error(f"Error clearing liked artists: {e}")
return 0 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 ==================== # ==================== Track Download Provenance Methods ====================
def record_track_download(self, file_path: str, source_service: str, source_username: str, 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}") 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']) @app.route('/api/discover/your-artists/info/<artist_id>', methods=['GET'])
def get_your_artist_info(artist_id): def get_your_artist_info(artist_id):
"""Get artist info for the Your Artists info modal. Checks library, cache, then API.""" """Get artist info for the Your Artists info modal. Checks library, cache, then API."""

View file

@ -3298,6 +3298,49 @@
</div> </div>
</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 --> <!-- Spotify Library Section -->
<div class="discover-section" id="spotify-library-section" style="display: none;"> <div class="discover-section" id="spotify-library-section" style="display: none;">
<div class="discover-section-header"> <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.', 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 ─────────────────────────────────────────
'#personal-settings-btn': { '#personal-settings-btn': {

View file

@ -45605,12 +45605,16 @@ function _showMobileTrackActions(track, album) {
const actions = []; const actions = [];
if (track.file_path) { 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); 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 }); 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) { if (admin && track.file_path) {
actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) }); actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) });
@ -52221,6 +52225,7 @@ async function loadDiscoverPage() {
await Promise.all([ await Promise.all([
loadDiscoverHero(), loadDiscoverHero(),
loadYourArtists(), loadYourArtists(),
loadYourAlbums(),
loadSpotifyLibrarySection(), loadSpotifyLibrarySection(),
loadDiscoverRecentReleases(), loadDiscoverRecentReleases(),
loadSeasonalContent(), // Seasonal discovery 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 // SPOTIFY LIBRARY SECTION
// =============================== // ===============================
@ -63990,8 +64395,10 @@ async function loadStatsData() {
if (!data.success) { if (!data.success) {
// Cache not available — show empty state, user should hit Sync // Cache not available — show empty state, user should hit Sync
data = { overview: {}, top_artists: [], top_albums: [], top_tracks: [], data = {
timeline: [], genres: [], recent: [], health: {} }; overview: {}, top_artists: [], top_albums: [], top_tracks: [],
timeline: [], genres: [], recent: [], health: {}
};
} }
const overview = data.overview || {}; const overview = data.overview || {};
@ -67059,61 +67466,102 @@ const AUTO_HUB_GROUPS = [
const AUTO_HUB_RECIPES = [ const AUTO_HUB_RECIPES = [
// Sync & Playlists // 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: '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.',
{ 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: '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: [], 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.' }, 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.', 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: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] }, category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
{ 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: [] }, {
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 // 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' } }], 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'], 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.' }, 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.', 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: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] }, category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: []
{ 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.', 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: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] }, 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 // 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: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.',
{ id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.', category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: []
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: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
{ 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: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: []
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: '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 // 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: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
{ 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_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
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: '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 // 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' } }], 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'], 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.' }, 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.', },
{
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: [], 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'], 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 = [ 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: [ steps: [
'Go to the <strong>Playlists</strong> page and find a Spotify playlist you want to track.', '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.', '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 WHEN to <strong>Schedule \u2192 Every 6 hours</strong>.',
'Set DO to <strong>Refresh Mirrored Playlists</strong>.', 'Set DO to <strong>Refresh Mirrored Playlists</strong>.',
'Save and enable \u2014 your playlist will now stay in sync automatically.' 'Save and enable \u2014 your playlist will now stay in sync automatically.'
], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab'] }, ], 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', },
{
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: [ steps: [
'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.', 'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.',
'Create a webhook and copy the URL.', 'Create a webhook and copy the URL.',
'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.', 'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.',
'Go to <strong>Automations \u2192 New Automation</strong>.', '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>.' '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'] }, ], 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', },
{
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: [ steps: [
'Create Automation 1: <strong>Schedule (12h) \u2192 Scan Watchlist</strong>, THEN fire signal <code>scan_done</code>.', '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>.', '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.', 'Test by manually running Automation 1 \u2014 watch the chain execute.',
'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.', '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.' 'Adjust the schedule interval based on how often you want new music checked.'
], relatedRecipes: ['complete-new-release', 'full-hands-free'] }, ], 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', },
{
id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced',
steps: [ 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.', '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>).', '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.', 'The second automation will fire automatically when the first one completes.',
'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.', '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>.' 'Use descriptive signal names like <code>watchlist_scanned</code> or <code>cleanup_finished</code>.'
], relatedRecipes: ['quality-assurance', 'complete-new-release'] }, ], 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', },
{
id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate',
steps: [ steps: [
'Create a <strong>Daily Time (04:00) \u2192 Backup Database</strong> automation.', '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>Weekly Time (Saturday, 05:00) \u2192 Full Cleanup</strong> automation.',
'Create a <strong>Daily Time (02:00) \u2192 Update Discovery Pool</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.', 'Stagger times by at least 1 hour to avoid resource contention.',
'Add Discord/Telegram notifications to any you want alerts for.' '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 = [ const AUTO_HUB_TIPS = [
@ -67172,45 +67629,58 @@ const AUTO_HUB_TIPS = [
const AUTO_HUB_REFERENCE = { const AUTO_HUB_REFERENCE = {
triggers: [ triggers: [
{ group: 'Time-Based', items: [ {
group: 'Time-Based', items: [
{ type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' }, { 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: '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)' }, { 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: '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: '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_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' }, { 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_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_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_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: '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_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' }, { 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_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: '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: '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' }, { 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: '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: '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: '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: '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: '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' }, { 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' }, { type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' },
]}, ]
},
], ],
actions: [ actions: [
{ group: 'Downloads & Sync', items: [ {
group: 'Downloads & Sync', items: [
{ type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' }, { type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' },
{ type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' }, { type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' },
{ type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' }, { type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' },
@ -67218,34 +67688,45 @@ const AUTO_HUB_REFERENCE = {
{ type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' }, { 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: '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' }, { 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: '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_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: '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' }, { 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: '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: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' },
{ type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' }, { type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' },
{ type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' }, { 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_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' },
{ type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' }, { 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.' }, { type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' },
]}, ]
},
], ],
thenActions: [ thenActions: [
{ group: 'Notifications', items: [ {
group: 'Notifications', items: [
{ type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' }, { 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: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' },
{ type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' }, { 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' }, { 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'; const sig = config.signal_name || 'unknown';
return 'Signal: ' + sig; 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', watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete', playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done', 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', watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored', import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done', 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'; let label = labels[type] || type || 'Unknown';
if (config && config.conditions && config.conditions.length) { if (config && config.conditions && config.conditions.length) {
const first = config.conditions[0]; const first = config.conditions[0];
@ -68140,7 +68623,8 @@ function _autoFormatTrigger(type, config) {
return label; return label;
} }
function _autoFormatAction(type) { 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', scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist', sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist',
notify_only: 'Notify Only', notify_only: 'Notify Only',
@ -68151,7 +68635,8 @@ function _autoFormatAction(type) {
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History', refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
clean_completed_downloads: 'Clean Completed Downloads', clean_completed_downloads: 'Clean Completed Downloads',
full_cleanup: 'Full Cleanup', full_cleanup: 'Full Cleanup',
playlist_pipeline: 'Playlist Pipeline' }; playlist_pipeline: 'Playlist Pipeline'
};
return labels[type] || type || 'Unknown'; return labels[type] || type || 'Unknown';
} }
function _autoFormatNotify(type) { function _autoFormatNotify(type) {
@ -68816,7 +69301,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
return `<div class="config-row"> return `<div class="config-row">
<label>Signal Name</label> <label>Signal Name</label>
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}" <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, '')" oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
style="font-family:monospace;"> style="font-family:monospace;">
<datalist id="known-signals-list-${slotKey}"> <datalist id="known-signals-list-${slotKey}">
@ -68831,7 +69316,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
return `<div class="config-row"> return `<div class="config-row">
<label>Signal Name</label> <label>Signal Name</label>
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}" <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, '')" oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
style="font-family:monospace;"> style="font-family:monospace;">
<datalist id="known-signals-fire-${slotKey}"> <datalist id="known-signals-fire-${slotKey}">