diff --git a/core/downloads/master.py b/core/downloads/master.py index ce30b8d5..005eb32a 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -389,6 +389,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_profile_id = 1 batch_source = 'spotify' batch_playlist_folder_mode = False + batch_keep_playlist_folder_copies = False batch_playlist_name = 'Unknown Playlist' batch_playlist_id = playlist_id batch_source_playlist_ref = '' @@ -409,6 +410,9 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_profile_id = download_batches[batch_id].get('profile_id', 1) or 1 batch_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify' batch_playlist_folder_mode = download_batches[batch_id].get('playlist_folder_mode', False) + batch_keep_playlist_folder_copies = download_batches[batch_id].get( + 'keep_playlist_folder_copies', False + ) batch_playlist_name = download_batches[batch_id].get('playlist_name', 'Unknown Playlist') batch_playlist_id = download_batches[batch_id].get('playlist_id', playlist_id) batch_source_playlist_ref = ( @@ -418,15 +422,25 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma from core.downloads.playlist_folder import ( resolve_playlist_folder_mode_for_batch, + resolve_wishlist_track_playlist_folder_mode, track_exists_in_playlist_folder_from_track_data, ) - effective_playlist_folder_mode, effective_playlist_name = resolve_playlist_folder_mode_for_batch( - db, - playlist_id=str(batch_playlist_id), - playlist_name=batch_playlist_name, - batch_playlist_folder_mode=batch_playlist_folder_mode, - profile_id=batch_profile_id, - source=batch_source, + effective_playlist_folder_mode, effective_playlist_name, keep_playlist_folder_copies = ( + resolve_playlist_folder_mode_for_batch( + db, + playlist_id=str(batch_playlist_id), + playlist_name=batch_playlist_name, + batch_playlist_folder_mode=batch_playlist_folder_mode, + batch_keep_playlist_folder_copies=batch_keep_playlist_folder_copies, + profile_id=batch_profile_id, + source=batch_source, + active_server=active_server or '', + ) + ) + skip_library_match_for_playlist_folder = ( + effective_playlist_folder_mode + and keep_playlist_folder_copies + and not force_download_all ) if effective_playlist_folder_mode and not batch_playlist_folder_mode: with tasks_lock: @@ -509,28 +523,38 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # Manual library matches are authoritative unless the user explicitly # requested a force re-download from the normal download modal. _stid = track_data.get('spotify_track_id') or track_data.get('source_track_id') or track_data.get('id', '') + _in_playlist_folder = ( + effective_playlist_folder_mode + and track_exists_in_playlist_folder_from_track_data( + effective_playlist_name, + track_data, + ) + ) if not ignore_manual_matches and _stid and _mlm.get_match_for_track( db, batch_profile_id, track_data, default_source=batch_source ): - logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download") - try: - deps.check_and_remove_track_from_wishlist_by_metadata(track_data) - except Exception as _wl_err: - logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}") - analysis_results.append({ - 'track_index': track_index, - 'track': track_data, - 'found': True, - 'confidence': 1.0, - 'match_reason': 'manual_library_match', - }) - continue + if skip_library_match_for_playlist_folder and not _in_playlist_folder: + logger.info( + f"[Playlist Folder Copies] '{track_name}' in library but missing from " + f"'{effective_playlist_name}' folder — will download copy" + ) + else: + logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download") + try: + deps.check_and_remove_track_from_wishlist_by_metadata(track_data) + except Exception as _wl_err: + logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}") + analysis_results.append({ + 'track_index': track_index, + 'track': track_data, + 'found': True, + 'confidence': 1.0, + 'match_reason': 'manual_library_match', + }) + continue if effective_playlist_folder_mode and not force_download_all: - if track_exists_in_playlist_folder_from_track_data( - effective_playlist_name, - track_data, - ): + if _in_playlist_folder: logger.info( f"[Playlist Folder] '{track_name}' already on disk in playlist folder — skipping download" ) @@ -547,10 +571,44 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma }) continue + # For wishlist tracks, check per-track playlist-folder existence regardless + # of force_download_all. Wishlist batches have force_download_all=True so + # the batch-level check above is skipped, but a file already on disk in the + # playlist folder must not be downloaded again (causes casing duplicates when + # the provider returns a differently-cased artist name on a subsequent run). + if playlist_id == 'wishlist': + _wl_pl_folder, _wl_pl_name = resolve_wishlist_track_playlist_folder_mode( + track_data.get('source_info'), + db, + profile_id=batch_profile_id, + default_playlist_name=batch_playlist_name, + ) + if _wl_pl_folder and track_exists_in_playlist_folder_from_track_data( + _wl_pl_name, track_data + ): + logger.info( + f"[Wishlist Folder] '{track_name}' already in playlist folder " + f"'{_wl_pl_name}' — skipping re-download" + ) + try: + deps.check_and_remove_track_from_wishlist_by_metadata(track_data) + except Exception as _wl_err: + logger.debug(f"[Wishlist Folder] Wishlist removal failed: {_wl_err}") + analysis_results.append({ + 'track_index': track_index, + 'track': track_data, + 'found': True, + 'confidence': 1.0, + 'match_reason': 'wishlist_playlist_folder_file', + }) + continue + # Skip database check if force download is enabled if force_download_all: logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing") found, confidence = False, 0.0 + elif skip_library_match_for_playlist_folder: + found, confidence = False, 0.0 elif album_tracks_map: # Album-scoped matching: check against known album tracks first track_name_lower = track_name.lower().strip() @@ -1043,6 +1101,18 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma task_id = str(uuid.uuid4()) track_info = res['track'].copy() + wishlist_track_pl_folder = False + wishlist_track_pl_name = batch_playlist_name + if playlist_id == 'wishlist': + wishlist_track_pl_folder, wishlist_track_pl_name = ( + resolve_wishlist_track_playlist_folder_mode( + track_info.get('source_info'), + db, + profile_id=batch_profile_id, + default_playlist_name=batch_playlist_name, + ) + ) + # Add explicit album context to track_info for artist album downloads if batch_is_album and batch_album_context and batch_artist_context: track_info['_explicit_album_context'] = batch_album_context @@ -1068,8 +1138,11 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma s_album = {'name': s_album} # Normalize string album to dict s_artists = spotify_data.get('artists', []) - # We need at least an album name and artist - if s_album and isinstance(s_album, dict) and s_album.get('name'): + # Album grouping for library paths — skip when playlist-folder layout applies. + if ( + s_album and isinstance(s_album, dict) and s_album.get('name') + and not wishlist_track_pl_folder + ): # Use pre-computed album-level artist for folder consistency. # All tracks from the same album get the same artist context, # preventing folder splits on collab albums (KPOP Demon Hunters, etc.) @@ -1124,25 +1197,9 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma # tracks tied to a mirrored playlist with organize_by_playlist enabled. task_pl_folder_mode = batch_playlist_folder_mode task_pl_name = batch_playlist_name - if not task_pl_folder_mode and playlist_id == 'wishlist': - wl_source = track_info.get('source_info') or {} - if isinstance(wl_source, str): - try: - wl_source = json.loads(wl_source) - except (json.JSONDecodeError, TypeError): - wl_source = {} - wl_pl_ref = wl_source.get('playlist_id') - wl_pl_name = wl_source.get('playlist_name') - wl_pl_source = wl_source.get('source') or 'spotify' - if wl_pl_ref and hasattr(db, 'resolve_mirrored_playlist'): - wl_mirrored = db.resolve_mirrored_playlist( - wl_pl_ref, - profile_id=batch_profile_id, - default_source=wl_pl_source, - ) - if wl_mirrored and wl_mirrored.get('organize_by_playlist'): - task_pl_folder_mode = True - task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name + if not task_pl_folder_mode and playlist_id == 'wishlist' and wishlist_track_pl_folder: + task_pl_folder_mode = True + task_pl_name = wishlist_track_pl_name if task_pl_folder_mode: track_info['_playlist_folder_mode'] = True track_info['_playlist_name'] = task_pl_name diff --git a/core/downloads/playlist_folder.py b/core/downloads/playlist_folder.py index f9ef994e..476c12cc 100644 --- a/core/downloads/playlist_folder.py +++ b/core/downloads/playlist_folder.py @@ -2,8 +2,9 @@ from __future__ import annotations +import json import os -from typing import Any, Dict, List, Optional +from typing import Any, Dict, List, Optional, Tuple from core.downloads.file_finder import AUDIO_EXTENSIONS from core.imports.paths import ( @@ -75,13 +76,66 @@ def track_exists_in_playlist_folder( artist: str, title: str, ) -> bool: - """Return True if any audio file exists at the playlist-folder path for this track.""" - for path in candidate_playlist_folder_paths(playlist_name, artist, title): + """Return True if any audio file exists at the playlist-folder path for this track. + + Uses a case-insensitive fallback scan of the playlist directory so that + provider-casing differences (e.g. "HUGEL" vs "hugel") don't cause the same + file to be downloaded twice under a differently-cased filename. + """ + candidates = candidate_playlist_folder_paths(playlist_name, artist, title) + for path in candidates: if os.path.isfile(path): return True + + # Case-insensitive fallback: list the playlist directory and compare basenames + # after lowercasing. On Linux (case-sensitive fs) a file written as + # "HUGEL - Song.flac" is invisible to an exact match for "hugel - Song.flac". + checked_dirs: set = set() + for path in candidates: + parent = os.path.dirname(path) + if parent in checked_dirs or not os.path.isdir(parent): + continue + checked_dirs.add(parent) + target_lower = os.path.basename(path).lower() + try: + for fname in os.listdir(parent): + if fname.lower() == target_lower: + return True + except OSError: + pass + return False +def is_soulsync_standalone_server(active_server: str) -> bool: + return (active_server or '').strip().lower() == 'soulsync' + + +def effective_keep_playlist_folder_copies( + mirrored: Optional[Dict[str, Any]], + active_server: str, + *, + batch_keep: bool = False, +) -> bool: + """True when per-playlist folder copies should be kept for this batch. + + In SoulSync standalone mode, mirrored playlists with organize-by-playlist + default to keeping copies unless the user explicitly opted out. + """ + if batch_keep: + return True + if not mirrored: + return False + if mirrored.get('keep_playlist_folder_copies'): + return True + if mirrored.get('keep_playlist_folder_copies_opt_out'): + return False + return ( + is_soulsync_standalone_server(active_server) + and bool(mirrored.get('organize_by_playlist')) + ) + + def track_exists_in_playlist_folder_from_track_data( playlist_name: str, track_data: Dict[str, Any], @@ -94,35 +148,101 @@ def track_exists_in_playlist_folder_from_track_data( return track_exists_in_playlist_folder(playlist_name, artist, title) +def resolve_wishlist_track_playlist_folder_mode( + wl_source: Any, + db: Any, + *, + profile_id: int = 1, + default_playlist_name: str = 'Unknown Playlist', +) -> Tuple[bool, str]: + """Resolve playlist-folder layout for a single wishlist track. + + Honors ``organize_by_playlist`` stored when the row was added from a download + modal, then falls back to the mirrored-playlist row (using ``playlist_source`` + or ``source`` for upstream lookup). + """ + if isinstance(wl_source, str): + try: + wl_source = json.loads(wl_source) + except (json.JSONDecodeError, TypeError): + wl_source = {} + if not isinstance(wl_source, dict): + return False, default_playlist_name + + playlist_name = wl_source.get('playlist_name') or default_playlist_name + + if wl_source.get('organize_by_playlist'): + return True, playlist_name + + wl_pl_ref = wl_source.get('playlist_id') + wl_pl_source = ( + wl_source.get('source') + or wl_source.get('playlist_source') + or 'spotify' + ) + if not wl_pl_ref or not hasattr(db, 'resolve_mirrored_playlist'): + return False, playlist_name + + refs_to_try: List[Tuple[str, str]] = [(str(wl_pl_ref).strip(), wl_pl_source)] + ui_ref = wl_source.get('ui_playlist_ref') + if ui_ref and str(ui_ref).strip() != str(wl_pl_ref).strip(): + refs_to_try.append((str(ui_ref).strip(), wl_pl_source)) + + for ref, src in refs_to_try: + if not ref: + continue + mirrored = db.resolve_mirrored_playlist( + ref, profile_id=profile_id, default_source=src or 'spotify' + ) + if mirrored and mirrored.get('organize_by_playlist'): + return True, playlist_name or mirrored.get('name') or default_playlist_name + + return False, playlist_name + + def resolve_playlist_folder_mode_for_batch( db: Any, *, playlist_id: str, playlist_name: str, batch_playlist_folder_mode: bool, + batch_keep_playlist_folder_copies: bool = False, profile_id: int = 1, source: str = 'spotify', -) -> tuple[bool, str]: - """Merge batch flag with persisted mirrored-playlist preference.""" - if batch_playlist_folder_mode: - return True, playlist_name + active_server: str = '', +) -> tuple[bool, str, bool]: + """Merge batch flags with persisted mirrored-playlist preferences. - if not hasattr(db, 'resolve_mirrored_playlist'): - return False, playlist_name + Returns ``(folder_mode, effective_playlist_name, keep_folder_copies)``. + """ + mirrored = None + if hasattr(db, 'resolve_mirrored_playlist'): + mirrored = db.resolve_mirrored_playlist( + playlist_id, profile_id=profile_id, default_source=source or 'spotify' + ) - # Pass the batch's source so numeric upstream ids (e.g. Deezer) resolve by - # source instead of colliding with the mirrored-playlists primary key. - mirrored = db.resolve_mirrored_playlist( - playlist_id, profile_id=profile_id, default_source=source or 'spotify' + keep = effective_keep_playlist_folder_copies( + mirrored, + active_server, + batch_keep=batch_keep_playlist_folder_copies, ) + + if batch_playlist_folder_mode: + name = (mirrored.get('name') if mirrored else None) or playlist_name + return True, name, keep + if mirrored and mirrored.get('organize_by_playlist'): - return True, mirrored.get('name') or playlist_name - return False, playlist_name + return True, mirrored.get('name') or playlist_name, keep + + return False, playlist_name, False __all__ = [ 'candidate_playlist_folder_paths', + 'effective_keep_playlist_folder_copies', + 'is_soulsync_standalone_server', 'track_exists_in_playlist_folder', 'track_exists_in_playlist_folder_from_track_data', + 'resolve_wishlist_track_playlist_folder_mode', 'resolve_playlist_folder_mode_for_batch', ] diff --git a/core/library/duplicate_cleaner.py b/core/library/duplicate_cleaner.py index 29fe4729..f07f1777 100644 --- a/core/library/duplicate_cleaner.py +++ b/core/library/duplicate_cleaner.py @@ -113,8 +113,10 @@ def _run_duplicate_cleaner(): if file_ext_lower not in audio_extensions: continue - # Group by directory and filename (without extension) - files_by_dir_and_name[root][file_name].append({ + # Group by directory and normalised filename (without extension, + # lower-cased so that casing variants like "HUGEL - Song.flac" + # and "hugel - Song.flac" are treated as the same track). + files_by_dir_and_name[root][file_name.lower()].append({ 'full_path': file_path, 'extension': file_ext_lower, 'size': os.path.getsize(file_path) diff --git a/core/playlists/organize_download.py b/core/playlists/organize_download.py index 2fc8d9cb..0766e48a 100644 --- a/core/playlists/organize_download.py +++ b/core/playlists/organize_download.py @@ -125,6 +125,7 @@ def run_playlist_organize_download( 'force_download_all': False, 'ignore_manual_matches': False, 'playlist_folder_mode': True, + 'keep_playlist_folder_copies': bool(pl.get('keep_playlist_folder_copies')), 'is_album_download': False, 'album_context': None, 'artist_context': None, diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index cac989fe..c43e0749 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -406,8 +406,12 @@ def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime } if batch.get('mirrored_playlist_id') is not None: context['mirrored_playlist_id'] = batch.get('mirrored_playlist_id') - if batch.get('organize_by_playlist'): + if batch.get('organize_by_playlist') or batch.get('playlist_folder_mode'): context['organize_by_playlist'] = True + batch_source = batch.get('batch_source') or batch.get('source_page') + if batch_source: + context['playlist_source'] = batch_source + context['source'] = batch_source # Preserve album-batch provenance so wishlist requeue has a real signal # for album-vs-single routing instead of relying on per-track album dicts # that may have been mangled by reconstruction fallbacks. diff --git a/database/music_database.py b/database/music_database.py index a3b2fb03..6128d369 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -579,6 +579,7 @@ class MusicDatabase: # Add explored_at to mirrored_playlists (migration) self._add_mirrored_playlist_explored_column(cursor) self._add_mirrored_playlist_organize_column(cursor) + self._add_mirrored_playlist_keep_copies_column(cursor) # Add notification columns to automations (migration) self._add_automation_notify_columns(cursor) @@ -1251,6 +1252,24 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {e}") + def _add_mirrored_playlist_keep_copies_column(self, cursor): + """Per-playlist copy in each playlist folder even when track exists in library.""" + try: + cursor.execute("PRAGMA table_info(mirrored_playlists)") + cols = [c[1] for c in cursor.fetchall()] + if 'keep_playlist_folder_copies' not in cols: + cursor.execute( + "ALTER TABLE mirrored_playlists ADD COLUMN keep_playlist_folder_copies INTEGER NOT NULL DEFAULT 0" + ) + logger.info("Added keep_playlist_folder_copies column to mirrored_playlists table") + if 'keep_playlist_folder_copies_opt_out' not in cols: + cursor.execute( + "ALTER TABLE mirrored_playlists ADD COLUMN keep_playlist_folder_copies_opt_out INTEGER NOT NULL DEFAULT 0" + ) + logger.info("Added keep_playlist_folder_copies_opt_out column to mirrored_playlists table") + except Exception as e: + logger.error(f"Error adding keep_playlist_folder_copies column to mirrored_playlists: {e}") + def _add_automation_notify_columns(self, cursor): """Add notification and result columns to automations table.""" try: @@ -13655,6 +13674,11 @@ class MusicDatabase: return None pl = dict(row) pl['organize_by_playlist'] = bool(pl.get('organize_by_playlist', 0)) + pl['keep_playlist_folder_copies'] = bool(pl.get('keep_playlist_folder_copies', 0)) + pl['keep_playlist_folder_copies_opt_out'] = bool(pl.get('keep_playlist_folder_copies_opt_out', 0)) + if not pl['organize_by_playlist']: + pl['keep_playlist_folder_copies'] = False + pl['keep_playlist_folder_copies_opt_out'] = False return pl def get_mirrored_playlist_by_source( @@ -13714,21 +13738,56 @@ class MusicDatabase: enabled: bool, ) -> bool: """Persist whether downloads for this playlist use playlist-folder layout.""" + return self.set_mirrored_playlist_preferences( + playlist_id, + organize_by_playlist=enabled, + ) + + def set_mirrored_playlist_preferences( + self, + playlist_id: int, + *, + organize_by_playlist: Optional[bool] = None, + keep_playlist_folder_copies: Optional[bool] = None, + ) -> bool: + """Update mirrored-playlist download layout preferences.""" try: + current = self.get_mirrored_playlist(playlist_id) + if not current: + return False + organize = ( + bool(organize_by_playlist) + if organize_by_playlist is not None + else bool(current.get('organize_by_playlist')) + ) + keep = ( + bool(keep_playlist_folder_copies) + if keep_playlist_folder_copies is not None + else bool(current.get('keep_playlist_folder_copies')) + ) + opt_out = bool(current.get('keep_playlist_folder_copies_opt_out')) + if keep_playlist_folder_copies is not None: + opt_out = not keep + if not organize: + keep = False + opt_out = False with self._get_connection() as conn: cursor = conn.cursor() cursor.execute( """ UPDATE mirrored_playlists - SET organize_by_playlist = ?, updated_at = CURRENT_TIMESTAMP + SET organize_by_playlist = ?, + keep_playlist_folder_copies = ?, + keep_playlist_folder_copies_opt_out = ?, + updated_at = CURRENT_TIMESTAMP WHERE id = ? """, - (1 if enabled else 0, playlist_id), + (1 if organize else 0, 1 if keep else 0, 1 if opt_out else 0, playlist_id), ) conn.commit() return cursor.rowcount > 0 except Exception as e: - logger.error(f"Error updating organize_by_playlist for playlist {playlist_id}: {e}") + logger.error(f"Error updating mirrored playlist preferences for {playlist_id}: {e}") return False def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]: diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index c2c5b758..288bcd0b 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -326,6 +326,59 @@ def test_analysis_phase_sets_state(monkeypatch): assert len(download_batches['B1']['analysis_results']) == 1 +def test_keep_playlist_folder_copies_skips_library_match(monkeypatch): + """With keep copies, a library hit still downloads when the track is absent from this playlist folder.""" + db = _FakeDB(found_tracks={('shared', 'artist'): 1.0}) + db.resolve_mirrored_playlist = lambda *args, **kwargs: { + 'id': 9, + 'name': 'Playlist B', + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': False, + 'keep_playlist_folder_copies_opt_out': False, + } + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + monkeypatch.setattr( + 'core.downloads.playlist_folder.track_exists_in_playlist_folder_from_track_data', + lambda *_a, **_k: False, + ) + + _seed_batch( + 'Bkeep', + playlist_folder_mode=True, + playlist_id='9', + playlist_name='Playlist B', + ) + deps = _build_deps(config=_FakeConfig({'_active_server': 'soulsync'})) + tracks = [{'name': 'Shared', 'artists': ['Artist']}] + + mw.run_full_missing_tracks_process('Bkeep', '9', tracks, deps) + + assert download_batches['Bkeep']['analysis_results'][0]['found'] is False + assert len(download_batches['Bkeep']['queue']) == 1 + + +def test_playlist_folder_mode_without_keep_copies_skips_library_match(monkeypatch): + db = _FakeDB(found_tracks={('shared', 'artist'): 1.0}) + db.resolve_mirrored_playlist = lambda *args, **kwargs: { + 'id': 9, + 'name': 'Playlist B', + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': False, + } + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + monkeypatch.setattr( + 'core.downloads.playlist_folder.track_exists_in_playlist_folder_from_track_data', + lambda *_a, **_k: False, + ) + _seed_batch('Bnokeep', playlist_folder_mode=True, playlist_id='9', playlist_name='Playlist B') + deps = _build_deps() + mw.run_full_missing_tracks_process( + 'Bnokeep', '9', [{'name': 'Shared', 'artists': ['Artist']}], deps + ) + assert download_batches['Bnokeep']['analysis_results'][0]['found'] is True + assert download_batches['Bnokeep']['queue'] == [] + + def test_force_download_treats_all_as_missing(monkeypatch): """force_download_all skips DB check — every track marked missing.""" db = _FakeDB(found_tracks={('t1', 'a'): 1.0, ('t2', 'a'): 1.0}) # would otherwise be found @@ -1066,6 +1119,37 @@ def test_playlist_folder_mode_propagates(monkeypatch): assert info['_playlist_name'] == 'My Mix' +def test_wishlist_source_info_organize_by_playlist_enables_folder_mode(monkeypatch): + """Wishlist requeue honors organize_by_playlist saved from the download modal.""" + db = _FakeDB() + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + + deps = _build_deps() + _seed_batch('Bwlf') + tracks = [{ + 'name': 'Song One', + 'artists': [{'name': 'Artist One'}], + 'source_info': { + 'playlist_id': '37i9dQZF1DX0XUsuxWHRQd', + 'playlist_name': 'Daily Mix', + 'organize_by_playlist': True, + 'playlist_source': 'spotify', + }, + 'spotify_data': { + 'album': {'id': 'album-1', 'name': 'Album One'}, + 'artists': [{'name': 'Artist One'}], + }, + }] + + mw.run_full_missing_tracks_process('Bwlf', 'wishlist', tracks, deps) + + task_id = download_batches['Bwlf']['queue'][0] + info = download_tasks[task_id]['track_info'] + assert info['_playlist_folder_mode'] is True + assert info['_playlist_name'] == 'Daily Mix' + assert '_is_explicit_album_download' not in info + + # --------------------------------------------------------------------------- # Hand-off to monitor + start_next_batch # --------------------------------------------------------------------------- diff --git a/tests/downloads/test_playlist_folder_exists.py b/tests/downloads/test_playlist_folder_exists.py index e3087829..7bb50c1c 100644 --- a/tests/downloads/test_playlist_folder_exists.py +++ b/tests/downloads/test_playlist_folder_exists.py @@ -7,7 +7,9 @@ import pytest from core.downloads.playlist_folder import ( candidate_playlist_folder_paths, + effective_keep_playlist_folder_copies, resolve_playlist_folder_mode_for_batch, + resolve_wishlist_track_playlist_folder_mode, track_exists_in_playlist_folder, ) @@ -36,6 +38,25 @@ def test_track_exists_in_playlist_folder_finds_file(tmp_path): assert track_exists_in_playlist_folder('My Playlist', 'Artist A', 'Song One') +def test_track_exists_in_playlist_folder_case_insensitive(tmp_path): + """File stored as 'HUGEL - Song.flac' must be detected when the lookup + uses lowercase 'hugel' — providers often return artist names with different + casing on different calls, which would cause spurious re-downloads.""" + playlist_dir = tmp_path / 'My Playlist' + playlist_dir.mkdir() + (playlist_dir / 'HUGEL - Song One.flac').write_bytes(b'x') + + with patch('core.downloads.playlist_folder._get_config_manager') as cfg: + cfg.return_value.get.return_value = str(tmp_path) + with patch('core.downloads.playlist_folder.docker_resolve_path', side_effect=lambda p: p): + with patch( + 'core.downloads.playlist_folder.get_file_path_from_template', + return_value=('', ''), + ): + # Lowercase artist lookup must still find the UPPER-CASE file + assert track_exists_in_playlist_folder('My Playlist', 'hugel', 'Song One') + + def test_track_exists_in_playlist_folder_missing(tmp_path): with patch('core.downloads.playlist_folder._get_config_manager') as cfg: cfg.return_value.get.return_value = str(tmp_path) @@ -66,7 +87,7 @@ def test_resolve_playlist_folder_mode_from_mirrored(): 'name': 'Rekordbox Set', 'organize_by_playlist': True, }) - enabled, name = resolve_playlist_folder_mode_for_batch( + enabled, name, keep = resolve_playlist_folder_mode_for_batch( db, playlist_id='37i9dQZF1', playlist_name='Other Name', @@ -74,11 +95,12 @@ def test_resolve_playlist_folder_mode_from_mirrored(): ) assert enabled is True assert name == 'Rekordbox Set' + assert keep is False def test_resolve_playlist_folder_mode_batch_flag(): db = _FakeDB() - enabled, name = resolve_playlist_folder_mode_for_batch( + enabled, name, keep = resolve_playlist_folder_mode_for_batch( db, playlist_id='1', playlist_name='Batch Name', @@ -86,3 +108,76 @@ def test_resolve_playlist_folder_mode_batch_flag(): ) assert enabled is True assert name == 'Batch Name' + assert keep is False + + +def test_resolve_playlist_folder_keep_copies_from_mirrored(): + db = _FakeDB(mirrored={ + 'id': 5, + 'name': 'USB Set', + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': True, + }) + enabled, name, keep = resolve_playlist_folder_mode_for_batch( + db, + playlist_id='37i9dQZF1', + playlist_name='Other', + batch_playlist_folder_mode=False, + active_server='soulsync', + ) + assert enabled is True + assert name == 'USB Set' + assert keep is True + + +def test_standalone_defaults_keep_copies_when_organize_without_explicit_keep(): + mirrored = { + 'id': 5, + 'name': 'USB Set', + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': False, + 'keep_playlist_folder_copies_opt_out': False, + } + assert effective_keep_playlist_folder_copies(mirrored, 'soulsync') is True + assert effective_keep_playlist_folder_copies(mirrored, 'plex') is False + + +def test_standalone_keep_copies_opt_out_honored(): + mirrored = { + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': False, + 'keep_playlist_folder_copies_opt_out': True, + } + assert effective_keep_playlist_folder_copies(mirrored, 'soulsync') is False + + +def test_wishlist_organize_flag_in_source_info_enables_folder_mode(): + enabled, name = resolve_wishlist_track_playlist_folder_mode( + { + 'playlist_id': '37i9dQZF1DX', + 'playlist_name': 'Daily Mix', + 'organize_by_playlist': True, + 'playlist_source': 'spotify', + }, + _FakeDB(), + ) + assert enabled is True + assert name == 'Daily Mix' + + +def test_wishlist_resolves_mirrored_playlist_via_playlist_source(): + db = _FakeDB(mirrored={ + 'id': 9, + 'name': 'Summer Mix', + 'organize_by_playlist': True, + }) + enabled, name = resolve_wishlist_track_playlist_folder_mode( + { + 'playlist_id': '12345', + 'playlist_name': 'Summer Mix', + 'playlist_source': 'deezer', + }, + db, + ) + assert enabled is True + assert name == 'Summer Mix' diff --git a/tests/wishlist/test_processing.py b/tests/wishlist/test_processing.py index 28f7e3ec..7c8bd69e 100644 --- a/tests/wishlist/test_processing.py +++ b/tests/wishlist/test_processing.py @@ -193,6 +193,39 @@ def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_bat assert context["organize_by_playlist"] is True +def test_build_wishlist_source_context_playlist_folder_mode_sets_organize(): + """Batches that only carry ``playlist_folder_mode`` (e.g. Download Missing UI) + must produce a context with ``organize_by_playlist`` so the wishlist requeue + routing logic picks up the playlist-folder flag even without an explicit + ``organize_by_playlist`` key on the batch.""" + batch = { + "playlist_name": "Chill Vibes", + "playlist_id": "spId99", + "playlist_folder_mode": True, + "batch_source": "spotify", + } + + context = processing.build_wishlist_source_context(batch) + + assert context["organize_by_playlist"] is True + assert context["playlist_id"] == "spId99" + assert context.get("source") == "spotify" + + +def test_build_wishlist_source_context_no_organize_when_folder_mode_off(): + """When ``playlist_folder_mode`` is False and ``organize_by_playlist`` is + absent the flag must NOT appear in the resulting context.""" + batch = { + "playlist_name": "Workout", + "playlist_id": "spId00", + "playlist_folder_mode": False, + } + + context = processing.build_wishlist_source_context(batch) + + assert "organize_by_playlist" not in context + + def test_build_wishlist_source_context_preserves_album_context_for_album_batches(): """Album batches must carry album_context/artist_context through to the wishlist row so a later requeue has authoritative routing data instead diff --git a/tests/wishlist/test_routes.py b/tests/wishlist/test_routes.py index 43c34943..5de4822f 100644 --- a/tests/wishlist/test_routes.py +++ b/tests/wishlist/test_routes.py @@ -435,6 +435,41 @@ def test_get_wishlist_cycle_returns_stored_value(): assert payload == {"cycle": "singles"} +def test_add_album_track_to_wishlist_preserves_playlist_modal_context(): + runtime, service, _db, _logger, _activity_calls = _build_runtime() + track = { + "id": "track-1", + "name": "Song One", + "artists": [{"name": "Artist One"}], + "duration_ms": 1234, + } + artist = {"id": "artist-1", "name": "Artist One"} + album = {"id": "album-1", "name": "Album One"} + + payload, status = add_album_track_to_wishlist( + runtime, + track=track, + artist=artist, + album=album, + source_type="playlist", + source_context={ + "playlist_id": "37i9dQZF1DX0XUsuxWHRQd", + "playlist_name": "Daily Mix", + "organize_by_playlist": True, + "added_from": "download_modal", + }, + ) + + assert status == 200 + assert payload["success"] is True + add_call = service.add_calls[0] + assert add_call["source_type"] == "playlist" + assert add_call["source_context"]["playlist_id"] == "37i9dQZF1DX0XUsuxWHRQd" + assert add_call["source_context"]["playlist_name"] == "Daily Mix" + assert add_call["source_context"]["organize_by_playlist"] is True + assert add_call["source_context"]["added_from"] == "download_modal" + + def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context(): runtime, service, _db, _logger, _activity_calls = _build_runtime() track = { diff --git a/web_server.py b/web_server.py index a8163ea4..66310905 100644 --- a/web_server.py +++ b/web_server.py @@ -19825,6 +19825,13 @@ def start_missing_tracks_process(playlist_id): playlist_name = data.get('playlist_name', 'Unknown Playlist') force_download_all = data.get('force_download_all', False) playlist_folder_mode = data.get('playlist_folder_mode', False) + keep_playlist_folder_copies = bool(data.get('keep_playlist_folder_copies', False)) + if ( + playlist_folder_mode + and config_manager.get_active_media_server() == 'soulsync' + and 'keep_playlist_folder_copies' not in data + ): + keep_playlist_folder_copies = True wing_it = data.get('wing_it', False) ignore_manual_matches = data.get('ignore_manual_matches') if ignore_manual_matches is None: @@ -19888,10 +19895,12 @@ def start_missing_tracks_process(playlist_id): default_source='spotify', ) if mirrored_pl and mirrored_pl.get('id'): - db_pref.set_mirrored_playlist_organize_by_playlist( - int(mirrored_pl['id']), - bool(playlist_folder_mode), - ) + pref_kwargs = {'organize_by_playlist': bool(playlist_folder_mode)} + if not playlist_folder_mode: + pref_kwargs['keep_playlist_folder_copies'] = False + elif keep_playlist_folder_copies: + pref_kwargs['keep_playlist_folder_copies'] = True + db_pref.set_mirrored_playlist_preferences(int(mirrored_pl['id']), **pref_kwargs) except Exception as pref_err: logger.debug(f"[Playlist Folder] Could not persist mirrored preference: {pref_err}") @@ -19930,6 +19939,8 @@ def start_missing_tracks_process(playlist_id): # at the modal, so the per-track filter (2a) skips this batch. 'ignore_blocklist': ignore_blocklist, 'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder + 'organize_by_playlist': bool(playlist_folder_mode), + 'keep_playlist_folder_copies': keep_playlist_folder_copies and playlist_folder_mode, # Album context for artist album downloads (explicit folder structure) 'is_album_download': is_album_download, 'album_context': album_context, @@ -33597,19 +33608,26 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id): @app.route('/api/mirrored-playlists//preferences', methods=['PATCH']) def update_mirrored_playlist_preferences_endpoint(playlist_id): - """Update per-playlist download preferences (e.g. organize by playlist folder).""" + """Update per-playlist download preferences (playlist-folder layout and copies).""" try: data = request.get_json() or {} - if 'organize_by_playlist' not in data: - return jsonify({"error": "organize_by_playlist is required"}), 400 + if 'organize_by_playlist' not in data and 'keep_playlist_folder_copies' not in data: + return jsonify({"error": "At least one preference field is required"}), 400 database = get_database() playlist = database.get_mirrored_playlist(playlist_id) if not playlist: return jsonify({"error": "Playlist not found"}), 404 - enabled = bool(data.get('organize_by_playlist')) - ok = database.set_mirrored_playlist_organize_by_playlist(playlist_id, enabled) + kwargs = {} + if 'organize_by_playlist' in data: + kwargs['organize_by_playlist'] = bool(data.get('organize_by_playlist')) + if 'keep_playlist_folder_copies' in data: + kwargs['keep_playlist_folder_copies'] = bool(data.get('keep_playlist_folder_copies')) + is_standalone = config_manager.get_active_media_server() == 'soulsync' + if is_standalone and kwargs.get('organize_by_playlist') and 'keep_playlist_folder_copies' not in data: + kwargs['keep_playlist_folder_copies'] = True + ok = database.set_mirrored_playlist_preferences(playlist_id, **kwargs) if not ok: return jsonify({"error": "Failed to update preferences"}), 500 diff --git a/webui/index.html b/webui/index.html index 58a9e3a7..4d9fd6c2 100644 --- a/webui/index.html +++ b/webui/index.html @@ -1120,7 +1120,15 @@

Your Spotify Playlists

- +
+ + + + +
Click 'Refresh' to load your Spotify playlists.
@@ -1131,7 +1139,15 @@

Your Tidal Playlists

- +
+ + + + +
Click 'Refresh' to load your Tidal playlists.
@@ -1142,7 +1158,15 @@

Your Deezer Playlists

- +
+ + + + +
Click 'Refresh' to load your Deezer playlists.
@@ -1153,7 +1177,15 @@

Your Qobuz Playlists

- +
+ + + + +
Click 'Refresh' to load your Qobuz playlists.
@@ -1168,6 +1200,14 @@
+
+ + + +
Paste a Deezer playlist URL above to get started.
@@ -1181,6 +1221,14 @@
+
+ + + +
Parsed YouTube playlists will appear here.
@@ -1194,6 +1242,14 @@
+
+ + + +
Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.
@@ -1207,6 +1263,14 @@
+
+ + + +
@@ -1628,7 +1692,15 @@

My Beatport Playlists

- +
+ + + + +
Your created Beatport playlists will appear here. @@ -1997,7 +2069,15 @@

SoulSync Discovery Playlists

- +
+ + + + +
Click 'Refresh' to load your personalized SoulSync Discovery playlists.
@@ -2008,7 +2088,15 @@

Your Last.fm Radio Playlists

- +
+ + + + +
Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.
@@ -2017,14 +2105,22 @@
-
+

Your ListenBrainz Playlists

+
+ + + + +
-
Click 'Refresh' to load your ListenBrainz playlists.
@@ -2130,6 +2226,20 @@
+ + +
@@ -8336,6 +8446,7 @@ + diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index fd243ed1..899dc0a9 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -1577,27 +1577,59 @@ function autoSyncAutomationCardHtml(auto, playlists) { `; } +function autoSyncEffectiveKeepCopies(playlist) { + if (typeof effectiveKeepPlaylistFolderCopies === 'function') { + return effectiveKeepPlaylistFolderCopies(playlist); + } + return !!playlist.keep_playlist_folder_copies; +} + function autoSyncOrganizeToggleHtml(playlist) { - const checked = playlist.organize_by_playlist ? 'checked' : ''; + const organizeChecked = playlist.organize_by_playlist ? 'checked' : ''; + const keepChecked = autoSyncEffectiveKeepCopies(playlist) ? 'checked' : ''; + const keepDisabled = playlist.organize_by_playlist ? '' : 'disabled'; + const keepClass = playlist.organize_by_playlist ? '' : 'is-disabled'; return ` - +
+ + +
`; } +async function patchAutoSyncMirroredPreferences(playlistId, body) { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference'); + const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (pl && data.playlist) { + pl.organize_by_playlist = !!data.playlist.organize_by_playlist; + pl.keep_playlist_folder_copies = !!data.playlist.keep_playlist_folder_copies; + pl.keep_playlist_folder_copies_opt_out = !!data.playlist.keep_playlist_folder_copies_opt_out; + } + return data; +} + async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) { try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ organize_by_playlist: !!enabled }), - }); - const data = await res.json(); - if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference'); - const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); - if (pl) pl.organize_by_playlist = !!enabled; + const body = { organize_by_playlist: !!enabled }; + if (!enabled) { + body.keep_playlist_folder_copies = false; + } else if (typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode()) { + body.keep_playlist_folder_copies = true; + } + await patchAutoSyncMirroredPreferences(playlistId, body); showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success'); } catch (err) { showToast(`Error: ${err.message}`, 'error'); @@ -1605,6 +1637,24 @@ async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) { } } +async function setAutoSyncKeepPlaylistFolderCopies(playlistId, enabled) { + try { + await patchAutoSyncMirroredPreferences(playlistId, { + keep_playlist_folder_copies: !!enabled, + organize_by_playlist: true, + }); + showToast( + enabled + ? 'Missing tracks will download into each playlist folder even if already in library' + : 'Tracks already in library will not be copied into playlist folders', + 'success', + ); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + await refreshAutoSyncScheduleModal(); + } +} + function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; diff --git a/webui/static/core.js b/webui/static/core.js index ca175627..b85e57f5 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -806,14 +806,14 @@ class SequentialSyncManager { this.startTime = null; } - start(playlistIds) { + start(playlistIds, bulkConfig = null) { if (this.isRunning) { console.warn('Sequential sync already running'); return; } - // Convert playlist IDs to ordered array (maintain display order) this.queue = Array.from(playlistIds); + this.bulkConfig = bulkConfig; this.currentIndex = 0; this.isRunning = true; this.startTime = Date.now(); @@ -830,42 +830,27 @@ class SequentialSyncManager { } const playlistId = this.queue[this.currentIndex]; - const playlist = spotifyPlaylists.find(p => p.id === playlistId); - console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`); + const label = this.bulkConfig?.getName(playlistId) + || spotifyPlaylists.find(p => p.id === playlistId)?.name + || playlistId; + console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${label}`); this.updateUI(); try { - // Use existing single sync function - await startPlaylistSync(playlistId); - - // Wait for sync to complete by monitoring the poller - await this.waitForSyncCompletion(playlistId); - + if (this.bulkConfig?.process) { + await this.bulkConfig.process(playlistId); + } else { + await startPlaylistSync(playlistId); + await waitForSyncPollerCompletion(playlistId); + } } catch (error) { console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, error); - showToast(`Failed to sync "${playlist?.name || playlistId}": ${error.message}`, 'error'); + showToast(`Failed to sync "${label}": ${error.message}`, 'error'); } - // Move to next playlist this.currentIndex++; - setTimeout(() => this.syncNext(), 1000); // Small delay between syncs - } - - async waitForSyncCompletion(playlistId) { - return new Promise((resolve) => { - // Monitor the existing sync poller for completion - const checkCompletion = () => { - if (!activeSyncPollers[playlistId]) { - // Poller stopped = sync completed - resolve(); - return; - } - // Check again in 1 second - setTimeout(checkCompletion, 1000); - }; - checkCompletion(); - }); + setTimeout(() => this.syncNext(), 1000); } complete() { @@ -875,14 +860,14 @@ class SequentialSyncManager { this.isRunning = false; this.queue = []; + this.bulkConfig = null; this.currentIndex = 0; this.startTime = null; - // Re-enable playlist selection disablePlaylistSelection(false); this.updateUI(); - updateRefreshButtonState(); // Refresh button state after completion + updateRefreshButtonState(); showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success'); // Hide sidebar after completion @@ -895,14 +880,14 @@ class SequentialSyncManager { console.log('🛑 Cancelling sequential sync'); this.isRunning = false; this.queue = []; + this.bulkConfig = null; this.currentIndex = 0; this.startTime = null; - // Re-enable playlist selection disablePlaylistSelection(false); this.updateUI(); - updateRefreshButtonState(); // Refresh button state after cancellation + updateRefreshButtonState(); showToast('Sequential sync cancelled', 'info'); // Hide sidebar after cancellation @@ -910,33 +895,8 @@ class SequentialSyncManager { } updateUI() { - const startSyncBtn = document.getElementById('start-sync-btn'); - const selectionInfo = document.getElementById('selection-info'); - - if (!this.isRunning) { - // Reset to normal state - if (startSyncBtn) { - startSyncBtn.textContent = 'Start Sync'; - startSyncBtn.disabled = selectedPlaylists.size === 0; - } - if (selectionInfo) { - const count = selectedPlaylists.size; - selectionInfo.textContent = count === 0 - ? 'Select playlists to sync' - : `${count} playlist${count > 1 ? 's' : ''} selected`; - } - } else { - // Show sequential sync status - if (startSyncBtn) { - startSyncBtn.textContent = 'Cancel Sequential Sync'; - startSyncBtn.disabled = false; - } - if (selectionInfo) { - const current = this.currentIndex + 1; - const total = this.queue.length; - const currentPlaylist = spotifyPlaylists.find(p => p.id === this.queue[this.currentIndex]); - selectionInfo.textContent = `Syncing ${current}/${total}: ${currentPlaylist?.name || 'Unknown'}`; - } + if (typeof updateSyncActionsUI === 'function') { + updateSyncActionsUI(); } } } diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 26303e3b..3f0dd19f 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -2469,6 +2469,9 @@ async function startMissingTracksProcess(playlistId) { const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function' ? isPlaylistOrganizeEnabled(playlistId) : (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false); + const keepPlaylistFolderCopies = typeof isPlaylistKeepFolderCopiesEnabled === 'function' + ? isPlaylistKeepFolderCopiesEnabled(playlistId) + : (document.getElementById(`playlist-keep-copies-mode-${playlistId}`)?.checked ?? false); // Hide the force download toggle during processing const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; @@ -2528,8 +2531,12 @@ async function startMissingTracksProcess(playlistId) { requestBody.playlist_name = process.playlist.name; // Add playlist folder mode flag for sync page playlists requestBody.playlist_folder_mode = playlistFolderMode; + requestBody.keep_playlist_folder_copies = playlistFolderMode && keepPlaylistFolderCopies; if (playlistFolderMode) { console.log(`📁 [Playlist Folder] Enabled for playlist: ${process.playlist.name}`); + if (keepPlaylistFolderCopies) { + console.log(`📁 [Playlist Folder] Keep folder copies enabled`); + } } } @@ -4703,42 +4710,33 @@ function startSequentialSync() { return; } - // Validate selection + const bulkConfig = typeof getActiveSyncBulkConfig === 'function' ? getActiveSyncBulkConfig() : null; + if (!bulkConfig) { + showToast('Bulk sync is not available on this tab', 'error'); + return; + } + if (selectedPlaylists.size === 0) { showToast('No playlists selected for sync', 'error'); return; } - // Get playlist order from DOM to maintain display order - const playlistCards = document.querySelectorAll('.playlist-card'); - const orderedPlaylistIds = []; + const orderedPlaylistIds = typeof getOrderedSelectedPlaylistIds === 'function' + ? getOrderedSelectedPlaylistIds() + : []; - playlistCards.forEach(card => { - const playlistId = card.dataset.playlistId; - if (selectedPlaylists.has(playlistId)) { - orderedPlaylistIds.push(playlistId); - } - }); + if (!orderedPlaylistIds.length) { + showToast('No playlists selected for sync', 'error'); + return; + } - console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists`); + console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists (${activeSyncSelectionSource})`); - // Show sidebar for sync progress showSyncSidebar(); - - // Start sequential sync - sequentialSyncManager.start(orderedPlaylistIds); - - // Disable playlist selection during sync + sequentialSyncManager.start(orderedPlaylistIds, bulkConfig); disablePlaylistSelection(true); } -function disablePlaylistSelection(disabled) { - const checkboxes = document.querySelectorAll('.playlist-checkbox'); - checkboxes.forEach(checkbox => { - checkbox.disabled = disabled; - }); -} - function hasActiveOperations() { const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; // Only check non-wishlist download processes for sync page refresh button diff --git a/webui/static/helper.js b/webui/static/helper.js index b583e031..efb54f98 100644 --- a/webui/static/helper.js +++ b/webui/static/helper.js @@ -817,7 +817,15 @@ const HELPER_CONTENT = { '#start-sync-btn': { title: 'Start Sync', description: 'Begin downloading missing tracks from all selected playlists. Playlists are processed sequentially — each one completes before the next starts.', - tips: ['Select playlists first using checkboxes on the cards', 'Progress bar and log update in real-time', 'Button is disabled until at least one playlist is selected'], + tips: ['Click playlist cards to select them, or use Select all in the Spotify header', 'Use Sync selected in the bottom bar (visible when playlists are selected)', 'Progress bar and log update in real-time when the sync sidebar is open'], + }, + '.sync-playlist-select-all-btn': { + title: 'Select All Playlists', + description: 'Select every loaded playlist in the current source tab for bulk sequential sync.', + }, + '.sync-playlist-start-btn': { + title: 'Sync Selected Playlists', + description: 'Start sequential sync for all selected playlists in this tab. Each playlist finishes before the next begins. Click again while running to cancel.', }, '#sync-log-area': { title: 'Sync Log', @@ -2402,7 +2410,7 @@ const HELPER_TOURS = { { page: 'sync', selector: '.sync-tab-button[data-tab="mirrored"]', title: 'Mirrored Playlists', description: 'Every imported playlist is saved here permanently. Re-sync anytime to catch new additions, check match status, or view the Discovery Pool for unmatched tracks.' }, // Sidebar - { page: 'sync', selector: '.sync-sidebar', title: 'Sync Controls', description: 'The command center. Select playlists with checkboxes on the left, then click "Start Sync" here. Progress bars, match counts, and logs update in real-time. That\'s the sync flow! 🎉' }, + { page: 'sync', selector: '#sync-playlist-bulk-bar', title: 'Bulk Sync', description: 'Click Spotify playlist cards to select them (or use Select all), then Sync selected in the bar at the bottom. Playlists sync one after another. Progress and logs appear in the sidebar on wide screens when sync is running.' }, ] }, // 'artists-browse' tour retired — the Artists sidebar entry was replaced by the diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 620fec26..6ca83ab8 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -979,6 +979,10 @@ function playlistDetailsOrganizeCheckboxId(playlistRef) { return `playlist-organize-${playlistRef}`; } +function playlistDetailsKeepCopiesCheckboxId(playlistRef) { + return `playlist-keep-copies-${playlistRef}`; +} + /** Infer mirrored-playlist API source from a UI playlist / virtual id. */ function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) { if (explicitSource) { @@ -991,6 +995,15 @@ function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) { if (ref.startsWith('deezer_arl_')) { return 'deezer'; } + if (ref.startsWith('tidal_')) { + return 'tidal'; + } + if (ref.startsWith('youtube_')) { + return 'youtube'; + } + if (ref.startsWith('qobuz_')) { + return 'qobuz'; + } return 'spotify'; } @@ -1008,29 +1021,59 @@ function normalizePlaylistOrganizeRef(playlistRef, source = 'spotify') { function downloadMissingModalOrganizeCheckboxHtml(playlistId) { return ` - `; +
+ + +
`; } function playlistOrganizeToggleHtml(playlistRef, source = 'spotify') { const safeRef = String(playlistRef).replace(/'/g, "\\'"); const safeSource = String(source).replace(/'/g, "\\'"); return ` - +
+ + +
`; } -function syncPlaylistOrganizeCheckboxes(playlistRef, enabled) { +function syncPlaylistOrganizeCheckboxes(playlistRef, organizeEnabled, keepCopiesEnabled = false) { const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef)); const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`); - if (detailsCb) detailsCb.checked = !!enabled; - if (downloadMissingCb) downloadMissingCb.checked = !!enabled; + const detailsKeep = document.getElementById(playlistDetailsKeepCopiesCheckboxId(playlistRef)); + const downloadKeep = document.getElementById(`playlist-keep-copies-mode-${playlistRef}`); + const detailsKeepWrap = document.getElementById(`${playlistDetailsKeepCopiesCheckboxId(playlistRef)}-wrap`); + const downloadKeepWrap = document.getElementById(`playlist-keep-copies-wrap-${playlistRef}`); + if (detailsCb) detailsCb.checked = !!organizeEnabled; + if (downloadMissingCb) downloadMissingCb.checked = !!organizeEnabled; + if (detailsKeep) { + detailsKeep.checked = !!organizeEnabled && !!keepCopiesEnabled; + detailsKeep.disabled = !organizeEnabled; + } + if (downloadKeep) { + downloadKeep.checked = !!organizeEnabled && !!keepCopiesEnabled; + downloadKeep.disabled = !organizeEnabled; + } + if (detailsKeepWrap) detailsKeepWrap.classList.toggle('is-disabled', !organizeEnabled); + if (downloadKeepWrap) downloadKeepWrap.classList.toggle('is-disabled', !organizeEnabled); } function isPlaylistOrganizeEnabled(playlistRef) { @@ -1040,42 +1083,96 @@ function isPlaylistOrganizeEnabled(playlistRef) { return downloadMissingCb ? downloadMissingCb.checked : false; } +function isSoulsyncStandaloneMode() { + return !!_isSoulsyncStandalone; +} + +function effectiveKeepPlaylistFolderCopies(playlist) { + if (!playlist?.organize_by_playlist) return false; + if (playlist.keep_playlist_folder_copies) return true; + if (playlist.keep_playlist_folder_copies_opt_out) return false; + return isSoulsyncStandaloneMode(); +} + +function isPlaylistKeepFolderCopiesEnabled(playlistRef) { + if (!isPlaylistOrganizeEnabled(playlistRef)) return false; + const detailsCb = document.getElementById(playlistDetailsKeepCopiesCheckboxId(playlistRef)); + if (detailsCb) return detailsCb.checked; + const downloadCb = document.getElementById(`playlist-keep-copies-mode-${playlistRef}`); + return downloadCb ? downloadCb.checked : false; +} + +async function resolveMirroredPlaylistForRef(playlistRef, source = null) { + const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); + const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); + const res = await fetch( + `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}` + ); + const data = await res.json(); + if (!data.found || !data.playlist) { + return null; + } + return data.playlist; +} + async function fetchMirroredOrganizePreference(playlistRef, source = null) { try { - const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); - const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); - const res = await fetch( - `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}` - ); - const data = await res.json(); - return !!(data.found && data.playlist?.organize_by_playlist); + const pl = await resolveMirroredPlaylistForRef(playlistRef, source); + return !!pl?.organize_by_playlist; } catch (err) { console.debug('Could not load organize-by-playlist preference:', err); return false; } } +async function fetchMirroredPlaylistFolderPreferences(playlistRef, source = null) { + try { + const pl = await resolveMirroredPlaylistForRef(playlistRef, source); + if (!pl) { + return { organize: false, keepCopies: false }; + } + return { + organize: !!pl.organize_by_playlist, + keepCopies: effectiveKeepPlaylistFolderCopies(pl), + }; + } catch (err) { + console.debug('Could not load mirrored playlist folder preferences:', err); + return { organize: false, keepCopies: false }; + } +} + +async function patchMirroredPlaylistPreferences(playlistId, body) { + const patchRes = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const patchData = await patchRes.json(); + if (!patchRes.ok || patchData.error) { + return null; + } + return patchData.playlist || null; +} + async function setMirroredOrganizePreference(playlistRef, enabled, source = null) { try { - const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); - const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); - const res = await fetch( - `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}` + const pl = await resolveMirroredPlaylistForRef(playlistRef, source); + if (!pl?.id) { + return false; + } + const body = { organize_by_playlist: !!enabled }; + if (!enabled) { + body.keep_playlist_folder_copies = false; + } else if (isSoulsyncStandaloneMode()) { + body.keep_playlist_folder_copies = true; + } + const updated = await patchMirroredPlaylistPreferences(pl.id, body); + if (!updated) return false; + syncPlaylistOrganizeCheckboxes( + playlistRef, + !!updated.organize_by_playlist, + !!updated.keep_playlist_folder_copies, ); - const data = await res.json(); - if (!data.found || !data.playlist?.id) { - return false; - } - const patchRes = await fetch(`/api/mirrored-playlists/${data.playlist.id}/preferences`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ organize_by_playlist: !!enabled }), - }); - const patchData = await patchRes.json(); - if (!patchRes.ok || patchData.error) { - return false; - } - syncPlaylistOrganizeCheckboxes(playlistRef, !!enabled); return true; } catch (err) { console.debug('Could not save organize-by-playlist preference:', err); @@ -1083,20 +1180,73 @@ async function setMirroredOrganizePreference(playlistRef, enabled, source = null } } +async function setMirroredKeepCopiesPreference(playlistRef, enabled, source = null) { + try { + const pl = await resolveMirroredPlaylistForRef(playlistRef, source); + if (!pl?.id) { + return false; + } + const updated = await patchMirroredPlaylistPreferences(pl.id, { + keep_playlist_folder_copies: !!enabled, + organize_by_playlist: true, + }); + if (!updated) return false; + syncPlaylistOrganizeCheckboxes( + playlistRef, + !!updated.organize_by_playlist, + !!updated.keep_playlist_folder_copies, + ); + return true; + } catch (err) { + console.debug('Could not save keep-playlist-folder-copies preference:', err); + return false; + } +} + async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = null) { - const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); - const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource); - syncPlaylistOrganizeCheckboxes(playlistRef, enabled); + const prefs = await fetchMirroredPlaylistFolderPreferences(playlistRef, source); + syncPlaylistOrganizeCheckboxes(playlistRef, prefs.organize, prefs.keepCopies); } async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source = 'spotify') { - syncPlaylistOrganizeCheckboxes(playlistRef, enabled); + const defaultKeep = enabled && isSoulsyncStandaloneMode(); + syncPlaylistOrganizeCheckboxes(playlistRef, enabled, defaultKeep); const ok = await setMirroredOrganizePreference(playlistRef, enabled, source); if (!ok) { showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning'); } } +async function onPlaylistKeepCopiesPreferenceChange(playlistRef, enabled, source = 'spotify') { + syncPlaylistOrganizeCheckboxes(playlistRef, true, enabled); + const ok = await setMirroredKeepCopiesPreference(playlistRef, enabled, source); + if (!ok) { + showToast('Could not save keep folder copies preference (mirror this playlist first)', 'warning'); + } +} + +async function onDownloadMissingOrganizeToggle(playlistId, enabled) { + syncPlaylistOrganizeCheckboxes(playlistId, enabled, enabled && isSoulsyncStandaloneMode()); + const source = playlistOrganizeSourceForRef(playlistId); + const ok = await setMirroredOrganizePreference(playlistId, enabled, source); + if (!ok) { + showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning'); + } +} + +async function onDownloadMissingKeepCopiesToggle(playlistId, enabled) { + const organizeCb = document.getElementById(`playlist-folder-mode-${playlistId}`); + if (organizeCb && !organizeCb.checked) { + organizeCb.checked = true; + } + syncPlaylistOrganizeCheckboxes(playlistId, true, enabled); + const source = playlistOrganizeSourceForRef(playlistId); + const ok = await setMirroredKeepCopiesPreference(playlistId, enabled, source); + if (!ok) { + showToast('Could not save keep folder copies preference (mirror this playlist first)', 'warning'); + } +} + async function applyMirroredOrganizePreference(playlistRef, source = null) { await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source); } diff --git a/webui/static/style.css b/webui/static/style.css index 754d32c7..c2cf3e93 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12179,21 +12179,47 @@ body.helper-mode-active #dashboard-activity-feed:hover { line-height: 1.3; } +.auto-sync-organize-toggles { + margin-top: 6px; +} + .auto-sync-organize-toggle { display: flex; align-items: center; gap: 6px; - margin-top: 6px; font-size: 11px; color: rgba(255, 255, 255, 0.72); cursor: pointer; user-select: none; } +.auto-sync-organize-subtoggle { + margin-top: 4px; + margin-left: 14px; + color: rgba(255, 255, 255, 0.58); +} + +.auto-sync-organize-subtoggle.is-disabled { + opacity: 0.45; + pointer-events: none; +} + .auto-sync-organize-toggle input { margin: 0; } +.playlist-organize-pref-group .playlist-organize-subtoggle { + display: block; + margin-top: 6px; + margin-left: 18px; + opacity: 0.85; +} + +.playlist-organize-pref-group .playlist-organize-subtoggle.is-disabled { + opacity: 0.45; + pointer-events: none; +} + .auto-sync-scheduled-timing { display: flex; flex-wrap: wrap; @@ -15739,6 +15765,136 @@ body.helper-mode-active #dashboard-activity-feed:hover { color: #ffffff; } +.playlist-header-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.playlist-scroll-container.selection-disabled .playlist-card, +.playlist-scroll-container.selection-disabled .youtube-playlist-card, +.playlist-scroll-container.selection-disabled .tidal-playlist-card, +.playlist-scroll-container.selection-disabled .qobuz-playlist-card, +.playlist-scroll-container.selection-disabled .deezer-playlist-card, +.playlist-scroll-container.selection-disabled .spotify-public-card, +.playlist-scroll-container.selection-disabled .itunes-link-card, +.playlist-scroll-container.selection-disabled .listenbrainz-playlist-card, +.playlist-scroll-container.selection-disabled .lastfm-playlist-card, +.playlist-scroll-container.selection-disabled .soulsync-discovery-playlist-card { + pointer-events: none; + opacity: 0.65; +} + +.playlist-scroll-container.selection-disabled .playlist-card .playlist-card-actions button, +.playlist-scroll-container.selection-disabled .youtube-playlist-card .playlist-card-action-btn { + pointer-events: auto; +} + +.playlist-bulk-toolbar { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; + padding: 0 10px 10px; +} + +.playlist-header--listenbrainz { + flex-wrap: wrap; + gap: 10px; +} + +.playlist-header--listenbrainz .listenbrainz-sub-tabs { + flex-basis: 100%; +} + +.youtube-playlist-card.selected, +.tidal-playlist-card.selected, +.qobuz-playlist-card.selected, +.deezer-playlist-card.selected, +.spotify-public-card.selected, +.itunes-link-card.selected, +.listenbrainz-playlist-card.selected, +.lastfm-playlist-card.selected, +.soulsync-discovery-playlist-card.selected, +[id^="beatport-card-"].selected { + border-color: rgba(var(--accent-rgb), 0.3); + background: rgba(var(--accent-rgb), 0.04); +} + +.sync-playlist-bulk-bar { + position: fixed; + bottom: -80px; + left: 240px; + right: 0; + height: 60px; + background: linear-gradient(135deg, rgba(30, 30, 30, 0.98), rgba(20, 20, 20, 0.98)); + backdrop-filter: blur(12px); + border-top: 1px solid rgba(var(--accent-rgb), 0.25); + display: flex; + align-items: center; + justify-content: space-between; + padding: 0 160px 0 24px; + z-index: 99990; + transition: bottom 0.3s cubic-bezier(0.4, 0, 0.2, 1); + box-shadow: 0 -4px 20px rgba(0, 0, 0, 0.4); + gap: 16px; +} + +.sync-playlist-bulk-bar.visible { + bottom: 0; +} + +.sync-playlist-start-btn--ready:not(:disabled) { + box-shadow: 0 0 16px rgba(var(--accent-rgb), 0.35); +} + +.sync-playlist-bulk-bar-info { + display: flex; + align-items: center; + gap: 10px; + min-width: 0; +} + +.sync-playlist-bulk-count { + font-size: 14px; + font-weight: 600; + color: rgb(var(--accent-light-rgb)); +} + +.sync-playlist-bulk-label { + font-size: 13px; + color: rgba(255, 255, 255, 0.65); + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.sync-playlist-bulk-bar-actions { + display: flex; + gap: 8px; + flex-shrink: 0; +} + +@media (max-width: 900px) { + .sync-playlist-bulk-bar { + left: 0; + flex-direction: column; + height: auto; + padding: 12px 16px 72px; + bottom: -120px; + } + + .sync-playlist-bulk-bar.visible { + bottom: 0; + } + + .sync-playlist-bulk-bar-actions { + width: 100%; + justify-content: flex-end; + } +} + .refresh-button { background: rgb(var(--accent-rgb)); border: none; diff --git a/webui/static/sync-bulk-selection.js b/webui/static/sync-bulk-selection.js new file mode 100644 index 00000000..a223ef14 --- /dev/null +++ b/webui/static/sync-bulk-selection.js @@ -0,0 +1,902 @@ +// Bulk playlist selection + sequential processing across Sync page sources. + +let activeSyncSelectionSource = null; + +const SYNC_TERMINAL_PHASES = ['sync_complete', 'downloading', 'download_complete']; + +function getSyncSourceKeyFromTab(tabId) { + const map = { + spotify: 'spotify', + tidal: 'tidal', + deezer: 'deezer', + 'deezer-link': 'deezer-link', + qobuz: 'qobuz', + youtube: 'youtube', + 'spotify-public': 'spotify-public', + 'itunes-link': 'itunes-link', + beatport: 'beatport', + 'listenbrainz-sync': 'listenbrainz-sync', + 'lastfm-sync': 'lastfm-sync', + 'soulsync-discovery-sync': 'soulsync-discovery-sync', + }; + return map[tabId] || null; +} + +function onSyncTabChanged(tabId) { + selectedPlaylists.clear(); + activeSyncSelectionSource = getSyncSourceKeyFromTab(tabId); + updateSyncActionsUI(); +} + +function getActiveSyncBulkConfig() { + if (!activeSyncSelectionSource) return null; + return SYNC_BULK_SOURCES[activeSyncSelectionSource] || null; +} + +function isSyncBulkTabActive() { + const config = getActiveSyncBulkConfig(); + if (!config) return false; + const tab = document.getElementById(config.tabContentId); + return !!(tab && tab.classList.contains('active')); +} + +function getCardSelectId(card, config) { + if (!card || !config) return null; + if (config.idFromCard) return config.idFromCard(card); + if (config.idAttr === 'playlistId') return card.dataset.playlistId; + if (config.idAttr === 'urlHash') return card.dataset.urlHash; + if (config.idAttr === 'lbMbid') return card.dataset.lbMbid; + if (config.idAttr === 'ssdId') return card.dataset.ssdId; + if (config.idAttr === 'chartHash') return card.dataset.chartHash; + return card.dataset.syncSelectId || null; +} + +function applySyncPlaylistSelectionToCards(sourceKey) { + const config = SYNC_BULK_SOURCES[sourceKey]; + if (!config) return; + document.querySelectorAll(config.cardQuery).forEach(card => { + const id = getCardSelectId(card, config); + if (id) card.classList.toggle('selected', selectedPlaylists.has(id)); + }); +} + +function toggleSyncPlaylistSelection(event, sourceKey, playlistId, cardEl) { + const config = SYNC_BULK_SOURCES[sourceKey]; + if (!config) return; + + const container = document.getElementById(config.containerId); + if (container?.classList.contains('selection-disabled')) return; + + const card = cardEl || event?.currentTarget; + if (!card || !playlistId) return; + + if (event?.target?.closest?.('button') && !event.target.closest('.playlist-card-action-btn')) { + return; + } + if (event?.target?.tagName === 'BUTTON' && event.target.classList.contains('playlist-card-action-btn')) { + return; + } + + const isSelected = !card.classList.contains('selected'); + card.classList.toggle('selected', isSelected); + if (isSelected) { + selectedPlaylists.add(playlistId); + } else { + selectedPlaylists.delete(playlistId); + } + updateSyncActionsUI(); +} + +function selectAllSyncPlaylists(sourceKey) { + const config = SYNC_BULK_SOURCES[sourceKey]; + if (!config || (sequentialSyncManager && sequentialSyncManager.isRunning)) return; + + const ids = config.getIds(); + if (!ids.length) return; + + selectedPlaylists.clear(); + ids.forEach(id => selectedPlaylists.add(id)); + applySyncPlaylistSelectionToCards(sourceKey); + updateSyncActionsUI(); +} + +function clearSyncPlaylistSelection() { + if (sequentialSyncManager && sequentialSyncManager.isRunning) return; + + const sourceKey = activeSyncSelectionSource; + selectedPlaylists.clear(); + if (sourceKey) applySyncPlaylistSelectionToCards(sourceKey); + updateSyncActionsUI(); +} + +function waitUntilSyncStep(predicate, timeoutMs = 600000, intervalMs = 1000) { + return new Promise((resolve, reject) => { + const started = Date.now(); + const tick = () => { + if (predicate()) return resolve(); + if (sequentialSyncManager && !sequentialSyncManager.isRunning) { + return reject(new Error('Cancelled')); + } + if (Date.now() - started > timeoutMs) { + return reject(new Error('Timed out waiting for playlist step')); + } + setTimeout(tick, intervalMs); + }; + tick(); + }); +} + +function isTerminalSyncPhase(phase) { + return SYNC_TERMINAL_PHASES.includes(phase); +} + +async function bulkAutoAdvancePhaseSource(sourceKey, playlistId) { + const handlers = { + tidal: () => bulkAutoAdvanceTidal(playlistId), + qobuz: () => bulkAutoAdvanceQobuz(playlistId), + 'deezer-link': () => bulkAutoAdvanceDeezerLink(playlistId), + youtube: () => bulkAutoAdvanceYouTube(playlistId), + 'spotify-public': () => bulkAutoAdvanceSpotifyPublic(playlistId), + 'itunes-link': () => bulkAutoAdvanceITunesLink(playlistId), + beatport: () => bulkAutoAdvanceBeatport(playlistId), + 'listenbrainz-sync': () => bulkAutoAdvanceListenBrainz(playlistId), + 'lastfm-sync': () => bulkAutoAdvanceListenBrainz(playlistId), + 'soulsync-discovery-sync': () => bulkAutoAdvanceSoulsyncDiscovery(playlistId), + }; + const fn = handlers[sourceKey]; + if (!fn) throw new Error(`Bulk sync not supported for source: ${sourceKey}`); + await fn(); +} + +async function bulkAutoAdvanceTidal(playlistId) { + const urlHash = `tidal_${playlistId}`; + for (let step = 0; step < 12; step++) { + const state = tidalPlaylistStates[playlistId]; + if (!state) return; + const phase = state.phase || 'fresh'; + + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + if (!state.playlist?.tracks?.length) { + const resp = await fetch(`/api/tidal/playlist/${playlistId}`); + if (resp.ok) { + const fullData = await resp.json(); + if (fullData.tracks?.length) { + state.playlist.tracks = fullData.tracks.map(t => ({ + id: t.id, name: t.name, artists: t.artists || [], + album: t.album || '', duration_ms: t.duration_ms || 0, + track_number: t.track_number || 0, + })); + } + } + } + const response = await fetch(`/api/tidal/discovery/start/${playlistId}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateTidalCardPhase(playlistId, 'discovering'); + startTidalDiscoveryPolling(urlHash, playlistId); + await waitUntilSyncStep(() => { + const p = tidalPlaylistStates[playlistId]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = tidalPlaylistStates[playlistId]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + await startTidalPlaylistSync(urlHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(tidalPlaylistStates[playlistId]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(tidalPlaylistStates[playlistId]?.phase)); + return; + } + + return; + } +} + +async function bulkAutoAdvanceQobuz(playlistId) { + const urlHash = `qobuz_${playlistId}`; + for (let step = 0; step < 12; step++) { + const state = qobuzPlaylistStates[playlistId]; + if (!state) return; + const phase = state.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + const response = await fetch(`/api/qobuz/discovery/start/${playlistId}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateQobuzCardPhase(playlistId, 'discovering'); + startQobuzDiscoveryPolling(urlHash, playlistId); + await waitUntilSyncStep(() => { + const p = qobuzPlaylistStates[playlistId]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = qobuzPlaylistStates[playlistId]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + await startQobuzPlaylistSync(urlHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(qobuzPlaylistStates[playlistId]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(qobuzPlaylistStates[playlistId]?.phase)); + return; + } + + return; + } +} + +async function bulkAutoAdvanceDeezerLink(playlistId) { + const urlHash = `deezer_${playlistId}`; + for (let step = 0; step < 12; step++) { + const state = deezerPlaylistStates[playlistId]; + if (!state) return; + const phase = state.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + const response = await fetch(`/api/deezer/discovery/start/${playlistId}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateDeezerCardPhase(playlistId, 'discovering'); + startDeezerDiscoveryPolling(urlHash, playlistId); + await waitUntilSyncStep(() => { + const p = deezerPlaylistStates[playlistId]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = deezerPlaylistStates[playlistId]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + await startDeezerPlaylistSync(urlHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(deezerPlaylistStates[playlistId]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(deezerPlaylistStates[playlistId]?.phase)); + return; + } + + return; + } +} + +async function bulkAutoAdvanceYouTube(urlHash) { + for (let step = 0; step < 12; step++) { + const state = youtubePlaylistStates[urlHash]; + if (!state) return; + const phase = state.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + const response = await fetch(`/api/youtube/discovery/start/${urlHash}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateYouTubeCardPhase(urlHash, 'discovering'); + startYouTubeDiscoveryPolling(urlHash); + await waitUntilSyncStep(() => { + const p = youtubePlaylistStates[urlHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = youtubePlaylistStates[urlHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + await startYouTubePlaylistSync(urlHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(youtubePlaylistStates[urlHash]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(youtubePlaylistStates[urlHash]?.phase)); + return; + } + + return; + } +} + +function mirrorYoutubeStateKey(fakeUrlHash, urlHash) { + if (youtubePlaylistStates[fakeUrlHash] && !youtubePlaylistStates[urlHash]) { + youtubePlaylistStates[urlHash] = { ...youtubePlaylistStates[fakeUrlHash] }; + } +} + +async function bulkAutoAdvanceSpotifyPublic(urlHash) { + const fakeUrlHash = `spotifypublic_${urlHash}`; + for (let step = 0; step < 12; step++) { + const state = spotifyPublicPlaylistStates[urlHash]; + if (!state) return; + const phase = state.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + const response = await fetch(`/api/spotify-public/discovery/start/${urlHash}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateSpotifyPublicCardPhase(urlHash, 'discovering'); + if (youtubePlaylistStates[fakeUrlHash]) youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + startSpotifyPublicDiscoveryPolling(fakeUrlHash, urlHash); + await waitUntilSyncStep(() => { + const p = spotifyPublicPlaylistStates[urlHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = spotifyPublicPlaylistStates[urlHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + mirrorYoutubeStateKey(fakeUrlHash, urlHash); + await startSpotifyPublicPlaylistSync(urlHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(spotifyPublicPlaylistStates[urlHash]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(spotifyPublicPlaylistStates[urlHash]?.phase)); + return; + } + + return; + } +} + +async function bulkAutoAdvanceITunesLink(urlHash) { + const fakeUrlHash = `ituneslink_${urlHash}`; + for (let step = 0; step < 12; step++) { + const state = itunesLinkPlaylistStates[urlHash]; + if (!state) return; + const phase = state.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + const response = await fetch(`/api/itunes-link/discovery/start/${urlHash}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateITunesLinkCardPhase(urlHash, 'discovering'); + if (youtubePlaylistStates[fakeUrlHash]) youtubePlaylistStates[fakeUrlHash].phase = 'discovering'; + startITunesLinkDiscoveryPolling(fakeUrlHash, urlHash); + await waitUntilSyncStep(() => { + const p = itunesLinkPlaylistStates[urlHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = itunesLinkPlaylistStates[urlHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + mirrorYoutubeStateKey(fakeUrlHash, urlHash); + await startITunesLinkPlaylistSync(urlHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(itunesLinkPlaylistStates[urlHash]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(itunesLinkPlaylistStates[urlHash]?.phase)); + return; + } + + return; + } +} + +async function bulkAutoAdvanceBeatport(chartHash) { + for (let step = 0; step < 12; step++) { + const state = beatportChartStates[chartHash]; + if (!state) return; + const phase = state.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh') { + const response = await fetch(`/api/beatport/discovery/start/${chartHash}`, { method: 'POST' }); + const result = await response.json(); + if (result.error) throw new Error(result.error); + state.phase = 'discovering'; + updateBeatportCardPhase(chartHash, 'discovering'); + startBeatportDiscoveryPolling(chartHash); + await waitUntilSyncStep(() => { + const p = beatportChartStates[chartHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovering') { + await waitUntilSyncStep(() => { + const p = beatportChartStates[chartHash]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + continue; + } + + if (phase === 'discovered') { + await startBeatportPlaylistSync(chartHash); + await waitUntilSyncStep(() => isTerminalSyncPhase(beatportChartStates[chartHash]?.phase)); + return; + } + + if (phase === 'syncing') { + await waitUntilSyncStep(() => isTerminalSyncPhase(beatportChartStates[chartHash]?.phase)); + return; + } + + return; + } +} + +async function ensureListenBrainzPlaylistState(playlistMbid, playlistTitle) { + if (listenbrainzPlaylistStates[playlistMbid]?.playlist?.tracks?.length) { + return listenbrainzPlaylistStates[playlistMbid]; + } + + if (typeof listenbrainzTracksCache === 'undefined') { + window.listenbrainzTracksCache = {}; + } + let tracks = listenbrainzTracksCache[playlistMbid]; + if (!tracks?.length) { + const resp = await fetch(`/api/discover/listenbrainz/playlist/${encodeURIComponent(playlistMbid)}`); + if (!resp.ok) throw new Error(`Failed to load playlist tracks (${resp.status})`); + const data = await resp.json(); + tracks = (data.tracks || []).map(t => ({ + track_name: t.track_name || '', + artist_name: t.artist_name || '', + album_name: t.album_name || '', + duration_ms: t.duration_ms || 0, + mbid: t.recording_mbid || t.mbid || '', + release_mbid: t.release_mbid || '', + album_cover_url: t.album_cover_url || '', + })); + listenbrainzTracksCache[playlistMbid] = tracks; + } + if (!tracks.length) throw new Error('Playlist has no tracks'); + + const title = playlistTitle || 'ListenBrainz playlist'; + listenbrainzPlaylistStates[playlistMbid] = { + phase: 'fresh', + playlist: { + name: title, + tracks: tracks.map(track => ({ ...track })), + description: `${tracks.length} tracks from ${title}`, + source: 'listenbrainz', + }, + is_listenbrainz_playlist: true, + playlist_mbid: playlistMbid, + discovery_results: [], + discoveryResults: [], + discovery_progress: 0, + discoveryProgress: 0, + spotify_matches: 0, + spotifyMatches: 0, + spotify_total: tracks.length, + spotifyTotal: tracks.length, + }; + return listenbrainzPlaylistStates[playlistMbid]; +} + +async function bulkAutoAdvanceListenBrainz(playlistMbid) { + const card = document.querySelector(`#listenbrainz-sync-card-${CSS.escape(playlistMbid)}, #lastfm-sync-card-${CSS.escape(playlistMbid)}`); + const title = card?.dataset.lbTitle || 'Playlist'; + await ensureListenBrainzPlaylistState(playlistMbid, title); + + const phase = listenbrainzPlaylistStates[playlistMbid]?.phase || 'fresh'; + if (isTerminalSyncPhase(phase)) return; + + if (phase === 'fresh' || phase === 'discovering') { + if (typeof startListenBrainzDiscovery === 'function') { + await startListenBrainzDiscovery(playlistMbid); + } + await waitUntilSyncStep(() => { + const p = listenbrainzPlaylistStates[playlistMbid]?.phase; + return p && p !== 'fresh' && p !== 'discovering'; + }); + } + + const afterDiscover = listenbrainzPlaylistStates[playlistMbid]?.phase; + if (afterDiscover === 'discovered' && typeof startListenBrainzPlaylistSync === 'function') { + await startListenBrainzPlaylistSync(playlistMbid); + await waitUntilSyncStep(() => isTerminalSyncPhase(listenbrainzPlaylistStates[playlistMbid]?.phase)); + } +} + +async function bulkAutoAdvanceSoulsyncDiscovery(syntheticId) { + const card = document.getElementById(`soulsync-discovery-sync-card-${syntheticId}`); + if (!card || typeof handleSoulsyncDiscoverySyncCardClick !== 'function') return; + const kind = card.dataset.ssdKind; + const variant = card.dataset.ssdVariant; + const name = card.dataset.ssdName; + await handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card); +} + +async function bulkSyncDeezerArlPlaylist(arlPlaylistId) { + const rawId = arlPlaylistId.replace(/^deezer_arl_/, ''); + const playlistMeta = deezerArlPlaylists.find(p => String(p.id) === String(rawId)); + const cacheStale = typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(arlPlaylistId, playlistMeta); + if (!playlistTrackCache[arlPlaylistId] || cacheStale) { + if (typeof fetchAndCacheDeezerArlPlaylistTracks === 'function') { + await fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, rawId); + } else { + const response = await fetch(`/api/deezer/arl-playlist/${rawId}`); + const data = await response.json(); + if (data.error) throw new Error(data.error); + playlistTrackCache[arlPlaylistId] = data.tracks; + } + } + await startPlaylistSync(arlPlaylistId); + await waitForSyncPollerCompletion(arlPlaylistId); +} + +async function waitForSyncPollerCompletion(playlistId) { + return new Promise((resolve) => { + const checkCompletion = () => { + if (!activeSyncPollers[playlistId]) { + resolve(); + return; + } + if (sequentialSyncManager && !sequentialSyncManager.isRunning) { + resolve(); + return; + } + setTimeout(checkCompletion, 1000); + }; + checkCompletion(); + }); +} + +function wirePhaseSyncCards(sourceKey, containerSelector, cardSelector, getIdFromCard, onActionClick) { + const container = document.querySelector(containerSelector); + if (!container) return; + + container.querySelectorAll(cardSelector).forEach(card => { + const id = getIdFromCard(card); + if (!id) return; + + card.dataset.syncSelectId = id; + + const btn = card.querySelector('.playlist-card-action-btn'); + if (btn && !btn.dataset.syncBulkWired) { + btn.dataset.syncBulkWired = '1'; + btn.addEventListener('click', (e) => { + e.stopPropagation(); + if (typeof onActionClick === 'function') onActionClick(id); + }); + } + + if (!card.dataset.syncBulkWired) { + card.dataset.syncBulkWired = '1'; + card.addEventListener('click', (e) => { + if (e.target.closest('.playlist-card-action-btn')) return; + toggleSyncPlaylistSelection(e, sourceKey, id, card); + }); + } + }); + + applySyncPlaylistSelectionToCards(sourceKey); +} + +function wirePlaylistCardSelection(sourceKey, containerId) { + const config = SYNC_BULK_SOURCES[sourceKey]; + if (!config) return; + const container = document.getElementById(containerId); + if (!container) return; + + container.querySelectorAll('.playlist-card[data-playlist-id]').forEach(card => { + const id = card.dataset.playlistId; + if (!id) return; + + if (!card.dataset.syncBulkWired) { + card.dataset.syncBulkWired = '1'; + card.addEventListener('click', (e) => { + if (e.target.tagName === 'BUTTON') return; + toggleSyncPlaylistSelection(e, sourceKey, id, card); + }); + } + }); + + applySyncPlaylistSelectionToCards(sourceKey); +} + +const SYNC_BULK_SOURCES = { + spotify: { + tabContentId: 'spotify-tab-content', + containerId: 'spotify-playlist-container', + cardQuery: '#spotify-playlist-container .playlist-card[data-playlist-id]', + idAttr: 'playlistId', + getIds: () => spotifyPlaylists.map(p => p.id), + getName: (id) => spotifyPlaylists.find(p => p.id === id)?.name || id, + process: async (id) => { + await startPlaylistSync(id); + await waitForSyncPollerCompletion(id); + }, + }, + deezer: { + tabContentId: 'deezer-tab-content', + containerId: 'deezer-arl-playlist-container', + cardQuery: '#deezer-arl-playlist-container .playlist-card[data-playlist-id]', + idAttr: 'playlistId', + getIds: () => deezerArlPlaylists.map(p => `deezer_arl_${p.id}`), + getName: (id) => { + const raw = id.replace(/^deezer_arl_/, ''); + return deezerArlPlaylists.find(p => String(p.id) === String(raw))?.name || id; + }, + process: (id) => bulkSyncDeezerArlPlaylist(id), + }, + tidal: { + tabContentId: 'tidal-tab-content', + containerId: 'tidal-playlist-container', + cardQuery: '#tidal-playlist-container .tidal-playlist-card', + idFromCard: (card) => card.id.replace(/^tidal-card-/, ''), + getIds: () => tidalPlaylists.map(p => p.id), + getName: (id) => tidalPlaylistStates[id]?.playlist?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('tidal', id), + }, + qobuz: { + tabContentId: 'qobuz-tab-content', + containerId: 'qobuz-playlist-container', + cardQuery: '#qobuz-playlist-container .qobuz-playlist-card', + idFromCard: (card) => card.id.replace(/^qobuz-card-/, ''), + getIds: () => qobuzPlaylists.map(p => p.id), + getName: (id) => qobuzPlaylistStates[id]?.playlist?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('qobuz', id), + }, + 'deezer-link': { + tabContentId: 'deezer-link-tab-content', + containerId: 'deezer-playlist-container', + cardQuery: '#deezer-playlist-container .deezer-playlist-card', + idFromCard: (card) => card.id.replace(/^deezer-card-/, ''), + getIds: () => deezerPlaylists.map(p => p.id), + getName: (id) => deezerPlaylistStates[id]?.playlist?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('deezer-link', id), + }, + youtube: { + tabContentId: 'youtube-tab-content', + containerId: 'youtube-playlist-container', + cardQuery: '#youtube-playlist-container .youtube-playlist-card[id^="youtube-card-"]', + idFromCard: (card) => card.id.replace(/^youtube-card-/, ''), + getIds: () => Object.keys(youtubePlaylistStates), + getName: (id) => youtubePlaylistStates[id]?.playlist?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('youtube', id), + }, + 'spotify-public': { + tabContentId: 'spotify-public-tab-content', + containerId: 'spotify-public-playlist-container', + cardQuery: '#spotify-public-playlist-container .spotify-public-card', + idFromCard: (card) => card.id.replace(/^spotify-public-card-/, ''), + getIds: () => spotifyPublicPlaylists.map(p => p.url_hash), + getName: (id) => spotifyPublicPlaylistStates[id]?.playlist?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('spotify-public', id), + }, + 'itunes-link': { + tabContentId: 'itunes-link-tab-content', + containerId: 'itunes-link-playlist-container', + cardQuery: '#itunes-link-playlist-container .itunes-link-card', + idFromCard: (card) => card.id.replace(/^itunes-link-card-/, ''), + getIds: () => itunesLinkPlaylists.map(p => p.url_hash), + getName: (id) => itunesLinkPlaylistStates[id]?.playlist?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('itunes-link', id), + }, + beatport: { + tabContentId: 'beatport-tab-content', + containerId: 'beatport-playlist-container', + cardQuery: '#youtube-playlist-container [id^="beatport-card-"], #beatport-playlist-container [id^="beatport-card-"]', + idFromCard: (card) => card.id.replace(/^beatport-card-/, ''), + getIds: () => Object.keys(beatportChartStates), + getName: (id) => beatportChartStates[id]?.chart?.name || beatportChartStates[id]?.name || id, + process: (id) => bulkAutoAdvancePhaseSource('beatport', id), + }, + 'listenbrainz-sync': { + tabContentId: 'listenbrainz-sync-tab-content', + containerId: 'listenbrainz-sync-playlist-container', + cardQuery: '#listenbrainz-sync-playlist-container .listenbrainz-playlist-card', + idAttr: 'lbMbid', + getIds: () => { + const ids = []; + document.querySelectorAll('#listenbrainz-sync-playlist-container .listenbrainz-playlist-card').forEach(card => { + if (card.dataset.lbMbid) ids.push(card.dataset.lbMbid); + }); + return ids; + }, + getName: (id) => { + const card = document.querySelector(`#listenbrainz-sync-card-${CSS.escape(id)}`); + return card?.dataset.lbTitle || id; + }, + process: (id) => bulkAutoAdvancePhaseSource('listenbrainz-sync', id), + }, + 'lastfm-sync': { + tabContentId: 'lastfm-sync-tab-content', + containerId: 'lastfm-sync-playlist-container', + cardQuery: '#lastfm-sync-playlist-container .lastfm-playlist-card', + idAttr: 'lbMbid', + getIds: () => { + const ids = []; + document.querySelectorAll('#lastfm-sync-playlist-container .lastfm-playlist-card').forEach(card => { + if (card.dataset.lbMbid) ids.push(card.dataset.lbMbid); + }); + return ids; + }, + getName: (id) => { + const card = document.querySelector(`#lastfm-sync-card-${CSS.escape(id)}`); + return card?.dataset.lbTitle || id; + }, + process: (id) => bulkAutoAdvancePhaseSource('lastfm-sync', id), + }, + 'soulsync-discovery-sync': { + tabContentId: 'soulsync-discovery-sync-tab-content', + containerId: 'soulsync-discovery-sync-playlist-container', + cardQuery: '#soulsync-discovery-sync-playlist-container .soulsync-discovery-playlist-card', + idAttr: 'ssdId', + getIds: () => { + const ids = []; + document.querySelectorAll('#soulsync-discovery-sync-playlist-container .soulsync-discovery-playlist-card').forEach(card => { + if (card.dataset.ssdId) ids.push(card.dataset.ssdId); + }); + return ids; + }, + getName: (id) => { + const card = document.getElementById(`soulsync-discovery-sync-card-${id}`); + return card?.dataset.ssdName || id; + }, + process: (id) => bulkAutoAdvancePhaseSource('soulsync-discovery-sync', id), + }, +}; + +function updateSyncActionsUI() { + const count = selectedPlaylists.size; + const isRunning = !!(sequentialSyncManager && sequentialSyncManager.isRunning); + const bulkTabActive = isSyncBulkTabActive(); + const config = getActiveSyncBulkConfig(); + + const selectionInfo = document.getElementById('selection-info'); + const startSyncBtn = document.getElementById('start-sync-btn'); + const bulkBar = document.getElementById('sync-playlist-bulk-bar'); + const bulkCount = document.getElementById('sync-playlist-bulk-count'); + const bulkLabel = document.getElementById('sync-playlist-bulk-label'); + let statusText; + let syncBtnLabel; + let syncEnabled = count > 0 && !!config; + + if (isRunning && sequentialSyncManager.bulkConfig) { + const current = sequentialSyncManager.currentIndex + 1; + const total = sequentialSyncManager.queue.length; + const currentId = sequentialSyncManager.queue[sequentialSyncManager.currentIndex]; + const name = sequentialSyncManager.bulkConfig.getName(currentId); + statusText = `Syncing ${current}/${total}: ${name || 'Unknown'}`; + syncBtnLabel = 'Cancel sync'; + syncEnabled = true; + } else if (!config) { + statusText = 'Select playlists to sync'; + syncBtnLabel = 'Sync selected'; + syncEnabled = false; + } else { + statusText = count === 0 + ? 'Select playlists to sync' + : `${count} playlist${count > 1 ? 's' : ''} selected`; + syncBtnLabel = 'Sync selected'; + } + + if (selectionInfo) selectionInfo.textContent = statusText; + if (startSyncBtn) { + startSyncBtn.textContent = isRunning ? 'Cancel Sequential Sync' : 'Start Sync'; + startSyncBtn.disabled = !syncEnabled; + } + document.querySelectorAll('.sync-playlist-start-btn').forEach(btn => { + const inActiveTab = btn.closest('.sync-tab-content.active'); + btn.textContent = isRunning && inActiveTab ? syncBtnLabel : 'Sync selected'; + btn.disabled = !(inActiveTab && syncEnabled); + btn.classList.toggle('sync-playlist-start-btn--ready', !!(inActiveTab && count > 0 && !isRunning)); + }); + if (bulkCount) bulkCount.textContent = isRunning ? '' : String(count); + if (bulkLabel) { + bulkLabel.textContent = isRunning ? statusText : `playlist${count === 1 ? '' : 's'} selected`; + } + if (bulkBar) { + const showBar = bulkTabActive && !!config && (count > 0 || isRunning); + bulkBar.hidden = !showBar; + bulkBar.classList.toggle('visible', showBar); + } + + document.querySelectorAll('.sync-playlist-select-all-btn').forEach(btn => { + const sourceKey = btn.dataset.syncSource; + const sourceConfig = SYNC_BULK_SOURCES[sourceKey]; + const tab = sourceConfig && document.getElementById(sourceConfig.tabContentId); + const tabActive = tab && tab.classList.contains('active'); + btn.disabled = !tabActive || isRunning || !sourceConfig?.getIds()?.length; + }); + document.querySelectorAll('.sync-playlist-clear-btn').forEach(btn => { + btn.disabled = count === 0 || isRunning; + }); +} + +function getOrderedSelectedPlaylistIds() { + const config = getActiveSyncBulkConfig(); + if (!config) return []; + + const ordered = []; + document.querySelectorAll(config.cardQuery).forEach(card => { + const id = getCardSelectId(card, config); + if (id && selectedPlaylists.has(id)) ordered.push(id); + }); + return ordered; +} + +function disablePlaylistSelection(disabled) { + const containerIds = new Set(Object.values(SYNC_BULK_SOURCES).map(c => c.containerId)); + containerIds.add('youtube-playlist-container'); + containerIds.forEach(id => { + const container = document.getElementById(id); + if (container) container.classList.toggle('selection-disabled', disabled); + }); + + document.querySelectorAll('.playlist-checkbox').forEach(checkbox => { + checkbox.disabled = disabled; + }); + + if (disabled) { + document.querySelectorAll('.sync-playlist-select-all-btn, .sync-playlist-clear-btn').forEach(btn => { + btn.disabled = true; + }); + } else { + updateSyncActionsUI(); + } +} diff --git a/webui/static/sync-lastfm.js b/webui/static/sync-lastfm.js index b54379a1..52d43cfd 100644 --- a/webui/static/sync-lastfm.js +++ b/webui/static/sync-lastfm.js @@ -102,18 +102,20 @@ function renderLastfmSyncPlaylists() { `; }).join(''); - container.querySelectorAll('.lastfm-playlist-card').forEach(card => { - card.addEventListener('click', () => { - const mbid = card.dataset.lbMbid; - const title = card.dataset.lbTitle; - // Reuses the LB Sync-tab click handler — Last.fm radios are - // stored in the same table + matched by the same discovery - // worker, so the click flow is byte-identical. - if (typeof handleListenBrainzSyncCardClick === 'function') { - handleListenBrainzSyncCardClick(mbid, title); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'lastfm-sync', + '#lastfm-sync-playlist-container', + '.lastfm-playlist-card', + card => card.dataset.lbMbid, + mbid => { + const c = document.querySelector(`#lastfm-sync-card-${CSS.escape(mbid)}`); + if (typeof handleListenBrainzSyncCardClick === 'function') { + handleListenBrainzSyncCardClick(mbid, c?.dataset.lbTitle || ''); + } } - }); - }); + ); + } // Reuse the shared refresh loop from sync-listenbrainz.js — it // already iterates Last.fm cards alongside LB cards. diff --git a/webui/static/sync-listenbrainz.js b/webui/static/sync-listenbrainz.js index b8415ce6..a32e6f51 100644 --- a/webui/static/sync-listenbrainz.js +++ b/webui/static/sync-listenbrainz.js @@ -139,13 +139,18 @@ function renderListenBrainzSyncPlaylists() { }).join(''); // Wire click handlers. - container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => { - card.addEventListener('click', () => { - const mbid = card.dataset.lbMbid; - const title = card.dataset.lbTitle; - handleListenBrainzSyncCardClick(mbid, title); - }); - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'listenbrainz-sync', + '#listenbrainz-sync-playlist-container', + '.listenbrainz-playlist-card', + card => card.dataset.lbMbid, + mbid => { + const c = document.querySelector(`#listenbrainz-sync-card-${CSS.escape(mbid)}`); + handleListenBrainzSyncCardClick(mbid, c?.dataset.lbTitle || ''); + } + ); + } // If the tab is currently visible, kick the refresh loop so cards // start showing live state immediately. ``_startLbSyncCardRefreshLoop`` diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index f375bdd3..321c2875 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -89,13 +89,15 @@ function renderTidalPlaylists() { return createTidalCard(p); }).join(''); - // Add click handlers to cards - tidalPlaylists.forEach(p => { - const card = document.getElementById(`tidal-card-${p.id}`); - if (card) { - card.addEventListener('click', () => handleTidalCardClick(p.id)); - } - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'tidal', + '#tidal-playlist-container', + '.tidal-playlist-card', + card => card.id.replace(/^tidal-card-/, ''), + handleTidalCardClick + ); + } } function createTidalCard(playlist) { @@ -1495,7 +1497,8 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, : 'spotify'; await applyMirroredOrganizePreference(virtualPlaylistId, orgSource); if (options.forcePlaylistFolder) { - syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true); + const defaultKeep = typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode(); + syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true, defaultKeep); if (typeof setMirroredOrganizePreference === 'function') { await setMirroredOrganizePreference(virtualPlaylistId, true, orgSource); } @@ -1597,12 +1600,15 @@ function renderQobuzPlaylists() { return createQobuzCard(p); }).join(''); - qobuzPlaylists.forEach(p => { - const card = document.getElementById(`qobuz-card-${p.id}`); - if (card) { - card.addEventListener('click', () => handleQobuzCardClick(p.id)); - } - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'qobuz', + '#qobuz-playlist-container', + '.qobuz-playlist-card', + card => card.id.replace(/^qobuz-card-/, ''), + handleQobuzCardClick + ); + } } function createQobuzCard(playlist) { @@ -2522,6 +2528,11 @@ function renderDeezerArlPlaylists() {
`; }).join(''); + + if (typeof wirePlaylistCardSelection === 'function') { + wirePlaylistCardSelection('deezer', 'deezer-arl-playlist-container'); + } + if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI(); } function handleDeezerArlViewProgressClick(event, playlistId) { @@ -2799,13 +2810,15 @@ function renderDeezerPlaylists() { return createDeezerCard(p); }).join(''); - // Add click handlers to cards - deezerPlaylists.forEach(p => { - const card = document.getElementById(`deezer-card-${p.id}`); - if (card) { - card.addEventListener('click', () => handleDeezerCardClick(p.id)); - } - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'deezer-link', + '#deezer-playlist-container', + '.deezer-playlist-card', + card => card.id.replace(/^deezer-card-/, ''), + handleDeezerCardClick + ); + } } function createDeezerCard(playlist) { @@ -3725,6 +3738,12 @@ function initializeSyncPage() { syncContentArea.style.gridTemplateColumns = '1fr'; } + if (typeof onSyncTabChanged === 'function') { + onSyncTabChanged(tabId); + } else if (typeof updateSyncActionsUI === 'function') { + updateSyncActionsUI(); + } + // Auto-load Deezer ARL playlists on first tab activation if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) { // Check ARL status first @@ -3810,6 +3829,11 @@ function initializeSyncPage() { ensureBeatportContentLoaded(); } + const initialActiveSyncTab = document.querySelector('.sync-tab-button.active'); + if (initialActiveSyncTab && typeof onSyncTabChanged === 'function') { + onSyncTabChanged(initialActiveSyncTab.dataset.tab); + } + // Logic for the Spotify refresh button const refreshBtn = document.getElementById('spotify-refresh-btn'); if (refreshBtn) { @@ -5127,9 +5151,14 @@ function addBeatportCardToContainer(chartData) { }; // Add click handler - const card = document.getElementById(`beatport-card-${chartData.hash}`); - if (card) { - card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash)); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'beatport', + '#beatport-playlist-container', + '[id^="beatport-card-"]', + card => card.id.replace(/^beatport-card-/, ''), + chartHash => handleBeatportCardClick(chartHash) + ); } console.log(`🃏 Created Beatport card: ${chartData.name}`); @@ -6775,13 +6804,15 @@ function renderSpotifyPublicPlaylists() { return createSpotifyPublicCard(p); }).join(''); - // Add click handlers to cards - spotifyPublicPlaylists.forEach(p => { - const card = document.getElementById(`spotify-public-card-${p.url_hash}`); - if (card) { - card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash)); - } - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'spotify-public', + '#spotify-public-playlist-container', + '.spotify-public-card', + card => card.id.replace(/^spotify-public-card-/, ''), + handleSpotifyPublicCardClick + ); + } } function createSpotifyPublicCard(playlist) { @@ -7801,12 +7832,15 @@ function renderITunesLinkPlaylists() { }).join(''); // Add click handlers to cards - itunesLinkPlaylists.forEach(p => { - const card = document.getElementById(`itunes-link-card-${p.url_hash}`); - if (card) { - card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash)); - } - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'itunes-link', + '#itunes-link-playlist-container', + '.itunes-link-card', + card => card.id.replace(/^itunes-link-card-/, ''), + handleITunesLinkCardClick + ); + } } function createITunesLinkCard(playlist) { @@ -9017,15 +9051,15 @@ function updateYouTubeCardData(urlHash, playlistData) { state.playlist = playlistData; state.urlHash = urlHash; - // Add click handler for card and action button - const handleCardClick = () => handleYouTubeCardClick(urlHash); - const actionBtn = card.querySelector('.playlist-card-action-btn'); - - card.addEventListener('click', handleCardClick); - actionBtn.addEventListener('click', (e) => { - e.stopPropagation(); // Prevent card click - handleCardClick(); - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'youtube', + '#youtube-playlist-container', + '.youtube-playlist-card[id^="youtube-card-"]', + c => c.id.replace(/^youtube-card-/, ''), + handleYouTubeCardClick + ); + } console.log('🃏 Updated YouTube card data:', playlistData.name); } diff --git a/webui/static/sync-soulsync-discovery.js b/webui/static/sync-soulsync-discovery.js index 6a707daa..dd4bd8aa 100644 --- a/webui/static/sync-soulsync-discovery.js +++ b/webui/static/sync-soulsync-discovery.js @@ -91,14 +91,24 @@ function renderSoulsyncDiscoverySyncPlaylists() { `; }).join(''); - container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => { - card.addEventListener('click', () => { - const kind = card.dataset.ssdKind; - const variant = card.dataset.ssdVariant; - const name = card.dataset.ssdName; - handleSoulsyncDiscoverySyncCardClick(kind, variant, name, card); - }); - }); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'soulsync-discovery-sync', + '#soulsync-discovery-sync-playlist-container', + '.soulsync-discovery-playlist-card', + card => card.dataset.ssdId, + syntheticId => { + const c = document.getElementById(`soulsync-discovery-sync-card-${syntheticId}`); + if (!c) return; + handleSoulsyncDiscoverySyncCardClick( + c.dataset.ssdKind, + c.dataset.ssdVariant, + c.dataset.ssdName, + c + ); + } + ); + } } function _soulsyncSyntheticId(kind, variant) { diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index 6af72167..8e6a1780 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -1199,10 +1199,14 @@ function createBeatportCardFromBackendState(chartInfo) { cardElement: document.getElementById(`beatport-card-${chartHash}`) }; - // Add click handler - const card = document.getElementById(`beatport-card-${chartHash}`); - if (card) { - card.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'beatport', + '#beatport-playlist-container', + '[id^="beatport-card-"]', + card => card.id.replace(/^beatport-card-/, ''), + chartHash => handleBeatportCardClick(chartHash) + ); } console.log(`🃏 Created Beatport card from backend state: ${chartInfo.name} (${phase})`); @@ -1357,7 +1361,7 @@ function createYouTubeCardFromBackendState(playlistInfo) { // Create card HTML (using EXACT same structure as createYouTubeCard) const cardHtml = ` -
+
${escapeHtml(playlist.name)}
@@ -1388,6 +1392,16 @@ function createYouTubeCardFromBackendState(playlistInfo) { backendSynced: true // Flag to indicate this came from backend }; + if (typeof wirePhaseSyncCards === 'function') { + wirePhaseSyncCards( + 'youtube', + '#youtube-playlist-container', + '.youtube-playlist-card[id^="youtube-card-"]', + c => c.id.replace(/^youtube-card-/, ''), + handleYouTubeCardClick + ); + } + console.log(`🃏 Created YouTube card from backend state: ${playlist.name} (${phase})`); } @@ -1643,7 +1657,7 @@ function renderSpotifyPlaylists() { // This HTML structure creates the interactive playlist cards return ` -
+
${escapeHtml(p.name)}
@@ -1663,6 +1677,10 @@ function renderSpotifyPlaylists() {
`; }).join(''); + if (typeof wirePlaylistCardSelection === 'function') { + wirePlaylistCardSelection('spotify', 'spotify-playlist-container'); + } + if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI(); } function handleViewProgressClick(event, playlistId) { @@ -1791,44 +1809,6 @@ async function cleanupDownloadProcess(playlistId) { updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist } -function togglePlaylistSelection(event) { - const card = event.currentTarget; - const playlistId = card.dataset.playlistId; - - // Don't toggle if clicking the button - if (event.target.tagName === 'BUTTON') return; - - const isSelected = !card.classList.contains('selected'); - card.classList.toggle('selected', isSelected); - - if (isSelected) { - selectedPlaylists.add(playlistId); - } else { - selectedPlaylists.delete(playlistId); - } - updateSyncActionsUI(); -} - -function updateSyncActionsUI() { - // If sequential sync is running, let the manager handle UI updates - if (sequentialSyncManager && sequentialSyncManager.isRunning) { - sequentialSyncManager.updateUI(); - return; - } - - const selectionInfo = document.getElementById('selection-info'); - const startSyncBtn = document.getElementById('start-sync-btn'); - const count = selectedPlaylists.size; - - if (count === 0) { - if (selectionInfo) selectionInfo.textContent = 'Select playlists to sync'; - if (startSyncBtn) startSyncBtn.disabled = true; - } else { - if (selectionInfo) selectionInfo.textContent = `${count} playlist${count > 1 ? 's' : ''} selected`; - if (startSyncBtn) startSyncBtn.disabled = false; - } -} - async function openPlaylistDetailsModal(event, playlistId) { event.stopPropagation(); diff --git a/webui/static/wishlist-tools.js b/webui/static/wishlist-tools.js index e47fd5d6..ed87e6d7 100644 --- a/webui/static/wishlist-tools.js +++ b/webui/static/wishlist-tools.js @@ -1327,6 +1327,83 @@ async function handleWishlistDownloadNow() { registerArtistDownload(artist, album, virtualPlaylistId, albumType); } +/** Playlist-modal ids that should NOT receive playlist-folder wishlist provenance. */ +const MODAL_ALBUM_WISHLIST_PREFIXES = [ + 'artist_album_', 'discover_album_', 'enhanced_search_album_', 'seasonal_album_', + 'spotify_library_', 'beatport_release_', 'discover_cache_', +]; +const MODAL_SINGLE_TRACK_WISHLIST_PREFIXES = [ + 'enhanced_search_track_', 'gsearch_track_', +]; +const MODAL_NON_PLAYLIST_WISHLIST_PREFIXES = [ + 'issue_download_', 'library_redownload_', 'redownload_', +]; + +function isModalPlaylistWishlistContext(playlistId) { + const id = String(playlistId || ''); + if (!id || id === 'wishlist' || id.startsWith('wishlist_')) { + return false; + } + if (MODAL_ALBUM_WISHLIST_PREFIXES.some((p) => id.startsWith(p))) { + return false; + } + if (MODAL_SINGLE_TRACK_WISHLIST_PREFIXES.some((p) => id.startsWith(p))) { + return false; + } + if (MODAL_NON_PLAYLIST_WISHLIST_PREFIXES.some((p) => id.startsWith(p))) { + return false; + } + return true; +} + +function resolveModalWishlistSourceType(playlistId) { + return isModalPlaylistWishlistContext(playlistId) ? 'playlist' : 'album'; +} + +/** + * Build source_context for wishlist rows added from a download modal. + * Playlist modals include playlist_id/name so wishlist requeue can use playlist-folder layout. + */ +function buildModalWishlistSourceContext(playlistId, process, trackAlbum, trackArtist, trackAlbumType) { + const timestamp = new Date().toISOString(); + if (!isModalPlaylistWishlistContext(playlistId)) { + return { + album_name: trackAlbum?.name, + artist_name: trackArtist?.name, + album_type: trackAlbumType || 'album', + added_from: 'download_modal', + timestamp, + }; + } + + const playlistName = process.playlist?.name || process.playlistName || 'Unknown Playlist'; + const organizeSource = typeof playlistOrganizeSourceForRef === 'function' + ? playlistOrganizeSourceForRef(playlistId) + : 'spotify'; + const resolveRef = typeof normalizePlaylistOrganizeRef === 'function' + ? normalizePlaylistOrganizeRef(playlistId, organizeSource) + : playlistId; + const organizeEnabled = typeof isPlaylistOrganizeEnabled === 'function' + ? isPlaylistOrganizeEnabled(playlistId) + : false; + + const context = { + playlist_name: playlistName, + playlist_id: resolveRef, + playlist_source: organizeSource, + source: organizeSource, + added_from: 'download_modal', + timestamp, + }; + if (organizeEnabled) { + context.organize_by_playlist = true; + } + if (resolveRef !== playlistId) { + context.ui_playlist_ref = playlistId; + } + return context; +} + /** * Add all tracks from any download modal to the wishlist * Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.) @@ -1363,8 +1440,12 @@ async function addModalTracksToWishlist(playlistId) { // not for playlists, so we must NOT use it as a blanket default. const processArtist = process.artist || null; const album = process.album || process.playlist || { name: 'Playlist', id: playlistId }; + const wishlistSourceType = resolveModalWishlistSourceType(playlistId); - console.log(`🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist (process artist: ${processArtist?.name || 'per-track'})`); + console.log( + `🔄 Adding ${tracks.length} tracks from "${album.name}" to wishlist ` + + `(source: ${wishlistSourceType}, process artist: ${processArtist?.name || 'per-track'})` + ); // Disable the button to prevent double-clicks const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); @@ -1407,7 +1488,7 @@ async function addModalTracksToWishlist(playlistId) { } }); } else { - formattedArtists = [{ name: artist.name }]; + formattedArtists = [{ name: 'Unknown Artist' }]; } const formattedTrack = { @@ -1460,6 +1541,14 @@ async function addModalTracksToWishlist(playlistId) { trackArtist = { name: 'Unknown Artist', id: null }; } + const sourceContext = buildModalWishlistSourceContext( + playlistId, + process, + trackAlbum, + trackArtist, + trackAlbumType, + ); + const response = await fetch('/api/add-album-to-wishlist', { method: 'POST', headers: { @@ -1469,12 +1558,8 @@ async function addModalTracksToWishlist(playlistId) { track: formattedTrack, artist: trackArtist, album: trackAlbum, - source_type: 'album', - source_context: { - album_name: trackAlbum.name, - artist_name: trackArtist.name, - album_type: trackAlbumType - } + source_type: wishlistSourceType, + source_context: sourceContext, }) });