Add Your Albums — multi-source liked albums pool (Spotify, Tidal, Deezer)
Builds a new Your Albums section on the Discover page that aggregates saved/liked albums from all connected services, mirroring the Your Artists pattern. Deezer works via both OAuth and ARL. - tidal_client: add get_favorite_albums() with V2/V1 API fallback - deezer_client: add get_user_favorite_albums() via OAuth (user/me/albums) - deezer_download_client: add get_user_favorite_albums() via ARL session - music_database: add liked_albums_pool table (deduped by artist::album normalized key), upsert_liked_album, get_liked_albums, get_liked_albums_last_fetch, clear_liked_albums - web_server: GET /api/discover/your-albums (ownership-checked, paginated), GET /api/discover/your-albums/sources, POST /api/discover/your-albums/refresh, _fetch_liked_albums background worker (Spotify + Tidal + Deezer OAuth/ARL) - frontend: Your Albums section with source selector cog, album grid reusing spotify-library-card styles, search/filter/sort/pagination, download missing button, auto-refresh poll on first load Also fix: Deezer greyed out in Your Artists sources when using ARL — connection check now accepts ARL auth (deezer_dl.is_authenticated()) in addition to OAuth, and _fetch_and_match_liked_artists falls back to ARL client for artist fetching.
This commit is contained in:
parent
453eb90f19
commit
3b8b369492
8 changed files with 1726 additions and 540 deletions
|
|
@ -738,6 +738,49 @@ class DeezerClient:
|
||||||
logger.error(f"Error fetching Deezer favorite artists: {e}")
|
logger.error(f"Error fetching Deezer favorite artists: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
@rate_limited
|
||||||
|
def get_user_favorite_albums(self, limit: int = 200) -> list:
|
||||||
|
"""Fetch user's favorite albums from Deezer. Requires OAuth access token.
|
||||||
|
Returns list of dicts with deezer_id, album_name, artist_name, image_url, release_date, total_tracks."""
|
||||||
|
if not self._access_token:
|
||||||
|
logger.debug("Deezer not user-authenticated — cannot fetch favorite albums")
|
||||||
|
return []
|
||||||
|
try:
|
||||||
|
albums = []
|
||||||
|
index = 0
|
||||||
|
while len(albums) < limit:
|
||||||
|
data = self._api_get('user/me/albums', params={
|
||||||
|
'limit': min(100, limit - len(albums)),
|
||||||
|
'index': index
|
||||||
|
})
|
||||||
|
if not data or 'data' not in data:
|
||||||
|
break
|
||||||
|
items = data['data']
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for a in items:
|
||||||
|
artist_name = ''
|
||||||
|
if isinstance(a.get('artist'), dict):
|
||||||
|
artist_name = a['artist'].get('name', '')
|
||||||
|
albums.append({
|
||||||
|
'deezer_id': str(a.get('id', '')),
|
||||||
|
'album_name': a.get('title', ''),
|
||||||
|
'artist_name': artist_name,
|
||||||
|
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
|
||||||
|
'release_date': a.get('release_date', ''),
|
||||||
|
'total_tracks': a.get('nb_tracks', 0),
|
||||||
|
})
|
||||||
|
if not data.get('next'):
|
||||||
|
break
|
||||||
|
index += len(items)
|
||||||
|
time.sleep(0.3)
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(albums)} favorite albums from Deezer")
|
||||||
|
return albums
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Deezer favorite albums: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# ==================== Stub Methods (match iTunesClient interface) ====================
|
# ==================== Stub Methods (match iTunesClient interface) ====================
|
||||||
|
|
||||||
def get_user_playlists(self) -> List[Playlist]:
|
def get_user_playlists(self) -> List[Playlist]:
|
||||||
|
|
|
||||||
|
|
@ -323,6 +323,53 @@ class DeezerDownloadClient:
|
||||||
logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
|
logger.info(f"Fetched {len(artists)} favorite artists from Deezer (ARL)")
|
||||||
return artists
|
return artists
|
||||||
|
|
||||||
|
def get_user_favorite_albums(self, limit: int = 200) -> list:
|
||||||
|
"""Fetch the authenticated user's favorite albums via public API with ARL cookies."""
|
||||||
|
if not self._authenticated or not self._user_data:
|
||||||
|
return []
|
||||||
|
user_id = self._user_data.get('USER_ID')
|
||||||
|
if not user_id:
|
||||||
|
return []
|
||||||
|
|
||||||
|
albums = []
|
||||||
|
index = 0
|
||||||
|
while len(albums) < limit:
|
||||||
|
try:
|
||||||
|
resp = self._session.get(
|
||||||
|
f'https://api.deezer.com/user/{user_id}/albums',
|
||||||
|
params={'index': index, 'limit': min(100, limit - len(albums))},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
resp.raise_for_status()
|
||||||
|
data = resp.json()
|
||||||
|
if 'error' in data:
|
||||||
|
logger.warning(f"Deezer albums error: {data['error']}")
|
||||||
|
break
|
||||||
|
items = data.get('data', [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for a in items:
|
||||||
|
artist_name = ''
|
||||||
|
if isinstance(a.get('artist'), dict):
|
||||||
|
artist_name = a['artist'].get('name', '')
|
||||||
|
albums.append({
|
||||||
|
'deezer_id': str(a.get('id', '')),
|
||||||
|
'album_name': a.get('title', ''),
|
||||||
|
'artist_name': artist_name,
|
||||||
|
'image_url': a.get('cover_xl') or a.get('cover_big') or a.get('cover_medium', ''),
|
||||||
|
'release_date': a.get('release_date', ''),
|
||||||
|
'total_tracks': a.get('nb_tracks', 0),
|
||||||
|
})
|
||||||
|
if not data.get('next'):
|
||||||
|
break
|
||||||
|
index += len(items)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching favorite albums at index {index}: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
logger.info(f"Fetched {len(albums)} favorite albums from Deezer (ARL)")
|
||||||
|
return albums
|
||||||
|
|
||||||
def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
|
def get_playlist_tracks(self, playlist_id: str) -> Optional[dict]:
|
||||||
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
|
"""Fetch full playlist details with tracks via public API (ARL cookies grant private access)."""
|
||||||
try:
|
try:
|
||||||
|
|
|
||||||
|
|
@ -1497,6 +1497,128 @@ class TidalClient:
|
||||||
logger.error(f"Error fetching Tidal favorite artists: {e}")
|
logger.error(f"Error fetching Tidal favorite artists: {e}")
|
||||||
return []
|
return []
|
||||||
|
|
||||||
|
def get_favorite_albums(self, limit: int = 200) -> list:
|
||||||
|
"""Fetch user's favorite albums from Tidal.
|
||||||
|
Returns list of dicts with tidal_id, album_name, artist_name, image_url, release_date, total_tracks."""
|
||||||
|
try:
|
||||||
|
if not self._ensure_valid_token():
|
||||||
|
logger.debug("Tidal not authenticated — cannot fetch favorite albums")
|
||||||
|
return []
|
||||||
|
|
||||||
|
user_id, api_version = self._get_user_id()
|
||||||
|
if not user_id:
|
||||||
|
logger.warning("Could not get Tidal user ID for favorite albums")
|
||||||
|
return []
|
||||||
|
|
||||||
|
albums = []
|
||||||
|
|
||||||
|
if api_version == 'v2':
|
||||||
|
offset = 0
|
||||||
|
while len(albums) < limit:
|
||||||
|
try:
|
||||||
|
headers = self.session.headers.copy()
|
||||||
|
headers['accept'] = 'application/vnd.api+json'
|
||||||
|
resp = requests.get(
|
||||||
|
f"{self.base_url}/favorites",
|
||||||
|
params={
|
||||||
|
'countryCode': 'US',
|
||||||
|
'filter[user.id]': user_id,
|
||||||
|
'filter[type]': 'ALBUMS',
|
||||||
|
'include': 'albums',
|
||||||
|
'page[limit]': min(50, limit - len(albums)),
|
||||||
|
'page[offset]': offset
|
||||||
|
},
|
||||||
|
headers=headers, timeout=15
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.debug(f"Tidal V2 favorite albums returned {resp.status_code}, trying V1")
|
||||||
|
break
|
||||||
|
data = resp.json()
|
||||||
|
included = data.get('included', [])
|
||||||
|
items = included if included else data.get('data', [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for item in items:
|
||||||
|
if included and item.get('type') not in ('albums', 'album'):
|
||||||
|
continue
|
||||||
|
attrs = item.get('attributes', {})
|
||||||
|
title = attrs.get('title', '')
|
||||||
|
if not title:
|
||||||
|
continue
|
||||||
|
img = None
|
||||||
|
img_rel = item.get('relationships', {}).get('image', {}).get('data', {})
|
||||||
|
if isinstance(img_rel, dict) and img_rel.get('id'):
|
||||||
|
img = f"https://resources.tidal.com/images/{img_rel['id'].replace('-', '/')}/750x750.jpg"
|
||||||
|
artist_name = ''
|
||||||
|
artist_rel = attrs.get('artists', [{}])
|
||||||
|
if artist_rel and isinstance(artist_rel, list):
|
||||||
|
artist_name = artist_rel[0].get('name', '') if isinstance(artist_rel[0], dict) else ''
|
||||||
|
albums.append({
|
||||||
|
'tidal_id': str(item.get('id', '')),
|
||||||
|
'album_name': title,
|
||||||
|
'artist_name': artist_name,
|
||||||
|
'image_url': img,
|
||||||
|
'release_date': attrs.get('releaseDate', ''),
|
||||||
|
'total_tracks': attrs.get('numberOfTracks', 0),
|
||||||
|
})
|
||||||
|
if not data.get('links', {}).get('next'):
|
||||||
|
break
|
||||||
|
offset += 50
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Tidal V2 favorite albums error: {e}")
|
||||||
|
break
|
||||||
|
|
||||||
|
# Fallback to V1 API
|
||||||
|
if not albums:
|
||||||
|
try:
|
||||||
|
offset = 0
|
||||||
|
while len(albums) < limit:
|
||||||
|
resp = self.session.get(
|
||||||
|
f"{self.alt_base_url}/users/{user_id}/favorites/albums",
|
||||||
|
params={'countryCode': 'US', 'limit': min(50, limit - len(albums)), 'offset': offset},
|
||||||
|
timeout=15
|
||||||
|
)
|
||||||
|
if resp.status_code != 200:
|
||||||
|
logger.debug(f"Tidal V1 favorite albums returned {resp.status_code}")
|
||||||
|
break
|
||||||
|
data = resp.json()
|
||||||
|
items = data.get('items', [])
|
||||||
|
if not items:
|
||||||
|
break
|
||||||
|
for item in items:
|
||||||
|
a = item.get('item', item)
|
||||||
|
img_id = (a.get('cover') or '').replace('-', '/')
|
||||||
|
img = f"https://resources.tidal.com/images/{img_id}/750x750.jpg" if img_id else None
|
||||||
|
artist_name = ''
|
||||||
|
if isinstance(a.get('artist'), dict):
|
||||||
|
artist_name = a['artist'].get('name', '')
|
||||||
|
elif isinstance(a.get('artists'), list) and a['artists']:
|
||||||
|
artist_name = a['artists'][0].get('name', '')
|
||||||
|
albums.append({
|
||||||
|
'tidal_id': str(a.get('id', '')),
|
||||||
|
'album_name': a.get('title', ''),
|
||||||
|
'artist_name': artist_name,
|
||||||
|
'image_url': img,
|
||||||
|
'release_date': a.get('releaseDate', ''),
|
||||||
|
'total_tracks': a.get('numberOfTracks', 0),
|
||||||
|
})
|
||||||
|
total = data.get('totalNumberOfItems', 0)
|
||||||
|
offset += len(items)
|
||||||
|
if offset >= total:
|
||||||
|
break
|
||||||
|
import time
|
||||||
|
time.sleep(0.5)
|
||||||
|
except Exception as e:
|
||||||
|
logger.debug(f"Tidal V1 favorite albums error: {e}")
|
||||||
|
|
||||||
|
logger.info(f"Retrieved {len(albums)} favorite albums from Tidal")
|
||||||
|
return albums
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error fetching Tidal favorite albums: {e}")
|
||||||
|
return []
|
||||||
|
|
||||||
# Global instance
|
# Global instance
|
||||||
_tidal_client = None
|
_tidal_client = None
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1358,6 +1358,30 @@ class MusicDatabase:
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_profile ON liked_artists_pool (profile_id)")
|
||||||
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lap_status ON liked_artists_pool (profile_id, match_status)")
|
||||||
|
|
||||||
|
# Liked albums pool — aggregated saved/liked albums from connected services
|
||||||
|
cursor.execute("""
|
||||||
|
CREATE TABLE IF NOT EXISTS liked_albums_pool (
|
||||||
|
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||||
|
album_name TEXT NOT NULL,
|
||||||
|
artist_name TEXT NOT NULL,
|
||||||
|
normalized_key TEXT NOT NULL,
|
||||||
|
spotify_album_id TEXT,
|
||||||
|
tidal_album_id TEXT,
|
||||||
|
deezer_album_id TEXT,
|
||||||
|
image_url TEXT,
|
||||||
|
release_date TEXT,
|
||||||
|
total_tracks INTEGER DEFAULT 0,
|
||||||
|
source_services TEXT DEFAULT '[]',
|
||||||
|
profile_id INTEGER DEFAULT 1,
|
||||||
|
last_fetched_at TIMESTAMP,
|
||||||
|
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||||
|
UNIQUE(profile_id, normalized_key)
|
||||||
|
)
|
||||||
|
""")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_profile ON liked_albums_pool (profile_id)")
|
||||||
|
cursor.execute("CREATE INDEX IF NOT EXISTS idx_lalp_spotify ON liked_albums_pool (spotify_album_id)")
|
||||||
|
|
||||||
logger.info("Discovery tables added/verified successfully")
|
logger.info("Discovery tables added/verified successfully")
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
|
|
@ -9298,6 +9322,181 @@ class MusicDatabase:
|
||||||
logger.error(f"Error clearing liked artists: {e}")
|
logger.error(f"Error clearing liked artists: {e}")
|
||||||
return 0
|
return 0
|
||||||
|
|
||||||
|
# ==================== Liked Albums Pool Methods ====================
|
||||||
|
|
||||||
|
@staticmethod
|
||||||
|
def _normalize_album_key(artist_name: str, album_name: str) -> str:
|
||||||
|
"""Normalize artist+album into a dedup key."""
|
||||||
|
import unicodedata
|
||||||
|
def _norm(s):
|
||||||
|
if not s:
|
||||||
|
return ''
|
||||||
|
n = unicodedata.normalize('NFKD', s)
|
||||||
|
n = ''.join(c for c in n if not unicodedata.combining(c))
|
||||||
|
n = n.lower().strip()
|
||||||
|
if n.startswith('the '):
|
||||||
|
n = n[4:]
|
||||||
|
return ' '.join(n.split())
|
||||||
|
return f"{_norm(artist_name)}::{_norm(album_name)}"
|
||||||
|
|
||||||
|
def upsert_liked_album(self, album_name: str, artist_name: str, source_service: str,
|
||||||
|
source_id: str = None, source_id_type: str = None,
|
||||||
|
image_url: str = None, release_date: str = None,
|
||||||
|
total_tracks: int = 0, profile_id: int = 1) -> bool:
|
||||||
|
"""Insert or merge a liked album into the pool. Deduplicates by normalized artist+album key."""
|
||||||
|
try:
|
||||||
|
import json
|
||||||
|
if self._is_placeholder_image(image_url):
|
||||||
|
image_url = None
|
||||||
|
normalized = self._normalize_album_key(artist_name, album_name)
|
||||||
|
if not normalized or '::' not in normalized:
|
||||||
|
return False
|
||||||
|
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT id, source_services FROM liked_albums_pool WHERE profile_id = ? AND normalized_key = ?",
|
||||||
|
(profile_id, normalized)
|
||||||
|
)
|
||||||
|
existing = cursor.fetchone()
|
||||||
|
|
||||||
|
if existing:
|
||||||
|
current_sources = json.loads(existing['source_services'] or '[]')
|
||||||
|
if source_service not in current_sources:
|
||||||
|
current_sources.append(source_service)
|
||||||
|
|
||||||
|
set_parts = [
|
||||||
|
"source_services = ?",
|
||||||
|
"updated_at = CURRENT_TIMESTAMP",
|
||||||
|
"last_fetched_at = CURRENT_TIMESTAMP",
|
||||||
|
]
|
||||||
|
params = [json.dumps(current_sources)]
|
||||||
|
|
||||||
|
if source_id and source_id_type:
|
||||||
|
col = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
|
||||||
|
'deezer': 'deezer_album_id'}.get(source_id_type)
|
||||||
|
if col:
|
||||||
|
set_parts.append(f"{col} = COALESCE({col}, ?)")
|
||||||
|
params.append(source_id)
|
||||||
|
if image_url:
|
||||||
|
set_parts.append("image_url = COALESCE(image_url, ?)")
|
||||||
|
params.append(image_url)
|
||||||
|
if release_date:
|
||||||
|
set_parts.append("release_date = COALESCE(release_date, ?)")
|
||||||
|
params.append(release_date)
|
||||||
|
if total_tracks:
|
||||||
|
set_parts.append("total_tracks = COALESCE(NULLIF(total_tracks, 0), ?)")
|
||||||
|
params.append(total_tracks)
|
||||||
|
|
||||||
|
params.extend([profile_id, normalized])
|
||||||
|
cursor.execute(
|
||||||
|
f"UPDATE liked_albums_pool SET {', '.join(set_parts)} WHERE profile_id = ? AND normalized_key = ?",
|
||||||
|
params
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
sources_json = json.dumps([source_service])
|
||||||
|
id_cols = {'spotify': 'spotify_album_id', 'tidal': 'tidal_album_id',
|
||||||
|
'deezer': 'deezer_album_id'}
|
||||||
|
col_values = {v: None for v in id_cols.values()}
|
||||||
|
if source_id and source_id_type and source_id_type in id_cols:
|
||||||
|
col_values[id_cols[source_id_type]] = source_id
|
||||||
|
|
||||||
|
cursor.execute("""
|
||||||
|
INSERT INTO liked_albums_pool
|
||||||
|
(album_name, artist_name, normalized_key, spotify_album_id, tidal_album_id,
|
||||||
|
deezer_album_id, image_url, release_date, total_tracks, source_services,
|
||||||
|
profile_id, last_fetched_at)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||||
|
""", (
|
||||||
|
album_name, artist_name, normalized,
|
||||||
|
col_values['spotify_album_id'], col_values['tidal_album_id'],
|
||||||
|
col_values['deezer_album_id'],
|
||||||
|
image_url, release_date, total_tracks or 0,
|
||||||
|
sources_json, profile_id
|
||||||
|
))
|
||||||
|
|
||||||
|
conn.commit()
|
||||||
|
return True
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error upserting liked album '{album_name}' by '{artist_name}': {e}")
|
||||||
|
return False
|
||||||
|
|
||||||
|
def get_liked_albums(self, profile_id: int = 1, page: int = 1, per_page: int = 50,
|
||||||
|
search: str = None, source_filter: str = None,
|
||||||
|
sort: str = 'artist_name') -> dict:
|
||||||
|
"""Get liked albums from the pool. Returns {albums: [...], total: N}."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
|
||||||
|
where = ["profile_id = ?"]
|
||||||
|
params = [profile_id]
|
||||||
|
if search:
|
||||||
|
where.append("(album_name LIKE ? COLLATE NOCASE OR artist_name LIKE ? COLLATE NOCASE)")
|
||||||
|
params.extend([f"%{search}%", f"%{search}%"])
|
||||||
|
if source_filter:
|
||||||
|
where.append("source_services LIKE ?")
|
||||||
|
params.append(f'%"{source_filter}"%')
|
||||||
|
|
||||||
|
where_clause = " AND ".join(where)
|
||||||
|
|
||||||
|
cursor.execute(f"SELECT COUNT(*) FROM liked_albums_pool WHERE {where_clause}", params)
|
||||||
|
total = cursor.fetchone()[0]
|
||||||
|
|
||||||
|
order = {
|
||||||
|
'artist_name': 'artist_name COLLATE NOCASE, album_name COLLATE NOCASE',
|
||||||
|
'album_name': 'album_name COLLATE NOCASE',
|
||||||
|
'recent': 'created_at DESC',
|
||||||
|
'release_date': 'release_date DESC',
|
||||||
|
}.get(sort, 'artist_name COLLATE NOCASE')
|
||||||
|
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
cursor.execute(f"""
|
||||||
|
SELECT * FROM liked_albums_pool
|
||||||
|
WHERE {where_clause}
|
||||||
|
ORDER BY {order}
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""", params + [per_page, offset])
|
||||||
|
|
||||||
|
import json
|
||||||
|
albums = []
|
||||||
|
for r in cursor.fetchall():
|
||||||
|
d = dict(r)
|
||||||
|
d['source_services'] = json.loads(d['source_services'] or '[]')
|
||||||
|
albums.append(d)
|
||||||
|
|
||||||
|
return {'albums': albums, 'total': total}
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting liked albums: {e}")
|
||||||
|
return {'albums': [], 'total': 0}
|
||||||
|
|
||||||
|
def get_liked_albums_last_fetch(self, profile_id: int = 1):
|
||||||
|
"""Get the most recent fetch timestamp."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute(
|
||||||
|
"SELECT MAX(last_fetched_at) FROM liked_albums_pool WHERE profile_id = ?",
|
||||||
|
(profile_id,)
|
||||||
|
)
|
||||||
|
row = cursor.fetchone()
|
||||||
|
return row[0] if row and row[0] else None
|
||||||
|
except Exception:
|
||||||
|
return None
|
||||||
|
|
||||||
|
def clear_liked_albums(self, profile_id: int = 1) -> int:
|
||||||
|
"""Clear all liked albums for a profile."""
|
||||||
|
try:
|
||||||
|
conn = self._get_connection()
|
||||||
|
cursor = conn.cursor()
|
||||||
|
cursor.execute("DELETE FROM liked_albums_pool WHERE profile_id = ?", (profile_id,))
|
||||||
|
conn.commit()
|
||||||
|
return cursor.rowcount
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error clearing liked albums: {e}")
|
||||||
|
return 0
|
||||||
|
|
||||||
# ==================== Track Download Provenance Methods ====================
|
# ==================== Track Download Provenance Methods ====================
|
||||||
|
|
||||||
def record_track_download(self, file_path: str, source_service: str, source_username: str,
|
def record_track_download(self, file_path: str, source_service: str, source_username: str,
|
||||||
|
|
|
||||||
242
web_server.py
242
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}")
|
logger.debug(f"[Your Artists] Image backfill error: {e}")
|
||||||
|
|
||||||
|
|
||||||
|
# ── Your Albums (Liked Albums Pool) ──
|
||||||
|
|
||||||
|
@app.route('/api/discover/your-albums', methods=['GET'])
|
||||||
|
def get_your_albums():
|
||||||
|
"""Get liked albums with library ownership status, paginated."""
|
||||||
|
try:
|
||||||
|
database = get_database()
|
||||||
|
profile_id = get_current_profile_id()
|
||||||
|
|
||||||
|
page = request.args.get('page', 1, type=int)
|
||||||
|
per_page = request.args.get('per_page', 48, type=int)
|
||||||
|
search = request.args.get('search', '', type=str).strip()
|
||||||
|
status_filter = request.args.get('status', 'all', type=str)
|
||||||
|
source_filter = request.args.get('source', '', type=str).strip()
|
||||||
|
sort = request.args.get('sort', 'artist_name', type=str)
|
||||||
|
|
||||||
|
# Auto-trigger refresh if stale (>24h or empty)
|
||||||
|
last_fetch = database.get_liked_albums_last_fetch(profile_id)
|
||||||
|
stale = True
|
||||||
|
if last_fetch:
|
||||||
|
from datetime import datetime, timedelta
|
||||||
|
try:
|
||||||
|
if isinstance(last_fetch, str):
|
||||||
|
last_dt = datetime.fromisoformat(last_fetch.replace('Z', '+00:00'))
|
||||||
|
else:
|
||||||
|
last_dt = last_fetch
|
||||||
|
stale = (datetime.now() - last_dt.replace(tzinfo=None)) > timedelta(hours=24)
|
||||||
|
except Exception:
|
||||||
|
stale = True
|
||||||
|
if stale:
|
||||||
|
_trigger_your_albums_refresh(profile_id)
|
||||||
|
|
||||||
|
# Fetch all (ownership check requires full set)
|
||||||
|
all_result = database.get_liked_albums(
|
||||||
|
profile_id=profile_id, page=1, per_page=100000,
|
||||||
|
search=search, source_filter=source_filter or None, sort=sort
|
||||||
|
)
|
||||||
|
all_albums = all_result['albums']
|
||||||
|
|
||||||
|
if not all_albums:
|
||||||
|
return jsonify({
|
||||||
|
"success": True, "albums": [], "total": 0,
|
||||||
|
"page": page, "per_page": per_page, "stale": stale,
|
||||||
|
"stats": {"total": 0, "owned": 0, "missing": 0}
|
||||||
|
})
|
||||||
|
|
||||||
|
# Ownership check — same strategy as Spotify library endpoint
|
||||||
|
library_spotify_ids = database.get_library_spotify_album_ids(profile_id)
|
||||||
|
library_album_names = database.get_library_album_names()
|
||||||
|
|
||||||
|
owned_count = 0
|
||||||
|
for album in all_albums:
|
||||||
|
if album.get('spotify_album_id') and album['spotify_album_id'] in library_spotify_ids:
|
||||||
|
album['in_library'] = True
|
||||||
|
elif (album['artist_name'].lower(), album['album_name'].lower()) in library_album_names:
|
||||||
|
album['in_library'] = True
|
||||||
|
else:
|
||||||
|
album['in_library'] = False
|
||||||
|
if album['in_library']:
|
||||||
|
owned_count += 1
|
||||||
|
|
||||||
|
# Apply status filter
|
||||||
|
if status_filter == 'missing':
|
||||||
|
filtered = [a for a in all_albums if not a['in_library']]
|
||||||
|
elif status_filter == 'owned':
|
||||||
|
filtered = [a for a in all_albums if a['in_library']]
|
||||||
|
else:
|
||||||
|
filtered = all_albums
|
||||||
|
|
||||||
|
filtered_total = len(filtered)
|
||||||
|
offset = (page - 1) * per_page
|
||||||
|
albums = filtered[offset:offset + per_page]
|
||||||
|
|
||||||
|
stats = {
|
||||||
|
'total': all_result['total'],
|
||||||
|
'owned': owned_count,
|
||||||
|
'missing': all_result['total'] - owned_count,
|
||||||
|
}
|
||||||
|
|
||||||
|
return jsonify({
|
||||||
|
"success": True, "albums": albums,
|
||||||
|
"total": filtered_total, "page": page, "per_page": per_page,
|
||||||
|
"stale": stale, "stats": stats,
|
||||||
|
})
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Error getting your albums: {e}")
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/discover/your-albums/refresh', methods=['POST'])
|
||||||
|
def refresh_your_albums():
|
||||||
|
"""Force-trigger a fetch cycle for liked albums. ?clear=true wipes pool first."""
|
||||||
|
try:
|
||||||
|
profile_id = get_current_profile_id()
|
||||||
|
if request.args.get('clear', '').lower() == 'true':
|
||||||
|
database = get_database()
|
||||||
|
cleared = database.clear_liked_albums(profile_id)
|
||||||
|
print(f"[Your Albums] Cleared {cleared} entries before refresh")
|
||||||
|
_trigger_your_albums_refresh(profile_id)
|
||||||
|
return jsonify({"success": True, "message": "Refresh started"})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
@app.route('/api/discover/your-albums/sources', methods=['GET'])
|
||||||
|
def get_your_albums_sources():
|
||||||
|
"""Return current source config + which services are connected (albums)."""
|
||||||
|
try:
|
||||||
|
enabled_raw = config_manager.get('discover.your_albums_sources', 'spotify,tidal,deezer')
|
||||||
|
enabled = [s.strip() for s in enabled_raw.split(',') if s.strip()]
|
||||||
|
|
||||||
|
connected = []
|
||||||
|
if spotify_client and spotify_client.is_spotify_authenticated():
|
||||||
|
connected.append('spotify')
|
||||||
|
try:
|
||||||
|
if tidal_client and hasattr(tidal_client, '_ensure_valid_token') and tidal_client._ensure_valid_token():
|
||||||
|
connected.append('tidal')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
try:
|
||||||
|
deezer_cl = _get_deezer_client()
|
||||||
|
deezer_oauth = deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated()
|
||||||
|
deezer_arl = (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl
|
||||||
|
and soulseek_client.deezer_dl.is_authenticated())
|
||||||
|
if deezer_oauth or deezer_arl:
|
||||||
|
connected.append('deezer')
|
||||||
|
except Exception:
|
||||||
|
pass
|
||||||
|
|
||||||
|
return jsonify({"success": True, "enabled": enabled, "connected": connected})
|
||||||
|
except Exception as e:
|
||||||
|
return jsonify({"success": False, "error": str(e)}), 500
|
||||||
|
|
||||||
|
|
||||||
|
_your_albums_refresh_lock = threading.Lock()
|
||||||
|
_your_albums_refreshing = False
|
||||||
|
|
||||||
|
def _trigger_your_albums_refresh(profile_id: int):
|
||||||
|
"""Start background album fetch if not already running."""
|
||||||
|
global _your_albums_refreshing
|
||||||
|
if _your_albums_refreshing:
|
||||||
|
return
|
||||||
|
with _your_albums_refresh_lock:
|
||||||
|
if _your_albums_refreshing:
|
||||||
|
return
|
||||||
|
_your_albums_refreshing = True
|
||||||
|
|
||||||
|
def _run():
|
||||||
|
global _your_albums_refreshing
|
||||||
|
try:
|
||||||
|
_fetch_liked_albums(profile_id)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Your albums refresh failed: {e}")
|
||||||
|
import traceback
|
||||||
|
traceback.print_exc()
|
||||||
|
finally:
|
||||||
|
_your_albums_refreshing = False
|
||||||
|
|
||||||
|
threading.Thread(target=_run, daemon=True, name="YourAlbumsRefresh").start()
|
||||||
|
|
||||||
|
|
||||||
|
def _fetch_liked_albums(profile_id: int):
|
||||||
|
"""Background worker: fetch liked/saved albums from all connected services."""
|
||||||
|
database = get_database()
|
||||||
|
fetched = 0
|
||||||
|
|
||||||
|
enabled_raw = config_manager.get('discover.your_albums_sources', 'spotify,tidal,deezer')
|
||||||
|
enabled_sources = {s.strip() for s in enabled_raw.split(',') if s.strip()}
|
||||||
|
|
||||||
|
# 1. Fetch from Spotify (saved albums)
|
||||||
|
try:
|
||||||
|
if 'spotify' not in enabled_sources:
|
||||||
|
print("[Your Albums] Spotify skipped (disabled in sources config)")
|
||||||
|
elif spotify_client and spotify_client.is_spotify_authenticated():
|
||||||
|
print("[Your Albums] Fetching saved albums from Spotify...")
|
||||||
|
albums = spotify_client.get_saved_albums()
|
||||||
|
for a in albums:
|
||||||
|
database.upsert_liked_album(
|
||||||
|
album_name=a['album_name'], artist_name=a['artist_name'],
|
||||||
|
source_service='spotify',
|
||||||
|
source_id=a['spotify_album_id'], source_id_type='spotify',
|
||||||
|
image_url=a.get('image_url'), release_date=a.get('release_date'),
|
||||||
|
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
|
||||||
|
)
|
||||||
|
fetched += len(albums)
|
||||||
|
print(f"[Your Albums] Fetched {len(albums)} from Spotify")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Your Albums] Spotify fetch error: {e}")
|
||||||
|
|
||||||
|
# 2. Fetch from Tidal (favorite albums)
|
||||||
|
try:
|
||||||
|
if 'tidal' not in enabled_sources:
|
||||||
|
print("[Your Albums] Tidal skipped (disabled in sources config)")
|
||||||
|
elif tidal_client and hasattr(tidal_client, 'get_favorite_albums'):
|
||||||
|
tidal_auth = tidal_client._ensure_valid_token() if hasattr(tidal_client, '_ensure_valid_token') else False
|
||||||
|
if tidal_auth:
|
||||||
|
print("[Your Albums] Fetching favorite albums from Tidal...")
|
||||||
|
albums = tidal_client.get_favorite_albums(limit=500)
|
||||||
|
for a in albums:
|
||||||
|
database.upsert_liked_album(
|
||||||
|
album_name=a['album_name'], artist_name=a['artist_name'],
|
||||||
|
source_service='tidal',
|
||||||
|
source_id=a.get('tidal_id'), source_id_type='tidal',
|
||||||
|
image_url=a.get('image_url'), release_date=a.get('release_date'),
|
||||||
|
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
|
||||||
|
)
|
||||||
|
fetched += len(albums)
|
||||||
|
print(f"[Your Albums] Fetched {len(albums)} from Tidal")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Your Albums] Tidal fetch error: {e}")
|
||||||
|
|
||||||
|
# 3. Fetch from Deezer (favorite albums — OAuth or ARL)
|
||||||
|
try:
|
||||||
|
if 'deezer' not in enabled_sources:
|
||||||
|
print("[Your Albums] Deezer skipped (disabled in sources config)")
|
||||||
|
else:
|
||||||
|
deezer_cl = _get_deezer_client()
|
||||||
|
albums = []
|
||||||
|
if deezer_cl and hasattr(deezer_cl, 'is_user_authenticated') and deezer_cl.is_user_authenticated():
|
||||||
|
print("[Your Albums] Fetching favorite albums from Deezer (OAuth)...")
|
||||||
|
albums = deezer_cl.get_user_favorite_albums(limit=500)
|
||||||
|
elif (hasattr(soulseek_client, 'deezer_dl') and soulseek_client.deezer_dl
|
||||||
|
and soulseek_client.deezer_dl.is_authenticated()):
|
||||||
|
print("[Your Albums] Fetching favorite albums from Deezer (ARL)...")
|
||||||
|
albums = soulseek_client.deezer_dl.get_user_favorite_albums(limit=500)
|
||||||
|
for a in albums:
|
||||||
|
database.upsert_liked_album(
|
||||||
|
album_name=a['album_name'], artist_name=a['artist_name'],
|
||||||
|
source_service='deezer',
|
||||||
|
source_id=a.get('deezer_id'), source_id_type='deezer',
|
||||||
|
image_url=a.get('image_url'), release_date=a.get('release_date'),
|
||||||
|
total_tracks=a.get('total_tracks', 0), profile_id=profile_id
|
||||||
|
)
|
||||||
|
fetched += len(albums)
|
||||||
|
if albums:
|
||||||
|
print(f"[Your Albums] Fetched {len(albums)} from Deezer")
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[Your Albums] Deezer fetch error: {e}")
|
||||||
|
|
||||||
|
print(f"[Your Albums] Total fetched: {fetched}")
|
||||||
|
|
||||||
|
|
||||||
@app.route('/api/discover/your-artists/info/<artist_id>', methods=['GET'])
|
@app.route('/api/discover/your-artists/info/<artist_id>', methods=['GET'])
|
||||||
def get_your_artist_info(artist_id):
|
def get_your_artist_info(artist_id):
|
||||||
"""Get artist info for the Your Artists info modal. Checks library, cache, then API."""
|
"""Get artist info for the Your Artists info modal. Checks library, cache, then API."""
|
||||||
|
|
|
||||||
|
|
@ -3298,6 +3298,49 @@
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Your Albums Section -->
|
||||||
|
<div class="discover-section" id="your-albums-section" style="display: none;">
|
||||||
|
<div class="discover-section-header">
|
||||||
|
<div>
|
||||||
|
<h2 class="discover-section-title">Your Albums</h2>
|
||||||
|
<p class="discover-section-subtitle" id="your-albums-subtitle">Albums you've saved across your music services</p>
|
||||||
|
</div>
|
||||||
|
<div class="discover-section-actions">
|
||||||
|
<button class="ya-header-btn ya-refresh-btn" id="your-albums-refresh-btn" onclick="refreshYourAlbums()" title="Refresh from services">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M23 4v6h-6"/><path d="M1 20v-6h6"/><path d="M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ya-header-btn ya-settings-btn" onclick="openYourAlbumsSourcesModal()" title="Configure sources">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><circle cx="12" cy="12" r="3"/><path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z"/></svg>
|
||||||
|
</button>
|
||||||
|
<button class="ya-header-btn" id="your-albums-download-btn" onclick="downloadMissingYourAlbums()" style="display:none;" title="Download missing albums">
|
||||||
|
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M7 10l5 5 5-5M12 15V3"/></svg>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="spotify-library-filters" id="your-albums-filters" style="display: none;">
|
||||||
|
<input type="text" class="spotify-library-search" id="your-albums-search"
|
||||||
|
placeholder="Search by artist or album..." oninput="debouncedYourAlbumsSearch()">
|
||||||
|
<select id="your-albums-status-filter" class="spotify-library-select" onchange="loadYourAlbumsGrid()">
|
||||||
|
<option value="all">All Albums</option>
|
||||||
|
<option value="missing">Missing</option>
|
||||||
|
<option value="owned">Owned</option>
|
||||||
|
</select>
|
||||||
|
<select id="your-albums-sort" class="spotify-library-select" onchange="loadYourAlbumsGrid()">
|
||||||
|
<option value="artist_name">Artist</option>
|
||||||
|
<option value="album_name">Album</option>
|
||||||
|
<option value="release_date">Release Date</option>
|
||||||
|
<option value="recent">Date Added</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
<div class="spotify-library-grid" id="your-albums-grid">
|
||||||
|
<div class="discover-loading">
|
||||||
|
<div class="loading-spinner"></div>
|
||||||
|
<p>Loading your albums...</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="spotify-library-pagination" id="your-albums-pagination" style="display: none;"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Spotify Library Section -->
|
<!-- Spotify Library Section -->
|
||||||
<div class="discover-section" id="spotify-library-section" style="display: none;">
|
<div class="discover-section" id="spotify-library-section" style="display: none;">
|
||||||
<div class="discover-section-header">
|
<div class="discover-section-header">
|
||||||
|
|
|
||||||
|
|
@ -2290,6 +2290,11 @@ const HELPER_CONTENT = {
|
||||||
description: 'Carousel of artists from your watchlist. Quick access to view their latest releases, discography, or manage watchlist settings.',
|
description: 'Carousel of artists from your watchlist. Quick access to view their latest releases, discography, or manage watchlist settings.',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
'#your-albums-section': {
|
||||||
|
title: 'Your Albums',
|
||||||
|
description: 'Albums you\'ve saved or liked across connected services (Spotify, Tidal, Deezer). Shows which are already in your library and lets you download missing ones.',
|
||||||
|
},
|
||||||
|
|
||||||
// ─── PERSONAL SETTINGS ─────────────────────────────────────────
|
// ─── PERSONAL SETTINGS ─────────────────────────────────────────
|
||||||
|
|
||||||
'#personal-settings-btn': {
|
'#personal-settings-btn': {
|
||||||
|
|
|
||||||
|
|
@ -45605,12 +45605,16 @@ function _showMobileTrackActions(track, album) {
|
||||||
|
|
||||||
const actions = [];
|
const actions = [];
|
||||||
if (track.file_path) {
|
if (track.file_path) {
|
||||||
actions.push({ icon: '▶', label: 'Play', action: () => {
|
actions.push({
|
||||||
|
icon: '▶', label: 'Play', action: () => {
|
||||||
playLibraryTrack({ id: track.id, title: track.title, file_path: track.file_path, bitrate: track.bitrate, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id }, album.title || '', artistName);
|
playLibraryTrack({ id: track.id, title: track.title, file_path: track.file_path, bitrate: track.bitrate, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id }, album.title || '', artistName);
|
||||||
}});
|
}
|
||||||
actions.push({ icon: '+', label: 'Add to Queue', action: () => {
|
});
|
||||||
|
actions.push({
|
||||||
|
icon: '+', label: 'Add to Queue', action: () => {
|
||||||
addToQueue({ title: track.title || 'Unknown', artist: artistName, album: album.title || '', file_path: track.file_path, filename: track.file_path, is_library: true, image_url: albumArt, id: track.id, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id, bitrate: track.bitrate });
|
addToQueue({ title: track.title || 'Unknown', artist: artistName, album: album.title || '', file_path: track.file_path, filename: track.file_path, is_library: true, image_url: albumArt, id: track.id, artist_id: artistDetailPageState.enhancedData?.artist?.id, album_id: album.id, bitrate: track.bitrate });
|
||||||
}});
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
if (admin && track.file_path) {
|
if (admin && track.file_path) {
|
||||||
actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) });
|
actions.push({ icon: '✎', label: 'Write Tags', action: () => showTagPreview(track.id) });
|
||||||
|
|
@ -52221,6 +52225,7 @@ async function loadDiscoverPage() {
|
||||||
await Promise.all([
|
await Promise.all([
|
||||||
loadDiscoverHero(),
|
loadDiscoverHero(),
|
||||||
loadYourArtists(),
|
loadYourArtists(),
|
||||||
|
loadYourAlbums(),
|
||||||
loadSpotifyLibrarySection(),
|
loadSpotifyLibrarySection(),
|
||||||
loadDiscoverRecentReleases(),
|
loadDiscoverRecentReleases(),
|
||||||
loadSeasonalContent(), // Seasonal discovery
|
loadSeasonalContent(), // Seasonal discovery
|
||||||
|
|
@ -53096,6 +53101,406 @@ async function loadDiscoverRecentReleases() {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===============================
|
||||||
|
// ===============================
|
||||||
|
// YOUR ALBUMS SECTION
|
||||||
|
// ===============================
|
||||||
|
|
||||||
|
let yourAlbums = [];
|
||||||
|
let yourAlbumsPage = 1;
|
||||||
|
let yourAlbumsTotal = 0;
|
||||||
|
const YOUR_ALBUMS_PAGE_SIZE = 48;
|
||||||
|
let _yourAlbumsSearchTimeout = null;
|
||||||
|
|
||||||
|
function debouncedYourAlbumsSearch() {
|
||||||
|
clearTimeout(_yourAlbumsSearchTimeout);
|
||||||
|
_yourAlbumsSearchTimeout = setTimeout(() => {
|
||||||
|
yourAlbumsPage = 1;
|
||||||
|
loadYourAlbumsGrid();
|
||||||
|
}, 400);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadYourAlbums() {
|
||||||
|
const section = document.getElementById('your-albums-section');
|
||||||
|
if (!section) return;
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) return;
|
||||||
|
|
||||||
|
const totalCount = (data.stats && data.stats.total) || 0;
|
||||||
|
if (totalCount === 0 && !data.stale) return; // Nothing to show yet
|
||||||
|
|
||||||
|
section.style.display = '';
|
||||||
|
yourAlbums = data.albums || [];
|
||||||
|
yourAlbumsTotal = data.total || 0;
|
||||||
|
yourAlbumsPage = 1;
|
||||||
|
|
||||||
|
const subtitle = document.getElementById('your-albums-subtitle');
|
||||||
|
if (subtitle && data.stats) {
|
||||||
|
const s = data.stats;
|
||||||
|
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const filters = document.getElementById('your-albums-filters');
|
||||||
|
if (filters && totalCount > 0) filters.style.display = '';
|
||||||
|
|
||||||
|
const downloadBtn = document.getElementById('your-albums-download-btn');
|
||||||
|
if (downloadBtn && data.stats && data.stats.missing > 0) downloadBtn.style.display = '';
|
||||||
|
|
||||||
|
_renderYourAlbumsGrid(yourAlbums);
|
||||||
|
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
|
||||||
|
|
||||||
|
if (data.stale && totalCount === 0) {
|
||||||
|
const grid = document.getElementById('your-albums-grid');
|
||||||
|
if (grid) grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Fetching your albums from connected services...</p></div>';
|
||||||
|
_pollYourAlbums();
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading your albums:', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _pollYourAlbums() {
|
||||||
|
let attempts = 0;
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
attempts++;
|
||||||
|
if (attempts > 12) { clearInterval(poll); return; }
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48&status=all');
|
||||||
|
if (!resp.ok) return;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) return;
|
||||||
|
const total = (data.stats && data.stats.total) || 0;
|
||||||
|
if (total > 0) {
|
||||||
|
clearInterval(poll);
|
||||||
|
loadYourAlbums();
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadYourAlbumsGrid() {
|
||||||
|
const grid = document.getElementById('your-albums-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
grid.innerHTML = '<div class="discover-loading"><div class="loading-spinner"></div><p>Loading...</p></div>';
|
||||||
|
try {
|
||||||
|
const search = (document.getElementById('your-albums-search')?.value || '').trim();
|
||||||
|
const status = document.getElementById('your-albums-status-filter')?.value || 'all';
|
||||||
|
const sort = document.getElementById('your-albums-sort')?.value || 'artist_name';
|
||||||
|
const params = new URLSearchParams({ page: yourAlbumsPage, per_page: YOUR_ALBUMS_PAGE_SIZE, sort, status });
|
||||||
|
if (search) params.set('search', search);
|
||||||
|
const resp = await fetch(`/api/discover/your-albums?${params}`);
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success) throw new Error(data.error);
|
||||||
|
yourAlbums = data.albums || [];
|
||||||
|
yourAlbumsTotal = data.total || 0;
|
||||||
|
const subtitle = document.getElementById('your-albums-subtitle');
|
||||||
|
if (subtitle && data.stats) {
|
||||||
|
const s = data.stats;
|
||||||
|
subtitle.textContent = `${s.total} albums \u00B7 ${s.owned} owned \u00B7 ${s.missing} missing`;
|
||||||
|
}
|
||||||
|
_renderYourAlbumsGrid(yourAlbums);
|
||||||
|
_renderYourAlbumsPagination(yourAlbumsTotal, yourAlbumsPage);
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error loading your albums grid:', e);
|
||||||
|
grid.innerHTML = '<div class="spotify-library-empty"><p>Failed to load albums</p></div>';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderYourAlbumsGrid(albums) {
|
||||||
|
const grid = document.getElementById('your-albums-grid');
|
||||||
|
if (!grid) return;
|
||||||
|
if (!albums || albums.length === 0) {
|
||||||
|
grid.innerHTML = '<div class="spotify-library-empty"><p>No albums found</p></div>';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let html = '';
|
||||||
|
albums.forEach((album, index) => {
|
||||||
|
const coverUrl = album.image_url || '/static/placeholder-album.png';
|
||||||
|
const year = album.release_date ? album.release_date.substring(0, 4) : '';
|
||||||
|
const badgeClass = album.in_library ? 'owned' : 'missing';
|
||||||
|
const badgeIcon = album.in_library ? '\u2713' : '\u2193';
|
||||||
|
const trackInfo = album.total_tracks ? `${album.total_tracks} tracks` : '';
|
||||||
|
const meta = [year, trackInfo].filter(Boolean).join(' \u00B7 ');
|
||||||
|
const sources = (album.source_services || []).join(', ');
|
||||||
|
html += `
|
||||||
|
<div class="spotify-library-card" onclick="openYourAlbumDownload(${index})" title="${escapeHtml(album.album_name)} \u2014 ${escapeHtml(album.artist_name)}">
|
||||||
|
<div class="spotify-library-card-img">
|
||||||
|
<img src="${coverUrl}" alt="${escapeHtml(album.album_name)}" loading="lazy">
|
||||||
|
<div class="spotify-library-card-badge ${badgeClass}">${badgeIcon}</div>
|
||||||
|
</div>
|
||||||
|
<div class="spotify-library-card-info">
|
||||||
|
<p class="spotify-library-card-title">${escapeHtml(album.album_name)}</p>
|
||||||
|
<p class="spotify-library-card-artist">${escapeHtml(album.artist_name)}</p>
|
||||||
|
<p class="spotify-library-card-meta">${escapeHtml(meta)}</p>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
});
|
||||||
|
grid.innerHTML = html;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _renderYourAlbumsPagination(total, page) {
|
||||||
|
const container = document.getElementById('your-albums-pagination');
|
||||||
|
if (!container) return;
|
||||||
|
if (total <= YOUR_ALBUMS_PAGE_SIZE) { container.style.display = 'none'; return; }
|
||||||
|
container.style.display = '';
|
||||||
|
const totalPages = Math.ceil(total / YOUR_ALBUMS_PAGE_SIZE);
|
||||||
|
const start = (page - 1) * YOUR_ALBUMS_PAGE_SIZE + 1;
|
||||||
|
const end = Math.min(page * YOUR_ALBUMS_PAGE_SIZE, total);
|
||||||
|
container.innerHTML = `
|
||||||
|
<button class="spotify-library-page-btn" onclick="_yourAlbumsPrevPage()" ${page <= 1 ? 'disabled' : ''}>← Previous</button>
|
||||||
|
<span class="spotify-library-page-info">${start}\u2013${end} of ${total}</span>
|
||||||
|
<button class="spotify-library-page-btn" onclick="_yourAlbumsNextPage()" ${page >= totalPages ? 'disabled' : ''}>Next →</button>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _yourAlbumsPrevPage() {
|
||||||
|
if (yourAlbumsPage > 1) { yourAlbumsPage--; loadYourAlbumsGrid(); }
|
||||||
|
}
|
||||||
|
function _yourAlbumsNextPage() {
|
||||||
|
const totalPages = Math.ceil(yourAlbumsTotal / YOUR_ALBUMS_PAGE_SIZE);
|
||||||
|
if (yourAlbumsPage < totalPages) { yourAlbumsPage++; loadYourAlbumsGrid(); }
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openYourAlbumDownload(index) {
|
||||||
|
const album = yourAlbums[index];
|
||||||
|
if (!album) { showToast('Album data not found', 'error'); return; }
|
||||||
|
showLoadingOverlay(`Loading tracks for ${album.album_name}...`);
|
||||||
|
try {
|
||||||
|
// Prefer Spotify ID, fall back to Deezer, then search by name
|
||||||
|
let albumData = null;
|
||||||
|
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
|
||||||
|
if (album.spotify_album_id) {
|
||||||
|
const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`);
|
||||||
|
if (r.ok) albumData = await r.json();
|
||||||
|
}
|
||||||
|
if (!albumData && album.deezer_album_id) {
|
||||||
|
const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`);
|
||||||
|
if (r.ok) albumData = await r.json();
|
||||||
|
}
|
||||||
|
if (!albumData) {
|
||||||
|
// Last resort — search by name
|
||||||
|
const r = await fetch(`/api/discover/album/spotify/search?${nameParams}`);
|
||||||
|
if (r.ok) albumData = await r.json();
|
||||||
|
}
|
||||||
|
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) {
|
||||||
|
throw new Error('No tracks found for this album');
|
||||||
|
}
|
||||||
|
const tracks = albumData.tracks.map(track => {
|
||||||
|
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
|
||||||
|
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
|
||||||
|
return {
|
||||||
|
id: track.id, name: track.name, artists,
|
||||||
|
album: {
|
||||||
|
id: albumData.id, name: albumData.name,
|
||||||
|
album_type: albumData.album_type || 'album',
|
||||||
|
total_tracks: albumData.total_tracks || 0,
|
||||||
|
release_date: albumData.release_date || '',
|
||||||
|
images: albumData.images || []
|
||||||
|
},
|
||||||
|
duration_ms: track.duration_ms || 0,
|
||||||
|
track_number: track.track_number || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || album.tidal_album_id || index}`;
|
||||||
|
await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks,
|
||||||
|
{ name: album.artist_name, source: albumData.source || 'spotify' },
|
||||||
|
{
|
||||||
|
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
|
||||||
|
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
|
||||||
|
images: albumData.images || []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
hideLoadingOverlay();
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error opening your album download:', e);
|
||||||
|
showToast(`Failed to load album: ${e.message}`, 'error');
|
||||||
|
hideLoadingOverlay();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function refreshYourAlbums() {
|
||||||
|
const btn = document.getElementById('your-albums-refresh-btn');
|
||||||
|
if (btn) btn.disabled = true;
|
||||||
|
const subtitle = document.getElementById('your-albums-subtitle');
|
||||||
|
if (subtitle) subtitle.textContent = 'Refreshing from connected services...';
|
||||||
|
try {
|
||||||
|
await fetch('/api/discover/your-albums/refresh?clear=true', { method: 'POST' });
|
||||||
|
showToast('Refresh started — checking for new albums...', 'info');
|
||||||
|
const poll = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/discover/your-albums?page=1&per_page=48');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.success && data.stats && data.stats.total > 0) {
|
||||||
|
clearInterval(poll);
|
||||||
|
loadYourAlbums();
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
}, 4000);
|
||||||
|
setTimeout(() => { clearInterval(poll); if (btn) btn.disabled = false; }, 60000);
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Failed to start refresh', 'error');
|
||||||
|
if (btn) btn.disabled = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function openYourAlbumsSourcesModal() {
|
||||||
|
const existing = document.getElementById('ya-albums-sources-modal-overlay');
|
||||||
|
if (existing) existing.remove();
|
||||||
|
|
||||||
|
let enabled = ['spotify', 'tidal', 'deezer'];
|
||||||
|
let connected = [];
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/discover/your-albums/sources');
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.enabled) enabled = data.enabled;
|
||||||
|
if (data.connected) connected = data.connected;
|
||||||
|
}
|
||||||
|
} catch (e) { }
|
||||||
|
|
||||||
|
const sourceInfo = [
|
||||||
|
{ id: 'spotify', label: 'Spotify', icon: '\uD83C\uDFB5' },
|
||||||
|
{ id: 'tidal', label: 'Tidal', icon: '\uD83C\uDF0A' },
|
||||||
|
{ id: 'deezer', label: 'Deezer', icon: '\uD83C\uDFB6' },
|
||||||
|
];
|
||||||
|
const state = {};
|
||||||
|
sourceInfo.forEach(s => { state[s.id] = enabled.includes(s.id); });
|
||||||
|
|
||||||
|
const overlay = document.createElement('div');
|
||||||
|
overlay.id = 'ya-albums-sources-modal-overlay';
|
||||||
|
overlay.className = 'modal-overlay';
|
||||||
|
overlay.onclick = (e) => { if (e.target === overlay) overlay.remove(); };
|
||||||
|
|
||||||
|
const rows = sourceInfo.map(s => {
|
||||||
|
const isConnected = connected.includes(s.id);
|
||||||
|
const isOn = state[s.id];
|
||||||
|
return `
|
||||||
|
<div class="ya-source-row${isConnected ? '' : ' disconnected'}" data-yaa-source="${s.id}" onclick="_yaaSourceRowClick('${s.id}')">
|
||||||
|
<div class="ya-source-row-left">
|
||||||
|
<span style="font-size:18px">${s.icon}</span>
|
||||||
|
<div>
|
||||||
|
<div class="ya-source-name">${s.label}</div>
|
||||||
|
<div class="ya-source-status">${isConnected ? 'Connected' : 'Not connected'}</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button class="ya-source-toggle${isOn ? ' on' : ''}" id="yaa-toggle-${s.id}" onclick="event.stopPropagation();_yaaSourceToggle('${s.id}')"></button>
|
||||||
|
</div>`;
|
||||||
|
}).join('');
|
||||||
|
|
||||||
|
overlay.innerHTML = `
|
||||||
|
<div class="ya-sources-modal">
|
||||||
|
<h2>Your Albums Sources</h2>
|
||||||
|
<p class="ya-sources-desc">Choose which connected services contribute albums to this section.</p>
|
||||||
|
<div class="ya-sources-list">${rows}</div>
|
||||||
|
<div class="ya-sources-footer">
|
||||||
|
<button class="ya-sources-cancel-btn" onclick="document.getElementById('ya-albums-sources-modal-overlay').remove()">Cancel</button>
|
||||||
|
<button class="ya-sources-save-btn" onclick="_yaaSourcesSave()">Save</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
document.body.appendChild(overlay);
|
||||||
|
window._yaaSourcesState = state;
|
||||||
|
}
|
||||||
|
|
||||||
|
function _yaaSourceRowClick(id) {
|
||||||
|
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
|
||||||
|
if (row && row.classList.contains('disconnected')) return;
|
||||||
|
_yaaSourceToggle(id);
|
||||||
|
}
|
||||||
|
function _yaaSourceToggle(id) {
|
||||||
|
const row = document.querySelector(`.ya-source-row[data-yaa-source="${id}"]`);
|
||||||
|
if (row && row.classList.contains('disconnected')) return;
|
||||||
|
window._yaaSourcesState[id] = !window._yaaSourcesState[id];
|
||||||
|
const btn = document.getElementById(`yaa-toggle-${id}`);
|
||||||
|
if (btn) btn.classList.toggle('on', window._yaaSourcesState[id]);
|
||||||
|
}
|
||||||
|
async function _yaaSourcesSave() {
|
||||||
|
const enabledArr = Object.entries(window._yaaSourcesState).filter(([, v]) => v).map(([k]) => k);
|
||||||
|
if (enabledArr.length === 0) { showToast('Select at least one source', 'error'); return; }
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/settings', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ discover: { your_albums_sources: enabledArr.join(',') } })
|
||||||
|
});
|
||||||
|
if (resp.ok) {
|
||||||
|
document.getElementById('ya-albums-sources-modal-overlay')?.remove();
|
||||||
|
showToast('Sources saved — refresh to apply', 'success');
|
||||||
|
const sourceNames = { spotify: 'Spotify', tidal: 'Tidal', deezer: 'Deezer' };
|
||||||
|
const subtitle = document.getElementById('your-albums-subtitle');
|
||||||
|
if (subtitle) {
|
||||||
|
const names = enabledArr.map(s => sourceNames[s] || s).join(' and ');
|
||||||
|
subtitle.textContent = `Albums you\u2019ve saved on ${names}`;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
showToast('Failed to save sources', 'error');
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
showToast('Failed to save sources', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadMissingYourAlbums() {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/discover/your-albums?page=1&per_page=1000&status=missing');
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!data.success || !data.albums || data.albums.length === 0) {
|
||||||
|
showToast('No missing albums to download', 'info');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const missing = data.albums.filter(a => !a.in_library);
|
||||||
|
if (missing.length === 0) { showToast('All albums are already in your library!', 'success'); return; }
|
||||||
|
if (!confirm(`Download ${missing.length} missing album${missing.length > 1 ? 's' : ''} from your saved albums?`)) return;
|
||||||
|
showToast(`Starting download for ${missing.length} albums...`, 'info');
|
||||||
|
for (let i = 0; i < missing.length; i++) {
|
||||||
|
const album = missing[i];
|
||||||
|
try {
|
||||||
|
showToast(`Queuing ${i + 1}/${missing.length}: ${album.album_name}`, 'info');
|
||||||
|
const nameParams = new URLSearchParams({ name: album.album_name || '', artist: album.artist_name || '' });
|
||||||
|
let albumData = null;
|
||||||
|
if (album.spotify_album_id) {
|
||||||
|
const r = await fetch(`/api/discover/album/spotify/${album.spotify_album_id}?${nameParams}`);
|
||||||
|
if (r.ok) albumData = await r.json();
|
||||||
|
}
|
||||||
|
if (!albumData && album.deezer_album_id) {
|
||||||
|
const r = await fetch(`/api/discover/album/deezer/${album.deezer_album_id}?${nameParams}`);
|
||||||
|
if (r.ok) albumData = await r.json();
|
||||||
|
}
|
||||||
|
if (!albumData || !albumData.tracks || albumData.tracks.length === 0) continue;
|
||||||
|
const tracks = albumData.tracks.map(track => {
|
||||||
|
let artists = track.artists || albumData.artists || [{ name: album.artist_name }];
|
||||||
|
if (Array.isArray(artists)) artists = artists.map(a => a.name || a);
|
||||||
|
return {
|
||||||
|
id: track.id, name: track.name, artists,
|
||||||
|
album: {
|
||||||
|
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
|
||||||
|
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
|
||||||
|
images: albumData.images || []
|
||||||
|
},
|
||||||
|
duration_ms: track.duration_ms || 0, track_number: track.track_number || 0
|
||||||
|
};
|
||||||
|
});
|
||||||
|
const virtualId = `your_albums_${album.spotify_album_id || album.deezer_album_id || i}`;
|
||||||
|
await openDownloadMissingModalForYouTube(virtualId, albumData.name, tracks,
|
||||||
|
{ name: album.artist_name, source: albumData.source || 'spotify' },
|
||||||
|
{
|
||||||
|
id: albumData.id, name: albumData.name, album_type: albumData.album_type || 'album',
|
||||||
|
total_tracks: albumData.total_tracks || 0, release_date: albumData.release_date || '',
|
||||||
|
images: albumData.images || []
|
||||||
|
}
|
||||||
|
);
|
||||||
|
} catch (err) { console.error(`Error queuing ${album.album_name}:`, err); }
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Error downloading missing your albums:', e);
|
||||||
|
showToast(`Error: ${e.message}`, 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ===============================
|
// ===============================
|
||||||
// SPOTIFY LIBRARY SECTION
|
// SPOTIFY LIBRARY SECTION
|
||||||
// ===============================
|
// ===============================
|
||||||
|
|
@ -63990,8 +64395,10 @@ async function loadStatsData() {
|
||||||
|
|
||||||
if (!data.success) {
|
if (!data.success) {
|
||||||
// Cache not available — show empty state, user should hit Sync
|
// Cache not available — show empty state, user should hit Sync
|
||||||
data = { overview: {}, top_artists: [], top_albums: [], top_tracks: [],
|
data = {
|
||||||
timeline: [], genres: [], recent: [], health: {} };
|
overview: {}, top_artists: [], top_albums: [], top_tracks: [],
|
||||||
|
timeline: [], genres: [], recent: [], health: {}
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const overview = data.overview || {};
|
const overview = data.overview || {};
|
||||||
|
|
@ -67059,61 +67466,102 @@ const AUTO_HUB_GROUPS = [
|
||||||
|
|
||||||
const AUTO_HUB_RECIPES = [
|
const AUTO_HUB_RECIPES = [
|
||||||
// Sync & Playlists
|
// Sync & Playlists
|
||||||
{ id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.',
|
{
|
||||||
category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] },
|
id: 'spotify-auto-sync', icon: '\uD83D\uDD01', name: 'Spotify Playlist Auto-Sync', desc: 'Refresh all mirrored playlists every 6 hours to keep them in sync with Spotify.',
|
||||||
{ id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.',
|
category: 'Sync', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 6, unit: 'hours' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'release-radar-pipeline', icon: '\uD83D\uDCE1', name: 'Release Radar Pipeline', desc: 'Every Friday, refresh mirrored playlists, discover new tracks, then sync. Chain 3 automations for a full pipeline.',
|
||||||
category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [],
|
category: 'Sync', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['friday'], time: '18:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [],
|
||||||
chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.' },
|
chain: ['Refresh Mirrored', 'Discover Playlist', 'Sync Playlist'], note: 'Create 3 separate automations and chain them with signals for the full pipeline.'
|
||||||
{ id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.',
|
},
|
||||||
category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: [] },
|
{
|
||||||
{ id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.',
|
id: 'discover-weekly-grab', icon: '\uD83C\uDFB5', name: 'Discover Weekly Grab', desc: 'Every Monday, refresh your mirrored Discover Weekly to capture the new playlist before Spotify replaces it.',
|
||||||
category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] },
|
category: 'Sync', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['monday'], time: '08:00' } }, do: { type: 'refresh_mirrored', config: {} }, then: []
|
||||||
{ id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.',
|
},
|
||||||
category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: [] },
|
{
|
||||||
|
id: 'playlist-change-watcher', icon: '\uD83D\uDD14', name: 'Playlist Change Watcher', desc: 'Get a Discord notification whenever any tracked playlist changes.',
|
||||||
|
category: 'Sync', difficulty: 'beginner', when: { type: 'playlist_changed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'new-mirror-discovery', icon: '\uD83D\uDD0D', name: 'New Mirror Auto-Discovery', desc: 'Automatically discover tracks when you mirror a new playlist.',
|
||||||
|
category: 'Sync', difficulty: 'beginner', when: { type: 'mirrored_playlist_created', config: {} }, do: { type: 'discover_playlist', config: {} }, then: []
|
||||||
|
},
|
||||||
// New Music Discovery
|
// New Music Discovery
|
||||||
{ id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.',
|
{
|
||||||
|
id: 'complete-new-release', icon: '\uD83D\uDE80', name: 'Complete New Release Pipeline', desc: 'Full hands-free chain: scan watchlist \u2192 process wishlist \u2192 quality scan \u2192 notify. Requires 3 automations linked by signals.',
|
||||||
category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }],
|
category: 'Discovery', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'watchlist_done' } }],
|
||||||
chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'],
|
chain: ['Scan Watchlist', '\u26A1 watchlist_done', 'Process Wishlist', '\u26A1 wishlist_done', 'Quality Scan', 'Discord'],
|
||||||
note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.' },
|
note: 'Create 3 automations: (1) Schedule\u2192Scan Watchlist\u2192fire watchlist_done, (2) Signal watchlist_done\u2192Process Wishlist\u2192fire wishlist_done, (3) Signal wishlist_done\u2192Quality Scan\u2192Discord.'
|
||||||
{ id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.',
|
},
|
||||||
category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [] },
|
{
|
||||||
{ id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.',
|
id: 'new-release-monitor', icon: '\uD83D\uDD14', name: 'New Release Monitor', desc: 'Scan your watchlist for new releases every 12 hours.',
|
||||||
category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] },
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: []
|
||||||
{ id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.',
|
},
|
||||||
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: [] },
|
{
|
||||||
{ id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.',
|
id: 'artist-watch-alert', icon: '\uD83C\uDFA4', name: 'Artist Watch Alert', desc: 'Get a Telegram notification when you add a new artist to your watchlist.',
|
||||||
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: [] },
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'watchlist_artist_added', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'discovery-pool-refresh', icon: '\uD83C\uDF10', name: 'Discovery Pool Refresh', desc: 'Refresh the discovery pool every night at 2 AM with fresh recommendations.',
|
||||||
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '02:00' } }, do: { type: 'update_discovery_pool', config: {} }, then: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'nightly-wishlist', icon: '\uD83C\uDF19', name: 'Nightly Wishlist Processor', desc: 'Process your wishlist at 3 AM every night while you sleep.',
|
||||||
|
category: 'Discovery', difficulty: 'beginner', when: { type: 'daily_time', config: { time: '03:00' } }, do: { type: 'process_wishlist', config: {} }, then: []
|
||||||
|
},
|
||||||
// Library Maintenance
|
// Library Maintenance
|
||||||
{ id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.',
|
{
|
||||||
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: [] },
|
id: 'full-library-maintenance', icon: '\uD83E\uDDF9', name: 'Full Library Maintenance', desc: 'Run full cleanup every Saturday at 5 AM \u2014 dedup, quarantine, wishlist tidy.',
|
||||||
{ id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
|
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'weekly_time', config: { days: ['saturday'], time: '05:00' } }, do: { type: 'full_cleanup', config: {} }, then: []
|
||||||
category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: [] },
|
},
|
||||||
{ id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.',
|
{
|
||||||
category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: [] },
|
id: 'post-batch-cleanup', icon: '\uD83E\uDDF9', name: 'Post-Batch Cleanup', desc: 'Run a full cleanup after any batch download completes.',
|
||||||
{ id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.',
|
category: 'Maintenance', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'full_cleanup', config: {} }, then: []
|
||||||
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }] },
|
},
|
||||||
{ id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.',
|
{
|
||||||
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: [] },
|
id: 'weekly-db-backup', icon: '\uD83D\uDCBE', name: 'Weekly Database Backup', desc: 'Back up your database every Sunday at 4 AM.',
|
||||||
|
category: 'Maintenance', difficulty: 'beginner', when: { type: 'weekly_time', config: { days: ['sunday'], time: '04:00' } }, do: { type: 'backup_database', config: {} }, then: []
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'quality-assurance', icon: '\u2705', name: 'Quality Assurance Pipeline', desc: 'After a library scan completes, run a quality scan and fire a signal when done.',
|
||||||
|
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'library_scan_completed', config: {} }, do: { type: 'start_quality_scan', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'quality_done' } }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'import-cleanup', icon: '\uD83D\uDCE5', name: 'Import Cleanup', desc: 'Automatically scan the library after an import completes to keep things tidy.',
|
||||||
|
category: 'Maintenance', difficulty: 'intermediate', when: { type: 'import_completed', config: {} }, do: { type: 'scan_library', config: {} }, then: []
|
||||||
|
},
|
||||||
// Notifications & Alerts
|
// Notifications & Alerts
|
||||||
{ id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
|
{
|
||||||
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }] },
|
id: 'download-failure-alert', icon: '\u274C', name: 'Download Failure Alert', desc: 'Get notified via Discord when a download fails.',
|
||||||
{ id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.',
|
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_failed', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'discord_webhook', config: {} }]
|
||||||
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }] },
|
},
|
||||||
{ id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.',
|
{
|
||||||
category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }] },
|
id: 'quarantine-alert', icon: '\u26A0\uFE0F', name: 'Quarantine Alert', desc: 'Get a Pushbullet alert when a file is quarantined.',
|
||||||
|
category: 'Alerts', difficulty: 'beginner', when: { type: 'download_quarantined', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'pushbullet', config: {} }]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'batch-complete-notify', icon: '\uD83C\uDFC1', name: 'Batch Complete Notification', desc: 'Get a Telegram message when a batch download finishes.',
|
||||||
|
category: 'Alerts', difficulty: 'beginner', when: { type: 'batch_complete', config: {} }, do: { type: 'notify_only', config: {} }, then: [{ type: 'telegram', config: {} }]
|
||||||
|
},
|
||||||
// Power User Chains
|
// Power User Chains
|
||||||
{ id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.',
|
{
|
||||||
|
id: 'full-hands-free', icon: '\uD83E\uDD16', name: 'Full Hands-Free Pipeline', desc: 'The ultimate automation chain: scan \u2192 process \u2192 download \u2192 clean \u2192 notify. Requires 5 automations linked by signals.',
|
||||||
category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }],
|
category: 'Chains', difficulty: 'advanced', when: { type: 'schedule', config: { interval: 12, unit: 'hours' } }, do: { type: 'scan_watchlist', config: {} }, then: [{ type: 'fire_signal', config: { signal_name: 'scan_done' } }],
|
||||||
chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'],
|
chain: ['Scan Watchlist', '\u26A1 scan_done', 'Process Wishlist', '\u26A1 process_done', 'Full Cleanup', '\u26A1 cleanup_done', 'Quality Scan', 'Discord'],
|
||||||
note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.' },
|
note: 'Build 4-5 automations, each firing a signal for the next step. Start small and add stages.'
|
||||||
{ id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.',
|
},
|
||||||
|
{
|
||||||
|
id: 'staggered-nightly', icon: '\uD83C\uDF03', name: 'Staggered Nightly Pipeline', desc: 'Spread tasks across the night: 1 AM scan, 2 AM process, 3 AM cleanup, 4 AM backup.',
|
||||||
category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [],
|
category: 'Chains', difficulty: 'intermediate', when: { type: 'daily_time', config: { time: '01:00' } }, do: { type: 'scan_watchlist', config: {} }, then: [],
|
||||||
chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'],
|
chain: ['1:00 Scan', '2:00 Process', '3:00 Cleanup', '4:00 Backup'],
|
||||||
note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.' },
|
note: 'Create 4 daily_time automations at staggered hours. No signals needed \u2014 just timing.'
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const AUTO_HUB_GUIDES = [
|
const AUTO_HUB_GUIDES = [
|
||||||
{ id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner',
|
{
|
||||||
|
id: 'auto-sync-playlists', icon: '\uD83D\uDD01', title: 'Auto-Sync Your Spotify Playlists', subtitle: 'Mirror a Spotify playlist and schedule automatic refreshes.', difficulty: 'beginner',
|
||||||
steps: [
|
steps: [
|
||||||
'Go to the <strong>Playlists</strong> page and find a Spotify playlist you want to track.',
|
'Go to the <strong>Playlists</strong> page and find a Spotify playlist you want to track.',
|
||||||
'Click <strong>Mirror Playlist</strong> to create a local copy.',
|
'Click <strong>Mirror Playlist</strong> to create a local copy.',
|
||||||
|
|
@ -67121,16 +67569,20 @@ const AUTO_HUB_GUIDES = [
|
||||||
'Set WHEN to <strong>Schedule \u2192 Every 6 hours</strong>.',
|
'Set WHEN to <strong>Schedule \u2192 Every 6 hours</strong>.',
|
||||||
'Set DO to <strong>Refresh Mirrored Playlists</strong>.',
|
'Set DO to <strong>Refresh Mirrored Playlists</strong>.',
|
||||||
'Save and enable \u2014 your playlist will now stay in sync automatically.'
|
'Save and enable \u2014 your playlist will now stay in sync automatically.'
|
||||||
], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab'] },
|
], relatedRecipes: ['spotify-auto-sync', 'discover-weekly-grab']
|
||||||
{ id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner',
|
},
|
||||||
|
{
|
||||||
|
id: 'discord-download-alerts', icon: '\uD83D\uDCE2', title: 'Get Discord Alerts for Downloads', subtitle: 'Set up Discord webhook notifications for download events.', difficulty: 'beginner',
|
||||||
steps: [
|
steps: [
|
||||||
'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.',
|
'In Discord, go to your channel\'s settings \u2192 <strong>Integrations \u2192 Webhooks</strong>.',
|
||||||
'Create a webhook and copy the URL.',
|
'Create a webhook and copy the URL.',
|
||||||
'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.',
|
'In SoulSync, go to <strong>Settings \u2192 Notifications</strong> and paste the Discord webhook URL.',
|
||||||
'Go to <strong>Automations \u2192 New Automation</strong>.',
|
'Go to <strong>Automations \u2192 New Automation</strong>.',
|
||||||
'Set WHEN to <strong>Download Failed</strong> (or any event), DO to <strong>Notify Only</strong>, THEN to <strong>Discord</strong>.'
|
'Set WHEN to <strong>Download Failed</strong> (or any event), DO to <strong>Notify Only</strong>, THEN to <strong>Discord</strong>.'
|
||||||
], relatedRecipes: ['download-failure-alert', 'batch-complete-notify'] },
|
], relatedRecipes: ['download-failure-alert', 'batch-complete-notify']
|
||||||
{ id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate',
|
},
|
||||||
|
{
|
||||||
|
id: 'hands-free-pipeline', icon: '\uD83E\uDD16', title: 'Build a Hands-Free Library Pipeline', subtitle: 'Chain watchlist scanning, wishlist processing, and cleanup with signals.', difficulty: 'intermediate',
|
||||||
steps: [
|
steps: [
|
||||||
'Create Automation 1: <strong>Schedule (12h) \u2192 Scan Watchlist</strong>, THEN fire signal <code>scan_done</code>.',
|
'Create Automation 1: <strong>Schedule (12h) \u2192 Scan Watchlist</strong>, THEN fire signal <code>scan_done</code>.',
|
||||||
'Create Automation 2: <strong>Signal scan_done \u2192 Process Wishlist</strong>, THEN fire signal <code>process_done</code>.',
|
'Create Automation 2: <strong>Signal scan_done \u2192 Process Wishlist</strong>, THEN fire signal <code>process_done</code>.',
|
||||||
|
|
@ -67139,8 +67591,10 @@ const AUTO_HUB_GUIDES = [
|
||||||
'Test by manually running Automation 1 \u2014 watch the chain execute.',
|
'Test by manually running Automation 1 \u2014 watch the chain execute.',
|
||||||
'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.',
|
'Add a THEN notification (Discord/Telegram) to the last automation for completion alerts.',
|
||||||
'Adjust the schedule interval based on how often you want new music checked.'
|
'Adjust the schedule interval based on how often you want new music checked.'
|
||||||
], relatedRecipes: ['complete-new-release', 'full-hands-free'] },
|
], relatedRecipes: ['complete-new-release', 'full-hands-free']
|
||||||
{ id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced',
|
},
|
||||||
|
{
|
||||||
|
id: 'signal-chains', icon: '\u26A1', title: 'Set Up Signal Chains', subtitle: 'Use fire_signal and signal_received to link automations together.', difficulty: 'advanced',
|
||||||
steps: [
|
steps: [
|
||||||
'Understand the concept: <strong>fire_signal</strong> is a THEN action that emits a named signal. <strong>signal_received</strong> is a WHEN trigger that listens for it.',
|
'Understand the concept: <strong>fire_signal</strong> is a THEN action that emits a named signal. <strong>signal_received</strong> is a WHEN trigger that listens for it.',
|
||||||
'In your first automation, add a THEN action \u2192 <strong>Fire Signal</strong> and name it (e.g., <code>step1_done</code>).',
|
'In your first automation, add a THEN action \u2192 <strong>Fire Signal</strong> and name it (e.g., <code>step1_done</code>).',
|
||||||
|
|
@ -67148,15 +67602,18 @@ const AUTO_HUB_GUIDES = [
|
||||||
'The second automation will fire automatically when the first one completes.',
|
'The second automation will fire automatically when the first one completes.',
|
||||||
'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.',
|
'Chain up to 5 levels deep (safety limit). SoulSync detects cycles automatically.',
|
||||||
'Use descriptive signal names like <code>watchlist_scanned</code> or <code>cleanup_finished</code>.'
|
'Use descriptive signal names like <code>watchlist_scanned</code> or <code>cleanup_finished</code>.'
|
||||||
], relatedRecipes: ['quality-assurance', 'complete-new-release'] },
|
], relatedRecipes: ['quality-assurance', 'complete-new-release']
|
||||||
{ id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate',
|
},
|
||||||
|
{
|
||||||
|
id: 'nightly-maintenance', icon: '\uD83C\uDF19', title: 'Schedule Nightly Maintenance', subtitle: 'Set up backup, cleanup, and quality scans to run overnight.', difficulty: 'intermediate',
|
||||||
steps: [
|
steps: [
|
||||||
'Create a <strong>Daily Time (04:00) \u2192 Backup Database</strong> automation.',
|
'Create a <strong>Daily Time (04:00) \u2192 Backup Database</strong> automation.',
|
||||||
'Create a <strong>Weekly Time (Saturday, 05:00) \u2192 Full Cleanup</strong> automation.',
|
'Create a <strong>Weekly Time (Saturday, 05:00) \u2192 Full Cleanup</strong> automation.',
|
||||||
'Create a <strong>Daily Time (02:00) \u2192 Update Discovery Pool</strong> automation.',
|
'Create a <strong>Daily Time (02:00) \u2192 Update Discovery Pool</strong> automation.',
|
||||||
'Stagger times by at least 1 hour to avoid resource contention.',
|
'Stagger times by at least 1 hour to avoid resource contention.',
|
||||||
'Add Discord/Telegram notifications to any you want alerts for.'
|
'Add Discord/Telegram notifications to any you want alerts for.'
|
||||||
], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly'] },
|
], relatedRecipes: ['weekly-db-backup', 'full-library-maintenance', 'staggered-nightly']
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
const AUTO_HUB_TIPS = [
|
const AUTO_HUB_TIPS = [
|
||||||
|
|
@ -67172,45 +67629,58 @@ const AUTO_HUB_TIPS = [
|
||||||
|
|
||||||
const AUTO_HUB_REFERENCE = {
|
const AUTO_HUB_REFERENCE = {
|
||||||
triggers: [
|
triggers: [
|
||||||
{ group: 'Time-Based', items: [
|
{
|
||||||
|
group: 'Time-Based', items: [
|
||||||
{ type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' },
|
{ type: 'schedule', label: 'Schedule', desc: 'Repeating interval (e.g., every 6 hours)' },
|
||||||
{ type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' },
|
{ type: 'daily_time', label: 'Daily Time', desc: 'Every day at a specific time (e.g., 03:00)' },
|
||||||
{ type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' },
|
{ type: 'weekly_time', label: 'Weekly Time', desc: 'Specific days + time (e.g., Saturday at 05:00)' },
|
||||||
]},
|
]
|
||||||
{ group: 'Download Events', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Download Events', items: [
|
||||||
{ type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' },
|
{ type: 'track_downloaded', label: 'Track Downloaded', desc: 'Fires when a single track download completes' },
|
||||||
{ type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' },
|
{ type: 'batch_complete', label: 'Batch Complete', desc: 'Fires when a batch download job finishes' },
|
||||||
{ type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' },
|
{ type: 'download_failed', label: 'Download Failed', desc: 'Fires when a download fails or errors out' },
|
||||||
{ type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' },
|
{ type: 'download_quarantined', label: 'File Quarantined', desc: 'Fires when a downloaded file is quarantined for quality issues' },
|
||||||
]},
|
]
|
||||||
{ group: 'Watchlist & Wishlist', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Watchlist & Wishlist', items: [
|
||||||
{ type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' },
|
{ type: 'watchlist_new_release', label: 'New Release Found', desc: 'Fires when a watched artist has a new release' },
|
||||||
{ type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' },
|
{ type: 'watchlist_scan_completed', label: 'Watchlist Scan Done', desc: 'Fires after a full watchlist scan completes' },
|
||||||
{ type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' },
|
{ type: 'watchlist_artist_added', label: 'Artist Watched', desc: 'Fires when a new artist is added to the watchlist' },
|
||||||
{ type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' },
|
{ type: 'watchlist_artist_removed', label: 'Artist Unwatched', desc: 'Fires when an artist is removed from the watchlist' },
|
||||||
{ type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' },
|
{ type: 'wishlist_item_added', label: 'Wishlist Item Added', desc: 'Fires when a new item is added to the wishlist' },
|
||||||
{ type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' },
|
{ type: 'wishlist_processing_completed', label: 'Wishlist Processed', desc: 'Fires after the wishlist processor completes a run' },
|
||||||
]},
|
]
|
||||||
{ group: 'Playlists', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Playlists', items: [
|
||||||
{ type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' },
|
{ type: 'playlist_synced', label: 'Playlist Synced', desc: 'Fires when a playlist sync operation completes' },
|
||||||
{ type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' },
|
{ type: 'playlist_changed', label: 'Playlist Changed', desc: 'Fires when a tracked playlist has changes detected' },
|
||||||
{ type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' },
|
{ type: 'mirrored_playlist_created', label: 'Playlist Mirrored', desc: 'Fires when a new mirrored playlist is created' },
|
||||||
{ type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' },
|
{ type: 'discovery_completed', label: 'Discovery Complete', desc: 'Fires when playlist discovery finishes' },
|
||||||
]},
|
]
|
||||||
{ group: 'Library & System', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Library & System', items: [
|
||||||
{ type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' },
|
{ type: 'app_started', label: 'App Started', desc: 'Fires once when SoulSync starts up' },
|
||||||
{ type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' },
|
{ type: 'import_completed', label: 'Import Complete', desc: 'Fires when a library import operation finishes' },
|
||||||
{ type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' },
|
{ type: 'library_scan_completed', label: 'Library Scan Done', desc: 'Fires after a full library scan completes' },
|
||||||
{ type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' },
|
{ type: 'quality_scan_completed', label: 'Quality Scan Done', desc: 'Fires when a quality scan finishes' },
|
||||||
{ type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' },
|
{ type: 'duplicate_scan_completed', label: 'Duplicate Scan Done', desc: 'Fires when the duplicate scanner finishes' },
|
||||||
{ type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' },
|
{ type: 'database_update_completed', label: 'Database Updated', desc: 'Fires after a database update operation' },
|
||||||
]},
|
]
|
||||||
{ group: 'Signals', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Signals', items: [
|
||||||
{ type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' },
|
{ type: 'signal_received', label: 'Signal Received', desc: 'Fires when a named signal is emitted by another automation\'s fire_signal THEN action' },
|
||||||
]},
|
]
|
||||||
|
},
|
||||||
],
|
],
|
||||||
actions: [
|
actions: [
|
||||||
{ group: 'Downloads & Sync', items: [
|
{
|
||||||
|
group: 'Downloads & Sync', items: [
|
||||||
{ type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' },
|
{ type: 'playlist_pipeline', label: 'Playlist Pipeline', desc: 'Full lifecycle: refresh → discover → sync → download missing' },
|
||||||
{ type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' },
|
{ type: 'process_wishlist', label: 'Process Wishlist', desc: 'Download all pending wishlist items' },
|
||||||
{ type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' },
|
{ type: 'refresh_mirrored', label: 'Refresh Mirrored', desc: 'Refresh all mirrored playlists from their sources' },
|
||||||
|
|
@ -67218,34 +67688,45 @@ const AUTO_HUB_REFERENCE = {
|
||||||
{ type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' },
|
{ type: 'discover_playlist', label: 'Discover Playlist', desc: 'Run track discovery on mirrored playlists' },
|
||||||
{ type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' },
|
{ type: 'scan_watchlist', label: 'Scan Watchlist', desc: 'Check watched artists for new releases' },
|
||||||
{ type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' },
|
{ type: 'update_discovery_pool', label: 'Update Discovery', desc: 'Refresh the discovery pool with new recommendations' },
|
||||||
]},
|
]
|
||||||
{ group: 'Library Tools', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Library Tools', items: [
|
||||||
{ type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' },
|
{ type: 'scan_library', label: 'Scan Library', desc: 'Full scan of local music library files' },
|
||||||
{ type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' },
|
{ type: 'start_quality_scan', label: 'Quality Scan', desc: 'Check library tracks for quality issues' },
|
||||||
{ type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' },
|
{ type: 'start_database_update', label: 'Update Database', desc: 'Run a database update/maintenance operation' },
|
||||||
{ type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' },
|
{ type: 'backup_database', label: 'Backup Database', desc: 'Create a backup of the music database' },
|
||||||
]},
|
]
|
||||||
{ group: 'Cleanup', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Cleanup', items: [
|
||||||
{ type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' },
|
{ type: 'full_cleanup', label: 'Full Cleanup', desc: 'Run all cleanup tasks: dedup, quarantine, wishlist tidy' },
|
||||||
{ type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' },
|
{ type: 'run_duplicate_cleaner', label: 'Duplicate Cleaner', desc: 'Find and handle duplicate tracks' },
|
||||||
{ type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' },
|
{ type: 'clear_quarantine', label: 'Clear Quarantine', desc: 'Remove all quarantined files' },
|
||||||
{ type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' },
|
{ type: 'cleanup_wishlist', label: 'Clean Wishlist', desc: 'Remove completed/invalid wishlist items' },
|
||||||
{ type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' },
|
{ type: 'clean_search_history', label: 'Clean Search History', desc: 'Clear old search history entries' },
|
||||||
{ type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' },
|
{ type: 'clean_completed_downloads', label: 'Clean Downloads', desc: 'Remove completed download records' },
|
||||||
]},
|
]
|
||||||
{ group: 'Other', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Other', items: [
|
||||||
{ type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' },
|
{ type: 'notify_only', label: 'Notify Only', desc: 'No action \u2014 just trigger THEN notifications. Great for testing.' },
|
||||||
]},
|
]
|
||||||
|
},
|
||||||
],
|
],
|
||||||
thenActions: [
|
thenActions: [
|
||||||
{ group: 'Notifications', items: [
|
{
|
||||||
|
group: 'Notifications', items: [
|
||||||
{ type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' },
|
{ type: 'discord_webhook', label: 'Discord Webhook', desc: 'Send a message to a Discord channel via webhook' },
|
||||||
{ type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' },
|
{ type: 'telegram', label: 'Telegram', desc: 'Send a message to a Telegram chat via bot' },
|
||||||
{ type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' },
|
{ type: 'pushbullet', label: 'Pushbullet', desc: 'Send a push notification via Pushbullet' },
|
||||||
]},
|
]
|
||||||
{ group: 'Chaining', items: [
|
},
|
||||||
|
{
|
||||||
|
group: 'Chaining', items: [
|
||||||
{ type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' },
|
{ type: 'fire_signal', label: 'Fire Signal', desc: 'Emit a named signal that other automations can listen for with signal_received' },
|
||||||
]},
|
]
|
||||||
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -68121,7 +68602,8 @@ function _autoFormatTrigger(type, config) {
|
||||||
const sig = config.signal_name || 'unknown';
|
const sig = config.signal_name || 'unknown';
|
||||||
return 'Signal: ' + sig;
|
return 'Signal: ' + sig;
|
||||||
}
|
}
|
||||||
const labels = { app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
|
const labels = {
|
||||||
|
app_started: 'App Started', track_downloaded: 'Track Downloaded', batch_complete: 'Batch Complete',
|
||||||
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
|
watchlist_new_release: 'New Release Found', playlist_synced: 'Playlist Synced',
|
||||||
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
|
playlist_changed: 'Playlist Changed', discovery_completed: 'Discovery Complete',
|
||||||
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
|
wishlist_processing_completed: 'Wishlist Processed', watchlist_scan_completed: 'Watchlist Scan Done',
|
||||||
|
|
@ -68130,7 +68612,8 @@ function _autoFormatTrigger(type, config) {
|
||||||
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
|
watchlist_artist_added: 'Artist Watched', watchlist_artist_removed: 'Artist Unwatched',
|
||||||
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
|
import_completed: 'Import Complete', mirrored_playlist_created: 'Playlist Mirrored',
|
||||||
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done',
|
quality_scan_completed: 'Quality Scan Done', duplicate_scan_completed: 'Duplicate Scan Done',
|
||||||
library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received' };
|
library_scan_completed: 'Library Scan Done', signal_received: 'Signal Received'
|
||||||
|
};
|
||||||
let label = labels[type] || type || 'Unknown';
|
let label = labels[type] || type || 'Unknown';
|
||||||
if (config && config.conditions && config.conditions.length) {
|
if (config && config.conditions && config.conditions.length) {
|
||||||
const first = config.conditions[0];
|
const first = config.conditions[0];
|
||||||
|
|
@ -68140,7 +68623,8 @@ function _autoFormatTrigger(type, config) {
|
||||||
return label;
|
return label;
|
||||||
}
|
}
|
||||||
function _autoFormatAction(type) {
|
function _autoFormatAction(type) {
|
||||||
const labels = { process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
|
const labels = {
|
||||||
|
process_wishlist: 'Process Wishlist', scan_watchlist: 'Scan Watchlist',
|
||||||
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
|
scan_library: 'Scan Library', refresh_mirrored: 'Refresh Mirrored',
|
||||||
sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist',
|
sync_playlist: 'Sync Playlist', discover_playlist: 'Discover Playlist',
|
||||||
notify_only: 'Notify Only',
|
notify_only: 'Notify Only',
|
||||||
|
|
@ -68151,7 +68635,8 @@ function _autoFormatAction(type) {
|
||||||
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
|
refresh_beatport_cache: 'Refresh Beatport Cache', clean_search_history: 'Clean Search History',
|
||||||
clean_completed_downloads: 'Clean Completed Downloads',
|
clean_completed_downloads: 'Clean Completed Downloads',
|
||||||
full_cleanup: 'Full Cleanup',
|
full_cleanup: 'Full Cleanup',
|
||||||
playlist_pipeline: 'Playlist Pipeline' };
|
playlist_pipeline: 'Playlist Pipeline'
|
||||||
|
};
|
||||||
return labels[type] || type || 'Unknown';
|
return labels[type] || type || 'Unknown';
|
||||||
}
|
}
|
||||||
function _autoFormatNotify(type) {
|
function _autoFormatNotify(type) {
|
||||||
|
|
@ -68816,7 +69301,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
|
||||||
return `<div class="config-row">
|
return `<div class="config-row">
|
||||||
<label>Signal Name</label>
|
<label>Signal Name</label>
|
||||||
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
||||||
list="known-signals-list-${slotKey}" placeholder="e.g. library_ready"
|
list="known-signals-list-${slotKey}" placeholder="e.g. libraryReady"
|
||||||
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
||||||
style="font-family:monospace;">
|
style="font-family:monospace;">
|
||||||
<datalist id="known-signals-list-${slotKey}">
|
<datalist id="known-signals-list-${slotKey}">
|
||||||
|
|
@ -68831,7 +69316,7 @@ function _renderBlockConfigFields(slotKey, blockType, config) {
|
||||||
return `<div class="config-row">
|
return `<div class="config-row">
|
||||||
<label>Signal Name</label>
|
<label>Signal Name</label>
|
||||||
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
<input type="text" id="cfg-${slotKey}-signal_name" value="${sigName}"
|
||||||
list="known-signals-fire-${slotKey}" placeholder="e.g. library_ready"
|
list="known-signals-fire-${slotKey}" placeholder="e.g. libraryReady"
|
||||||
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
oninput="this.value = this.value.toLowerCase().replace(/[^a-z0-9_\\-]/g, '_').replace(/_+/g, '_').replace(/^_|_$/g, '')"
|
||||||
style="font-family:monospace;">
|
style="font-family:monospace;">
|
||||||
<datalist id="known-signals-fire-${slotKey}">
|
<datalist id="known-signals-fire-${slotKey}">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue