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:
parent
83e3f6b660
commit
8d564a1178
12 changed files with 577 additions and 131 deletions
|
|
@ -344,6 +344,7 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
batch_profile_id = 1
|
||||
batch_source = 'spotify'
|
||||
batch_playlist_folder_mode = False
|
||||
batch_keep_playlist_folder_copies = False
|
||||
batch_playlist_name = 'Unknown Playlist'
|
||||
batch_playlist_id = playlist_id
|
||||
batch_source_playlist_ref = ''
|
||||
|
|
@ -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_source = download_batches[batch_id].get('batch_source', 'spotify') or 'spotify'
|
||||
batch_playlist_folder_mode = download_batches[batch_id].get('playlist_folder_mode', False)
|
||||
batch_keep_playlist_folder_copies = download_batches[batch_id].get(
|
||||
'keep_playlist_folder_copies', False
|
||||
)
|
||||
batch_playlist_name = download_batches[batch_id].get('playlist_name', 'Unknown Playlist')
|
||||
batch_playlist_id = download_batches[batch_id].get('playlist_id', playlist_id)
|
||||
batch_source_playlist_ref = (
|
||||
|
|
@ -367,13 +371,22 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
resolve_playlist_folder_mode_for_batch,
|
||||
track_exists_in_playlist_folder_from_track_data,
|
||||
)
|
||||
effective_playlist_folder_mode, effective_playlist_name = resolve_playlist_folder_mode_for_batch(
|
||||
db,
|
||||
playlist_id=str(batch_playlist_id),
|
||||
playlist_name=batch_playlist_name,
|
||||
batch_playlist_folder_mode=batch_playlist_folder_mode,
|
||||
profile_id=batch_profile_id,
|
||||
source=batch_source,
|
||||
effective_playlist_folder_mode, effective_playlist_name, keep_playlist_folder_copies = (
|
||||
resolve_playlist_folder_mode_for_batch(
|
||||
db,
|
||||
playlist_id=str(batch_playlist_id),
|
||||
playlist_name=batch_playlist_name,
|
||||
batch_playlist_folder_mode=batch_playlist_folder_mode,
|
||||
batch_keep_playlist_folder_copies=batch_keep_playlist_folder_copies,
|
||||
profile_id=batch_profile_id,
|
||||
source=batch_source,
|
||||
active_server=active_server or '',
|
||||
)
|
||||
)
|
||||
skip_library_match_for_playlist_folder = (
|
||||
effective_playlist_folder_mode
|
||||
and keep_playlist_folder_copies
|
||||
and not force_download_all
|
||||
)
|
||||
if effective_playlist_folder_mode and not batch_playlist_folder_mode:
|
||||
with tasks_lock:
|
||||
|
|
@ -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
|
||||
# requested a force re-download from the normal download modal.
|
||||
_stid = track_data.get('spotify_track_id') or track_data.get('source_track_id') or track_data.get('id', '')
|
||||
_in_playlist_folder = (
|
||||
effective_playlist_folder_mode
|
||||
and track_exists_in_playlist_folder_from_track_data(
|
||||
effective_playlist_name,
|
||||
track_data,
|
||||
)
|
||||
)
|
||||
if not ignore_manual_matches and _stid and _mlm.get_match_for_track(
|
||||
db, batch_profile_id, track_data, default_source=batch_source
|
||||
):
|
||||
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
|
||||
try:
|
||||
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||
except Exception as _wl_err:
|
||||
logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}")
|
||||
analysis_results.append({
|
||||
'track_index': track_index,
|
||||
'track': track_data,
|
||||
'found': True,
|
||||
'confidence': 1.0,
|
||||
'match_reason': 'manual_library_match',
|
||||
})
|
||||
continue
|
||||
if skip_library_match_for_playlist_folder and not _in_playlist_folder:
|
||||
logger.info(
|
||||
f"[Playlist Folder Copies] '{track_name}' in library but missing from "
|
||||
f"'{effective_playlist_name}' folder — will download copy"
|
||||
)
|
||||
else:
|
||||
logger.info(f"[Manual Match] '{track_name}' already matched in library — skipping download")
|
||||
try:
|
||||
deps.check_and_remove_track_from_wishlist_by_metadata(track_data)
|
||||
except Exception as _wl_err:
|
||||
logger.debug(f"[Manual Match] Wishlist removal attempt failed: {_wl_err}")
|
||||
analysis_results.append({
|
||||
'track_index': track_index,
|
||||
'track': track_data,
|
||||
'found': True,
|
||||
'confidence': 1.0,
|
||||
'match_reason': 'manual_library_match',
|
||||
})
|
||||
continue
|
||||
|
||||
if effective_playlist_folder_mode and not force_download_all:
|
||||
if track_exists_in_playlist_folder_from_track_data(
|
||||
effective_playlist_name,
|
||||
track_data,
|
||||
):
|
||||
if _in_playlist_folder:
|
||||
logger.info(
|
||||
f"[Playlist Folder] '{track_name}' already on disk in playlist folder — skipping download"
|
||||
)
|
||||
|
|
@ -498,6 +521,8 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma
|
|||
if force_download_all:
|
||||
logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing")
|
||||
found, confidence = False, 0.0
|
||||
elif skip_library_match_for_playlist_folder:
|
||||
found, confidence = False, 0.0
|
||||
elif album_tracks_map:
|
||||
# Album-scoped matching: check against known album tracks first
|
||||
track_name_lower = track_name.lower().strip()
|
||||
|
|
|
|||
|
|
@ -82,6 +82,35 @@ def track_exists_in_playlist_folder(
|
|||
return False
|
||||
|
||||
|
||||
def is_soulsync_standalone_server(active_server: str) -> bool:
|
||||
return (active_server or '').strip().lower() == 'soulsync'
|
||||
|
||||
|
||||
def effective_keep_playlist_folder_copies(
|
||||
mirrored: Optional[Dict[str, Any]],
|
||||
active_server: str,
|
||||
*,
|
||||
batch_keep: bool = False,
|
||||
) -> bool:
|
||||
"""True when per-playlist folder copies should be kept for this batch.
|
||||
|
||||
In SoulSync standalone mode, mirrored playlists with organize-by-playlist
|
||||
default to keeping copies unless the user explicitly opted out.
|
||||
"""
|
||||
if batch_keep:
|
||||
return True
|
||||
if not mirrored:
|
||||
return False
|
||||
if mirrored.get('keep_playlist_folder_copies'):
|
||||
return True
|
||||
if mirrored.get('keep_playlist_folder_copies_opt_out'):
|
||||
return False
|
||||
return (
|
||||
is_soulsync_standalone_server(active_server)
|
||||
and bool(mirrored.get('organize_by_playlist'))
|
||||
)
|
||||
|
||||
|
||||
def track_exists_in_playlist_folder_from_track_data(
|
||||
playlist_name: str,
|
||||
track_data: Dict[str, Any],
|
||||
|
|
@ -100,28 +129,41 @@ def resolve_playlist_folder_mode_for_batch(
|
|||
playlist_id: str,
|
||||
playlist_name: str,
|
||||
batch_playlist_folder_mode: bool,
|
||||
batch_keep_playlist_folder_copies: bool = False,
|
||||
profile_id: int = 1,
|
||||
source: str = 'spotify',
|
||||
) -> tuple[bool, str]:
|
||||
"""Merge batch flag with persisted mirrored-playlist preference."""
|
||||
if batch_playlist_folder_mode:
|
||||
return True, playlist_name
|
||||
active_server: str = '',
|
||||
) -> tuple[bool, str, bool]:
|
||||
"""Merge batch flags with persisted mirrored-playlist preferences.
|
||||
|
||||
if not hasattr(db, 'resolve_mirrored_playlist'):
|
||||
return False, playlist_name
|
||||
Returns ``(folder_mode, effective_playlist_name, keep_folder_copies)``.
|
||||
"""
|
||||
mirrored = None
|
||||
if hasattr(db, 'resolve_mirrored_playlist'):
|
||||
mirrored = db.resolve_mirrored_playlist(
|
||||
playlist_id, profile_id=profile_id, default_source=source or 'spotify'
|
||||
)
|
||||
|
||||
# Pass the batch's source so numeric upstream ids (e.g. Deezer) resolve by
|
||||
# source instead of colliding with the mirrored-playlists primary key.
|
||||
mirrored = db.resolve_mirrored_playlist(
|
||||
playlist_id, profile_id=profile_id, default_source=source or 'spotify'
|
||||
keep = effective_keep_playlist_folder_copies(
|
||||
mirrored,
|
||||
active_server,
|
||||
batch_keep=batch_keep_playlist_folder_copies,
|
||||
)
|
||||
|
||||
if batch_playlist_folder_mode:
|
||||
name = (mirrored.get('name') if mirrored else None) or playlist_name
|
||||
return True, name, keep
|
||||
|
||||
if mirrored and mirrored.get('organize_by_playlist'):
|
||||
return True, mirrored.get('name') or playlist_name
|
||||
return False, playlist_name
|
||||
return True, mirrored.get('name') or playlist_name, keep
|
||||
|
||||
return False, playlist_name, False
|
||||
|
||||
|
||||
__all__ = [
|
||||
'candidate_playlist_folder_paths',
|
||||
'effective_keep_playlist_folder_copies',
|
||||
'is_soulsync_standalone_server',
|
||||
'track_exists_in_playlist_folder',
|
||||
'track_exists_in_playlist_folder_from_track_data',
|
||||
'resolve_playlist_folder_mode_for_batch',
|
||||
|
|
|
|||
|
|
@ -125,6 +125,7 @@ def run_playlist_organize_download(
|
|||
'force_download_all': False,
|
||||
'ignore_manual_matches': False,
|
||||
'playlist_folder_mode': True,
|
||||
'keep_playlist_folder_copies': bool(pl.get('keep_playlist_folder_copies')),
|
||||
'is_album_download': False,
|
||||
'album_context': None,
|
||||
'artist_context': None,
|
||||
|
|
|
|||
|
|
@ -575,6 +575,7 @@ class MusicDatabase:
|
|||
# Add explored_at to mirrored_playlists (migration)
|
||||
self._add_mirrored_playlist_explored_column(cursor)
|
||||
self._add_mirrored_playlist_organize_column(cursor)
|
||||
self._add_mirrored_playlist_keep_copies_column(cursor)
|
||||
|
||||
# Add notification columns to automations (migration)
|
||||
self._add_automation_notify_columns(cursor)
|
||||
|
|
@ -1125,6 +1126,24 @@ class MusicDatabase:
|
|||
except Exception as e:
|
||||
logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {e}")
|
||||
|
||||
def _add_mirrored_playlist_keep_copies_column(self, cursor):
|
||||
"""Per-playlist copy in each playlist folder even when track exists in library."""
|
||||
try:
|
||||
cursor.execute("PRAGMA table_info(mirrored_playlists)")
|
||||
cols = [c[1] for c in cursor.fetchall()]
|
||||
if 'keep_playlist_folder_copies' not in cols:
|
||||
cursor.execute(
|
||||
"ALTER TABLE mirrored_playlists ADD COLUMN keep_playlist_folder_copies INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
logger.info("Added keep_playlist_folder_copies column to mirrored_playlists table")
|
||||
if 'keep_playlist_folder_copies_opt_out' not in cols:
|
||||
cursor.execute(
|
||||
"ALTER TABLE mirrored_playlists ADD COLUMN keep_playlist_folder_copies_opt_out INTEGER NOT NULL DEFAULT 0"
|
||||
)
|
||||
logger.info("Added keep_playlist_folder_copies_opt_out column to mirrored_playlists table")
|
||||
except Exception as e:
|
||||
logger.error(f"Error adding keep_playlist_folder_copies column to mirrored_playlists: {e}")
|
||||
|
||||
def _add_automation_notify_columns(self, cursor):
|
||||
"""Add notification and result columns to automations table."""
|
||||
try:
|
||||
|
|
@ -12470,6 +12489,11 @@ class MusicDatabase:
|
|||
return None
|
||||
pl = dict(row)
|
||||
pl['organize_by_playlist'] = bool(pl.get('organize_by_playlist', 0))
|
||||
pl['keep_playlist_folder_copies'] = bool(pl.get('keep_playlist_folder_copies', 0))
|
||||
pl['keep_playlist_folder_copies_opt_out'] = bool(pl.get('keep_playlist_folder_copies_opt_out', 0))
|
||||
if not pl['organize_by_playlist']:
|
||||
pl['keep_playlist_folder_copies'] = False
|
||||
pl['keep_playlist_folder_copies_opt_out'] = False
|
||||
return pl
|
||||
|
||||
def get_mirrored_playlist_by_source(
|
||||
|
|
@ -12529,21 +12553,56 @@ class MusicDatabase:
|
|||
enabled: bool,
|
||||
) -> bool:
|
||||
"""Persist whether downloads for this playlist use playlist-folder layout."""
|
||||
return self.set_mirrored_playlist_preferences(
|
||||
playlist_id,
|
||||
organize_by_playlist=enabled,
|
||||
)
|
||||
|
||||
def set_mirrored_playlist_preferences(
|
||||
self,
|
||||
playlist_id: int,
|
||||
*,
|
||||
organize_by_playlist: Optional[bool] = None,
|
||||
keep_playlist_folder_copies: Optional[bool] = None,
|
||||
) -> bool:
|
||||
"""Update mirrored-playlist download layout preferences."""
|
||||
try:
|
||||
current = self.get_mirrored_playlist(playlist_id)
|
||||
if not current:
|
||||
return False
|
||||
organize = (
|
||||
bool(organize_by_playlist)
|
||||
if organize_by_playlist is not None
|
||||
else bool(current.get('organize_by_playlist'))
|
||||
)
|
||||
keep = (
|
||||
bool(keep_playlist_folder_copies)
|
||||
if keep_playlist_folder_copies is not None
|
||||
else bool(current.get('keep_playlist_folder_copies'))
|
||||
)
|
||||
opt_out = bool(current.get('keep_playlist_folder_copies_opt_out'))
|
||||
if keep_playlist_folder_copies is not None:
|
||||
opt_out = not keep
|
||||
if not organize:
|
||||
keep = False
|
||||
opt_out = False
|
||||
with self._get_connection() as conn:
|
||||
cursor = conn.cursor()
|
||||
cursor.execute(
|
||||
"""
|
||||
UPDATE mirrored_playlists
|
||||
SET organize_by_playlist = ?, updated_at = CURRENT_TIMESTAMP
|
||||
SET organize_by_playlist = ?,
|
||||
keep_playlist_folder_copies = ?,
|
||||
keep_playlist_folder_copies_opt_out = ?,
|
||||
updated_at = CURRENT_TIMESTAMP
|
||||
WHERE id = ?
|
||||
""",
|
||||
(1 if enabled else 0, playlist_id),
|
||||
(1 if organize else 0, 1 if keep else 0, 1 if opt_out else 0, playlist_id),
|
||||
)
|
||||
conn.commit()
|
||||
return cursor.rowcount > 0
|
||||
except Exception as e:
|
||||
logger.error(f"Error updating organize_by_playlist for playlist {playlist_id}: {e}")
|
||||
logger.error(f"Error updating mirrored playlist preferences for {playlist_id}: {e}")
|
||||
return False
|
||||
|
||||
def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]:
|
||||
|
|
|
|||
|
|
@ -326,6 +326,59 @@ def test_analysis_phase_sets_state(monkeypatch):
|
|||
assert len(download_batches['B1']['analysis_results']) == 1
|
||||
|
||||
|
||||
def test_keep_playlist_folder_copies_skips_library_match(monkeypatch):
|
||||
"""With keep copies, a library hit still downloads when the track is absent from this playlist folder."""
|
||||
db = _FakeDB(found_tracks={('shared', 'artist'): 1.0})
|
||||
db.resolve_mirrored_playlist = lambda *args, **kwargs: {
|
||||
'id': 9,
|
||||
'name': 'Playlist B',
|
||||
'organize_by_playlist': True,
|
||||
'keep_playlist_folder_copies': False,
|
||||
'keep_playlist_folder_copies_opt_out': False,
|
||||
}
|
||||
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
||||
monkeypatch.setattr(
|
||||
'core.downloads.playlist_folder.track_exists_in_playlist_folder_from_track_data',
|
||||
lambda *_a, **_k: False,
|
||||
)
|
||||
|
||||
_seed_batch(
|
||||
'Bkeep',
|
||||
playlist_folder_mode=True,
|
||||
playlist_id='9',
|
||||
playlist_name='Playlist B',
|
||||
)
|
||||
deps = _build_deps(config=_FakeConfig({'_active_server': 'soulsync'}))
|
||||
tracks = [{'name': 'Shared', 'artists': ['Artist']}]
|
||||
|
||||
mw.run_full_missing_tracks_process('Bkeep', '9', tracks, deps)
|
||||
|
||||
assert download_batches['Bkeep']['analysis_results'][0]['found'] is False
|
||||
assert len(download_batches['Bkeep']['queue']) == 1
|
||||
|
||||
|
||||
def test_playlist_folder_mode_without_keep_copies_skips_library_match(monkeypatch):
|
||||
db = _FakeDB(found_tracks={('shared', 'artist'): 1.0})
|
||||
db.resolve_mirrored_playlist = lambda *args, **kwargs: {
|
||||
'id': 9,
|
||||
'name': 'Playlist B',
|
||||
'organize_by_playlist': True,
|
||||
'keep_playlist_folder_copies': False,
|
||||
}
|
||||
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db)
|
||||
monkeypatch.setattr(
|
||||
'core.downloads.playlist_folder.track_exists_in_playlist_folder_from_track_data',
|
||||
lambda *_a, **_k: False,
|
||||
)
|
||||
_seed_batch('Bnokeep', playlist_folder_mode=True, playlist_id='9', playlist_name='Playlist B')
|
||||
deps = _build_deps()
|
||||
mw.run_full_missing_tracks_process(
|
||||
'Bnokeep', '9', [{'name': 'Shared', 'artists': ['Artist']}], deps
|
||||
)
|
||||
assert download_batches['Bnokeep']['analysis_results'][0]['found'] is True
|
||||
assert download_batches['Bnokeep']['queue'] == []
|
||||
|
||||
|
||||
def test_force_download_treats_all_as_missing(monkeypatch):
|
||||
"""force_download_all skips DB check — every track marked missing."""
|
||||
db = _FakeDB(found_tracks={('t1', 'a'): 1.0, ('t2', 'a'): 1.0}) # would otherwise be found
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import pytest
|
|||
|
||||
from core.downloads.playlist_folder import (
|
||||
candidate_playlist_folder_paths,
|
||||
effective_keep_playlist_folder_copies,
|
||||
resolve_playlist_folder_mode_for_batch,
|
||||
track_exists_in_playlist_folder,
|
||||
)
|
||||
|
|
@ -66,7 +67,7 @@ def test_resolve_playlist_folder_mode_from_mirrored():
|
|||
'name': 'Rekordbox Set',
|
||||
'organize_by_playlist': True,
|
||||
})
|
||||
enabled, name = resolve_playlist_folder_mode_for_batch(
|
||||
enabled, name, keep = resolve_playlist_folder_mode_for_batch(
|
||||
db,
|
||||
playlist_id='37i9dQZF1',
|
||||
playlist_name='Other Name',
|
||||
|
|
@ -74,11 +75,12 @@ def test_resolve_playlist_folder_mode_from_mirrored():
|
|||
)
|
||||
assert enabled is True
|
||||
assert name == 'Rekordbox Set'
|
||||
assert keep is False
|
||||
|
||||
|
||||
def test_resolve_playlist_folder_mode_batch_flag():
|
||||
db = _FakeDB()
|
||||
enabled, name = resolve_playlist_folder_mode_for_batch(
|
||||
enabled, name, keep = resolve_playlist_folder_mode_for_batch(
|
||||
db,
|
||||
playlist_id='1',
|
||||
playlist_name='Batch Name',
|
||||
|
|
@ -86,3 +88,44 @@ def test_resolve_playlist_folder_mode_batch_flag():
|
|||
)
|
||||
assert enabled is True
|
||||
assert name == 'Batch Name'
|
||||
assert keep is False
|
||||
|
||||
|
||||
def test_resolve_playlist_folder_keep_copies_from_mirrored():
|
||||
db = _FakeDB(mirrored={
|
||||
'id': 5,
|
||||
'name': 'USB Set',
|
||||
'organize_by_playlist': True,
|
||||
'keep_playlist_folder_copies': True,
|
||||
})
|
||||
enabled, name, keep = resolve_playlist_folder_mode_for_batch(
|
||||
db,
|
||||
playlist_id='37i9dQZF1',
|
||||
playlist_name='Other',
|
||||
batch_playlist_folder_mode=False,
|
||||
active_server='soulsync',
|
||||
)
|
||||
assert enabled is True
|
||||
assert name == 'USB Set'
|
||||
assert keep is True
|
||||
|
||||
|
||||
def test_standalone_defaults_keep_copies_when_organize_without_explicit_keep():
|
||||
mirrored = {
|
||||
'id': 5,
|
||||
'name': 'USB Set',
|
||||
'organize_by_playlist': True,
|
||||
'keep_playlist_folder_copies': False,
|
||||
'keep_playlist_folder_copies_opt_out': False,
|
||||
}
|
||||
assert effective_keep_playlist_folder_copies(mirrored, 'soulsync') is True
|
||||
assert effective_keep_playlist_folder_copies(mirrored, 'plex') is False
|
||||
|
||||
|
||||
def test_standalone_keep_copies_opt_out_honored():
|
||||
mirrored = {
|
||||
'organize_by_playlist': True,
|
||||
'keep_playlist_folder_copies': False,
|
||||
'keep_playlist_folder_copies_opt_out': True,
|
||||
}
|
||||
assert effective_keep_playlist_folder_copies(mirrored, 'soulsync') is False
|
||||
|
|
|
|||
|
|
@ -18730,6 +18730,13 @@ def start_missing_tracks_process(playlist_id):
|
|||
playlist_name = data.get('playlist_name', 'Unknown Playlist')
|
||||
force_download_all = data.get('force_download_all', False)
|
||||
playlist_folder_mode = data.get('playlist_folder_mode', False)
|
||||
keep_playlist_folder_copies = bool(data.get('keep_playlist_folder_copies', False))
|
||||
if (
|
||||
playlist_folder_mode
|
||||
and config_manager.get_active_media_server() == 'soulsync'
|
||||
and 'keep_playlist_folder_copies' not in data
|
||||
):
|
||||
keep_playlist_folder_copies = True
|
||||
wing_it = data.get('wing_it', False)
|
||||
ignore_manual_matches = data.get('ignore_manual_matches')
|
||||
if ignore_manual_matches is None:
|
||||
|
|
@ -18762,10 +18769,12 @@ def start_missing_tracks_process(playlist_id):
|
|||
default_source='spotify',
|
||||
)
|
||||
if mirrored_pl and mirrored_pl.get('id'):
|
||||
db_pref.set_mirrored_playlist_organize_by_playlist(
|
||||
int(mirrored_pl['id']),
|
||||
bool(playlist_folder_mode),
|
||||
)
|
||||
pref_kwargs = {'organize_by_playlist': bool(playlist_folder_mode)}
|
||||
if not playlist_folder_mode:
|
||||
pref_kwargs['keep_playlist_folder_copies'] = False
|
||||
elif keep_playlist_folder_copies:
|
||||
pref_kwargs['keep_playlist_folder_copies'] = True
|
||||
db_pref.set_mirrored_playlist_preferences(int(mirrored_pl['id']), **pref_kwargs)
|
||||
except Exception as pref_err:
|
||||
logger.debug(f"[Playlist Folder] Could not persist mirrored preference: {pref_err}")
|
||||
|
||||
|
|
@ -18801,6 +18810,7 @@ def start_missing_tracks_process(playlist_id):
|
|||
'force_download_all': force_download_all, # Pass the force flag to the batch
|
||||
'ignore_manual_matches': ignore_manual_matches,
|
||||
'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)
|
||||
'is_album_download': is_album_download,
|
||||
'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'])
|
||||
def update_mirrored_playlist_preferences_endpoint(playlist_id):
|
||||
"""Update per-playlist download preferences (e.g. organize by playlist folder)."""
|
||||
"""Update per-playlist download preferences (playlist-folder layout and copies)."""
|
||||
try:
|
||||
data = request.get_json() or {}
|
||||
if 'organize_by_playlist' not in data:
|
||||
return jsonify({"error": "organize_by_playlist is required"}), 400
|
||||
if 'organize_by_playlist' not in data and 'keep_playlist_folder_copies' not in data:
|
||||
return jsonify({"error": "At least one preference field is required"}), 400
|
||||
|
||||
database = get_database()
|
||||
playlist = database.get_mirrored_playlist(playlist_id)
|
||||
if not playlist:
|
||||
return jsonify({"error": "Playlist not found"}), 404
|
||||
|
||||
enabled = bool(data.get('organize_by_playlist'))
|
||||
ok = database.set_mirrored_playlist_organize_by_playlist(playlist_id, enabled)
|
||||
kwargs = {}
|
||||
if 'organize_by_playlist' in data:
|
||||
kwargs['organize_by_playlist'] = bool(data.get('organize_by_playlist'))
|
||||
if 'keep_playlist_folder_copies' in data:
|
||||
kwargs['keep_playlist_folder_copies'] = bool(data.get('keep_playlist_folder_copies'))
|
||||
is_standalone = config_manager.get_active_media_server() == 'soulsync'
|
||||
if is_standalone and kwargs.get('organize_by_playlist') and 'keep_playlist_folder_copies' not in data:
|
||||
kwargs['keep_playlist_folder_copies'] = True
|
||||
ok = database.set_mirrored_playlist_preferences(playlist_id, **kwargs)
|
||||
if not ok:
|
||||
return jsonify({"error": "Failed to update preferences"}), 500
|
||||
|
||||
|
|
|
|||
|
|
@ -1577,27 +1577,59 @@ function autoSyncAutomationCardHtml(auto, playlists) {
|
|||
`;
|
||||
}
|
||||
|
||||
function autoSyncEffectiveKeepCopies(playlist) {
|
||||
if (typeof effectiveKeepPlaylistFolderCopies === 'function') {
|
||||
return effectiveKeepPlaylistFolderCopies(playlist);
|
||||
}
|
||||
return !!playlist.keep_playlist_folder_copies;
|
||||
}
|
||||
|
||||
function autoSyncOrganizeToggleHtml(playlist) {
|
||||
const checked = playlist.organize_by_playlist ? 'checked' : '';
|
||||
const organizeChecked = playlist.organize_by_playlist ? 'checked' : '';
|
||||
const keepChecked = autoSyncEffectiveKeepCopies(playlist) ? 'checked' : '';
|
||||
const keepDisabled = playlist.organize_by_playlist ? '' : 'disabled';
|
||||
const keepClass = playlist.organize_by_playlist ? '' : 'is-disabled';
|
||||
return `
|
||||
<label class="auto-sync-organize-toggle" onclick="event.stopPropagation();" title="Download missing tracks into a playlist-named folder (artist - track)">
|
||||
<input type="checkbox" ${checked} onchange="setAutoSyncOrganizeByPlaylist(${playlist.id}, this.checked)">
|
||||
<span>Organize by playlist</span>
|
||||
</label>
|
||||
<div class="auto-sync-organize-toggles" onclick="event.stopPropagation();">
|
||||
<label class="auto-sync-organize-toggle" title="Download missing tracks into a playlist-named folder (artist - track)">
|
||||
<input type="checkbox" ${organizeChecked} onchange="setAutoSyncOrganizeByPlaylist(${playlist.id}, this.checked)">
|
||||
<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) {
|
||||
try {
|
||||
const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organize_by_playlist: !!enabled }),
|
||||
});
|
||||
const data = await res.json();
|
||||
if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference');
|
||||
const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10));
|
||||
if (pl) pl.organize_by_playlist = !!enabled;
|
||||
const body = { organize_by_playlist: !!enabled };
|
||||
if (!enabled) {
|
||||
body.keep_playlist_folder_copies = false;
|
||||
} else if (typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode()) {
|
||||
body.keep_playlist_folder_copies = true;
|
||||
}
|
||||
await patchAutoSyncMirroredPreferences(playlistId, body);
|
||||
showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success');
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
|
|
@ -1605,6 +1637,24 @@ async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) {
|
|||
}
|
||||
}
|
||||
|
||||
async function setAutoSyncKeepPlaylistFolderCopies(playlistId, enabled) {
|
||||
try {
|
||||
await patchAutoSyncMirroredPreferences(playlistId, {
|
||||
keep_playlist_folder_copies: !!enabled,
|
||||
organize_by_playlist: true,
|
||||
});
|
||||
showToast(
|
||||
enabled
|
||||
? 'Missing tracks will download into each playlist folder even if already in library'
|
||||
: 'Tracks already in library will not be copied into playlist folders',
|
||||
'success',
|
||||
);
|
||||
} catch (err) {
|
||||
showToast(`Error: ${err.message}`, 'error');
|
||||
await refreshAutoSyncScheduleModal();
|
||||
}
|
||||
}
|
||||
|
||||
function autoSyncScheduledCardHtml(playlist, schedule) {
|
||||
const enabled = schedule?.enabled !== false;
|
||||
const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : '';
|
||||
|
|
|
|||
|
|
@ -2455,6 +2455,9 @@ async function startMissingTracksProcess(playlistId) {
|
|||
const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function'
|
||||
? isPlaylistOrganizeEnabled(playlistId)
|
||||
: (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false);
|
||||
const keepPlaylistFolderCopies = typeof isPlaylistKeepFolderCopiesEnabled === 'function'
|
||||
? isPlaylistKeepFolderCopiesEnabled(playlistId)
|
||||
: (document.getElementById(`playlist-keep-copies-mode-${playlistId}`)?.checked ?? false);
|
||||
|
||||
// Hide the force download toggle during processing
|
||||
const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null;
|
||||
|
|
@ -2513,8 +2516,12 @@ async function startMissingTracksProcess(playlistId) {
|
|||
requestBody.playlist_name = process.playlist.name;
|
||||
// Add playlist folder mode flag for sync page playlists
|
||||
requestBody.playlist_folder_mode = playlistFolderMode;
|
||||
requestBody.keep_playlist_folder_copies = playlistFolderMode && keepPlaylistFolderCopies;
|
||||
if (playlistFolderMode) {
|
||||
console.log(`📁 [Playlist Folder] Enabled for playlist: ${process.playlist.name}`);
|
||||
if (keepPlaylistFolderCopies) {
|
||||
console.log(`📁 [Playlist Folder] Keep folder copies enabled`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -4621,42 +4628,33 @@ function startSequentialSync() {
|
|||
return;
|
||||
}
|
||||
|
||||
// Validate selection
|
||||
const bulkConfig = typeof getActiveSyncBulkConfig === 'function' ? getActiveSyncBulkConfig() : null;
|
||||
if (!bulkConfig) {
|
||||
showToast('Bulk sync is not available on this tab', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedPlaylists.size === 0) {
|
||||
showToast('No playlists selected for sync', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
// Get playlist order from DOM to maintain display order
|
||||
const playlistCards = document.querySelectorAll('.playlist-card');
|
||||
const orderedPlaylistIds = [];
|
||||
const orderedPlaylistIds = typeof getOrderedSelectedPlaylistIds === 'function'
|
||||
? getOrderedSelectedPlaylistIds()
|
||||
: [];
|
||||
|
||||
playlistCards.forEach(card => {
|
||||
const playlistId = card.dataset.playlistId;
|
||||
if (selectedPlaylists.has(playlistId)) {
|
||||
orderedPlaylistIds.push(playlistId);
|
||||
}
|
||||
});
|
||||
if (!orderedPlaylistIds.length) {
|
||||
showToast('No playlists selected for sync', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists`);
|
||||
console.log(`🚀 Starting sequential sync for ${orderedPlaylistIds.length} playlists (${activeSyncSelectionSource})`);
|
||||
|
||||
// Show sidebar for sync progress
|
||||
showSyncSidebar();
|
||||
|
||||
// Start sequential sync
|
||||
sequentialSyncManager.start(orderedPlaylistIds);
|
||||
|
||||
// Disable playlist selection during sync
|
||||
sequentialSyncManager.start(orderedPlaylistIds, bulkConfig);
|
||||
disablePlaylistSelection(true);
|
||||
}
|
||||
|
||||
function disablePlaylistSelection(disabled) {
|
||||
const checkboxes = document.querySelectorAll('.playlist-checkbox');
|
||||
checkboxes.forEach(checkbox => {
|
||||
checkbox.disabled = disabled;
|
||||
});
|
||||
}
|
||||
|
||||
function hasActiveOperations() {
|
||||
const hasActiveSyncs = Object.keys(activeSyncPollers).length > 0;
|
||||
// Only check non-wishlist download processes for sync page refresh button
|
||||
|
|
|
|||
|
|
@ -965,6 +965,10 @@ function playlistDetailsOrganizeCheckboxId(playlistRef) {
|
|||
return `playlist-organize-${playlistRef}`;
|
||||
}
|
||||
|
||||
function playlistDetailsKeepCopiesCheckboxId(playlistRef) {
|
||||
return `playlist-keep-copies-${playlistRef}`;
|
||||
}
|
||||
|
||||
/** Infer mirrored-playlist API source from a UI playlist / virtual id. */
|
||||
function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) {
|
||||
if (explicitSource) {
|
||||
|
|
@ -994,29 +998,59 @@ function normalizePlaylistOrganizeRef(playlistRef, source = 'spotify') {
|
|||
|
||||
function downloadMissingModalOrganizeCheckboxHtml(playlistId) {
|
||||
return `
|
||||
<label class="force-download-toggle">
|
||||
<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync">
|
||||
<span>Organize by Playlist (Downloads/Playlist/Artist - Track.ext)</span>
|
||||
</label>`;
|
||||
<div class="playlist-organize-pref-group">
|
||||
<label class="force-download-toggle">
|
||||
<input type="checkbox" id="playlist-folder-mode-${playlistId}" class="playlist-folder-mode-sync"
|
||||
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') {
|
||||
const safeRef = String(playlistRef).replace(/'/g, "\\'");
|
||||
const safeSource = String(source).replace(/'/g, "\\'");
|
||||
return `
|
||||
<label class="playlist-modal-organize-toggle" title="Download into a playlist-named folder (Artist - Track) under your transfer path">
|
||||
<input type="checkbox" id="${playlistDetailsOrganizeCheckboxId(playlistRef)}"
|
||||
onchange="onPlaylistOrganizePreferenceChange('${safeRef}', this.checked, '${safeSource}')">
|
||||
<span>Organize by playlist</span>
|
||||
</label>
|
||||
<div class="playlist-organize-pref-group">
|
||||
<label class="playlist-modal-organize-toggle" title="Download into a playlist-named folder (Artist - Track) under your transfer path">
|
||||
<input type="checkbox" id="${playlistDetailsOrganizeCheckboxId(playlistRef)}"
|
||||
onchange="onPlaylistOrganizePreferenceChange('${safeRef}', this.checked, '${safeSource}')">
|
||||
<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 downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`);
|
||||
if (detailsCb) detailsCb.checked = !!enabled;
|
||||
if (downloadMissingCb) downloadMissingCb.checked = !!enabled;
|
||||
const detailsKeep = document.getElementById(playlistDetailsKeepCopiesCheckboxId(playlistRef));
|
||||
const downloadKeep = document.getElementById(`playlist-keep-copies-mode-${playlistRef}`);
|
||||
const detailsKeepWrap = document.getElementById(`${playlistDetailsKeepCopiesCheckboxId(playlistRef)}-wrap`);
|
||||
const downloadKeepWrap = document.getElementById(`playlist-keep-copies-wrap-${playlistRef}`);
|
||||
if (detailsCb) detailsCb.checked = !!organizeEnabled;
|
||||
if (downloadMissingCb) downloadMissingCb.checked = !!organizeEnabled;
|
||||
if (detailsKeep) {
|
||||
detailsKeep.checked = !!organizeEnabled && !!keepCopiesEnabled;
|
||||
detailsKeep.disabled = !organizeEnabled;
|
||||
}
|
||||
if (downloadKeep) {
|
||||
downloadKeep.checked = !!organizeEnabled && !!keepCopiesEnabled;
|
||||
downloadKeep.disabled = !organizeEnabled;
|
||||
}
|
||||
if (detailsKeepWrap) detailsKeepWrap.classList.toggle('is-disabled', !organizeEnabled);
|
||||
if (downloadKeepWrap) downloadKeepWrap.classList.toggle('is-disabled', !organizeEnabled);
|
||||
}
|
||||
|
||||
function isPlaylistOrganizeEnabled(playlistRef) {
|
||||
|
|
@ -1026,42 +1060,96 @@ function isPlaylistOrganizeEnabled(playlistRef) {
|
|||
return downloadMissingCb ? downloadMissingCb.checked : false;
|
||||
}
|
||||
|
||||
function isSoulsyncStandaloneMode() {
|
||||
return !!_isSoulsyncStandalone;
|
||||
}
|
||||
|
||||
function effectiveKeepPlaylistFolderCopies(playlist) {
|
||||
if (!playlist?.organize_by_playlist) return false;
|
||||
if (playlist.keep_playlist_folder_copies) return true;
|
||||
if (playlist.keep_playlist_folder_copies_opt_out) return false;
|
||||
return isSoulsyncStandaloneMode();
|
||||
}
|
||||
|
||||
function isPlaylistKeepFolderCopiesEnabled(playlistRef) {
|
||||
if (!isPlaylistOrganizeEnabled(playlistRef)) return false;
|
||||
const detailsCb = document.getElementById(playlistDetailsKeepCopiesCheckboxId(playlistRef));
|
||||
if (detailsCb) return detailsCb.checked;
|
||||
const downloadCb = document.getElementById(`playlist-keep-copies-mode-${playlistRef}`);
|
||||
return downloadCb ? downloadCb.checked : false;
|
||||
}
|
||||
|
||||
async function resolveMirroredPlaylistForRef(playlistRef, source = null) {
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
|
||||
const res = await fetch(
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!data.found || !data.playlist) {
|
||||
return null;
|
||||
}
|
||||
return data.playlist;
|
||||
}
|
||||
|
||||
async function fetchMirroredOrganizePreference(playlistRef, source = null) {
|
||||
try {
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
|
||||
const res = await fetch(
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
|
||||
);
|
||||
const data = await res.json();
|
||||
return !!(data.found && data.playlist?.organize_by_playlist);
|
||||
const pl = await resolveMirroredPlaylistForRef(playlistRef, source);
|
||||
return !!pl?.organize_by_playlist;
|
||||
} catch (err) {
|
||||
console.debug('Could not load organize-by-playlist preference:', err);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function fetchMirroredPlaylistFolderPreferences(playlistRef, source = null) {
|
||||
try {
|
||||
const pl = await resolveMirroredPlaylistForRef(playlistRef, source);
|
||||
if (!pl) {
|
||||
return { organize: false, keepCopies: false };
|
||||
}
|
||||
return {
|
||||
organize: !!pl.organize_by_playlist,
|
||||
keepCopies: effectiveKeepPlaylistFolderCopies(pl),
|
||||
};
|
||||
} catch (err) {
|
||||
console.debug('Could not load mirrored playlist folder preferences:', err);
|
||||
return { organize: false, keepCopies: false };
|
||||
}
|
||||
}
|
||||
|
||||
async function patchMirroredPlaylistPreferences(playlistId, body) {
|
||||
const patchRes = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
const patchData = await patchRes.json();
|
||||
if (!patchRes.ok || patchData.error) {
|
||||
return null;
|
||||
}
|
||||
return patchData.playlist || null;
|
||||
}
|
||||
|
||||
async function setMirroredOrganizePreference(playlistRef, enabled, source = null) {
|
||||
try {
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource);
|
||||
const res = await fetch(
|
||||
`/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}`
|
||||
const pl = await resolveMirroredPlaylistForRef(playlistRef, source);
|
||||
if (!pl?.id) {
|
||||
return false;
|
||||
}
|
||||
const body = { organize_by_playlist: !!enabled };
|
||||
if (!enabled) {
|
||||
body.keep_playlist_folder_copies = false;
|
||||
} else if (isSoulsyncStandaloneMode()) {
|
||||
body.keep_playlist_folder_copies = true;
|
||||
}
|
||||
const updated = await patchMirroredPlaylistPreferences(pl.id, body);
|
||||
if (!updated) return false;
|
||||
syncPlaylistOrganizeCheckboxes(
|
||||
playlistRef,
|
||||
!!updated.organize_by_playlist,
|
||||
!!updated.keep_playlist_folder_copies,
|
||||
);
|
||||
const data = await res.json();
|
||||
if (!data.found || !data.playlist?.id) {
|
||||
return false;
|
||||
}
|
||||
const patchRes = await fetch(`/api/mirrored-playlists/${data.playlist.id}/preferences`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ organize_by_playlist: !!enabled }),
|
||||
});
|
||||
const patchData = await patchRes.json();
|
||||
if (!patchRes.ok || patchData.error) {
|
||||
return false;
|
||||
}
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, !!enabled);
|
||||
return true;
|
||||
} catch (err) {
|
||||
console.debug('Could not save organize-by-playlist preference:', err);
|
||||
|
|
@ -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) {
|
||||
const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source);
|
||||
const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource);
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, enabled);
|
||||
const prefs = await fetchMirroredPlaylistFolderPreferences(playlistRef, source);
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, prefs.organize, prefs.keepCopies);
|
||||
}
|
||||
|
||||
async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source = 'spotify') {
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, enabled);
|
||||
const defaultKeep = enabled && isSoulsyncStandaloneMode();
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, enabled, defaultKeep);
|
||||
const ok = await setMirroredOrganizePreference(playlistRef, enabled, source);
|
||||
if (!ok) {
|
||||
showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
async function onPlaylistKeepCopiesPreferenceChange(playlistRef, enabled, source = 'spotify') {
|
||||
syncPlaylistOrganizeCheckboxes(playlistRef, true, enabled);
|
||||
const ok = await setMirroredKeepCopiesPreference(playlistRef, enabled, source);
|
||||
if (!ok) {
|
||||
showToast('Could not save keep folder copies preference (mirror this playlist first)', 'warning');
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12124,21 +12124,47 @@ body.helper-mode-active #dashboard-activity-feed:hover {
|
|||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.auto-sync-organize-toggles {
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.auto-sync-organize-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
margin-top: 6px;
|
||||
font-size: 11px;
|
||||
color: rgba(255, 255, 255, 0.72);
|
||||
cursor: pointer;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.auto-sync-organize-subtoggle {
|
||||
margin-top: 4px;
|
||||
margin-left: 14px;
|
||||
color: rgba(255, 255, 255, 0.58);
|
||||
}
|
||||
|
||||
.auto-sync-organize-subtoggle.is-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auto-sync-organize-toggle input {
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.playlist-organize-pref-group .playlist-organize-subtoggle {
|
||||
display: block;
|
||||
margin-top: 6px;
|
||||
margin-left: 18px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
|
||||
.playlist-organize-pref-group .playlist-organize-subtoggle.is-disabled {
|
||||
opacity: 0.45;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.auto-sync-scheduled-timing {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
|
|
|
|||
|
|
@ -1495,7 +1495,8 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName,
|
|||
: 'spotify';
|
||||
await applyMirroredOrganizePreference(virtualPlaylistId, orgSource);
|
||||
if (options.forcePlaylistFolder) {
|
||||
syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true);
|
||||
const defaultKeep = typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode();
|
||||
syncPlaylistOrganizeCheckboxes(virtualPlaylistId, true, defaultKeep);
|
||||
if (typeof setMirroredOrganizePreference === 'function') {
|
||||
await setMirroredOrganizePreference(virtualPlaylistId, true, orgSource);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue