This commit is contained in:
Francesco Durighetto 2026-06-11 23:08:13 +02:00 committed by GitHub
commit 3686ae393a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
25 changed files with 2311 additions and 352 deletions

View file

@ -389,6 +389,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
batch_profile_id = 1 batch_profile_id = 1
batch_source = 'spotify' batch_source = 'spotify'
batch_playlist_folder_mode = False batch_playlist_folder_mode = False
batch_keep_playlist_folder_copies = False
batch_playlist_name = 'Unknown Playlist' batch_playlist_name = 'Unknown Playlist'
batch_playlist_id = playlist_id batch_playlist_id = playlist_id
batch_source_playlist_ref = '' 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_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_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_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_name = download_batches[batch_id].get('playlist_name', 'Unknown Playlist')
batch_playlist_id = download_batches[batch_id].get('playlist_id', playlist_id) batch_playlist_id = download_batches[batch_id].get('playlist_id', playlist_id)
batch_source_playlist_ref = ( 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 ( from core.downloads.playlist_folder import (
resolve_playlist_folder_mode_for_batch, resolve_playlist_folder_mode_for_batch,
resolve_wishlist_track_playlist_folder_mode,
track_exists_in_playlist_folder_from_track_data, track_exists_in_playlist_folder_from_track_data,
) )
effective_playlist_folder_mode, effective_playlist_name = resolve_playlist_folder_mode_for_batch( effective_playlist_folder_mode, effective_playlist_name, keep_playlist_folder_copies = (
db, resolve_playlist_folder_mode_for_batch(
playlist_id=str(batch_playlist_id), db,
playlist_name=batch_playlist_name, playlist_id=str(batch_playlist_id),
batch_playlist_folder_mode=batch_playlist_folder_mode, playlist_name=batch_playlist_name,
profile_id=batch_profile_id, batch_playlist_folder_mode=batch_playlist_folder_mode,
source=batch_source, 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: if effective_playlist_folder_mode and not batch_playlist_folder_mode:
with tasks_lock: 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 # Manual library matches are authoritative unless the user explicitly
# requested a force re-download from the normal download modal. # 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', '') _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( if not ignore_manual_matches and _stid and _mlm.get_match_for_track(
db, batch_profile_id, track_data, default_source=batch_source db, batch_profile_id, track_data, default_source=batch_source
): ):
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download") if skip_library_match_for_playlist_folder and not _in_playlist_folder:
try: logger.info(
deps.check_and_remove_track_from_wishlist_by_metadata(track_data) f"[Playlist Folder Copies] '{track_name}' in library but missing from "
except Exception as _wl_err: f"'{effective_playlist_name}' folder — will download copy"
logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}") )
analysis_results.append({ else:
'track_index': track_index, logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
'track': track_data, try:
'found': True, deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
'confidence': 1.0, except Exception as _wl_err:
'match_reason': 'manual_library_match', logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}")
}) analysis_results.append({
continue '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 effective_playlist_folder_mode and not force_download_all:
if track_exists_in_playlist_folder_from_track_data( if _in_playlist_folder:
effective_playlist_name,
track_data,
):
logger.info( logger.info(
f"[Playlist Folder] '{track_name}' already on disk in playlist folder — skipping download" 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 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 # Skip database check if force download is enabled
if force_download_all: if force_download_all:
logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing") logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing")
found, confidence = False, 0.0 found, confidence = False, 0.0
elif skip_library_match_for_playlist_folder:
found, confidence = False, 0.0
elif album_tracks_map: elif album_tracks_map:
# Album-scoped matching: check against known album tracks first # Album-scoped matching: check against known album tracks first
track_name_lower = track_name.lower().strip() 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()) task_id = str(uuid.uuid4())
track_info = res['track'].copy() 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 # Add explicit album context to track_info for artist album downloads
if batch_is_album and batch_album_context and batch_artist_context: if batch_is_album and batch_album_context and batch_artist_context:
track_info['_explicit_album_context'] = batch_album_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_album = {'name': s_album} # Normalize string album to dict
s_artists = spotify_data.get('artists', []) s_artists = spotify_data.get('artists', [])
# We need at least an album name and artist # Album grouping for library paths — skip when playlist-folder layout applies.
if s_album and isinstance(s_album, dict) and s_album.get('name'): 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. # Use pre-computed album-level artist for folder consistency.
# All tracks from the same album get the same artist context, # All tracks from the same album get the same artist context,
# preventing folder splits on collab albums (KPOP Demon Hunters, etc.) # 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. # tracks tied to a mirrored playlist with organize_by_playlist enabled.
task_pl_folder_mode = batch_playlist_folder_mode task_pl_folder_mode = batch_playlist_folder_mode
task_pl_name = batch_playlist_name task_pl_name = batch_playlist_name
if not task_pl_folder_mode and playlist_id == 'wishlist': if not task_pl_folder_mode and playlist_id == 'wishlist' and wishlist_track_pl_folder:
wl_source = track_info.get('source_info') or {} task_pl_folder_mode = True
if isinstance(wl_source, str): task_pl_name = wishlist_track_pl_name
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 task_pl_folder_mode: if task_pl_folder_mode:
track_info['_playlist_folder_mode'] = True track_info['_playlist_folder_mode'] = True
track_info['_playlist_name'] = task_pl_name track_info['_playlist_name'] = task_pl_name

View file

@ -2,8 +2,9 @@
from __future__ import annotations from __future__ import annotations
import json
import os 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.downloads.file_finder import AUDIO_EXTENSIONS
from core.imports.paths import ( from core.imports.paths import (
@ -75,13 +76,66 @@ def track_exists_in_playlist_folder(
artist: str, artist: str,
title: str, title: str,
) -> bool: ) -> bool:
"""Return True if any audio file exists at the playlist-folder path for this track.""" """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):
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): if os.path.isfile(path):
return True 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 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( def track_exists_in_playlist_folder_from_track_data(
playlist_name: str, playlist_name: str,
track_data: Dict[str, Any], 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) 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( def resolve_playlist_folder_mode_for_batch(
db: Any, db: Any,
*, *,
playlist_id: str, playlist_id: str,
playlist_name: str, playlist_name: str,
batch_playlist_folder_mode: bool, batch_playlist_folder_mode: bool,
batch_keep_playlist_folder_copies: bool = False,
profile_id: int = 1, profile_id: int = 1,
source: str = 'spotify', source: str = 'spotify',
) -> tuple[bool, str]: active_server: str = '',
"""Merge batch flag with persisted mirrored-playlist preference.""" ) -> tuple[bool, str, bool]:
if batch_playlist_folder_mode: """Merge batch flags with persisted mirrored-playlist preferences.
return True, playlist_name
if not hasattr(db, 'resolve_mirrored_playlist'): Returns ``(folder_mode, effective_playlist_name, keep_folder_copies)``.
return False, playlist_name """
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 keep = effective_keep_playlist_folder_copies(
# source instead of colliding with the mirrored-playlists primary key. mirrored,
mirrored = db.resolve_mirrored_playlist( active_server,
playlist_id, profile_id=profile_id, default_source=source or 'spotify' 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'): if mirrored and mirrored.get('organize_by_playlist'):
return True, mirrored.get('name') or playlist_name return True, mirrored.get('name') or playlist_name, keep
return False, playlist_name
return False, playlist_name, False
__all__ = [ __all__ = [
'candidate_playlist_folder_paths', 'candidate_playlist_folder_paths',
'effective_keep_playlist_folder_copies',
'is_soulsync_standalone_server',
'track_exists_in_playlist_folder', 'track_exists_in_playlist_folder',
'track_exists_in_playlist_folder_from_track_data', 'track_exists_in_playlist_folder_from_track_data',
'resolve_wishlist_track_playlist_folder_mode',
'resolve_playlist_folder_mode_for_batch', 'resolve_playlist_folder_mode_for_batch',
] ]

View file

@ -113,8 +113,10 @@ def _run_duplicate_cleaner():
if file_ext_lower not in audio_extensions: if file_ext_lower not in audio_extensions:
continue continue
# Group by directory and filename (without extension) # Group by directory and normalised filename (without extension,
files_by_dir_and_name[root][file_name].append({ # 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, 'full_path': file_path,
'extension': file_ext_lower, 'extension': file_ext_lower,
'size': os.path.getsize(file_path) 'size': os.path.getsize(file_path)

View file

@ -125,6 +125,7 @@ def run_playlist_organize_download(
'force_download_all': False, 'force_download_all': False,
'ignore_manual_matches': False, 'ignore_manual_matches': False,
'playlist_folder_mode': True, 'playlist_folder_mode': True,
'keep_playlist_folder_copies': bool(pl.get('keep_playlist_folder_copies')),
'is_album_download': False, 'is_album_download': False,
'album_context': None, 'album_context': None,
'artist_context': None, 'artist_context': None,

View file

@ -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: if batch.get('mirrored_playlist_id') is not None:
context['mirrored_playlist_id'] = batch.get('mirrored_playlist_id') 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 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 # Preserve album-batch provenance so wishlist requeue has a real signal
# for album-vs-single routing instead of relying on per-track album dicts # for album-vs-single routing instead of relying on per-track album dicts
# that may have been mangled by reconstruction fallbacks. # that may have been mangled by reconstruction fallbacks.

View file

@ -579,6 +579,7 @@ class MusicDatabase:
# Add explored_at to mirrored_playlists (migration) # Add explored_at to mirrored_playlists (migration)
self._add_mirrored_playlist_explored_column(cursor) self._add_mirrored_playlist_explored_column(cursor)
self._add_mirrored_playlist_organize_column(cursor) self._add_mirrored_playlist_organize_column(cursor)
self._add_mirrored_playlist_keep_copies_column(cursor)
# Add notification columns to automations (migration) # Add notification columns to automations (migration)
self._add_automation_notify_columns(cursor) self._add_automation_notify_columns(cursor)
@ -1251,6 +1252,24 @@ class MusicDatabase:
except Exception as e: except Exception as e:
logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {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): def _add_automation_notify_columns(self, cursor):
"""Add notification and result columns to automations table.""" """Add notification and result columns to automations table."""
try: try:
@ -13655,6 +13674,11 @@ class MusicDatabase:
return None return None
pl = dict(row) pl = dict(row)
pl['organize_by_playlist'] = bool(pl.get('organize_by_playlist', 0)) 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 return pl
def get_mirrored_playlist_by_source( def get_mirrored_playlist_by_source(
@ -13714,21 +13738,56 @@ class MusicDatabase:
enabled: bool, enabled: bool,
) -> bool: ) -> bool:
"""Persist whether downloads for this playlist use playlist-folder layout.""" """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: 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: with self._get_connection() as conn:
cursor = conn.cursor() cursor = conn.cursor()
cursor.execute( cursor.execute(
""" """
UPDATE mirrored_playlists 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 = ? 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() conn.commit()
return cursor.rowcount > 0 return cursor.rowcount > 0
except Exception as e: 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 return False
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]: def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:

View file

@ -326,6 +326,59 @@ def test_analysis_phase_sets_state(monkeypatch):
assert len(download_batches['B1']['analysis_results']) == 1 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): def test_force_download_treats_all_as_missing(monkeypatch):
"""force_download_all skips DB check — every track marked missing.""" """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 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' 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 # Hand-off to monitor + start_next_batch
# --------------------------------------------------------------------------- # ---------------------------------------------------------------------------

View file

@ -7,7 +7,9 @@ import pytest
from core.downloads.playlist_folder import ( from core.downloads.playlist_folder import (
candidate_playlist_folder_paths, candidate_playlist_folder_paths,
effective_keep_playlist_folder_copies,
resolve_playlist_folder_mode_for_batch, resolve_playlist_folder_mode_for_batch,
resolve_wishlist_track_playlist_folder_mode,
track_exists_in_playlist_folder, 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') 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): def test_track_exists_in_playlist_folder_missing(tmp_path):
with patch('core.downloads.playlist_folder._get_config_manager') as cfg: with patch('core.downloads.playlist_folder._get_config_manager') as cfg:
cfg.return_value.get.return_value = str(tmp_path) cfg.return_value.get.return_value = str(tmp_path)
@ -66,7 +87,7 @@ def test_resolve_playlist_folder_mode_from_mirrored():
'name': 'Rekordbox Set', 'name': 'Rekordbox Set',
'organize_by_playlist': True, 'organize_by_playlist': True,
}) })
enabled, name = resolve_playlist_folder_mode_for_batch( enabled, name, keep = resolve_playlist_folder_mode_for_batch(
db, db,
playlist_id='37i9dQZF1', playlist_id='37i9dQZF1',
playlist_name='Other Name', playlist_name='Other Name',
@ -74,11 +95,12 @@ def test_resolve_playlist_folder_mode_from_mirrored():
) )
assert enabled is True assert enabled is True
assert name == 'Rekordbox Set' assert name == 'Rekordbox Set'
assert keep is False
def test_resolve_playlist_folder_mode_batch_flag(): def test_resolve_playlist_folder_mode_batch_flag():
db = _FakeDB() db = _FakeDB()
enabled, name = resolve_playlist_folder_mode_for_batch( enabled, name, keep = resolve_playlist_folder_mode_for_batch(
db, db,
playlist_id='1', playlist_id='1',
playlist_name='Batch Name', playlist_name='Batch Name',
@ -86,3 +108,76 @@ def test_resolve_playlist_folder_mode_batch_flag():
) )
assert enabled is True assert enabled is True
assert name == 'Batch Name' 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'

View file

@ -193,6 +193,39 @@ def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_bat
assert context["organize_by_playlist"] is True 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(): def test_build_wishlist_source_context_preserves_album_context_for_album_batches():
"""Album batches must carry album_context/artist_context through to the """Album batches must carry album_context/artist_context through to the
wishlist row so a later requeue has authoritative routing data instead wishlist row so a later requeue has authoritative routing data instead

View file

@ -435,6 +435,41 @@ def test_get_wishlist_cycle_returns_stored_value():
assert payload == {"cycle": "singles"} 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(): def test_add_album_track_to_wishlist_builds_spotify_payload_and_merges_context():
runtime, service, _db, _logger, _activity_calls = _build_runtime() runtime, service, _db, _logger, _activity_calls = _build_runtime()
track = { track = {

View file

@ -19825,6 +19825,13 @@ def start_missing_tracks_process(playlist_id):
playlist_name = data.get('playlist_name', 'Unknown Playlist') playlist_name = data.get('playlist_name', 'Unknown Playlist')
force_download_all = data.get('force_download_all', False) force_download_all = data.get('force_download_all', False)
playlist_folder_mode = data.get('playlist_folder_mode', 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) wing_it = data.get('wing_it', False)
ignore_manual_matches = data.get('ignore_manual_matches') ignore_manual_matches = data.get('ignore_manual_matches')
if ignore_manual_matches is None: if ignore_manual_matches is None:
@ -19888,10 +19895,12 @@ def start_missing_tracks_process(playlist_id):
default_source='spotify', default_source='spotify',
) )
if mirrored_pl and mirrored_pl.get('id'): if mirrored_pl and mirrored_pl.get('id'):
db_pref.set_mirrored_playlist_organize_by_playlist( pref_kwargs = {'organize_by_playlist': bool(playlist_folder_mode)}
int(mirrored_pl['id']), if not playlist_folder_mode:
bool(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: except Exception as pref_err:
logger.debug(f"[Playlist Folder] Could not persist mirrored preference: {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. # at the modal, so the per-track filter (2a) skips this batch.
'ignore_blocklist': ignore_blocklist, 'ignore_blocklist': ignore_blocklist,
'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder '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) # Album context for artist album downloads (explicit folder structure)
'is_album_download': is_album_download, 'is_album_download': is_album_download,
'album_context': album_context, 'album_context': album_context,
@ -33597,19 +33608,26 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id):
@app.route('/api/mirrored-playlists/<int:playlist_id>/preferences', methods=['PATCH']) @app.route('/api/mirrored-playlists/<int:playlist_id>/preferences', methods=['PATCH'])
def update_mirrored_playlist_preferences_endpoint(playlist_id): 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: try:
data = request.get_json() or {} data = request.get_json() or {}
if 'organize_by_playlist' not in data: if 'organize_by_playlist' not in data and 'keep_playlist_folder_copies' not in data:
return jsonify({"error": "organize_by_playlist is required"}), 400 return jsonify({"error": "At least one preference field is required"}), 400
database = get_database() database = get_database()
playlist = database.get_mirrored_playlist(playlist_id) playlist = database.get_mirrored_playlist(playlist_id)
if not playlist: if not playlist:
return jsonify({"error": "Playlist not found"}), 404 return jsonify({"error": "Playlist not found"}), 404
enabled = bool(data.get('organize_by_playlist')) kwargs = {}
ok = database.set_mirrored_playlist_organize_by_playlist(playlist_id, enabled) 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: if not ok:
return jsonify({"error": "Failed to update preferences"}), 500 return jsonify({"error": "Failed to update preferences"}), 500

View file

@ -1120,7 +1120,15 @@
<div class="sync-tab-content" id="spotify-tab-content"> <div class="sync-tab-content" id="spotify-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Spotify Playlists</h3> <h3>Your Spotify Playlists</h3>
<button class="refresh-button" id="spotify-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="spotify"
onclick="selectAllSyncPlaylists('spotify')" disabled title="Select all loaded playlists">Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled title="Clear playlist selection">Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled title="Sync all selected playlists">Sync selected</button>
<button class="refresh-button" id="spotify-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="spotify-playlist-container"> <div class="playlist-scroll-container" id="spotify-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Spotify playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Spotify playlists.</div>
@ -1131,7 +1139,15 @@
<div class="sync-tab-content" id="tidal-tab-content"> <div class="sync-tab-content" id="tidal-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Tidal Playlists</h3> <h3>Your Tidal Playlists</h3>
<button class="refresh-button tidal" id="tidal-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="tidal"
onclick="selectAllSyncPlaylists('tidal')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button tidal" id="tidal-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="tidal-playlist-container"> <div class="playlist-scroll-container" id="tidal-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Tidal playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Tidal playlists.</div>
@ -1142,7 +1158,15 @@
<div class="sync-tab-content" id="deezer-tab-content"> <div class="sync-tab-content" id="deezer-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Deezer Playlists</h3> <h3>Your Deezer Playlists</h3>
<button class="refresh-button deezer" id="deezer-arl-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="deezer"
onclick="selectAllSyncPlaylists('deezer')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button deezer" id="deezer-arl-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="deezer-arl-playlist-container"> <div class="playlist-scroll-container" id="deezer-arl-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Deezer playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Deezer playlists.</div>
@ -1153,7 +1177,15 @@
<div class="sync-tab-content" id="qobuz-tab-content"> <div class="sync-tab-content" id="qobuz-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Qobuz Playlists</h3> <h3>Your Qobuz Playlists</h3>
<button class="refresh-button qobuz" id="qobuz-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="qobuz"
onclick="selectAllSyncPlaylists('qobuz')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button qobuz" id="qobuz-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="qobuz-playlist-container"> <div class="playlist-scroll-container" id="qobuz-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Qobuz playlists.</div>
@ -1168,6 +1200,14 @@
<button id="deezer-parse-btn">Load Playlist</button> <button id="deezer-parse-btn">Load Playlist</button>
</div> </div>
<div class="url-history-bar" id="deezer-url-history" style="display:none"></div> <div class="url-history-bar" id="deezer-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="deezer-link"
onclick="selectAllSyncPlaylists('deezer-link')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="deezer-playlist-container"> <div class="playlist-scroll-container" id="deezer-playlist-container">
<div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div> <div class="playlist-placeholder">Paste a Deezer playlist URL above to get started.</div>
</div> </div>
@ -1181,6 +1221,14 @@
<button id="youtube-parse-btn">Parse Playlist</button> <button id="youtube-parse-btn">Parse Playlist</button>
</div> </div>
<div class="url-history-bar" id="youtube-url-history" style="display:none"></div> <div class="url-history-bar" id="youtube-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="youtube"
onclick="selectAllSyncPlaylists('youtube')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="youtube-playlist-container"> <div class="playlist-scroll-container" id="youtube-playlist-container">
<div class="playlist-placeholder">Parsed YouTube playlists will appear here.</div> <div class="playlist-placeholder">Parsed YouTube playlists will appear here.</div>
</div> </div>
@ -1194,6 +1242,14 @@
<button id="spotify-public-parse-btn">Load</button> <button id="spotify-public-parse-btn">Load</button>
</div> </div>
<div class="url-history-bar" id="spotify-public-url-history" style="display:none"></div> <div class="url-history-bar" id="spotify-public-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="spotify-public"
onclick="selectAllSyncPlaylists('spotify-public')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="spotify-public-playlist-container"> <div class="playlist-scroll-container" id="spotify-public-playlist-container">
<div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div> <div class="playlist-placeholder">Paste a Spotify playlist or album URL above to load tracks without needing Spotify API credentials.</div>
</div> </div>
@ -1207,6 +1263,14 @@
<button id="itunes-link-parse-btn">Load</button> <button id="itunes-link-parse-btn">Load</button>
</div> </div>
<div class="url-history-bar" id="itunes-link-url-history" style="display:none"></div> <div class="url-history-bar" id="itunes-link-url-history" style="display:none"></div>
<div class="playlist-bulk-toolbar">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="itunes-link"
onclick="selectAllSyncPlaylists('itunes-link')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
<div class="playlist-scroll-container" id="itunes-link-playlist-container"> <div class="playlist-scroll-container" id="itunes-link-playlist-container">
<div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div> <div class="playlist-placeholder">Paste an iTunes or Apple Music album/track URL above to load tracks.</div>
</div> </div>
@ -1628,7 +1692,15 @@
<div class="beatport-tab-content" id="beatport-playlists-content"> <div class="beatport-tab-content" id="beatport-playlists-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>My Beatport Playlists</h3> <h3>My Beatport Playlists</h3>
<button class="refresh-button beatport" id="beatport-clear-btn">🗑️ Clear</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="beatport"
onclick="selectAllSyncPlaylists('beatport')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button beatport" id="beatport-clear-btn">🗑️ Clear charts</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="beatport-playlist-container"> <div class="playlist-scroll-container" id="beatport-playlist-container">
<div class="playlist-placeholder">Your created Beatport playlists will appear here. <div class="playlist-placeholder">Your created Beatport playlists will appear here.
@ -1997,7 +2069,15 @@
<div class="sync-tab-content" id="soulsync-discovery-sync-tab-content"> <div class="sync-tab-content" id="soulsync-discovery-sync-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>SoulSync Discovery Playlists</h3> <h3>SoulSync Discovery Playlists</h3>
<button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="soulsync-discovery-sync"
onclick="selectAllSyncPlaylists('soulsync-discovery-sync')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button soulsync-discovery" id="soulsync-discovery-sync-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container"> <div class="playlist-scroll-container" id="soulsync-discovery-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your personalized SoulSync Discovery playlists.</div>
@ -2008,7 +2088,15 @@
<div class="sync-tab-content" id="lastfm-sync-tab-content"> <div class="sync-tab-content" id="lastfm-sync-tab-content">
<div class="playlist-header"> <div class="playlist-header">
<h3>Your Last.fm Radio Playlists</h3> <h3>Your Last.fm Radio Playlists</h3>
<button class="refresh-button lastfm" id="lastfm-sync-refresh-btn">🔄 Refresh</button> <div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="lastfm-sync"
onclick="selectAllSyncPlaylists('lastfm-sync')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button lastfm" id="lastfm-sync-refresh-btn">🔄 Refresh</button>
</div>
</div> </div>
<div class="playlist-scroll-container" id="lastfm-sync-playlist-container"> <div class="playlist-scroll-container" id="lastfm-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.</div> <div class="playlist-placeholder">Click 'Refresh' to load your Last.fm Radio playlists. Generate new ones from the Discover page.</div>
@ -2017,14 +2105,22 @@
<!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) --> <!-- ListenBrainz Sync Tab Content (separate ID from Discover-page LB UI) -->
<div class="sync-tab-content" id="listenbrainz-sync-tab-content"> <div class="sync-tab-content" id="listenbrainz-sync-tab-content">
<div class="playlist-header"> <div class="playlist-header playlist-header--listenbrainz">
<h3>Your ListenBrainz Playlists</h3> <h3>Your ListenBrainz Playlists</h3>
<div class="playlist-header-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-select-all-btn" data-sync-source="listenbrainz-sync"
onclick="selectAllSyncPlaylists('listenbrainz-sync')" disabled>Select all</button>
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()" disabled>Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
</div>
<div class="listenbrainz-sub-tabs"> <div class="listenbrainz-sub-tabs">
<button class="listenbrainz-sub-tab-btn active" data-lb-type="created_for_user">For You</button> <button class="listenbrainz-sub-tab-btn active" data-lb-type="created_for_user">For You</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="user_created">My Playlists</button> <button class="listenbrainz-sub-tab-btn" data-lb-type="user_created">My Playlists</button>
<button class="listenbrainz-sub-tab-btn" data-lb-type="collaborative">Collaborative</button> <button class="listenbrainz-sub-tab-btn" data-lb-type="collaborative">Collaborative</button>
</div> </div>
<button class="refresh-button listenbrainz" id="listenbrainz-sync-refresh-btn">🔄 Refresh</button>
</div> </div>
<div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container"> <div class="playlist-scroll-container" id="listenbrainz-sync-playlist-container">
<div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div> <div class="playlist-placeholder">Click 'Refresh' to load your ListenBrainz playlists.</div>
@ -2130,6 +2226,20 @@
</div> </div>
</div> </div>
</div> </div>
<!-- Bulk actions when Spotify playlists are selected (sidebar is hidden until sync starts) -->
<div class="sync-playlist-bulk-bar" id="sync-playlist-bulk-bar" hidden>
<div class="sync-playlist-bulk-bar-info">
<span class="sync-playlist-bulk-count" id="sync-playlist-bulk-count">0</span>
<span class="sync-playlist-bulk-label" id="sync-playlist-bulk-label">playlists selected</span>
</div>
<div class="sync-playlist-bulk-bar-actions">
<button type="button" class="btn btn--sm btn--secondary sync-playlist-clear-btn"
onclick="clearSyncPlaylistSelection()">Clear</button>
<button type="button" class="btn btn--sm btn--primary sync-playlist-start-btn"
onclick="startSequentialSync()" disabled>Sync selected</button>
</div>
</div>
</div> </div>
</div> </div>
@ -8336,6 +8446,7 @@
<script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-listenbrainz.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-lastfm.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='sync-soulsync-discovery.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='sync-bulk-selection.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='api-monitor.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='library.js', v=static_v) }}"></script>
<script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script> <script src="{{ url_for('static', filename='beatport-ui.js', v=static_v) }}"></script>

View file

@ -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) { 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 ` return `
<label class="auto-sync-organize-toggle" onclick="event.stopPropagation();" title="Download missing tracks into a playlist-named folder (artist - track)"> <div class="auto-sync-organize-toggles" onclick="event.stopPropagation();">
<input type="checkbox" ${checked} onchange="setAutoSyncOrganizeByPlaylist(${playlist.id}, this.checked)"> <label class="auto-sync-organize-toggle" title="Download missing tracks into a playlist-named folder (artist - track)">
<span>Organize by playlist</span> <input type="checkbox" ${organizeChecked} onchange="setAutoSyncOrganizeByPlaylist(${playlist.id}, this.checked)">
</label> <span>Organize by playlist</span>
</label>
<label class="auto-sync-organize-toggle auto-sync-organize-subtoggle ${keepClass}" title="Also download a file into this playlist folder when the track already exists in your library (e.g. Rekordbox USB sets)">
<input type="checkbox" ${keepChecked} ${keepDisabled}
onchange="setAutoSyncKeepPlaylistFolderCopies(${playlist.id}, this.checked)">
<span>Keep folder copies</span>
</label>
</div>
`; `;
} }
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) { async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) {
try { try {
const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { const body = { organize_by_playlist: !!enabled };
method: 'PATCH', if (!enabled) {
headers: { 'Content-Type': 'application/json' }, body.keep_playlist_folder_copies = false;
body: JSON.stringify({ organize_by_playlist: !!enabled }), } else if (typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode()) {
}); body.keep_playlist_folder_copies = true;
const data = await res.json(); }
if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference'); await patchAutoSyncMirroredPreferences(playlistId, body);
const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
if (pl) pl.organize_by_playlist = !!enabled;
showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success'); showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success');
} catch (err) { } catch (err) {
showToast(`Error: ${err.message}`, 'error'); 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) { function autoSyncScheduledCardHtml(playlist, schedule) {
const enabled = schedule?.enabled !== false; const enabled = schedule?.enabled !== false;
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';

View file

@ -806,14 +806,14 @@ class SequentialSyncManager {
this.startTime = null; this.startTime = null;
} }
start(playlistIds) { start(playlistIds, bulkConfig = null) {
if (this.isRunning) { if (this.isRunning) {
console.warn('Sequential sync already running'); console.warn('Sequential sync already running');
return; return;
} }
// Convert playlist IDs to ordered array (maintain display order)
this.queue = Array.from(playlistIds); this.queue = Array.from(playlistIds);
this.bulkConfig = bulkConfig;
this.currentIndex = 0; this.currentIndex = 0;
this.isRunning = true; this.isRunning = true;
this.startTime = Date.now(); this.startTime = Date.now();
@ -830,42 +830,27 @@ class SequentialSyncManager {
} }
const playlistId = this.queue[this.currentIndex]; const playlistId = this.queue[this.currentIndex];
const playlist = spotifyPlaylists.find(p => p.id === playlistId); const label = this.bulkConfig?.getName(playlistId)
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${playlist?.name || playlistId}`); || spotifyPlaylists.find(p => p.id === playlistId)?.name
|| playlistId;
console.log(`🔄 Sequential sync: Processing playlist ${this.currentIndex + 1}/${this.queue.length}: ${label}`);
this.updateUI(); this.updateUI();
try { try {
// Use existing single sync function if (this.bulkConfig?.process) {
await startPlaylistSync(playlistId); await this.bulkConfig.process(playlistId);
} else {
// Wait for sync to complete by monitoring the poller await startPlaylistSync(playlistId);
await this.waitForSyncCompletion(playlistId); await waitForSyncPollerCompletion(playlistId);
}
} catch (error) { } catch (error) {
console.error(`❌ Sequential sync: Failed to sync playlist ${playlistId}:`, 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++; this.currentIndex++;
setTimeout(() => this.syncNext(), 1000); // Small delay between syncs setTimeout(() => this.syncNext(), 1000);
}
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();
});
} }
complete() { complete() {
@ -875,14 +860,14 @@ class SequentialSyncManager {
this.isRunning = false; this.isRunning = false;
this.queue = []; this.queue = [];
this.bulkConfig = null;
this.currentIndex = 0; this.currentIndex = 0;
this.startTime = null; this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false); disablePlaylistSelection(false);
this.updateUI(); this.updateUI();
updateRefreshButtonState(); // Refresh button state after completion updateRefreshButtonState();
showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success'); showToast(`Sequential sync completed for ${completedCount} playlists in ${duration}s`, 'success');
// Hide sidebar after completion // Hide sidebar after completion
@ -895,14 +880,14 @@ class SequentialSyncManager {
console.log('🛑 Cancelling sequential sync'); console.log('🛑 Cancelling sequential sync');
this.isRunning = false; this.isRunning = false;
this.queue = []; this.queue = [];
this.bulkConfig = null;
this.currentIndex = 0; this.currentIndex = 0;
this.startTime = null; this.startTime = null;
// Re-enable playlist selection
disablePlaylistSelection(false); disablePlaylistSelection(false);
this.updateUI(); this.updateUI();
updateRefreshButtonState(); // Refresh button state after cancellation updateRefreshButtonState();
showToast('Sequential sync cancelled', 'info'); showToast('Sequential sync cancelled', 'info');
// Hide sidebar after cancellation // Hide sidebar after cancellation
@ -910,33 +895,8 @@ class SequentialSyncManager {
} }
updateUI() { updateUI() {
const startSyncBtn = document.getElementById('start-sync-btn'); if (typeof updateSyncActionsUI === 'function') {
const selectionInfo = document.getElementById('selection-info'); updateSyncActionsUI();
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'}`;
}
} }
} }
} }

View file

@ -2469,6 +2469,9 @@ async function startMissingTracksProcess(playlistId) {
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function' const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
? isPlaylistOrganizeEnabled(playlistId) ? isPlaylistOrganizeEnabled(playlistId)
: (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false); : (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 // Hide the force download toggle during processing
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
@ -2528,8 +2531,12 @@ async function startMissingTracksProcess(playlistId) {
requestBody.playlist_name = process.playlist.name; requestBody.playlist_name = process.playlist.name;
// Add playlist folder mode flag for sync page playlists // Add playlist folder mode flag for sync page playlists
requestBody.playlist_folder_mode = playlistFolderMode; requestBody.playlist_folder_mode = playlistFolderMode;
requestBody.keep_playlist_folder_copies = playlistFolderMode && keepPlaylistFolderCopies;
if (playlistFolderMode) { if (playlistFolderMode) {
console.log(`📁 [Playlist Folder] Enabled for playlist: ${process.playlist.name}`); 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; 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) { if (selectedPlaylists.size === 0) {
showToast('No playlists selected for sync', 'error'); showToast('No playlists selected for sync', 'error');
return; return;
} }
// Get playlist order from DOM to maintain display order const orderedPlaylistIds = typeof getOrderedSelectedPlaylistIds === 'function'
const playlistCards = document.querySelectorAll('.playlist-card'); ? getOrderedSelectedPlaylistIds()
const orderedPlaylistIds = []; : [];
playlistCards.forEach(card => { if (!orderedPlaylistIds.length) {
const playlistId = card.dataset.playlistId; showToast('No playlists selected for sync', 'error');
if (selectedPlaylists.has(playlistId)) { return;
orderedPlaylistIds.push(playlistId); }
}
});
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(); showSyncSidebar();
sequentialSyncManager.start(orderedPlaylistIds, bulkConfig);
// Start sequential sync
sequentialSyncManager.start(orderedPlaylistIds);
// Disable playlist selection during sync
disablePlaylistSelection(true); disablePlaylistSelection(true);
} }
function disablePlaylistSelection(disabled) {
const checkboxes = document.querySelectorAll('.playlist-checkbox');
checkboxes.forEach(checkbox => {
checkbox.disabled = disabled;
});
}
function hasActiveOperations() { function hasActiveOperations() {
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0; const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
// Only check non-wishlist download processes for sync page refresh button // Only check non-wishlist download processes for sync page refresh button

View file

@ -817,7 +817,15 @@ const HELPER_CONTENT = {
'#start-sync-btn': { '#start-sync-btn': {
title: 'Start Sync', title: 'Start Sync',
description: 'Begin downloading missing tracks from all selected playlists. Playlists are processed sequentially — each one completes before the next starts.', 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': { '#sync-log-area': {
title: 'Sync Log', 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.' }, { 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 // 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 // 'artists-browse' tour retired — the Artists sidebar entry was replaced by the

View file

@ -979,6 +979,10 @@ function playlistDetailsOrganizeCheckboxId(playlistRef) {
return `playlist-organize-${playlistRef}`; return `playlist-organize-${playlistRef}`;
} }
function playlistDetailsKeepCopiesCheckboxId(playlistRef) {
return `playlist-keep-copies-${playlistRef}`;
}
/** Infer mirrored-playlist API source from a UI playlist / virtual id. */ /** Infer mirrored-playlist API source from a UI playlist / virtual id. */
function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) { function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) {
if (explicitSource) { if (explicitSource) {
@ -991,6 +995,15 @@ function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) {
if (ref.startsWith('deezer_arl_')) { if (ref.startsWith('deezer_arl_')) {
return 'deezer'; return 'deezer';
} }
if (ref.startsWith('tidal_')) {
return 'tidal';
}
if (ref.startsWith('youtube_')) {
return 'youtube';
}
if (ref.startsWith('qobuz_')) {
return 'qobuz';
}
return 'spotify'; return 'spotify';
} }
@ -1008,29 +1021,59 @@ function normalizePlaylistOrganizeRef(playlistRef, source = 'spotify') {
function downloadMissingModalOrganizeCheckboxHtml(playlistId) { function downloadMissingModalOrganizeCheckboxHtml(playlistId) {
return ` return `
<label class="force-download-toggle"> <div class="playlist-organize-pref-group">
<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync"> <label class="force-download-toggle">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span> <input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync"
</label>`; onchange="onDownloadMissingOrganizeToggle('${String(playlistId).replace(/'/g, "\\'")}', this.checked)">
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
</label>
<label class="force-download-toggle playlist-organize-subtoggle is-disabled" id="playlist-keep-copies-wrap-${playlistId}">
<input type="checkbox" id="playlist-keep-copies-mode-${playlistId}" disabled
onchange="onDownloadMissingKeepCopiesToggle('${String(playlistId).replace(/'/g, "\\'")}', this.checked)">
<span>Keep folder copies (download even if already in library)</span>
</label>
</div>`;
} }
function playlistOrganizeToggleHtml(playlistRef, source = 'spotify') { function playlistOrganizeToggleHtml(playlistRef, source = 'spotify') {
const safeRef = String(playlistRef).replace(/'/g, "\\'"); const safeRef = String(playlistRef).replace(/'/g, "\\'");
const safeSource = String(source).replace(/'/g, "\\'"); const safeSource = String(source).replace(/'/g, "\\'");
return ` return `
<label class="playlist-modal-organize-toggle" title="Download into a playlist-named folder (Artist - Track) under your transfer path"> <div class="playlist-organize-pref-group">
<input type="checkbox" id="${playlistDetailsOrganizeCheckboxId(playlistRef)}" <label class="playlist-modal-organize-toggle" title="Download into a playlist-named folder (Artist - Track) under your transfer path">
onchange="onPlaylistOrganizePreferenceChange('${safeRef}', this.checked, '${safeSource}')"> <input type="checkbox" id="${playlistDetailsOrganizeCheckboxId(playlistRef)}"
<span>Organize by playlist</span> onchange="onPlaylistOrganizePreferenceChange('${safeRef}', this.checked, '${safeSource}')">
</label> <span>Organize by playlist</span>
</label>
<label class="playlist-modal-organize-toggle playlist-organize-subtoggle is-disabled"
id="${playlistDetailsKeepCopiesCheckboxId(playlistRef)}-wrap" title="Download into this playlist folder even when the track is already in your library">
<input type="checkbox" id="${playlistDetailsKeepCopiesCheckboxId(playlistRef)}" disabled
onchange="onPlaylistKeepCopiesPreferenceChange('${safeRef}', this.checked, '${safeSource}')">
<span>Keep folder copies</span>
</label>
</div>
`; `;
} }
function syncPlaylistOrganizeCheckboxes(playlistRef, enabled) { function syncPlaylistOrganizeCheckboxes(playlistRef, organizeEnabled, keepCopiesEnabled = false) {
const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef)); const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef));
const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`); const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`);
if (detailsCb) detailsCb.checked = !!enabled; const detailsKeep = document.getElementById(playlistDetailsKeepCopiesCheckboxId(playlistRef));
if (downloadMissingCb) downloadMissingCb.checked = !!enabled; 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) { function isPlaylistOrganizeEnabled(playlistRef) {
@ -1040,42 +1083,96 @@ function isPlaylistOrganizeEnabled(playlistRef) {
return downloadMissingCb ? downloadMissingCb.checked : false; 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) { async function fetchMirroredOrganizePreference(playlistRef, source = null) {
try { try {
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); const pl = await resolveMirroredPlaylistForRef(playlistRef, source);
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); return !!pl?.organize_by_playlist;
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);
} catch (err) { } catch (err) {
console.debug('Could not load organize-by-playlist preference:', err); console.debug('Could not load organize-by-playlist preference:', err);
return false; 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) { async function setMirroredOrganizePreference(playlistRef, enabled, source = null) {
try { try {
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); const pl = await resolveMirroredPlaylistForRef(playlistRef, source);
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); if (!pl?.id) {
const res = await fetch( return false;
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}` }
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; return true;
} catch (err) { } catch (err) {
console.debug('Could not save organize-by-playlist preference:', 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) { async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = null) {
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); const prefs = await fetchMirroredPlaylistFolderPreferences(playlistRef, source);
const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource); syncPlaylistOrganizeCheckboxes(playlistRef, prefs.organize, prefs.keepCopies);
syncPlaylistOrganizeCheckboxes(playlistRef, enabled);
} }
async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source = 'spotify') { 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); const ok = await setMirroredOrganizePreference(playlistRef, enabled, source);
if (!ok) { if (!ok) {
showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning'); 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) { async function applyMirroredOrganizePreference(playlistRef, source = null) {
await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source); await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source);
} }

View file

@ -12179,21 +12179,47 @@ body.helper-mode-active #dashboard-activity-feed:hover {
line-height: 1.3; line-height: 1.3;
} }
.auto-sync-organize-toggles {
margin-top: 6px;
}
.auto-sync-organize-toggle { .auto-sync-organize-toggle {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 6px; gap: 6px;
margin-top: 6px;
font-size: 11px; font-size: 11px;
color: rgba(255, 255, 255, 0.72); color: rgba(255, 255, 255, 0.72);
cursor: pointer; cursor: pointer;
user-select: none; 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 { .auto-sync-organize-toggle input {
margin: 0; 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 { .auto-sync-scheduled-timing {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
@ -15739,6 +15765,136 @@ body.helper-mode-active #dashboard-activity-feed:hover {
color: #ffffff; 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 { .refresh-button {
background: rgb(var(--accent-rgb)); background: rgb(var(--accent-rgb));
border: none; border: none;

View file

@ -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();
}
}

View file

@ -102,18 +102,20 @@ function renderLastfmSyncPlaylists() {
`; `;
}).join(''); }).join('');
container.querySelectorAll('.lastfm-playlist-card').forEach(card => { if (typeof wirePhaseSyncCards === 'function') {
card.addEventListener('click', () => { wirePhaseSyncCards(
const mbid = card.dataset.lbMbid; 'lastfm-sync',
const title = card.dataset.lbTitle; '#lastfm-sync-playlist-container',
// Reuses the LB Sync-tab click handler — Last.fm radios are '.lastfm-playlist-card',
// stored in the same table + matched by the same discovery card => card.dataset.lbMbid,
// worker, so the click flow is byte-identical. mbid => {
if (typeof handleListenBrainzSyncCardClick === 'function') { const c = document.querySelector(`#lastfm-sync-card-${CSS.escape(mbid)}`);
handleListenBrainzSyncCardClick(mbid, title); if (typeof handleListenBrainzSyncCardClick === 'function') {
handleListenBrainzSyncCardClick(mbid, c?.dataset.lbTitle || '');
}
} }
}); );
}); }
// Reuse the shared refresh loop from sync-listenbrainz.js — it // Reuse the shared refresh loop from sync-listenbrainz.js — it
// already iterates Last.fm cards alongside LB cards. // already iterates Last.fm cards alongside LB cards.

View file

@ -139,13 +139,18 @@ function renderListenBrainzSyncPlaylists() {
}).join(''); }).join('');
// Wire click handlers. // Wire click handlers.
container.querySelectorAll('.listenbrainz-playlist-card').forEach(card => { if (typeof wirePhaseSyncCards === 'function') {
card.addEventListener('click', () => { wirePhaseSyncCards(
const mbid = card.dataset.lbMbid; 'listenbrainz-sync',
const title = card.dataset.lbTitle; '#listenbrainz-sync-playlist-container',
handleListenBrainzSyncCardClick(mbid, title); '.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 // If the tab is currently visible, kick the refresh loop so cards
// start showing live state immediately. ``_startLbSyncCardRefreshLoop`` // start showing live state immediately. ``_startLbSyncCardRefreshLoop``

View file

@ -89,13 +89,15 @@ function renderTidalPlaylists() {
return createTidalCard(p); return createTidalCard(p);
}).join(''); }).join('');
// Add click handlers to cards if (typeof wirePhaseSyncCards === 'function') {
tidalPlaylists.forEach(p => { wirePhaseSyncCards(
const card = document.getElementById(`tidal-card-${p.id}`); 'tidal',
if (card) { '#tidal-playlist-container',
card.addEventListener('click', () => handleTidalCardClick(p.id)); '.tidal-playlist-card',
} card => card.id.replace(/^tidal-card-/, ''),
}); handleTidalCardClick
);
}
} }
function createTidalCard(playlist) { function createTidalCard(playlist) {
@ -1495,7 +1497,8 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
: 'spotify'; : 'spotify';
await applyMirroredOrganizePreference(virtualPlaylistId, orgSource); await applyMirroredOrganizePreference(virtualPlaylistId, orgSource);
if (options.forcePlaylistFolder) { if (options.forcePlaylistFolder) {
syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true); const defaultKeep = typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode();
syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true, defaultKeep);
if (typeof setMirroredOrganizePreference === 'function') { if (typeof setMirroredOrganizePreference === 'function') {
await setMirroredOrganizePreference(virtualPlaylistId, true, orgSource); await setMirroredOrganizePreference(virtualPlaylistId, true, orgSource);
} }
@ -1597,12 +1600,15 @@ function renderQobuzPlaylists() {
return createQobuzCard(p); return createQobuzCard(p);
}).join(''); }).join('');
qobuzPlaylists.forEach(p => { if (typeof wirePhaseSyncCards === 'function') {
const card = document.getElementById(`qobuz-card-${p.id}`); wirePhaseSyncCards(
if (card) { 'qobuz',
card.addEventListener('click', () => handleQobuzCardClick(p.id)); '#qobuz-playlist-container',
} '.qobuz-playlist-card',
}); card => card.id.replace(/^qobuz-card-/, ''),
handleQobuzCardClick
);
}
} }
function createQobuzCard(playlist) { function createQobuzCard(playlist) {
@ -2522,6 +2528,11 @@ function renderDeezerArlPlaylists() {
</div> </div>
`; `;
}).join(''); }).join('');
if (typeof wirePlaylistCardSelection === 'function') {
wirePlaylistCardSelection('deezer', 'deezer-arl-playlist-container');
}
if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI();
} }
function handleDeezerArlViewProgressClick(event, playlistId) { function handleDeezerArlViewProgressClick(event, playlistId) {
@ -2799,13 +2810,15 @@ function renderDeezerPlaylists() {
return createDeezerCard(p); return createDeezerCard(p);
}).join(''); }).join('');
// Add click handlers to cards if (typeof wirePhaseSyncCards === 'function') {
deezerPlaylists.forEach(p => { wirePhaseSyncCards(
const card = document.getElementById(`deezer-card-${p.id}`); 'deezer-link',
if (card) { '#deezer-playlist-container',
card.addEventListener('click', () => handleDeezerCardClick(p.id)); '.deezer-playlist-card',
} card => card.id.replace(/^deezer-card-/, ''),
}); handleDeezerCardClick
);
}
} }
function createDeezerCard(playlist) { function createDeezerCard(playlist) {
@ -3725,6 +3738,12 @@ function initializeSyncPage() {
syncContentArea.style.gridTemplateColumns = '1fr'; 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 // Auto-load Deezer ARL playlists on first tab activation
if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) { if (tabId === 'deezer' && !deezerArlPlaylistsLoaded) {
// Check ARL status first // Check ARL status first
@ -3810,6 +3829,11 @@ function initializeSyncPage() {
ensureBeatportContentLoaded(); ensureBeatportContentLoaded();
} }
const initialActiveSyncTab = document.querySelector('.sync-tab-button.active');
if (initialActiveSyncTab && typeof onSyncTabChanged === 'function') {
onSyncTabChanged(initialActiveSyncTab.dataset.tab);
}
// Logic for the Spotify refresh button // Logic for the Spotify refresh button
const refreshBtn = document.getElementById('spotify-refresh-btn'); const refreshBtn = document.getElementById('spotify-refresh-btn');
if (refreshBtn) { if (refreshBtn) {
@ -5127,9 +5151,14 @@ function addBeatportCardToContainer(chartData) {
}; };
// Add click handler // Add click handler
const card = document.getElementById(`beatport-card-${chartData.hash}`); if (typeof wirePhaseSyncCards === 'function') {
if (card) { wirePhaseSyncCards(
card.addEventListener('click', async () => await handleBeatportCardClick(chartData.hash)); 'beatport',
'#beatport-playlist-container',
'[id^="beatport-card-"]',
card => card.id.replace(/^beatport-card-/, ''),
chartHash => handleBeatportCardClick(chartHash)
);
} }
console.log(`🃏 Created Beatport card: ${chartData.name}`); console.log(`🃏 Created Beatport card: ${chartData.name}`);
@ -6775,13 +6804,15 @@ function renderSpotifyPublicPlaylists() {
return createSpotifyPublicCard(p); return createSpotifyPublicCard(p);
}).join(''); }).join('');
// Add click handlers to cards if (typeof wirePhaseSyncCards === 'function') {
spotifyPublicPlaylists.forEach(p => { wirePhaseSyncCards(
const card = document.getElementById(`spotify-public-card-${p.url_hash}`); 'spotify-public',
if (card) { '#spotify-public-playlist-container',
card.addEventListener('click', () => handleSpotifyPublicCardClick(p.url_hash)); '.spotify-public-card',
} card => card.id.replace(/^spotify-public-card-/, ''),
}); handleSpotifyPublicCardClick
);
}
} }
function createSpotifyPublicCard(playlist) { function createSpotifyPublicCard(playlist) {
@ -7801,12 +7832,15 @@ function renderITunesLinkPlaylists() {
}).join(''); }).join('');
// Add click handlers to cards // Add click handlers to cards
itunesLinkPlaylists.forEach(p => { if (typeof wirePhaseSyncCards === 'function') {
const card = document.getElementById(`itunes-link-card-${p.url_hash}`); wirePhaseSyncCards(
if (card) { 'itunes-link',
card.addEventListener('click', () => handleITunesLinkCardClick(p.url_hash)); '#itunes-link-playlist-container',
} '.itunes-link-card',
}); card => card.id.replace(/^itunes-link-card-/, ''),
handleITunesLinkCardClick
);
}
} }
function createITunesLinkCard(playlist) { function createITunesLinkCard(playlist) {
@ -9017,15 +9051,15 @@ function updateYouTubeCardData(urlHash, playlistData) {
state.playlist = playlistData; state.playlist = playlistData;
state.urlHash = urlHash; state.urlHash = urlHash;
// Add click handler for card and action button if (typeof wirePhaseSyncCards === 'function') {
const handleCardClick = () => handleYouTubeCardClick(urlHash); wirePhaseSyncCards(
const actionBtn = card.querySelector('.playlist-card-action-btn'); 'youtube',
'#youtube-playlist-container',
card.addEventListener('click', handleCardClick); '.youtube-playlist-card[id^="youtube-card-"]',
actionBtn.addEventListener('click', (e) => { c => c.id.replace(/^youtube-card-/, ''),
e.stopPropagation(); // Prevent card click handleYouTubeCardClick
handleCardClick(); );
}); }
console.log('🃏 Updated YouTube card data:', playlistData.name); console.log('🃏 Updated YouTube card data:', playlistData.name);
} }

View file

@ -91,14 +91,24 @@ function renderSoulsyncDiscoverySyncPlaylists() {
`; `;
}).join(''); }).join('');
container.querySelectorAll('.soulsync-discovery-playlist-card').forEach(card => { if (typeof wirePhaseSyncCards === 'function') {
card.addEventListener('click', () => { wirePhaseSyncCards(
const kind = card.dataset.ssdKind; 'soulsync-discovery-sync',
const variant = card.dataset.ssdVariant; '#soulsync-discovery-sync-playlist-container',
const name = card.dataset.ssdName; '.soulsync-discovery-playlist-card',
handleSoulsyncDiscoverySyncCardClick(kind, variant, name, 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) { function _soulsyncSyntheticId(kind, variant) {

View file

@ -1199,10 +1199,14 @@ function createBeatportCardFromBackendState(chartInfo) {
cardElement: document.getElementById(`beatport-card-${chartHash}`) cardElement: document.getElementById(`beatport-card-${chartHash}`)
}; };
// Add click handler if (typeof wirePhaseSyncCards === 'function') {
const card = document.getElementById(`beatport-card-${chartHash}`); wirePhaseSyncCards(
if (card) { 'beatport',
card.addEventListener('click', async () => await handleBeatportCardClick(chartHash)); '#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})`); 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) // Create card HTML (using EXACT same structure as createYouTubeCard)
const cardHtml = ` const cardHtml = `
<div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}" onclick="handleYouTubeCardClick('${urlHash}')"> <div class="youtube-playlist-card" id="youtube-card-${urlHash}" data-url="${playlistInfo.url}">
<div class="playlist-card-icon youtube-icon"></div> <div class="playlist-card-icon youtube-icon"></div>
<div class="playlist-card-content"> <div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(playlist.name)}</div> <div class="playlist-card-name">${escapeHtml(playlist.name)}</div>
@ -1388,6 +1392,16 @@ function createYouTubeCardFromBackendState(playlistInfo) {
backendSynced: true // Flag to indicate this came from backend 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})`); 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 // This HTML structure creates the interactive playlist cards
return ` return `
<div class="playlist-card" data-playlist-id="${p.id}" onclick="togglePlaylistSelection(event)"> <div class="playlist-card" data-playlist-id="${p.id}">
<div class="playlist-card-main"> <div class="playlist-card-main">
<div class="playlist-card-content"> <div class="playlist-card-content">
<div class="playlist-card-name">${escapeHtml(p.name)}</div> <div class="playlist-card-name">${escapeHtml(p.name)}</div>
@ -1663,6 +1677,10 @@ function renderSpotifyPlaylists() {
</div> </div>
`; `;
}).join(''); }).join('');
if (typeof wirePlaylistCardSelection === 'function') {
wirePlaylistCardSelection('spotify', 'spotify-playlist-container');
}
if (typeof updateSyncActionsUI === 'function') updateSyncActionsUI();
} }
function handleViewProgressClick(event, playlistId) { function handleViewProgressClick(event, playlistId) {
@ -1791,44 +1809,6 @@ async function cleanupDownloadProcess(playlistId) {
updateRefreshButtonState(); // Now safe since hasActiveOperations() excludes wishlist 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) { async function openPlaylistDetailsModal(event, playlistId) {
event.stopPropagation(); event.stopPropagation();

View file

@ -1327,6 +1327,83 @@ async function handleWishlistDownloadNow() {
registerArtistDownload(artist, album, virtualPlaylistId, albumType); 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 * Add all tracks from any download modal to the wishlist
* Universal handler for all modal types (artist albums, playlists, YouTube, Tidal, etc.) * 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. // not for playlists, so we must NOT use it as a blanket default.
const processArtist = process.artist || null; const processArtist = process.artist || null;
const album = process.album || process.playlist || { name: 'Playlist', id: playlistId }; 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 // Disable the button to prevent double-clicks
const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`); const wishlistBtn = document.getElementById(`add-to-wishlist-btn-${playlistId}`);
@ -1407,7 +1488,7 @@ async function addModalTracksToWishlist(playlistId) {
} }
}); });
} else { } else {
formattedArtists = [{ name: artist.name }]; formattedArtists = [{ name: 'Unknown Artist' }];
} }
const formattedTrack = { const formattedTrack = {
@ -1460,6 +1541,14 @@ async function addModalTracksToWishlist(playlistId) {
trackArtist = { name: 'Unknown Artist', id: null }; trackArtist = { name: 'Unknown Artist', id: null };
} }
const sourceContext = buildModalWishlistSourceContext(
playlistId,
process,
trackAlbum,
trackArtist,
trackAlbumType,
);
const response = await fetch('/api/add-album-to-wishlist', { const response = await fetch('/api/add-album-to-wishlist', {
method: 'POST', method: 'POST',
headers: { headers: {
@ -1469,12 +1558,8 @@ async function addModalTracksToWishlist(playlistId) {
track: formattedTrack, track: formattedTrack,
artist: trackArtist, artist: trackArtist,
album: trackAlbum, album: trackAlbum,
source_type: 'album', source_type: wishlistSourceType,
source_context: { source_context: sourceContext,
album_name: trackAlbum.name,
artist_name: trackArtist.name,
album_type: trackAlbumType
}
}) })
}); });