diff --git a/core/deezer_client.py b/core/deezer_client.py index cf7a6186..0684cad2 100644 --- a/core/deezer_client.py +++ b/core/deezer_client.py @@ -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]: diff --git a/core/deezer_download_client.py b/core/deezer_download_client.py index d0f290e2..e34c7f8e 100644 --- a/core/deezer_download_client.py +++ b/core/deezer_download_client.py @@ -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: diff --git a/core/tidal_client.py b/core/tidal_client.py index 22c3507c..254af6eb 100644 --- a/core/tidal_client.py +++ b/core/tidal_client.py @@ -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 diff --git a/database/music_database.py b/database/music_database.py index eba95e57..8a95c172 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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, diff --git a/web_server.py b/web_server.py index 58b980b0..977c08d8 100644 --- a/web_server.py +++ b/web_server.py @@ -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/', methods=['GET']) def get_your_artist_info(artist_id): """Get artist info for the Your Artists info modal. Checks library, cache, then API.""" diff --git a/webui/index.html b/webui/index.html index 352ef469..6c96be56 100644 --- a/webui/index.html +++ b/webui/index.html @@ -3298,6 +3298,49 @@ + + + `; @@ -17613,7 +17613,7 @@ function _downloadMusicVideo(cardEl, video) { if (errorIcon) errorIcon.classList.remove('hidden'); cardEl.onclick = () => _downloadMusicVideo(cardEl, video); } - } catch (e) {} + } catch (e) { } }, 500); }).catch(e => { cardEl.classList.remove('downloading'); @@ -17854,7 +17854,7 @@ async function _gsFetchSourceStream(src, query) { if (_gsState.activeSource === src && _gsState.data) { _gsRender(_gsState.data); } - } catch (e) {} + } catch (e) { } } } _gsRenderTabs(); @@ -17885,7 +17885,7 @@ function _gsRender(data) { h += '
'; h += videos.map(v => { const dur = v.duration ? `${Math.floor(v.duration / 60)}:${String(v.duration % 60).padStart(2, '0')}` : ''; - const views = v.view_count >= 1000000 ? `${(v.view_count/1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count/1000).toFixed(1)}K` : (v.view_count || ''); + const views = v.view_count >= 1000000 ? `${(v.view_count / 1000000).toFixed(1)}M` : v.view_count >= 1000 ? `${(v.view_count / 1000).toFixed(1)}K` : (v.view_count || ''); const vJson = btoa(unescape(encodeURIComponent(JSON.stringify(v)))); return `
@@ -18444,8 +18444,8 @@ function populateVersionModal(versionData, updateInfo) {
${isDocker ? 'Repo update detected' : 'New update available'} ${isDocker - ? 'A new update has been pushed to the repo. The Docker image will be updated soon — no action needed yet.' - : `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`} + ? 'A new update has been pushed to the repo. The Docker image will be updated soon — no action needed yet.' + : `Your version: ${updateInfo.current_sha || 'unknown'} → Latest: ${updateInfo.latest_sha || 'unknown'}`}
`; container.appendChild(banner); @@ -21798,7 +21798,7 @@ function renderHistoryEntry(entry) { const srcArtist = entry.source_artist || ''; if (srcTitle || srcArtist) { const isMismatch = (srcTitle && entry.title && srcTitle.toLowerCase() !== entry.title.toLowerCase()) - || (srcArtist && entry.artist_name && srcArtist.toLowerCase() !== entry.artist_name.toLowerCase()); + || (srcArtist && entry.artist_name && srcArtist.toLowerCase() !== entry.artist_name.toLowerCase()); const mismatchClass = isMismatch ? ' lh-prov-mismatch' : ''; lines.push(`Downloaded: ${escapeHtml(srcTitle || '?')} by ${escapeHtml(srcArtist || '?')}`); } @@ -22513,7 +22513,7 @@ async function openBlacklistModal() { body.innerHTML = data.entries.map(e => { const displayFile = (e.blocked_filename || '').replace(/\\/g, '/').split('/').pop() || 'Unknown'; - const svc = e.blocked_username && ['youtube','tidal','qobuz','hifi','deezer_dl'].includes(e.blocked_username) ? e.blocked_username : 'soulseek'; + const svc = e.blocked_username && ['youtube', 'tidal', 'qobuz', 'hifi', 'deezer_dl'].includes(e.blocked_username) ? e.blocked_username : 'soulseek'; const icon = serviceIcons[svc] || '🔍'; const ago = e.created_at ? timeAgo(e.created_at) : ''; return ` @@ -28189,7 +28189,7 @@ function initializeSyncPage() { } else if (container) { container.innerHTML = `
Deezer ARL not configured. Add your ARL token in Settings > Downloads to see your playlists here.
`; } - }).catch(() => {}); + }).catch(() => { }); } // Auto-load mirrored playlists on first tab activation @@ -32040,9 +32040,9 @@ async function startSpotifyPublicDownloadMissing(urlHash) { const URL_HISTORY_MAX = 10; const URL_HISTORY_SOURCES = { - youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() }, - deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() }, - 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() } + youtube: { key: 'soulsync-url-history-youtube', icon: '▶', inputId: 'youtube-url-input', containerId: 'youtube-url-history', loadFn: () => parseYouTubePlaylist() }, + deezer: { key: 'soulsync-url-history-deezer', icon: '🎵', inputId: 'deezer-url-input', containerId: 'deezer-url-history', loadFn: () => loadDeezerPlaylist() }, + 'spotify-public': { key: 'soulsync-url-history-spotify-public', icon: '🎧', inputId: 'spotify-public-url-input', containerId: 'spotify-public-url-history', loadFn: () => parseSpotifyPublicUrl() } }; function getUrlHistory(source) { @@ -32692,18 +32692,18 @@ function openYouTubeDiscoveryModal(urlHash) { const isMirrored = state.is_mirrored_playlist; const modalTitle = isMirrored ? '🎵 Mirrored Playlist Discovery' : isSpotifyPublic ? '🎵 Spotify Playlist Discovery' : - isDeezer ? '🎵 Deezer Playlist Discovery' : - isTidal ? '🎵 Tidal Playlist Discovery' : - isBeatport ? '🎵 Beatport Chart Discovery' : - isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' : - '🎵 YouTube Playlist Discovery'; + isDeezer ? '🎵 Deezer Playlist Discovery' : + isTidal ? '🎵 Tidal Playlist Discovery' : + isBeatport ? '🎵 Beatport Chart Discovery' : + isListenBrainz ? '🎵 ListenBrainz Playlist Discovery' : + '🎵 YouTube Playlist Discovery'; const sourceLabel = isMirrored ? (state.mirrored_source ? state.mirrored_source.charAt(0).toUpperCase() + state.mirrored_source.slice(1) : 'Source') : isSpotifyPublic ? 'Spotify' : - isDeezer ? 'Deezer' : - isTidal ? 'Tidal' : - isBeatport ? 'Beatport' : - isListenBrainz ? 'LB' : - 'YT'; + isDeezer ? 'Deezer' : + isTidal ? 'Tidal' : + isBeatport ? 'Beatport' : + isListenBrainz ? 'LB' : + 'YT'; const modalHtml = ` `; @@ -56546,7 +56951,7 @@ async function refreshYourArtists() { if (btn) { btn.disabled = false; btn.style.opacity = ''; } showToast(`Found ${data.total} artists from your services`, 'success'); } - } catch (e) {} + } catch (e) { } }, 5000); } catch (err) { showToast('Failed to start refresh', 'error'); @@ -56568,13 +56973,13 @@ async function openYourArtistsSourcesModal() { if (data.enabled) enabled = data.enabled; if (data.connected) connected = data.connected; } - } catch (e) {} + } catch (e) { } const sourceInfo = [ - { id: 'spotify', label: 'Spotify', icon: '🎵' }, - { id: 'tidal', label: 'Tidal', icon: '🌊' }, - { id: 'lastfm', label: 'Last.fm', icon: '📻' }, - { id: 'deezer', label: 'Deezer', icon: '🎶' }, + { id: 'spotify', label: 'Spotify', icon: '🎵' }, + { id: 'tidal', label: 'Tidal', icon: '🌊' }, + { id: 'lastfm', label: 'Last.fm', icon: '📻' }, + { id: 'deezer', label: 'Deezer', icon: '🎶' }, ]; const state = {}; @@ -56773,7 +57178,7 @@ async function _yaLoadModal() { } } -function loadDiscoveryBlacklist() {} +function loadDiscoveryBlacklist() { } // ── Artist Map — Circle-packed staged canvas visualization ── const _artMap = { @@ -57323,125 +57728,125 @@ function _artMapRender() { const n = _artMap.hoveredNode || (_artMap._constellationCache ? (_artMap._nodeById || {})[_artMap._constellationCache.nodeId] : null); if (!n) { _artMap._constellationFade = 0; _artMap._constellationCache = null; } if (n) { - ctx.save(); - ctx.translate(_artMap.offsetX, _artMap.offsetY); - ctx.scale(z, z); + ctx.save(); + ctx.translate(_artMap.offsetX, _artMap.offsetY); + ctx.scale(z, z); - // Cache connected node lookup (don't recompute every frame) - if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) { - const connectedIds = new Set(); - if (n.type === 'watchlist') { - for (const e of _artMap.edges) { - if (e.source === n.id) connectedIds.add(e.target); - } - } else { - const sourceIds = new Set(); - for (const e of _artMap.edges) { - if (e.target === n.id) sourceIds.add(e.source); - } - for (const sid of sourceIds) { - connectedIds.add(sid); + // Cache connected node lookup (don't recompute every frame) + if (!_artMap._constellationCache || _artMap._constellationCache.nodeId !== n.id) { + const connectedIds = new Set(); + if (n.type === 'watchlist') { for (const e of _artMap.edges) { - if (e.source === sid) connectedIds.add(e.target); + if (e.source === n.id) connectedIds.add(e.target); + } + } else { + const sourceIds = new Set(); + for (const e of _artMap.edges) { + if (e.target === n.id) sourceIds.add(e.source); + } + for (const sid of sourceIds) { + connectedIds.add(sid); + for (const e of _artMap.edges) { + if (e.source === sid) connectedIds.add(e.target); + } } } - } - const nById = _artMap._nodeById || {}; - _artMap._constellationCache = { - nodeId: n.id, - nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)], - }; - } - - const highlightNodes = _artMap._constellationCache.nodes; - - if (highlightNodes.length > 1) { - // Semi-transparent dark overlay on entire visible area - ctx.save(); - ctx.resetTransform(); - ctx.globalAlpha = 0.6 * cFade; - ctx.fillStyle = '#0a0a14'; - ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height); - ctx.globalAlpha = 1; - ctx.restore(); - - // Draw glowing connection lines - for (const cn of highlightNodes) { - if (cn === n) continue; - ctx.beginPath(); - ctx.moveTo(n.x, n.y); - ctx.lineTo(cn.x, cn.y); - // Gradient line - const lineGrad = ctx.createLinearGradient(n.x, n.y, cn.x, cn.y); - lineGrad.addColorStop(0, `rgba(138,43,226,${0.5 * cFade})`); - lineGrad.addColorStop(1, `rgba(138,43,226,${0.15 * cFade})`); - ctx.strokeStyle = lineGrad; - ctx.lineWidth = 2; - ctx.stroke(); + const nById = _artMap._nodeById || {}; + _artMap._constellationCache = { + nodeId: n.id, + nodes: [n, ...[...connectedIds].map(id => nById[id]).filter(Boolean)], + }; } - // Redraw highlighted nodes on top - ctx.globalAlpha = cFade; - for (const hn of highlightNodes) { - const r = hn.radius; - const isW = hn.type === 'watchlist'; - const isHov = hn === n; + const highlightNodes = _artMap._constellationCache.nodes; - // Glow - if (isHov) { + if (highlightNodes.length > 1) { + // Semi-transparent dark overlay on entire visible area + ctx.save(); + ctx.resetTransform(); + ctx.globalAlpha = 0.6 * cFade; + ctx.fillStyle = '#0a0a14'; + ctx.fillRect(0, 0, _artMap.canvas.width, _artMap.canvas.height); + ctx.globalAlpha = 1; + ctx.restore(); + + // Draw glowing connection lines + for (const cn of highlightNodes) { + if (cn === n) continue; ctx.beginPath(); - ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(138,43,226,0.4)'; - ctx.lineWidth = 6; + ctx.moveTo(n.x, n.y); + ctx.lineTo(cn.x, cn.y); + // Gradient line + const lineGrad = ctx.createLinearGradient(n.x, n.y, cn.x, cn.y); + lineGrad.addColorStop(0, `rgba(138,43,226,${0.5 * cFade})`); + lineGrad.addColorStop(1, `rgba(138,43,226,${0.15 * cFade})`); + ctx.strokeStyle = lineGrad; + ctx.lineWidth = 2; ctx.stroke(); } - // Circle + image - ctx.save(); - ctx.beginPath(); - ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); - ctx.closePath(); - ctx.clip(); + // Redraw highlighted nodes on top + ctx.globalAlpha = cFade; + for (const hn of highlightNodes) { + const r = hn.radius; + const isW = hn.type === 'watchlist'; + const isHov = hn === n; - const img = _artMap.images[hn.id]; - if (img) { - ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2); - ctx.fillStyle = 'rgba(0,0,0,0.35)'; - ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); - } else { - ctx.fillStyle = isW ? '#1a0a30' : '#141420'; - ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + // Glow + if (isHov) { + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r + 8, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(138,43,226,0.4)'; + ctx.lineWidth = 6; + ctx.stroke(); + } + + // Circle + image + ctx.save(); + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.closePath(); + ctx.clip(); + + const img = _artMap.images[hn.id]; + if (img) { + ctx.drawImage(img, hn.x - r, hn.y - r, r * 2, r * 2); + ctx.fillStyle = 'rgba(0,0,0,0.35)'; + ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + } else { + ctx.fillStyle = isW ? '#1a0a30' : '#141420'; + ctx.fillRect(hn.x - r, hn.y - r, r * 2, r * 2); + } + ctx.restore(); + + // Border + ctx.beginPath(); + ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); + ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)'; + ctx.lineWidth = isHov ? 3 : 1.5; + ctx.stroke(); + + // Name + const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3); + ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillStyle = '#fff'; + const maxC = isW ? 20 : 12; + const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + '…' : hn.name; + ctx.fillText(label, hn.x, hn.y); } - ctx.restore(); - - // Border + ctx.globalAlpha = 1; + } else { + // Single node, no connections ctx.beginPath(); - ctx.arc(hn.x, hn.y, r, 0, Math.PI * 2); - ctx.strokeStyle = isHov ? 'rgba(255,255,255,0.7)' : isW ? 'rgba(138,43,226,0.5)' : 'rgba(255,255,255,0.3)'; - ctx.lineWidth = isHov ? 3 : 1.5; + ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); + ctx.strokeStyle = 'rgba(255,255,255,0.5)'; + ctx.lineWidth = 3; ctx.stroke(); - - // Name - const fontSize = isW ? Math.max(14, r * 0.14) : Math.max(8, r * 0.3); - ctx.font = `${isW ? '700' : '600'} ${fontSize}px system-ui`; - ctx.textAlign = 'center'; - ctx.textBaseline = 'middle'; - ctx.fillStyle = '#fff'; - const maxC = isW ? 20 : 12; - const label = hn.name.length > maxC ? hn.name.substring(0, maxC - 1) + '…' : hn.name; - ctx.fillText(label, hn.x, hn.y); } - ctx.globalAlpha = 1; - } else { - // Single node, no connections - ctx.beginPath(); - ctx.arc(n.x, n.y, n.radius + 4, 0, Math.PI * 2); - ctx.strokeStyle = 'rgba(255,255,255,0.5)'; - ctx.lineWidth = 3; - ctx.stroke(); - } - ctx.restore(); + ctx.restore(); } // end if(n) } else if (_artMap.hoveredNode && !_artMap._constellationActive) { // Pre-constellation: just show a simple highlight ring (instant, no delay) @@ -58065,11 +58470,11 @@ async function _openArtistMapExplorerWithName(name) { function _gc(x, y, r) { const cx = Math.floor(x / CELL), cy = Math.floor(y / CELL); for (let dx = -3; dx <= 3; dx++) for (let dy = -3; dy <= 3; dy++) { - const cell = grid[`${cx+dx},${cy+dy}`]; + const cell = grid[`${cx + dx},${cy + dy}`]; if (!cell) continue; for (const p of cell) { - const ddx = x-p.x, ddy = y-p.y; - if (ddx*ddx+ddy*ddy < (r+p.radius+BUF)*(r+p.radius+BUF)) return true; + const ddx = x - p.x, ddy = y - p.y; + if (ddx * ddx + ddy * ddy < (r + p.radius + BUF) * (r + p.radius + BUF)) return true; } } return false; @@ -58533,11 +58938,11 @@ async function openYourArtistInfoModal_direct(node) { let bestId = '', bestSource = ''; // Check what the active source is const activeSource = window._yaActiveSource || 'spotify'; - const sourceOrder = activeSource === 'spotify' ? ['spotify_id','itunes_id','deezer_id','discogs_id'] - : activeSource === 'itunes' ? ['itunes_id','spotify_id','deezer_id','discogs_id'] - : activeSource === 'deezer' ? ['deezer_id','spotify_id','itunes_id','discogs_id'] - : ['spotify_id','itunes_id','deezer_id','discogs_id']; - const sourceMap = {spotify_id:'spotify', itunes_id:'itunes', deezer_id:'deezer', discogs_id:'discogs'}; + const sourceOrder = activeSource === 'spotify' ? ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id'] + : activeSource === 'itunes' ? ['itunes_id', 'spotify_id', 'deezer_id', 'discogs_id'] + : activeSource === 'deezer' ? ['deezer_id', 'spotify_id', 'itunes_id', 'discogs_id'] + : ['spotify_id', 'itunes_id', 'deezer_id', 'discogs_id']; + const sourceMap = { spotify_id: 'spotify', itunes_id: 'itunes', deezer_id: 'deezer', discogs_id: 'discogs' }; for (const key of sourceOrder) { if (node[key]) { bestId = node[key]; bestSource = sourceMap[key]; break; } } @@ -58962,8 +59367,8 @@ async function openGenreDeepDive(genre) { const _esc = (s) => (s || '').replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"').replace(/'/g, '''); const _fmtNum = (n) => { if (!n) return ''; - if (n >= 1000000) return (n/1000000).toFixed(1) + 'M'; - if (n >= 1000) return (n/1000).toFixed(0) + 'K'; + if (n >= 1000000) return (n / 1000000).toFixed(1) + 'M'; + if (n >= 1000) return (n / 1000).toFixed(0) + 'K'; return n.toString(); }; const _fmtDur = (ms) => { @@ -59031,12 +59436,12 @@ async function openGenreDeepDive(genre) {

🎤 Artists in ${_esc(genre)}

${data.artists.map(a => { - // Always open on Artists page with discography — pass source for correct routing - const imgUrl = _esc(a.image_url || ''); - const artSource = _esc(a.source || ''); - const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; - const srcClass = (a.source || '').toLowerCase(); - return `
+ // Always open on Artists page with discography — pass source for correct routing + const imgUrl = _esc(a.image_url || ''); + const artSource = _esc(a.source || ''); + const clickAction = `onclick="document.getElementById('genre-deep-dive-modal').remove();navigateToPage('artists');setTimeout(()=>selectArtistForDetail({id:'${_esc(a.entity_id)}',name:'${_esc(a.name)}',image_url:'${imgUrl}'},{source:'${artSource}'}),300)"`; + const srcClass = (a.source || '').toLowerCase(); + return `
${!a.image_url ? '🎤' : ''}
@@ -59045,7 +59450,7 @@ async function openGenreDeepDive(genre) { ${a.followers ? `
${_fmtNum(a.followers)} followers
` : ''} ${a.library_id ? '
In Library
' : ''}
`; - }).join('')} + }).join('')}
`; } @@ -59057,8 +59462,8 @@ async function openGenreDeepDive(genre) {

🎵 Popular Tracks

${data.tracks.map((t, i) => { - const tSrcClass = (t.source || '').toLowerCase(); - return ` + const tSrcClass = (t.source || '').toLowerCase(); + return `
${i + 1}
@@ -62116,7 +62521,7 @@ async function loadRepairJobs() { const settingsRows = Object.entries(job.settings).map(([key, val]) => { const label = key.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase()); const inputType = typeof val === 'boolean' ? 'checkbox' : - typeof val === 'number' ? 'number' : 'text'; + typeof val === 'number' ? 'number' : 'text'; const inputVal = inputType === 'checkbox' ? (val ? ' checked' : '') : ` value="${val}"`; @@ -62158,7 +62563,7 @@ async function loadRepairJobs() { ${Object.keys(job.settings || {}).length > 0 ? - `` : ''} @@ -62583,19 +62988,19 @@ async function openCacheHealthModal() {
By Source
${(() => { - const allSources = {...(s.by_source || {})}; - if (s.total_musicbrainz) allSources['musicbrainz'] = s.total_musicbrainz; - const maxCount = Math.max(...Object.values(allSources), 1); - return Object.entries(allSources).map(([src, count]) => { - const pct = Math.round(count / maxCount * 100); - const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : src === 'musicbrainz' ? '#BA478F' : '#666'; - return `
+ const allSources = { ...(s.by_source || {}) }; + if (s.total_musicbrainz) allSources['musicbrainz'] = s.total_musicbrainz; + const maxCount = Math.max(...Object.values(allSources), 1); + return Object.entries(allSources).map(([src, count]) => { + const pct = Math.round(count / maxCount * 100); + const color = src === 'spotify' ? '#1DB954' : src === 'itunes' ? '#FC3C44' : src === 'deezer' ? '#A238FF' : src === 'musicbrainz' ? '#BA478F' : '#666'; + return `
${src === 'musicbrainz' ? 'MusicBrainz' : src}
${count.toLocaleString()}
`; - }).join(''); - })()} + }).join(''); + })()}
@@ -63832,7 +64237,7 @@ async function loadRepairHistory() { const duration = run.duration_seconds ? `${run.duration_seconds.toFixed(1)}s` : '-'; const age = formatCacheAge(run.started_at); const statusClass = run.status === 'completed' ? 'success' : - run.status === 'failed' ? 'error' : 'running'; + run.status === 'failed' ? 'error' : 'running'; // Build stat pills const stats = []; @@ -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 || {}; @@ -64038,7 +64445,7 @@ async function loadStatsData() { ${i + 1} ${item.image_url ? `` : ''}
-
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !item.soul_id.startsWith('soul_unnamed_') ? ' ' : ''}
+
${item.id ? `${_esc(item.name)}` : _esc(item.name)}${item.soul_id && !item.soul_id.startsWith('soul_unnamed_') ? ' ' : ''}
${item.global_listeners ? _fmt(item.global_listeners) + ' global listeners' : ''}
${_fmt(item.play_count)} plays @@ -64052,7 +64459,7 @@ async function loadStatsData() { ${item.image_url ? `` : ''}
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}
${_fmt(item.play_count)} plays
@@ -64065,9 +64472,9 @@ async function loadStatsData() { ${item.image_url ? `` : ''}
${_esc(item.name)}
-
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}
+
${item.artist_id ? `${_esc(item.artist || '')}` : _esc(item.artist || '')}${item.album ? ' · ' + _esc(item.album) : ''}
- + ${_fmt(item.play_count)} plays
`); @@ -64103,9 +64510,9 @@ function _renderTopArtistsVisual(artists) { el.innerHTML = `
${top5.map((a, i) => { - const pct = Math.round((a.play_count / maxPlays) * 100); - const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 - return `
+ const pct = Math.round((a.play_count / maxPlays) * 100); + const size = 44 + (4 - i) * 6; // Largest first: 68, 62, 56, 50, 44 + return `
${!a.image_url ? `${(a.name || '?')[0]}` : ''}
@@ -64115,7 +64522,7 @@ function _renderTopArtistsVisual(artists) {
${_esc(a.name)}
${_fmt(a.play_count)}
`; - }).join('')} + }).join('')}
`; } @@ -64396,7 +64803,7 @@ function _renderRecentPlays(tracks) { el.innerHTML = tracks.map(t => `
- + ${_esc(t.title)} ${_esc(t.artist || '')} ${_ago(t.played_at)} @@ -64437,7 +64844,7 @@ async function importPageRefreshStaging() { const totalSize = importPageState.stagingFiles.reduce((s, f) => s + (f.size || 0), 0); const sizeStr = totalSize > 1073741824 ? `${(totalSize / 1073741824).toFixed(1)} GB` : totalSize > 1048576 ? `${(totalSize / 1048576).toFixed(0)} MB` - : `${(totalSize / 1024).toFixed(0)} KB`; + : `${(totalSize / 1024).toFixed(0)} KB`; document.getElementById('import-page-staging-stats').textContent = `${importPageState.stagingFiles.length} file${importPageState.stagingFiles.length !== 1 ? 's' : ''}${totalSize ? ' · ' + sizeStr : ''}`; @@ -64593,7 +65000,7 @@ function _renderSuggestionCard(a) { ${_escAttr(a.name)}
${_esc(a.name)}
${_esc(a.artist)}
-
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0,4) : ''}
+
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
`; } @@ -64621,7 +65028,7 @@ async function importPageSearchAlbum() { ${_escAttr(a.name)}
${_esc(a.name)}
${_esc(a.artist)}
-
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0,4) : ''}
+
${a.total_tracks} tracks · ${a.release_date ? a.release_date.substring(0, 4) : ''}
`).join(''); document.getElementById('import-page-album-clear-btn').classList.remove('hidden'); @@ -64648,7 +65055,7 @@ async function importPageSelectAlbum(albumId) { } const resp = await fetch('/api/import/album/match', { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(matchBody) }); const data = await resp.json(); @@ -64667,7 +65074,7 @@ async function importPageSelectAlbum(albumId) {
${_esc(album.name)}
${_esc(album.artist)}
-
${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0,4) : ''}
+
${album.total_tracks} tracks · ${album.release_date ? album.release_date.substring(0, 4) : ''}
`; @@ -64737,9 +65144,9 @@ function importPageRenderMatchList() { ${_esc(m.spotify_track.name)} ${file - ? `${_esc(file.filename)} + ? `${_esc(file.filename)} ${confPercent}%` - : `Drop a file here`} + : `Drop a file here`} ${file ? `` : ''}
@@ -64759,7 +65166,7 @@ function importPageRenderMatchList() { return m.staging_file && m.staging_file.filename === f.filename; }); if (autoUsed) return; - unmatchedFiles.push({file: f, index: i}); + unmatchedFiles.push({ file: f, index: i }); }); const poolChips = document.getElementById('import-page-pool-chips'); @@ -64768,7 +65175,7 @@ function importPageRenderMatchList() { if (unmatchedFiles.length === 0) { poolChips.innerHTML = 'All files matched'; } else { - poolChips.innerHTML = unmatchedFiles.map(({file, index}) => ` + poolChips.innerHTML = unmatchedFiles.map(({ file, index }) => ` @@ -65161,7 +65568,7 @@ async function _importQueueRunJob(entry, job) { if (job.type === 'album') { resp = await fetch('/api/import/album/process', { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ album: job.albumData, matches: [job.items[i]] @@ -65170,7 +65577,7 @@ async function _importQueueRunJob(entry, job) { } else { resp = await fetch('/api/import/singles/process', { method: 'POST', - headers: {'Content-Type': 'application/json'}, + headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ files: [job.items[i]] }) }); } @@ -65229,8 +65636,8 @@ function _importQueueRender() { return `
${j.imageUrl - ? `` - : `
`} + ? `` + : `
`}
${_esc(j.label)}
${_esc(j.sublabel)}
@@ -66146,7 +66553,7 @@ async function loadDiscoveryPoolStats() { const failedEl = document.getElementById('discovery-pool-failed-count'); if (matchedEl) matchedEl.textContent = data.stats.matched || 0; if (failedEl) failedEl.textContent = data.stats.failed || 0; - } catch (e) {} + } catch (e) { } } async function openDiscoveryPoolModal(playlistId = null) { @@ -66397,8 +66804,8 @@ function renderPoolList() { const md = e.matched_data || {}; const matchedName = md.name || ''; return (e.original_title || '').toLowerCase().includes(query) || - (e.original_artist || '').toLowerCase().includes(query) || - matchedName.toLowerCase().includes(query); + (e.original_artist || '').toLowerCase().includes(query) || + matchedName.toLowerCase().includes(query); }); } if (entries.length === 0) { @@ -67059,104 +67466,154 @@ 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.', - 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: [] }, + { + 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: [] + }, // 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.', - 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: [] }, + { + 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: [] + }, // 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.', - 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.', - 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.' }, + { + 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.', + 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.' + }, ]; 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', - steps: [ - 'Go to the Playlists page and find a Spotify playlist you want to track.', - 'Click Mirror Playlist to create a local copy.', - 'Go to Automations and click New Automation.', - 'Set WHEN to Schedule \u2192 Every 6 hours.', - 'Set DO to Refresh Mirrored Playlists.', - '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', - steps: [ - 'In Discord, go to your channel\'s settings \u2192 Integrations \u2192 Webhooks.', - 'Create a webhook and copy the URL.', - 'In SoulSync, go to Settings \u2192 Notifications and paste the Discord webhook URL.', - 'Go to Automations \u2192 New Automation.', - 'Set WHEN to Download Failed (or any event), DO to Notify Only, THEN to Discord.' - ], 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: Schedule (12h) \u2192 Scan Watchlist, THEN fire signal scan_done.', - 'Create Automation 2: Signal scan_done \u2192 Process Wishlist, THEN fire signal process_done.', - 'Create Automation 3: Signal process_done \u2192 Full Cleanup.', - 'Enable all three automations.', - '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', - steps: [ - 'Understand the concept: fire_signal is a THEN action that emits a named signal. signal_received is a WHEN trigger that listens for it.', - 'In your first automation, add a THEN action \u2192 Fire Signal and name it (e.g., step1_done).', - 'Create a second automation with WHEN \u2192 Signal Received \u2192 signal name step1_done.', - '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 watchlist_scanned or cleanup_finished.' - ], 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 Daily Time (04:00) \u2192 Backup Database automation.', - 'Create a Weekly Time (Saturday, 05:00) \u2192 Full Cleanup automation.', - 'Create a Daily Time (02:00) \u2192 Update Discovery Pool 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'] }, + { + 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 Playlists page and find a Spotify playlist you want to track.', + 'Click Mirror Playlist to create a local copy.', + 'Go to Automations and click New Automation.', + 'Set WHEN to Schedule \u2192 Every 6 hours.', + 'Set DO to Refresh Mirrored Playlists.', + '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', + steps: [ + 'In Discord, go to your channel\'s settings \u2192 Integrations \u2192 Webhooks.', + 'Create a webhook and copy the URL.', + 'In SoulSync, go to Settings \u2192 Notifications and paste the Discord webhook URL.', + 'Go to Automations \u2192 New Automation.', + 'Set WHEN to Download Failed (or any event), DO to Notify Only, THEN to Discord.' + ], 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: Schedule (12h) \u2192 Scan Watchlist, THEN fire signal scan_done.', + 'Create Automation 2: Signal scan_done \u2192 Process Wishlist, THEN fire signal process_done.', + 'Create Automation 3: Signal process_done \u2192 Full Cleanup.', + 'Enable all three automations.', + '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', + steps: [ + 'Understand the concept: fire_signal is a THEN action that emits a named signal. signal_received is a WHEN trigger that listens for it.', + 'In your first automation, add a THEN action \u2192 Fire Signal and name it (e.g., step1_done).', + 'Create a second automation with WHEN \u2192 Signal Received \u2192 signal name step1_done.', + '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 watchlist_scanned or cleanup_finished.' + ], 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 Daily Time (04:00) \u2192 Backup Database automation.', + 'Create a Weekly Time (Saturday, 05:00) \u2192 Full Cleanup automation.', + 'Create a Daily Time (02:00) \u2192 Update Discovery Pool 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'] + }, ]; const AUTO_HUB_TIPS = [ @@ -67172,80 +67629,104 @@ const AUTO_HUB_TIPS = [ const AUTO_HUB_REFERENCE = { triggers: [ - { 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: [ - { 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: [ - { 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: [ - { 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: [ - { 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: [ - { type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' }, - ]}, + { + 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: [ + { 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: [ + { 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: [ + { 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: [ + { 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: [ + { 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: [ - { type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' }, - { type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' }, - { type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' }, - { type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' }, - { 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: [ - { 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: [ - { 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: [ - { type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' }, - ]}, + { + group: 'Downloads & Sync', items: [ + { type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' }, + { type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' }, + { type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' }, + { type: 'sync_playlist', label: 'Sync Playlist', desc: 'Sync a specific playlist to your library' }, + { 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: [ + { 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: [ + { 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: [ + { type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' }, + ] + }, ], thenActions: [ - { 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: [ - { type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' }, - ]}, + { + 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: [ + { type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' }, + ] + }, ], }; @@ -67340,7 +67821,7 @@ async function loadAutomations() { const progRes = await fetch('/api/automations/progress'); const progData = await progRes.json(); if (!progData.error) updateAutomationProgressFromData(progData); - } catch (e) {} + } catch (e) { } } catch (err) { list.innerHTML = ''; empty.style.display = ''; if (statsBar) statsBar.innerHTML = ''; @@ -67682,9 +68163,9 @@ function _buildHubGuides() { ` : ''} `; @@ -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) { @@ -68170,15 +68655,15 @@ function _autoParseUTC(ts) { function _autoTimeAgo(ts) { if (!ts) return 'Never'; const d = (Date.now() - _autoParseUTC(ts)) / 1000; - if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d/60) + 'm ago'; - if (d < 86400) return Math.floor(d/3600) + 'h ago'; return Math.floor(d/86400) + 'd ago'; + if (d < 60) return 'just now'; if (d < 3600) return Math.floor(d / 60) + 'm ago'; + if (d < 86400) return Math.floor(d / 3600) + 'h ago'; return Math.floor(d / 86400) + 'd ago'; } function _autoTimeUntil(ts) { if (!ts) return ''; const d = (_autoParseUTC(ts) - Date.now()) / 1000; if (d <= 0) return 'soon'; if (d < 60) return 'in ' + Math.ceil(d) + 's'; - if (d < 3600) return 'in ' + Math.ceil(d/60) + 'm'; if (d < 86400) return 'in ' + Math.round(d/3600) + 'h'; - return 'in ' + Math.round(d/86400) + 'd'; + if (d < 3600) return 'in ' + Math.ceil(d / 60) + 'm'; if (d < 86400) return 'in ' + Math.round(d / 3600) + 'h'; + return 'in ' + Math.round(d / 86400) + 'd'; } // --- Live countdown for "Next: in Xs" --- @@ -68403,7 +68888,7 @@ function _renderResultStats(resultJson, actionType) { var fields = _RESULT_DISPLAY_MAP[actionType]; var items = []; if (fields) { - fields.forEach(function(f) { + fields.forEach(function (f) { var val = resultJson[f.key]; if (val == null) return; if (f.hideZero && (val === 0 || val === '0')) return; @@ -68411,15 +68896,15 @@ function _renderResultStats(resultJson, actionType) { }); } else { // Generic fallback: show all non-status, non-underscore keys - Object.keys(resultJson).forEach(function(k) { + Object.keys(resultJson).forEach(function (k) { if (k === 'status' || k.startsWith('_')) return; - var label = k.replace(/_/g, ' ').replace(/\b\w/g, function(c) { return c.toUpperCase(); }); + var label = k.replace(/_/g, ' ').replace(/\b\w/g, function (c) { return c.toUpperCase(); }); items.push({ label: label, value: resultJson[k] }); }); } if (items.length === 0) return ''; var html = '
'; - items.forEach(function(it) { + items.forEach(function (it) { html += '
' + _esc(it.label) + '
' + _esc(String(it.value)) + '
'; }); html += '
'; @@ -68436,7 +68921,7 @@ async function showAutomationHistory(automationId, automationName, actionType) { } modal.innerHTML = ''; modal.style.display = 'flex'; - modal.onclick = function(e) { if (e.target === modal) modal.style.display = 'none'; }; + modal.onclick = function (e) { if (e.target === modal) modal.style.display = 'none'; }; try { const res = await fetch('/api/automations/' + automationId + '/history?limit=50'); @@ -68448,7 +68933,7 @@ async function showAutomationHistory(automationId, automationName, actionType) { return; } let html = '
'; - data.history.forEach(function(entry) { + data.history.forEach(function (entry) { const statusClass = 'history-status-' + (entry.status || 'completed'); const statusLabel = (entry.status || 'completed').charAt(0).toUpperCase() + (entry.status || 'completed').slice(1); const timeAgo = _autoTimeAgo(entry.started_at); @@ -68470,7 +68955,7 @@ async function showAutomationHistory(automationId, automationName, actionType) { } if (hasLogs) { html += '
'; - entry.log_lines.forEach(function(log) { + entry.log_lines.forEach(function (log) { html += '
' + _esc(log.text || '') + '
'; }); html += '
'; @@ -68534,9 +69019,9 @@ async function saveAutomation() { try { let res; if (_autoBuilder.editId) { - res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + res = await fetch('/api/automations/' + _autoBuilder.editId, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } else { - res = await fetch('/api/automations', { method: 'POST', headers: {'Content-Type':'application/json'}, body: JSON.stringify(body) }); + res = await fetch('/api/automations', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body) }); } const data = await res.json(); if (data.error) throw new Error(data.error); @@ -68569,7 +69054,7 @@ async function showAutomationBuilder(editId) { if (Array.isArray(allAutos)) allAutos.forEach(a => { if (a.group_name) groupSet.add(a.group_name); }); const datalist = document.getElementById('builder-group-list'); if (datalist) datalist.innerHTML = [...groupSet].sort().map(g => `
`; } @@ -68780,7 +69265,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { if (blockType === 'weekly_time') { const timeVal = config.time || '03:00'; const selectedDays = config.days || []; - const allDays = [['mon','Mon'],['tue','Tue'],['wed','Wed'],['thu','Thu'],['fri','Fri'],['sat','Sat'],['sun','Sun']]; + const allDays = [['mon', 'Mon'], ['tue', 'Tue'], ['wed', 'Wed'], ['thu', 'Thu'], ['fri', 'Fri'], ['sat', 'Sat'], ['sun', 'Sun']]; let dayHtml = '
'; allDays.forEach(([val, lbl]) => { const active = selectedDays.includes(val) ? ' active' : ''; @@ -68804,9 +69289,9 @@ function _renderBlockConfigFields(slotKey, blockType, config) { return `
`; } @@ -68816,7 +69301,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { return `
@@ -68831,7 +69316,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) { return `
@@ -69011,8 +69496,8 @@ function _renderConditionBuilder(slotKey, blockDef, config) { html += `
`; @@ -69040,7 +69525,7 @@ function _renderConditionRow(slotKey, index, fields, cond) { const value = cond ? _escAttr(cond.value) : ''; let fieldOpts = ''; - fields.forEach(f => { fieldOpts += ``; }); + fields.forEach(f => { fieldOpts += ``; }); // For playlist-related triggers, use a mirrored playlist dropdown instead of free text const triggerType = _autoBuilder.when ? _autoBuilder.when.type : ''; @@ -69052,10 +69537,10 @@ function _renderConditionRow(slotKey, index, fields, cond) { return `
${valueHtml} @@ -69265,7 +69750,7 @@ function _readPlacedConfig(slotKey) { function _findBlockDef(type) { if (!_autoBlocks) return null; - for (const cat of ['triggers','actions','notifications']) { + for (const cat of ['triggers', 'actions', 'notifications']) { const found = (_autoBlocks[cat] || []).find(b => b.type === type); if (found) return found; } @@ -69308,7 +69793,7 @@ function _autoDrop(e, slotKey) { _autoBuilder[slotKey] = { type: data.type, config: {} }; } _renderBuilderCanvas(); - } catch (err) {} + } catch (err) { } } // Click-to-add (alternative to drag) @@ -69476,7 +69961,7 @@ function renderIssueCard(issue) { const admin = isEnhancedAdmin(); let snapshot = {}; - try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) {} + try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; @@ -69566,8 +70051,8 @@ function showReportIssueModal(entityType, entityId, entityName, artistName, albu
${Object.entries(ISSUE_CATEGORIES) - .filter(([, cat]) => !cat.applies || cat.applies.includes(entityType)) - .map(([key, cat]) => ` + .filter(([, cat]) => !cat.applies || cat.applies.includes(entityType)) + .map(([key, cat]) => `
${cat.icon}
${_esc(cat.label)}
@@ -69728,7 +70213,7 @@ function renderIssueDetail(issue, body, footer, titleEl) { const statusMeta = ISSUE_STATUS_META[issue.status] || ISSUE_STATUS_META.open; let snapshot = {}; - try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) {} + try { snapshot = typeof issue.snapshot_data === 'string' ? JSON.parse(issue.snapshot_data || '{}') : (issue.snapshot_data || {}); } catch (e) { } const entityLabel = issue.entity_type === 'track' ? 'Track' : (issue.entity_type === 'album' ? 'Album' : 'Artist'); const entityName = snapshot.title || snapshot.name || `${entityLabel} #${issue.entity_id}`; @@ -70370,7 +70855,7 @@ async function loadIssuesBadge() { badge.textContent = openCount; badge.classList.toggle('hidden', openCount === 0); } - } catch (e) {} + } catch (e) { } } // ===== END ISSUES PAGE ===== @@ -70833,7 +71318,7 @@ function _explorerLoadPlaylists() { explorerRenderPickerCards(activeSource); } }) - .catch(() => {}); + .catch(() => { }); } function explorerSwitchPickerTab(source) { @@ -71124,9 +71609,9 @@ function _explorerRenderRoot(meta) {
${meta.playlist_image - ? `` - : '
' - } + ? `` + : '
' + }
SOURCE
${meta.playlist_name}
@@ -71177,9 +71662,9 @@ function _explorerRenderArtistNode(artist, index) { id="explorer-node-${safeKey}" data-key="${safeKey}" onclick="${hasError || albumCount === 0 ? '' : `explorerToggleArtist('${safeKey}')`}"> ${artist.image_url - ? `` - : '' - } + ? `` + : '' + }
${artist.name || 'Unknown'}
${hasError ? 'Not found' : albumCount + ' album' + (albumCount !== 1 ? 's' : '')}
@@ -71223,9 +71708,9 @@ function explorerToggleArtist(key) { onclick="explorerToggleAlbum('${id}'); event.stopPropagation();" title="${(album.title || '').replace(/"/g, '"')}\n${album.year || ''} · ${typeLabel} · ${album.track_count || '?'} tracks${owned ? '\n✓ Already in library' : ''}${inPlaylist ? '\n♫ Track from this playlist' : ''}\nClick to select · Double-click for tracklist"> ${album.image_url - ? `` - : '' - } + ? `` + : '' + }
${album.title || 'Unknown'}
${album.year || ''} · ${album.track_count || '?'} tracks
@@ -71592,7 +72077,7 @@ async function _explorerWishlistSubmit(artistSections) { item.classList.add('error'); } } - } catch (e) {} + } catch (e) { } } } } catch (e) { @@ -71883,14 +72368,14 @@ async function loadServerPlaylists() { // Show skeleton loader if (container) { - container.innerHTML = `
${Array.from({length: 6}, (_, i) => ` + container.innerHTML = `
${Array.from({ length: 6 }, (_, i) => `
-
+