diff --git a/core/downloads/master.py b/core/downloads/master.py index 3769dca3..ca3e72aa 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -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() diff --git a/core/downloads/playlist_folder.py b/core/downloads/playlist_folder.py index f9ef994e..f9e96c3b 100644 --- a/core/downloads/playlist_folder.py +++ b/core/downloads/playlist_folder.py @@ -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', diff --git a/core/playlists/organize_download.py b/core/playlists/organize_download.py index 2fc8d9cb..0766e48a 100644 --- a/core/playlists/organize_download.py +++ b/core/playlists/organize_download.py @@ -125,6 +125,7 @@ def run_playlist_organize_download( 'force_download_all': False, 'ignore_manual_matches': False, 'playlist_folder_mode': True, + 'keep_playlist_folder_copies': bool(pl.get('keep_playlist_folder_copies')), 'is_album_download': False, 'album_context': None, 'artist_context': None, diff --git a/database/music_database.py b/database/music_database.py index e978df0c..9f8b7eb6 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -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]: diff --git a/tests/downloads/test_downloads_master.py b/tests/downloads/test_downloads_master.py index c2c5b758..d6101d61 100644 --- a/tests/downloads/test_downloads_master.py +++ b/tests/downloads/test_downloads_master.py @@ -326,6 +326,59 @@ def test_analysis_phase_sets_state(monkeypatch): assert len(download_batches['B1']['analysis_results']) == 1 +def test_keep_playlist_folder_copies_skips_library_match(monkeypatch): + """With keep copies, a library hit still downloads when the track is absent from this playlist folder.""" + db = _FakeDB(found_tracks={('shared', 'artist'): 1.0}) + db.resolve_mirrored_playlist = lambda *args, **kwargs: { + 'id': 9, + 'name': 'Playlist B', + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': False, + 'keep_playlist_folder_copies_opt_out': False, + } + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + monkeypatch.setattr( + 'core.downloads.playlist_folder.track_exists_in_playlist_folder_from_track_data', + lambda *_a, **_k: False, + ) + + _seed_batch( + 'Bkeep', + playlist_folder_mode=True, + playlist_id='9', + playlist_name='Playlist B', + ) + deps = _build_deps(config=_FakeConfig({'_active_server': 'soulsync'})) + tracks = [{'name': 'Shared', 'artists': ['Artist']}] + + mw.run_full_missing_tracks_process('Bkeep', '9', tracks, deps) + + assert download_batches['Bkeep']['analysis_results'][0]['found'] is False + assert len(download_batches['Bkeep']['queue']) == 1 + + +def test_playlist_folder_mode_without_keep_copies_skips_library_match(monkeypatch): + db = _FakeDB(found_tracks={('shared', 'artist'): 1.0}) + db.resolve_mirrored_playlist = lambda *args, **kwargs: { + 'id': 9, + 'name': 'Playlist B', + 'organize_by_playlist': True, + 'keep_playlist_folder_copies': False, + } + monkeypatch.setattr('database.music_database.MusicDatabase', lambda: db) + monkeypatch.setattr( + 'core.downloads.playlist_folder.track_exists_in_playlist_folder_from_track_data', + lambda *_a, **_k: False, + ) + _seed_batch('Bnokeep', playlist_folder_mode=True, playlist_id='9', playlist_name='Playlist B') + deps = _build_deps() + mw.run_full_missing_tracks_process( + 'Bnokeep', '9', [{'name': 'Shared', 'artists': ['Artist']}], deps + ) + assert download_batches['Bnokeep']['analysis_results'][0]['found'] is True + assert download_batches['Bnokeep']['queue'] == [] + + def test_force_download_treats_all_as_missing(monkeypatch): """force_download_all skips DB check — every track marked missing.""" db = _FakeDB(found_tracks={('t1', 'a'): 1.0, ('t2', 'a'): 1.0}) # would otherwise be found diff --git a/tests/downloads/test_playlist_folder_exists.py b/tests/downloads/test_playlist_folder_exists.py index e3087829..5d0dfbe6 100644 --- a/tests/downloads/test_playlist_folder_exists.py +++ b/tests/downloads/test_playlist_folder_exists.py @@ -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 diff --git a/web_server.py b/web_server.py index b107fccf..b59b7cd0 100644 --- a/web_server.py +++ b/web_server.py @@ -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//preferences', methods=['PATCH']) def update_mirrored_playlist_preferences_endpoint(playlist_id): - """Update per-playlist download preferences (e.g. organize by playlist folder).""" + """Update per-playlist download preferences (playlist-folder layout and copies).""" try: data = request.get_json() or {} - if 'organize_by_playlist' not in data: - return jsonify({"error": "organize_by_playlist is required"}), 400 + if 'organize_by_playlist' not in data and 'keep_playlist_folder_copies' not in data: + return jsonify({"error": "At least one preference field is required"}), 400 database = get_database() playlist = database.get_mirrored_playlist(playlist_id) if not playlist: return jsonify({"error": "Playlist not found"}), 404 - enabled = bool(data.get('organize_by_playlist')) - ok = database.set_mirrored_playlist_organize_by_playlist(playlist_id, enabled) + kwargs = {} + if 'organize_by_playlist' in data: + kwargs['organize_by_playlist'] = bool(data.get('organize_by_playlist')) + if 'keep_playlist_folder_copies' in data: + kwargs['keep_playlist_folder_copies'] = bool(data.get('keep_playlist_folder_copies')) + is_standalone = config_manager.get_active_media_server() == 'soulsync' + if is_standalone and kwargs.get('organize_by_playlist') and 'keep_playlist_folder_copies' not in data: + kwargs['keep_playlist_folder_copies'] = True + ok = database.set_mirrored_playlist_preferences(playlist_id, **kwargs) if not ok: return jsonify({"error": "Failed to update preferences"}), 500 diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index fd243ed1..899dc0a9 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -1577,27 +1577,59 @@ function autoSyncAutomationCardHtml(auto, playlists) { `; } +function autoSyncEffectiveKeepCopies(playlist) { + if (typeof effectiveKeepPlaylistFolderCopies === 'function') { + return effectiveKeepPlaylistFolderCopies(playlist); + } + return !!playlist.keep_playlist_folder_copies; +} + function autoSyncOrganizeToggleHtml(playlist) { - const checked = playlist.organize_by_playlist ? 'checked' : ''; + const organizeChecked = playlist.organize_by_playlist ? 'checked' : ''; + const keepChecked = autoSyncEffectiveKeepCopies(playlist) ? 'checked' : ''; + const keepDisabled = playlist.organize_by_playlist ? '' : 'disabled'; + const keepClass = playlist.organize_by_playlist ? '' : 'is-disabled'; return ` - +
+ + +
`; } +async function patchAutoSyncMirroredPreferences(playlistId, body) { + const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + const data = await res.json(); + if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference'); + const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); + if (pl && data.playlist) { + pl.organize_by_playlist = !!data.playlist.organize_by_playlist; + pl.keep_playlist_folder_copies = !!data.playlist.keep_playlist_folder_copies; + pl.keep_playlist_folder_copies_opt_out = !!data.playlist.keep_playlist_folder_copies_opt_out; + } + return data; +} + async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) { try { - const res = await fetch(`/api/mirrored-playlists/${playlistId}/preferences`, { - method: 'PATCH', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ organize_by_playlist: !!enabled }), - }); - const data = await res.json(); - if (!res.ok || data.error) throw new Error(data.error || 'Failed to update preference'); - const pl = _autoSyncScheduleState.playlists.find(p => parseInt(p.id, 10) === parseInt(playlistId, 10)); - if (pl) pl.organize_by_playlist = !!enabled; + const body = { organize_by_playlist: !!enabled }; + if (!enabled) { + body.keep_playlist_folder_copies = false; + } else if (typeof isSoulsyncStandaloneMode === 'function' && isSoulsyncStandaloneMode()) { + body.keep_playlist_folder_copies = true; + } + await patchAutoSyncMirroredPreferences(playlistId, body); showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', 'success'); } catch (err) { showToast(`Error: ${err.message}`, 'error'); @@ -1605,6 +1637,24 @@ async function setAutoSyncOrganizeByPlaylist(playlistId, enabled) { } } +async function setAutoSyncKeepPlaylistFolderCopies(playlistId, enabled) { + try { + await patchAutoSyncMirroredPreferences(playlistId, { + keep_playlist_folder_copies: !!enabled, + organize_by_playlist: true, + }); + showToast( + enabled + ? 'Missing tracks will download into each playlist folder even if already in library' + : 'Tracks already in library will not be copied into playlist folders', + 'success', + ); + } catch (err) { + showToast(`Error: ${err.message}`, 'error'); + await refreshAutoSyncScheduleModal(); + } +} + function autoSyncScheduledCardHtml(playlist, schedule) { const enabled = schedule?.enabled !== false; const nextLabel = schedule?.next_run ? autoSyncNextRunLabel(schedule.next_run) : ''; diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 67df1945..0ab5c58a 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -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 diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index e641daf9..2a4b9f99 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -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 ` - `; +
+ + +
`; } function playlistOrganizeToggleHtml(playlistRef, source = 'spotify') { const safeRef = String(playlistRef).replace(/'/g, "\\'"); const safeSource = String(source).replace(/'/g, "\\'"); return ` - +
+ + +
`; } -function syncPlaylistOrganizeCheckboxes(playlistRef, enabled) { +function syncPlaylistOrganizeCheckboxes(playlistRef, organizeEnabled, keepCopiesEnabled = false) { const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef)); const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`); - if (detailsCb) detailsCb.checked = !!enabled; - if (downloadMissingCb) downloadMissingCb.checked = !!enabled; + const detailsKeep = document.getElementById(playlistDetailsKeepCopiesCheckboxId(playlistRef)); + const downloadKeep = document.getElementById(`playlist-keep-copies-mode-${playlistRef}`); + const detailsKeepWrap = document.getElementById(`${playlistDetailsKeepCopiesCheckboxId(playlistRef)}-wrap`); + const downloadKeepWrap = document.getElementById(`playlist-keep-copies-wrap-${playlistRef}`); + if (detailsCb) detailsCb.checked = !!organizeEnabled; + if (downloadMissingCb) downloadMissingCb.checked = !!organizeEnabled; + if (detailsKeep) { + detailsKeep.checked = !!organizeEnabled && !!keepCopiesEnabled; + detailsKeep.disabled = !organizeEnabled; + } + if (downloadKeep) { + downloadKeep.checked = !!organizeEnabled && !!keepCopiesEnabled; + downloadKeep.disabled = !organizeEnabled; + } + if (detailsKeepWrap) detailsKeepWrap.classList.toggle('is-disabled', !organizeEnabled); + if (downloadKeepWrap) downloadKeepWrap.classList.toggle('is-disabled', !organizeEnabled); } function isPlaylistOrganizeEnabled(playlistRef) { @@ -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); } diff --git a/webui/static/style.css b/webui/static/style.css index 8c521124..a4f1bba1 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -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; diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 69dd0cef..6acd1ea6 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -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); }