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_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()
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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,
|
||||||
|
|
|
||||||
|
|
@ -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]:
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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) : '';
|
||||||
|
|
|
||||||
|
|
@ -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
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue