Add per-playlist keep-folder-copies for organize-by-playlist downloads.

Mirrored playlists can keep a separate on-disk copy per playlist folder even when the track is already in the library. SoulSync standalone defaults this on when organize-by-playlist is enabled, with an explicit opt-out toggle in Auto-Sync and download modals.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
kekkokk 2026-06-04 15:48:38 +02:00
parent 83e3f6b660
commit 8d564a1178
12 changed files with 577 additions and 131 deletions

View file

@ -344,6 +344,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 = ''
@ -357,6 +358,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 = (
@ -367,13 +371,22 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
resolve_playlist_folder_mode_for_batch, resolve_playlist_folder_mode_for_batch,
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:
@ -456,28 +469,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"
) )
@ -498,6 +521,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
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()

View file

@ -82,6 +82,35 @@ def track_exists_in_playlist_folder(
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],
@ -100,28 +129,41 @@ def resolve_playlist_folder_mode_for_batch(
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_playlist_folder_mode_for_batch', 'resolve_playlist_folder_mode_for_batch',

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

@ -575,6 +575,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)
@ -1125,6 +1126,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:
@ -12470,6 +12489,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(
@ -12529,21 +12553,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

View file

@ -7,6 +7,7 @@ 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,
track_exists_in_playlist_folder, track_exists_in_playlist_folder,
) )
@ -66,7 +67,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 +75,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 +88,44 @@ 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

View file

@ -18730,6 +18730,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:
@ -18762,10 +18769,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}")
@ -18801,6 +18810,7 @@ def start_missing_tracks_process(playlist_id):
'force_download_all': force_download_all, # Pass the force flag to the batch 'force_download_all': force_download_all, # Pass the force flag to the batch
'ignore_manual_matches': ignore_manual_matches, 'ignore_manual_matches': ignore_manual_matches,
'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder 'playlist_folder_mode': playlist_folder_mode, # Organize downloads by playlist folder
'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,
@ -31706,19 +31716,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

@ -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

@ -2455,6 +2455,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;
@ -2513,8 +2516,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`);
}
} }
} }
@ -4621,42 +4628,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

@ -965,6 +965,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) {
@ -994,29 +998,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) {
@ -1026,42 +1060,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);
@ -1069,20 +1157,63 @@ 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');
}
}
function onDownloadMissingOrganizeToggle(playlistId, enabled) {
syncPlaylistOrganizeCheckboxes(playlistId, enabled, enabled && isSoulsyncStandaloneMode());
}
function onDownloadMissingKeepCopiesToggle(playlistId, enabled) {
const organizeCb = document.getElementById(`playlist-folder-mode-${playlistId}`);
if (organizeCb && !organizeCb.checked) {
organizeCb.checked = true;
}
syncPlaylistOrganizeCheckboxes(playlistId, true, enabled);
}
async function applyMirroredOrganizePreference(playlistRef, source = null) { async function applyMirroredOrganizePreference(playlistRef, source = null) {
await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source); await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source);
} }

View file

@ -12124,21 +12124,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;

View file

@ -1495,7 +1495,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);
} }