From 9ff2e7084a381592ec27b325b5d1b7afd9b302fa Mon Sep 17 00:00:00 2001 From: Francesco Durighetto Date: Tue, 2 Jun 2026 21:42:02 +0200 Subject: [PATCH 1/2] Fix organize-by-playlist downloads: library entries, wishlist, and stale Spotify cache Persist organize_by_playlist on mirrored playlists and run playlist-folder downloads from the auto-sync pipeline instead of the global wishlist phase. Register SoulSync library rows after playlist-folder post-processing, route failed organize batches to the wishlist correctly, and skip sync-time unmatched wishlist only when organize download handles retries. Invalidate stale playlist track caches on refresh (Spotify and Deezer ARL), re-mirror on refetch, and improve standalone playlist modals (re-analysis, Open in Mirrored). Add filesystem missing-track detection and tests. Co-authored-by: Cursor --- core/automation/deps.py | 4 +- core/automation/handlers/_pipeline_shared.py | 39 ++- core/automation/handlers/sync_playlist.py | 2 + core/discovery/sync.py | 19 +- core/downloads/master.py | 90 ++++- core/downloads/playlist_folder.py | 123 +++++++ core/imports/pipeline.py | 20 ++ core/playlists/organize_download.py | 174 ++++++++++ core/wishlist/processing.py | 7 +- database/music_database.py | 91 ++++- services/sync_service.py | 7 +- tests/automation/test_handler_registration.py | 2 + tests/automation/test_handlers_maintenance.py | 2 + .../test_handlers_personalized_pipeline.py | 2 + tests/automation/test_handlers_playlist.py | 31 ++ tests/automation/test_handlers_simple.py | 2 + .../test_playlist_pipeline_folder_mode.py | 117 +++++++ tests/automation/test_progress_callbacks.py | 2 + .../downloads/test_playlist_folder_exists.py | 88 +++++ tests/imports/test_import_pipeline.py | 9 +- tests/wishlist/test_processing.py | 16 + web_server.py | 97 +++++- webui/static/auto-sync.js | 30 ++ webui/static/core.js | 1 + webui/static/downloads.js | 41 ++- webui/static/shared-helpers.js | 317 ++++++++++++++++++ webui/static/style.css | 33 ++ webui/static/sync-services.js | 63 ++-- webui/static/sync-spotify.js | 140 +++++--- 29 files changed, 1465 insertions(+), 104 deletions(-) create mode 100644 core/downloads/playlist_folder.py create mode 100644 core/playlists/organize_download.py create mode 100644 tests/automation/test_playlist_pipeline_folder_mode.py create mode 100644 tests/downloads/test_playlist_folder_exists.py diff --git a/core/automation/deps.py b/core/automation/deps.py index 08d73d47..09c52a17 100644 --- a/core/automation/deps.py +++ b/core/automation/deps.py @@ -28,7 +28,7 @@ from __future__ import annotations import threading from dataclasses import dataclass, field -from typing import Any, Callable, Optional +from typing import Any, Callable, Dict, Optional @dataclass @@ -105,6 +105,8 @@ class AutomationDeps: # --- Playlist pipeline entry points --- run_playlist_discovery_worker: Callable[..., Any] run_sync_task: Callable[..., Any] + run_playlist_organize_download: Callable[..., Dict[str, Any]] + missing_download_executor: Any load_sync_status_file: Callable[[], dict] get_deezer_client: Callable[[], Any] parse_youtube_playlist: Callable[[str], Any] diff --git a/core/automation/handlers/_pipeline_shared.py b/core/automation/handlers/_pipeline_shared.py index 560ae3c3..b5261cbb 100644 --- a/core/automation/handlers/_pipeline_shared.py +++ b/core/automation/handlers/_pipeline_shared.py @@ -148,9 +148,45 @@ def run_sync_and_wishlist( log_type='success' if sync_errors == 0 else 'warning', ) + organize_playlists = [pl for pl in playlists if pl.get('organize_by_playlist')] + organize_started = 0 + if organize_playlists and hasattr(deps, 'run_playlist_organize_download'): + for pl in organize_playlists: + pl_id = pl.get('id') + if not pl_id: + continue + pl_name = pl.get('name', '') + try: + org_result = deps.run_playlist_organize_download( + mirrored_playlist_id=int(pl_id), + automation_id=automation_id, + ) + if org_result.get('status') == 'started': + organize_started += 1 + deps.update_progress( + automation_id, + log_line=f'Organize download started for "{pl_name}"', + log_type='success', + ) + elif org_result.get('status') == 'skipped': + deps.update_progress( + automation_id, + log_line=f'Organize download skipped for "{pl_name}": {org_result.get("reason", "")}', + log_type='skip', + ) + except Exception as org_err: # noqa: BLE001 + deps.update_progress( + automation_id, + log_line=f'Organize download error for "{pl_name}": {org_err}', + log_type='warning', + ) + + all_organize = bool(playlists) and len(organize_playlists) == len(playlists) + effective_skip_wishlist = skip_wishlist or all_organize + wishlist_queued = run_wishlist_phase( deps, automation_id, - skip=skip_wishlist, + skip=effective_skip_wishlist, progress_pct=progress_end + 1, wishlist_phase_label=wishlist_phase_label, wishlist_phase_start_log=wishlist_phase_start_log, @@ -161,6 +197,7 @@ def run_sync_and_wishlist( 'skipped': total_skipped, 'errors': sync_errors, 'wishlist_queued': wishlist_queued, + 'organize_downloads_started': organize_started, } diff --git a/core/automation/handlers/sync_playlist.py b/core/automation/handlers/sync_playlist.py index 5734b48f..631a546f 100644 --- a/core/automation/handlers/sync_playlist.py +++ b/core/automation/handlers/sync_playlist.py @@ -180,9 +180,11 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str log_line=f'Starting sync: {len(tracks_json)} tracks', log_type='success', ) + skip_wishlist_add = bool(pl.get('organize_by_playlist')) threading.Thread( target=deps.run_sync_task, args=(sync_id, pl['name'], tracks_json, auto_id, 1, pl.get('image_url', '')), + kwargs={'skip_wishlist_add': skip_wishlist_add}, daemon=True, name=f'auto-sync-{playlist_id}', ).start() diff --git a/core/discovery/sync.py b/core/discovery/sync.py index eb338edc..2af7ad27 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -133,7 +133,17 @@ async def _database_only_find_track(spotify_track, candidate_pool=None): return None, 0.0 -def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'): +def run_sync_task( + playlist_id, + playlist_name, + tracks_json, + automation_id=None, + profile_id=1, + playlist_image_url='', + deps: SyncDeps = None, + sync_mode: str = 'replace', + skip_wishlist_add: bool = False, +): """The actual sync function that runs in the background thread.""" sync_states = deps.sync_states sync_lock = deps.sync_lock @@ -360,7 +370,14 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p # Wing It mode — skip wishlist for unmatched tracks with sync_lock: is_wing_it = sync_states.get(playlist_id, {}).get('wing_it', False) + sync_service._skip_unmatched_wishlist = is_wing_it or skip_wishlist_add sync_service._skip_wishlist = is_wing_it + if skip_wishlist_add: + logger.info( + "[Organize by Playlist] Skipping sync-time wishlist for '%s' — " + "organize download + batch failure handling cover missing tracks", + playlist_name, + ) # Run the sync (this is a blocking call within this thread) result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode)) diff --git a/core/downloads/master.py b/core/downloads/master.py index f80d46b6..f63488d8 100644 --- a/core/downloads/master.py +++ b/core/downloads/master.py @@ -343,6 +343,10 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_is_album = False batch_profile_id = 1 batch_source = 'spotify' + batch_playlist_folder_mode = False + batch_playlist_name = 'Unknown Playlist' + batch_playlist_id = playlist_id + batch_source_playlist_ref = '' with tasks_lock: if batch_id in download_batches: force_download_all = download_batches[batch_id].get('force_download_all', False) @@ -352,6 +356,29 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma batch_artist_context = download_batches[batch_id].get('artist_context') 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_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 = ( + download_batches[batch_id].get('source_playlist_ref') or '' + ).strip() + + from core.downloads.playlist_folder import ( + 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, + ) + if effective_playlist_folder_mode and not batch_playlist_folder_mode: + with tasks_lock: + if batch_id in download_batches: + download_batches[batch_id]['playlist_folder_mode'] = True + download_batches[batch_id]['playlist_name'] = effective_playlist_name if force_download_all: logger.warning(f"[Force Download] Force download mode enabled for batch {batch_id} - treating all tracks as missing") @@ -445,6 +472,27 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma }) 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, + ): + logger.info( + f"[Playlist Folder] '{track_name}' already on disk in playlist folder — skipping download" + ) + try: + deps.check_and_remove_track_from_wishlist_by_metadata(track_data) + except Exception as _wl_err: + logger.debug(f"[Playlist Folder] Wishlist removal attempt failed: {_wl_err}") + analysis_results.append({ + 'track_index': track_index, + 'track': track_data, + 'found': True, + 'confidence': 1.0, + 'match_reason': 'playlist_folder_file', + }) + continue + # Skip database check if force download is enabled if force_download_all: logger.warning(f"[Force Download] Skipping database check for '{track_name}' - treating as missing") @@ -982,13 +1030,45 @@ def run_full_missing_tracks_process(batch_id, playlist_id, tracks_json, deps: Ma logger.info(f"[Wishlist] Added album context for: '{track_info.get('name')}' -> '{album_ctx['name']}'") - # Add playlist folder mode flag for sync page playlists - if batch_playlist_folder_mode: + # Add playlist folder mode flag for sync page playlists and wishlist + # tracks tied to a mirrored playlist with organize_by_playlist enabled. + task_pl_folder_mode = batch_playlist_folder_mode + task_pl_name = batch_playlist_name + if not task_pl_folder_mode and playlist_id == 'wishlist': + wl_source = track_info.get('source_info') or {} + if isinstance(wl_source, str): + try: + wl_source = json.loads(wl_source) + except (json.JSONDecodeError, TypeError): + wl_source = {} + wl_pl_ref = wl_source.get('playlist_id') + wl_pl_name = wl_source.get('playlist_name') + if wl_pl_ref and hasattr(db, 'resolve_mirrored_playlist'): + wl_mirrored = db.resolve_mirrored_playlist( + wl_pl_ref, + profile_id=batch_profile_id, + ) + if wl_mirrored and wl_mirrored.get('organize_by_playlist'): + task_pl_folder_mode = True + task_pl_name = wl_pl_name or wl_mirrored.get('name') or batch_playlist_name + if task_pl_folder_mode: track_info['_playlist_folder_mode'] = True - track_info['_playlist_name'] = batch_playlist_name - logger.info(f"[Task Creation] Added playlist folder mode for: {track_info.get('name')} → {batch_playlist_name}") + track_info['_playlist_name'] = task_pl_name + if batch_source_playlist_ref: + track_info['source_info'] = { + 'playlist_id': batch_source_playlist_ref, + 'playlist_name': task_pl_name, + 'source': batch_source, + } + logger.info( + f"[Task Creation] Added playlist folder mode for: " + f"{track_info.get('name')} → {task_pl_name}" + ) else: - logger.debug(f"[Debug] Task Creation - playlist folder mode NOT enabled for: {track_info.get('name')}") + logger.debug( + f"[Debug] Task Creation - playlist folder mode NOT enabled for: " + f"{track_info.get('name')}" + ) download_tasks[task_id] = { 'status': 'pending', 'track_info': track_info, diff --git a/core/downloads/playlist_folder.py b/core/downloads/playlist_folder.py new file mode 100644 index 00000000..521cc41e --- /dev/null +++ b/core/downloads/playlist_folder.py @@ -0,0 +1,123 @@ +"""Playlist-folder layout helpers for download analysis and existence checks.""" + +from __future__ import annotations + +import os +from typing import Any, Dict, List, Optional + +from core.downloads.file_finder import AUDIO_EXTENSIONS +from core.imports.paths import ( + _get_config_manager, + docker_resolve_path, + get_file_path_from_template, + sanitize_filename, +) + + +def _first_artist_name(artists: Any) -> str: + if not artists: + return '' + first = artists[0] + if isinstance(first, dict): + return str(first.get('name', '') or '').strip() + return str(first).strip() + + +def candidate_playlist_folder_paths( + playlist_name: str, + artist: str, + title: str, +) -> List[str]: + """Return absolute candidate paths for a track in playlist-folder layout.""" + if not playlist_name or not title: + return [] + + artist_name = (artist or 'Unknown Artist').strip() + track_name = title.strip() + transfer_dir = docker_resolve_path( + _get_config_manager().get('soulseek.transfer_path', './Transfer') + ) + + template_context = { + 'artist': artist_name, + 'albumartist': artist_name, + 'album': track_name, + 'title': track_name, + 'playlist_name': playlist_name, + 'track_number': 1, + 'disc_number': 1, + 'year': '', + 'quality': '', + 'albumtype': '', + '_artists_list': [{'name': artist_name}], + } + + candidates: List[str] = [] + folder_path, filename_base = get_file_path_from_template(template_context, 'playlist_path') + if folder_path and filename_base: + base = os.path.join(transfer_dir, folder_path, filename_base) + for ext in AUDIO_EXTENSIONS: + candidates.append(base + ext) + else: + playlist_name_sanitized = sanitize_filename(playlist_name) + playlist_dir = os.path.join(transfer_dir, playlist_name_sanitized) + artist_name_sanitized = sanitize_filename(artist_name) + track_name_sanitized = sanitize_filename(track_name) + stem = f'{artist_name_sanitized} - {track_name_sanitized}' + for ext in AUDIO_EXTENSIONS: + candidates.append(os.path.join(playlist_dir, stem + ext)) + + return candidates + + +def track_exists_in_playlist_folder( + playlist_name: str, + artist: str, + title: str, +) -> bool: + """Return True if any audio file exists at the playlist-folder path for this track.""" + for path in candidate_playlist_folder_paths(playlist_name, artist, title): + if os.path.isfile(path): + return True + return False + + +def track_exists_in_playlist_folder_from_track_data( + playlist_name: str, + track_data: Dict[str, Any], +) -> bool: + """Check playlist-folder existence using Spotify-style track payload.""" + title = track_data.get('name', '') or track_data.get('track_name', '') + artist = _first_artist_name(track_data.get('artists', [])) + if not artist: + artist = str(track_data.get('artist_name', '') or '').strip() + return track_exists_in_playlist_folder(playlist_name, artist, title) + + +def resolve_playlist_folder_mode_for_batch( + db: Any, + *, + playlist_id: str, + playlist_name: str, + batch_playlist_folder_mode: bool, + profile_id: int = 1, +) -> tuple[bool, str]: + """Merge batch flag with persisted mirrored-playlist preference.""" + if batch_playlist_folder_mode: + return True, playlist_name + + if not hasattr(db, 'resolve_mirrored_playlist'): + return False, playlist_name + + mirrored = db.resolve_mirrored_playlist(playlist_id, profile_id=profile_id) + if mirrored and mirrored.get('organize_by_playlist'): + return True, mirrored.get('name') or playlist_name + return False, playlist_name + + +__all__ = [ + 'candidate_playlist_folder_paths', + 'track_exists_in_playlist_folder', + 'track_exists_in_playlist_folder_from_track_data', + 'resolve_playlist_folder_mode_for_batch', +] diff --git a/core/imports/pipeline.py b/core/imports/pipeline.py index fa196aa5..f43e34e8 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -563,6 +563,26 @@ def post_process_matched_download(context_key, context, file_path, runtime, meta record_library_history_download(context) record_download_provenance(context) + try: + pf_album_info = build_import_album_info(context, force_album=False) + if not pf_album_info or not pf_album_info.get("album_name"): + pf_album_info = { + "is_album": True, + "album_name": playlist_name, + "track_number": track_info.get("track_number", 1) or 1, + "disc_number": track_info.get("disc_number", 1) or 1, + "clean_track_name": get_import_clean_title( + context, + default=get_import_original_search(context).get("title", "Unknown"), + ), + "source": get_import_source(context) or "spotify", + } + elif not pf_album_info.get("is_album"): + pf_album_info["is_album"] = True + record_soulsync_library_entry(context, artist_context, pf_album_info) + except Exception as lib_err: + logger.error(f"[Playlist Folder] SoulSync library registration failed: {lib_err}") + task_id = context.get('task_id') batch_id = context.get('batch_id') if task_id and batch_id: diff --git a/core/playlists/organize_download.py b/core/playlists/organize_download.py new file mode 100644 index 00000000..84ee2756 --- /dev/null +++ b/core/playlists/organize_download.py @@ -0,0 +1,174 @@ +"""Download missing tracks into playlist-folder layout for mirrored playlists.""" + +from __future__ import annotations + +import json +import logging +import uuid +from typing import Any, Callable, Dict, List, Optional + +logger = logging.getLogger(__name__) + + +def mirrored_tracks_to_download_json(tracks: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + """Convert mirrored playlist rows to the payload expected by the download master.""" + out: List[Dict[str, Any]] = [] + for t in tracks: + extra = {} + if t.get('extra_data'): + try: + extra = json.loads(t['extra_data']) if isinstance(t['extra_data'], str) else t['extra_data'] + except (json.JSONDecodeError, TypeError): + extra = {} + + if extra.get('discovered') and extra.get('matched_data'): + md = extra['matched_data'] + album_raw = md.get('album', '') + album_obj = album_raw if isinstance(album_raw, dict) else {'name': album_raw or ''} + entry = { + 'name': md.get('name', ''), + 'artists': md.get('artists', [{'name': t.get('artist_name', '')}]), + 'album': album_obj, + 'duration_ms': md.get('duration_ms', 0), + 'id': md.get('id', ''), + } + if md.get('track_number'): + entry['track_number'] = md['track_number'] + if md.get('disc_number'): + entry['disc_number'] = md['disc_number'] + out.append(entry) + continue + + hint = extra.get('spotify_hint', {}) + track_image = (t.get('image_url') or '').strip() + album_obj = { + 'name': (t.get('album_name') or '').strip(), + 'images': [{'url': track_image, 'height': 300, 'width': 300}] if track_image else [], + } + + if hint.get('id') and hint.get('name'): + hint_artists = hint.get('artists', []) + if hint_artists and isinstance(hint_artists[0], str): + hint_artists = [{'name': a} for a in hint_artists] + elif not hint_artists: + hint_artists = [{'name': t.get('artist_name', '')}] + out.append({ + 'name': hint['name'], + 'artists': hint_artists, + 'album': album_obj, + 'duration_ms': t.get('duration_ms', 0), + 'id': hint['id'], + }) + elif t.get('source_track_id') and (t.get('track_name') or '').strip(): + out.append({ + 'name': t['track_name'].strip(), + 'artists': [{'name': (t.get('artist_name') or '').strip() or 'Unknown Artist'}], + 'album': album_obj, + 'duration_ms': t.get('duration_ms', 0), + 'id': t['source_track_id'], + }) + return out + + +def run_playlist_organize_download( + deps: Any, + *, + mirrored_playlist_id: int, + profile_id: int = 1, + get_batch_max_concurrent: Callable[[bool], int], + run_full_missing_tracks_process: Callable[..., Any], + record_sync_history_start: Optional[Callable[..., Any]] = None, + detect_sync_source: Optional[Callable[[str], str]] = None, +) -> Dict[str, Any]: + """Queue a playlist-folder missing-tracks batch for one mirrored playlist.""" + db = deps.get_database() + pl = db.get_mirrored_playlist(int(mirrored_playlist_id)) + if not pl: + return {'status': 'error', 'reason': 'Playlist not found'} + + source_playlist_ref = (pl.get('source_playlist_id') or '').strip() + source = (pl.get('source') or 'spotify').strip() or 'spotify' + + tracks = db.get_mirrored_playlist_tracks(int(mirrored_playlist_id)) + tracks_json = mirrored_tracks_to_download_json(tracks) + if not tracks_json: + return {'status': 'skipped', 'reason': 'No processable tracks'} + + batch_id = str(uuid.uuid4()) + playlist_id = str(mirrored_playlist_id) + playlist_name = pl.get('name', 'Unknown Playlist') + + download_batches = deps.get_download_batches() + tasks_lock = deps.tasks_lock + + with tasks_lock: + active_analysis = sum( + 1 for batch in download_batches.values() if batch.get('phase') == 'analysis' + ) + if active_analysis >= 3: + return {'status': 'error', 'reason': 'Too many analysis processes running'} + + download_batches[batch_id] = { + 'phase': 'analysis', + 'playlist_id': playlist_id, + 'playlist_name': playlist_name, + 'queue': [], + 'active_count': 0, + 'max_concurrent': get_batch_max_concurrent(False), + 'permanently_failed_tracks': [], + 'cancelled_tracks': set(), + 'queue_index': 0, + 'analysis_total': len(tracks_json), + 'profile_id': profile_id, + 'analysis_processed': 0, + 'analysis_results': [], + 'force_download_all': False, + 'ignore_manual_matches': False, + 'playlist_folder_mode': True, + 'is_album_download': False, + 'album_context': None, + 'artist_context': None, + 'wing_it': False, + 'batch_source': source, + 'auto_initiated': False, + 'organize_by_playlist': True, + 'source_playlist_ref': source_playlist_ref, + 'mirrored_playlist_id': int(mirrored_playlist_id), + } + + if record_sync_history_start: + try: + record_sync_history_start( + batch_id, + playlist_id, + playlist_name, + tracks_json, + False, + None, + None, + True, + source_page='automation', + ) + except Exception as hist_err: + logger.debug("organize download sync history: %s", hist_err) + + deps.missing_download_executor.submit( + run_full_missing_tracks_process, + batch_id, + playlist_id, + tracks_json, + ) + logger.info( + "[Organize Download] Started batch %s for mirrored playlist %s (%s tracks)", + batch_id, + playlist_name, + len(tracks_json), + ) + return { + 'status': 'started', + 'batch_id': batch_id, + 'track_count': len(tracks_json), + } + + +__all__ = ['mirrored_tracks_to_download_json', 'run_playlist_organize_download'] diff --git a/core/wishlist/processing.py b/core/wishlist/processing.py index fb6cc20e..2074921e 100644 --- a/core/wishlist/processing.py +++ b/core/wishlist/processing.py @@ -394,12 +394,17 @@ def resolve_wishlist_source_type_for_batch(batch: Dict[str, Any]) -> str: def build_wishlist_source_context(batch: Dict[str, Any], current_time: datetime | None = None) -> Dict[str, Any]: """Build the source_context payload used when adding failed tracks back to the wishlist.""" current_time = current_time or datetime.now() + playlist_id = batch.get('source_playlist_ref') or batch.get('playlist_id') context = { 'playlist_name': batch.get('playlist_name', 'Unknown Playlist'), - 'playlist_id': batch.get('playlist_id', None), + 'playlist_id': playlist_id, 'added_from': 'webui_modal', 'timestamp': current_time.isoformat(), } + if batch.get('mirrored_playlist_id') is not None: + context['mirrored_playlist_id'] = batch.get('mirrored_playlist_id') + if batch.get('organize_by_playlist'): + context['organize_by_playlist'] = True # Preserve album-batch provenance so wishlist requeue has a real signal # for album-vs-single routing instead of relying on per-track album dicts # that may have been mangled by reconstruction fallbacks. diff --git a/database/music_database.py b/database/music_database.py index 85beef86..9b39329f 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -571,6 +571,7 @@ class MusicDatabase: # Add explored_at to mirrored_playlists (migration) self._add_mirrored_playlist_explored_column(cursor) + self._add_mirrored_playlist_organize_column(cursor) # Add notification columns to automations (migration) self._add_automation_notify_columns(cursor) @@ -970,6 +971,19 @@ class MusicDatabase: except Exception as e: logger.error(f"Error adding explored_at column to mirrored_playlists: {e}") + def _add_mirrored_playlist_organize_column(self, cursor): + """Add organize_by_playlist preference for playlist-folder downloads.""" + try: + cursor.execute("PRAGMA table_info(mirrored_playlists)") + cols = [c[1] for c in cursor.fetchall()] + if 'organize_by_playlist' not in cols: + cursor.execute( + "ALTER TABLE mirrored_playlists ADD COLUMN organize_by_playlist INTEGER NOT NULL DEFAULT 0" + ) + logger.info("Added organize_by_playlist column to mirrored_playlists table") + except Exception as e: + logger.error(f"Error adding organize_by_playlist column to mirrored_playlists: {e}") + def _add_automation_notify_columns(self, cursor): """Add notification and result columns to automations table.""" try: @@ -12171,7 +12185,11 @@ class MusicDatabase: WHERE profile_id = ? ORDER BY updated_at DESC """, (profile_id,)) - return [dict(row) for row in cursor.fetchall()] + return [ + self._normalize_mirrored_playlist_row(row) + for row in cursor.fetchall() + if row + ] except Exception as e: logger.error(f"Error getting mirrored playlists: {e}") return [] @@ -12198,11 +12216,80 @@ class MusicDatabase: cursor = conn.cursor() cursor.execute("SELECT * FROM mirrored_playlists WHERE id = ?", (playlist_id,)) row = cursor.fetchone() - return dict(row) if row else None + return self._normalize_mirrored_playlist_row(row) except Exception as e: logger.error(f"Error getting mirrored playlist: {e}") return None + @staticmethod + def _normalize_mirrored_playlist_row(row) -> Optional[Dict]: + if not row: + return None + pl = dict(row) + pl['organize_by_playlist'] = bool(pl.get('organize_by_playlist', 0)) + return pl + + def get_mirrored_playlist_by_source( + self, + source: str, + source_playlist_id: str, + profile_id: int = 1, + ) -> Optional[Dict]: + """Return a mirrored playlist by upstream source id.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + SELECT * FROM mirrored_playlists + WHERE source = ? AND source_playlist_id = ? AND profile_id = ? + """, + (source, str(source_playlist_id), profile_id), + ) + row = cursor.fetchone() + return self._normalize_mirrored_playlist_row(row) + except Exception as e: + logger.error(f"Error getting mirrored playlist by source: {e}") + return None + + def resolve_mirrored_playlist( + self, + playlist_ref: Any, + profile_id: int = 1, + *, + default_source: str = 'spotify', + ) -> Optional[Dict]: + """Resolve a mirrored playlist from numeric id or upstream source id.""" + if playlist_ref is None or playlist_ref == '': + return None + ref = str(playlist_ref).strip() + if ref.isdigit(): + return self.get_mirrored_playlist(int(ref)) + return self.get_mirrored_playlist_by_source(default_source, ref, profile_id) + + def set_mirrored_playlist_organize_by_playlist( + self, + playlist_id: int, + enabled: bool, + ) -> bool: + """Persist whether downloads for this playlist use playlist-folder layout.""" + try: + with self._get_connection() as conn: + cursor = conn.cursor() + cursor.execute( + """ + UPDATE mirrored_playlists + SET organize_by_playlist = ?, updated_at = CURRENT_TIMESTAMP + WHERE id = ? + """, + (1 if enabled 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}") + return False + def get_mirrored_playlist_tracks(self, playlist_id: int) -> List[Dict]: """Return all tracks for a mirrored playlist ordered by position.""" try: diff --git a/services/sync_service.py b/services/sync_service.py index e51980ef..2a3488c7 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -357,8 +357,11 @@ class PlaylistSyncService: # Auto-add unmatched tracks to wishlist (skip in Wing It mode) wishlist_added_count = 0 - if unmatched_tracks and getattr(self, '_skip_wishlist', False): - logger.info(f"[Wing It] Skipping wishlist for {len(unmatched_tracks)} unmatched tracks") + if unmatched_tracks and getattr(self, '_skip_unmatched_wishlist', False): + logger.info( + "Skipping sync-time wishlist for %s unmatched tracks (wing-it or organize-by-playlist)", + len(unmatched_tracks), + ) unmatched_tracks = [] # Clear so the loop below doesn't run if unmatched_tracks: try: diff --git a/tests/automation/test_handler_registration.py b/tests/automation/test_handler_registration.py index b6660541..357ebc06 100644 --- a/tests/automation/test_handler_registration.py +++ b/tests/automation/test_handler_registration.py @@ -126,6 +126,8 @@ def _build_deps(engine, scan_mgr=None) -> AutomationDeps: get_watchlist_scan_state=lambda: {}, run_playlist_discovery_worker=lambda *a, **k: None, run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'skipped'}, + missing_download_executor=None, load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, diff --git a/tests/automation/test_handlers_maintenance.py b/tests/automation/test_handlers_maintenance.py index a3844b77..e9c62a05 100644 --- a/tests/automation/test_handlers_maintenance.py +++ b/tests/automation/test_handlers_maintenance.py @@ -73,6 +73,8 @@ def _build_deps(**overrides) -> AutomationDeps: get_watchlist_scan_state=lambda: {}, run_playlist_discovery_worker=lambda *a, **k: None, run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'skipped'}, + missing_download_executor=None, load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, diff --git a/tests/automation/test_handlers_personalized_pipeline.py b/tests/automation/test_handlers_personalized_pipeline.py index 049cd466..af91bb03 100644 --- a/tests/automation/test_handlers_personalized_pipeline.py +++ b/tests/automation/test_handlers_personalized_pipeline.py @@ -47,6 +47,8 @@ def _build_deps(**overrides) -> AutomationDeps: get_watchlist_scan_state=lambda: {}, run_playlist_discovery_worker=lambda *a, **k: None, run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'skipped'}, + missing_download_executor=None, load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, diff --git a/tests/automation/test_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index ff8b132b..9cd5f2e1 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -108,6 +108,8 @@ def _build_deps(**overrides) -> AutomationDeps: get_watchlist_scan_state=lambda: {}, run_playlist_discovery_worker=lambda *a, **k: None, run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'skipped'}, + missing_download_executor=None, load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, @@ -737,6 +739,35 @@ class TestSyncPlaylist: time.sleep(0.01) assert len(sync_calls) == 1 + def test_organize_by_playlist_passes_skip_wishlist_add(self): + discovered_track = { + 'extra_data': json.dumps({ + 'discovered': True, + 'matched_data': { + 'id': 'spot-1', 'name': 'Track', 'artists': [{'name': 'X'}], + 'album': {'name': 'Album'}, 'duration_ms': 200000, + }, + }), + 'artist_name': 'X', + } + db = _StubDB( + playlists=[{'id': 1, 'name': 'P', 'organize_by_playlist': True}], + playlist_tracks={1: [discovered_track]}, + ) + sync_calls: List[tuple] = [] + deps = _build_deps( + get_database=lambda: db, + run_sync_task=lambda *a, **k: sync_calls.append((a, k)), + ) + auto_sync_playlist({'playlist_id': '1'}, deps) + for _ in range(50): + if sync_calls: + break + import time + time.sleep(0.01) + assert sync_calls + assert sync_calls[0][1].get('skip_wishlist_add') is True + def test_unchanged_since_last_sync_returns_skipped(self): discovered_track = { 'extra_data': json.dumps({ diff --git a/tests/automation/test_handlers_simple.py b/tests/automation/test_handlers_simple.py index 5e535884..9320b96d 100644 --- a/tests/automation/test_handlers_simple.py +++ b/tests/automation/test_handlers_simple.py @@ -60,6 +60,8 @@ def _build_deps(**overrides: Any) -> AutomationDeps: get_watchlist_scan_state=lambda: {}, run_playlist_discovery_worker=lambda *a, **k: None, run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'skipped'}, + missing_download_executor=None, load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, diff --git a/tests/automation/test_playlist_pipeline_folder_mode.py b/tests/automation/test_playlist_pipeline_folder_mode.py new file mode 100644 index 00000000..9c412fce --- /dev/null +++ b/tests/automation/test_playlist_pipeline_folder_mode.py @@ -0,0 +1,117 @@ +"""Tests for organize-by-playlist integration in the playlist pipeline tail.""" + +from unittest.mock import MagicMock + +from core.automation.deps import AutomationDeps, AutomationState +from core.automation.handlers._pipeline_shared import run_sync_and_wishlist +import threading + + +def _minimal_deps(**overrides): + base = dict( + engine=MagicMock(), + state=AutomationState(), + config_manager=MagicMock(), + update_progress=lambda *a, **k: None, + logger=MagicMock(), + get_database=lambda: MagicMock(), + spotify_client=None, + tidal_client=None, + web_scan_manager=None, + process_wishlist_automatically=lambda **k: None, + process_watchlist_scan_automatically=lambda **k: None, + is_wishlist_actually_processing=lambda: False, + is_watchlist_actually_scanning=lambda: False, + get_watchlist_scan_state=lambda: {}, + run_playlist_discovery_worker=lambda *a, **k: None, + run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'started', 'batch_id': 'b1'}, + missing_download_executor=None, + load_sync_status_file=lambda: {}, + get_deezer_client=lambda: None, + parse_youtube_playlist=lambda url: None, + get_sync_states=lambda: {}, + set_db_update_automation_id=lambda v: None, + get_db_update_state=lambda: {}, + db_update_lock=threading.Lock(), + db_update_executor=None, + run_db_update_task=lambda *a, **k: None, + run_deep_scan_task=lambda *a, **k: None, + get_duplicate_cleaner_state=lambda: {}, + duplicate_cleaner_lock=threading.Lock(), + duplicate_cleaner_executor=None, + run_duplicate_cleaner=lambda: None, + get_quality_scanner_state=lambda: {}, + quality_scanner_lock=threading.Lock(), + quality_scanner_executor=None, + run_quality_scanner=lambda *a, **k: None, + download_orchestrator=None, + run_async=lambda coro: None, + tasks_lock=threading.Lock(), + get_download_batches=lambda: {}, + get_download_tasks=lambda: {}, + sweep_empty_download_directories=lambda: 0, + get_staging_path=lambda: '/staging', + docker_resolve_path=lambda p: p, + get_current_profile_id=lambda: 1, + get_watchlist_scanner=lambda spc: None, + get_app=lambda: None, + get_beatport_data_cache=lambda: {'cache_lock': threading.Lock(), 'homepage': {}}, + init_automation_progress=lambda *a, **k: None, + record_progress_history=lambda *a, **k: None, + build_personalized_manager=lambda: None, + ) + base.update(overrides) + return AutomationDeps(**base) # type: ignore[arg-type] + + +def test_all_organize_playlists_skips_wishlist(): + wishlist_calls = [] + + def sync_one(_pl): + return {'status': 'skipped', 'reason': 'unchanged'} + + deps = _minimal_deps( + process_wishlist_automatically=lambda **k: wishlist_calls.append(k), + ) + playlists = [ + {'id': 1, 'name': 'A', 'organize_by_playlist': True}, + ] + result = run_sync_and_wishlist( + deps, + 'auto-1', + playlists, + sync_one_fn=sync_one, + sync_id_for_fn=lambda pl: f'mirror_{pl["id"]}', + skip_wishlist=False, + ) + assert result['wishlist_queued'] == 0 + assert result['organize_downloads_started'] == 1 + assert wishlist_calls == [] + + +def test_mixed_playlists_still_runs_wishlist(): + wishlist_calls = [] + + deps = _minimal_deps( + process_wishlist_automatically=lambda **k: wishlist_calls.append(1), + is_wishlist_actually_processing=lambda: False, + ) + playlists = [ + {'id': 1, 'name': 'Organized', 'organize_by_playlist': True}, + {'id': 2, 'name': 'Normal', 'organize_by_playlist': False}, + ] + + def sync_one(_pl): + return {'status': 'skipped'} + + result = run_sync_and_wishlist( + deps, + None, + playlists, + sync_one_fn=sync_one, + sync_id_for_fn=lambda pl: f'mirror_{pl["id"]}', + ) + assert result['organize_downloads_started'] == 1 + assert result['wishlist_queued'] == 1 + assert len(wishlist_calls) == 1 diff --git a/tests/automation/test_progress_callbacks.py b/tests/automation/test_progress_callbacks.py index 10456bfd..16728255 100644 --- a/tests/automation/test_progress_callbacks.py +++ b/tests/automation/test_progress_callbacks.py @@ -46,6 +46,8 @@ def _build_deps(**overrides) -> AutomationDeps: get_watchlist_scan_state=lambda: {}, run_playlist_discovery_worker=lambda *a, **k: None, run_sync_task=lambda *a, **k: None, + run_playlist_organize_download=lambda **k: {'status': 'skipped'}, + missing_download_executor=None, load_sync_status_file=lambda: {}, get_deezer_client=lambda: None, parse_youtube_playlist=lambda url: None, diff --git a/tests/downloads/test_playlist_folder_exists.py b/tests/downloads/test_playlist_folder_exists.py new file mode 100644 index 00000000..e3087829 --- /dev/null +++ b/tests/downloads/test_playlist_folder_exists.py @@ -0,0 +1,88 @@ +"""Tests for playlist-folder existence detection.""" + +import os +from unittest.mock import patch + +import pytest + +from core.downloads.playlist_folder import ( + candidate_playlist_folder_paths, + resolve_playlist_folder_mode_for_batch, + track_exists_in_playlist_folder, +) + + +class _FakeDB: + def __init__(self, mirrored=None): + self._mirrored = mirrored + + def resolve_mirrored_playlist(self, playlist_ref, profile_id=1, default_source='spotify'): + return self._mirrored + + +def test_track_exists_in_playlist_folder_finds_file(tmp_path): + playlist_dir = tmp_path / 'My Playlist' + playlist_dir.mkdir() + track_file = playlist_dir / 'Artist A - Song One.flac' + track_file.write_bytes(b'x') + + with patch('core.downloads.playlist_folder._get_config_manager') as cfg: + cfg.return_value.get.return_value = str(tmp_path) + with patch('core.downloads.playlist_folder.docker_resolve_path', side_effect=lambda p: p): + with patch( + 'core.downloads.playlist_folder.get_file_path_from_template', + return_value=('', ''), + ): + assert track_exists_in_playlist_folder('My Playlist', 'Artist A', 'Song One') + + +def test_track_exists_in_playlist_folder_missing(tmp_path): + with patch('core.downloads.playlist_folder._get_config_manager') as cfg: + cfg.return_value.get.return_value = str(tmp_path) + with patch('core.downloads.playlist_folder.docker_resolve_path', side_effect=lambda p: p): + with patch( + 'core.downloads.playlist_folder.get_file_path_from_template', + return_value=('', ''), + ): + assert not track_exists_in_playlist_folder('My Playlist', 'Artist A', 'Song One') + + +def test_candidate_paths_template_layout(tmp_path): + with patch('core.downloads.playlist_folder._get_config_manager') as cfg: + cfg.return_value.get.return_value = str(tmp_path) + with patch('core.downloads.playlist_folder.docker_resolve_path', side_effect=lambda p: p): + with patch( + 'core.downloads.playlist_folder.get_file_path_from_template', + return_value=('Cool Mix', 'Artist - Title'), + ): + paths = candidate_playlist_folder_paths('Cool Mix', 'Artist', 'Title') + assert any(p.endswith('.flac') for p in paths) + assert all('Cool Mix' in p for p in paths) + + +def test_resolve_playlist_folder_mode_from_mirrored(): + db = _FakeDB(mirrored={ + 'id': 5, + 'name': 'Rekordbox Set', + 'organize_by_playlist': True, + }) + enabled, name = resolve_playlist_folder_mode_for_batch( + db, + playlist_id='37i9dQZF1', + playlist_name='Other Name', + batch_playlist_folder_mode=False, + ) + assert enabled is True + assert name == 'Rekordbox Set' + + +def test_resolve_playlist_folder_mode_batch_flag(): + db = _FakeDB() + enabled, name = resolve_playlist_folder_mode_for_batch( + db, + playlist_id='1', + playlist_name='Batch Name', + batch_playlist_folder_mode=True, + ) + assert enabled is True + assert name == 'Batch Name' diff --git a/tests/imports/test_import_pipeline.py b/tests/imports/test_import_pipeline.py index 1a4048ae..13684241 100644 --- a/tests/imports/test_import_pipeline.py +++ b/tests/imports/test_import_pipeline.py @@ -202,7 +202,12 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa monkeypatch.setattr(import_pipeline, "emit_track_downloaded", lambda *args, **kwargs: None) monkeypatch.setattr(import_pipeline, "record_library_history_download", lambda *args, **kwargs: None) monkeypatch.setattr(import_pipeline, "record_download_provenance", lambda *args, **kwargs: None) - monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", lambda *args, **kwargs: None) + library_calls = [] + + def _record_library(context, artist_context, album_info): + library_calls.append((context, artist_context, album_info)) + + monkeypatch.setattr(import_pipeline, "record_soulsync_library_entry", _record_library) monkeypatch.setattr(import_pipeline, "check_and_remove_from_wishlist", lambda *args, **kwargs: None) monkeypatch.setattr(import_pipeline, "record_retag_download", lambda *args, **kwargs: None) @@ -221,6 +226,8 @@ def test_post_process_matched_download_forwards_separate_metadata_runtime(tmp_pa ) assert seen["runtime"] is metadata_runtime + assert len(library_calls) == 1 + assert library_calls[0][2]["album_name"] == "Album" # --------------------------------------------------------------------------- diff --git a/tests/wishlist/test_processing.py b/tests/wishlist/test_processing.py index 5cfa638c..28f7e3ec 100644 --- a/tests/wishlist/test_processing.py +++ b/tests/wishlist/test_processing.py @@ -177,6 +177,22 @@ def test_build_wishlist_source_context_minimal_batch_skips_album_provenance(): assert "artist_context" not in context +def test_build_wishlist_source_context_uses_source_playlist_ref_for_organize_batches(): + batch = { + "playlist_name": "Summer Mix", + "playlist_id": "42", + "source_playlist_ref": "spotifyPlaylistId123", + "mirrored_playlist_id": 42, + "organize_by_playlist": True, + } + + context = processing.build_wishlist_source_context(batch) + + assert context["playlist_id"] == "spotifyPlaylistId123" + assert context["mirrored_playlist_id"] == 42 + assert context["organize_by_playlist"] is True + + def test_build_wishlist_source_context_preserves_album_context_for_album_batches(): """Album batches must carry album_context/artist_context through to the wishlist row so a later requeue has authoritative routing data instead diff --git a/web_server.py b/web_server.py index 9572dfe8..538f2591 100644 --- a/web_server.py +++ b/web_server.py @@ -1084,6 +1084,8 @@ def _register_automation_handlers(): get_watchlist_scan_state=lambda: watchlist_scan_state, run_playlist_discovery_worker=_run_playlist_discovery_worker, run_sync_task=_run_sync_task, + run_playlist_organize_download=_run_playlist_organize_download, + missing_download_executor=missing_download_executor, load_sync_status_file=_load_sync_status_file, get_deezer_client=_get_deezer_client, parse_youtube_playlist=parse_youtube_playlist, @@ -18788,6 +18790,23 @@ def start_missing_tracks_process(playlist_id): if playlist_folder_mode: logger.info(f"[Playlist Folder] Enabled for playlist: '{playlist_name}'") + # Persist organize-by-playlist preference on the mirrored playlist row + try: + profile_id = get_current_profile_id() + db_pref = get_database() + mirrored_pl = db_pref.resolve_mirrored_playlist( + playlist_id, + profile_id=profile_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), + ) + except Exception as pref_err: + logger.debug(f"[Playlist Folder] Could not persist mirrored preference: {pref_err}") + # Limit concurrent analysis processes to prevent resource exhaustion with tasks_lock: active_analysis_count = sum(1 for batch in download_batches.values() @@ -23615,11 +23634,38 @@ def _build_sync_deps(): ) -def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', sync_mode='replace'): +def _run_sync_task( + playlist_id, + playlist_name, + tracks_json, + automation_id=None, + profile_id=1, + playlist_image_url='', + sync_mode='replace', + skip_wishlist_add=False, +): return _discovery_sync.run_sync_task( playlist_id, playlist_name, tracks_json, automation_id, profile_id, playlist_image_url, _build_sync_deps(), sync_mode=sync_mode, + skip_wishlist_add=skip_wishlist_add, + ) + + +def _run_playlist_organize_download(mirrored_playlist_id, automation_id=None, profile_id=None): + """Start a playlist-folder missing-tracks batch for automation / pipeline.""" + from core.playlists.organize_download import run_playlist_organize_download + + if profile_id is None: + profile_id = get_current_profile_id() + return run_playlist_organize_download( + _automation_deps, + mirrored_playlist_id=int(mirrored_playlist_id), + profile_id=profile_id, + get_batch_max_concurrent=_get_batch_max_concurrent, + run_full_missing_tracks_process=_run_full_missing_tracks_process, + record_sync_history_start=_record_sync_history_start, + detect_sync_source=_downloads_history.detect_sync_source, ) @@ -31664,6 +31710,55 @@ def update_mirrored_playlist_source_ref_endpoint(playlist_id): return jsonify({"error": str(e)}), 500 +@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).""" + try: + data = request.get_json() or {} + if 'organize_by_playlist' not in data: + return jsonify({"error": "organize_by_playlist 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) + if not ok: + return jsonify({"error": "Failed to update preferences"}), 500 + + updated = database.get_mirrored_playlist(playlist_id) or {} + return jsonify({"success": True, "playlist": updated}) + except Exception as e: + logger.error(f"Error updating mirrored playlist preferences: {e}") + return jsonify({"error": str(e)}), 500 + + +@app.route('/api/mirrored-playlists/resolve', methods=['GET']) +def resolve_mirrored_playlist_endpoint(): + """Resolve mirrored playlist by numeric id or upstream source id (e.g. Spotify playlist id).""" + try: + playlist_ref = request.args.get('ref') or request.args.get('playlist_id') + source = request.args.get('source', 'spotify') + profile_id = get_current_profile_id() + if not playlist_ref: + return jsonify({"error": "ref or playlist_id query param required"}), 400 + + database = get_database() + playlist = database.resolve_mirrored_playlist( + playlist_ref, + profile_id=profile_id, + default_source=source, + ) + if not playlist: + return jsonify({"found": False, "playlist": None}) + return jsonify({"found": True, "playlist": playlist}) + except Exception as e: + logger.error(f"Error resolving mirrored playlist: {e}") + return jsonify({"error": str(e)}), 500 + + def _playlist_pipeline_state_key(playlist_id): return f"mirrored_{int(playlist_id)}" diff --git a/webui/static/auto-sync.js b/webui/static/auto-sync.js index 2b16e7e7..fd243ed1 100644 --- a/webui/static/auto-sync.js +++ b/webui/static/auto-sync.js @@ -675,6 +675,7 @@ function autoSyncWeeklyCardHtml(playlist, schedule) { ${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
+ ${autoSyncOrganizeToggleHtml(playlist)}
${_esc(label)} ${_esc(tz)} @@ -1576,6 +1577,34 @@ function autoSyncAutomationCardHtml(auto, playlists) { `; } +function autoSyncOrganizeToggleHtml(playlist) { + const checked = playlist.organize_by_playlist ? 'checked' : ''; + return ` + + `; +} + +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; + showToast(enabled ? 'Auto-Sync will use playlist folders' : 'Auto-Sync will use standard download layout', '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) : ''; @@ -1592,6 +1621,7 @@ function autoSyncScheduledCardHtml(playlist, schedule) { ${_esc(playlist.name)}
${_esc(autoSyncSourceLabel(playlist.source))} · ${playlist.track_count || 0} tracks
+ ${autoSyncOrganizeToggleHtml(playlist)}
${_esc(autoSyncIntervalLabel(schedule?.hours || 24))} ${nextLabel ? `${_esc(nextLabel)}` : ''} diff --git a/webui/static/core.js b/webui/static/core.js index 2ba0d6b2..0723239d 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -521,6 +521,7 @@ function handleServiceStatusUpdate(data) { _isSoulsyncStandalone = isSoulsyncStandalone; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. + if (btn.classList.contains('soulsync-standalone-action')) return; if (isSoulsyncStandalone) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/downloads.js b/webui/static/downloads.js index 70091e06..dd1df920 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -2449,8 +2449,9 @@ async function startMissingTracksProcess(playlistId) { const forceDownloadAll = forceDownloadCheckbox ? forceDownloadCheckbox.checked : false; // Check if playlist folder mode toggle is enabled (only for sync page playlists) - const playlistFolderModeCheckbox = document.getElementById(`playlist-folder-mode-${playlistId}`); - const playlistFolderMode = playlistFolderModeCheckbox ? playlistFolderModeCheckbox.checked : false; + const playlistFolderMode = typeof isPlaylistOrganizeEnabled === 'function' + ? isPlaylistOrganizeEnabled(playlistId) + : (document.getElementById(`playlist-folder-mode-${playlistId}`)?.checked ?? false); // Hide the force download toggle during processing const forceToggleContainer = forceDownloadCheckbox ? forceDownloadCheckbox.closest('.force-download-toggle-container') : null; @@ -4425,20 +4426,34 @@ async function startPlaylistSync(playlistId, syncModeOverride = null) { } // Ensure we have the full track list before starting + const playlistMeta = spotifyPlaylists.find(p => p.id === playlistId); let tracks = playlistTrackCache[playlistId]; - if (!tracks) { + const cacheStale = typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(playlistId, playlistMeta); + if (!tracks || cacheStale) { const trackFetchStart = Date.now(); - console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] Cache miss - fetching tracks for playlist ${playlistId}`); + console.log(`🔄 [${new Date().toTimeString().split(' ')[0]}] ${cacheStale ? 'Cache stale' : 'Cache miss'} - fetching tracks for playlist ${playlistId}`); try { - // Use the right endpoint based on playlist source - const fetchUrl = playlistId.startsWith('deezer_arl_') - ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` - : `/api/spotify/playlist/${playlistId}`; - const response = await fetch(fetchUrl); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - tracks = fullPlaylist.tracks; - playlistTrackCache[playlistId] = tracks; // Cache it + if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') { + invalidatePlaylistTrackCache(playlistId); + } + if (playlistId.startsWith('deezer_arl_') && typeof fetchAndCacheDeezerArlPlaylistTracks === 'function') { + const deezerId = playlistId.replace('deezer_arl_', ''); + const fullPlaylist = await fetchAndCacheDeezerArlPlaylistTracks(playlistId, deezerId); + tracks = fullPlaylist.tracks; + } else if (typeof fetchAndCacheSpotifyPlaylistTracks === 'function' && !playlistId.startsWith('deezer_arl_')) { + const fullPlaylist = await fetchAndCacheSpotifyPlaylistTracks(playlistId); + tracks = fullPlaylist.tracks; + } else { + const fetchUrl = playlistId.startsWith('deezer_arl_') + ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` + : `/api/spotify/playlist/${playlistId}`; + const response = await fetch(fetchUrl); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + tracks = fullPlaylist.tracks; + playlistTrackCache[playlistId] = tracks; + } const trackFetchTime = Date.now() - trackFetchStart; console.log(`✅ [${new Date().toTimeString().split(' ')[0]}] Fetched and cached ${tracks.length} tracks (took ${trackFetchTime}ms)`); } catch (error) { diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 43c184f9..6ce927e2 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -961,6 +961,322 @@ function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { // in artists.js but used broadly across the app. // ---------------------------------------------------------------------------- +function playlistDetailsOrganizeCheckboxId(playlistRef) { + return `playlist-organize-${playlistRef}`; +} + +/** Map UI playlist ids (e.g. deezer_arl_123) to mirrored-playlist resolve refs. */ +function normalizePlaylistOrganizeRef(playlistRef, source = 'spotify') { + const ref = String(playlistRef || '').trim(); + if (source === 'deezer' && ref.startsWith('deezer_arl_')) { + return ref.slice('deezer_arl_'.length); + } + return ref; +} + +function playlistOrganizeToggleHtml(playlistRef, source = 'spotify') { + const safeRef = String(playlistRef).replace(/'/g, "\\'"); + const safeSource = String(source).replace(/'/g, "\\'"); + return ` + + `; +} + +function syncPlaylistOrganizeCheckboxes(playlistRef, enabled) { + const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef)); + const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`); + if (detailsCb) detailsCb.checked = !!enabled; + if (downloadMissingCb) downloadMissingCb.checked = !!enabled; +} + +function isPlaylistOrganizeEnabled(playlistRef) { + const detailsCb = document.getElementById(playlistDetailsOrganizeCheckboxId(playlistRef)); + if (detailsCb) return detailsCb.checked; + const downloadMissingCb = document.getElementById(`playlist-folder-mode-${playlistRef}`); + return downloadMissingCb ? downloadMissingCb.checked : false; +} + +async function fetchMirroredOrganizePreference(playlistRef, source = 'spotify') { + try { + const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source); + const res = await fetch( + `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}` + ); + const data = await res.json(); + return !!(data.found && data.playlist?.organize_by_playlist); + } catch (err) { + console.debug('Could not load organize-by-playlist preference:', err); + return false; + } +} + +async function setMirroredOrganizePreference(playlistRef, enabled, source = 'spotify') { + try { + const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source); + const res = await fetch( + `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}` + ); + 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); + return false; + } +} + +async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = 'spotify') { + const enabled = await fetchMirroredOrganizePreference(playlistRef, source); + syncPlaylistOrganizeCheckboxes(playlistRef, enabled); +} + +async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source = 'spotify') { + syncPlaylistOrganizeCheckboxes(playlistRef, enabled); + const ok = await setMirroredOrganizePreference(playlistRef, enabled, source); + if (!ok) { + showToast('Could not save playlist folder preference (mirror this playlist first)', 'warning'); + } +} + +async function applyMirroredOrganizePreference(playlistRef, source = 'spotify') { + await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source); +} + +function playlistTrackCacheIsStale(playlistId, playlist) { + const cached = playlistTrackCache[playlistId]; + if (!cached) { + return false; + } + const expected = playlist?.track_count; + if (expected != null && Number(expected) !== cached.length) { + return true; + } + return false; +} + +function clearPlaylistDownloadProcess(playlistId) { + const proc = activeDownloadProcesses?.[playlistId]; + if (!proc) { + return; + } + if (proc.poller) { + clearInterval(proc.poller); + proc.poller = null; + } + if (proc.modalElement) { + proc.modalElement.remove(); + } + delete activeDownloadProcesses[playlistId]; +} + +/** True when Spotify list or track cache changed since the last Download Missing run. */ +function isPlaylistDownloadProcessStale(playlistId, playlistMeta) { + if (typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(playlistId, playlistMeta)) { + return true; + } + const proc = activeDownloadProcesses?.[playlistId]; + if (!proc) { + return false; + } + const expected = playlistMeta?.track_count; + if (expected != null && Array.isArray(proc.tracks) + && Number(expected) !== proc.tracks.length) { + return true; + } + // Refresh clears the track cache but can leave a completed modal from the old run. + if (!playlistTrackCache[playlistId] && proc.status === 'complete') { + return true; + } + return false; +} + +function invalidatePlaylistTrackCache(playlistId = null) { + if (playlistId) { + delete playlistTrackCache[playlistId]; + if (currentModalPlaylistId === playlistId) { + currentPlaylistTracks = []; + } + clearPlaylistDownloadProcess(playlistId); + return; + } + playlistTrackCache = {}; + currentPlaylistTracks = []; + const ids = new Set(); + if (typeof spotifyPlaylists !== 'undefined' && Array.isArray(spotifyPlaylists)) { + for (const p of spotifyPlaylists) { + if (p?.id) { + ids.add(p.id); + } + } + } + if (typeof deezerArlPlaylists !== 'undefined' && Array.isArray(deezerArlPlaylists)) { + for (const p of deezerArlPlaylists) { + const arlId = String(p.id || '').startsWith('deezer_arl_') + ? p.id + : `deezer_arl_${p.id}`; + ids.add(arlId); + } + } + for (const id of ids) { + clearPlaylistDownloadProcess(id); + } +} + +function mirrorPlaylistTracksForSource(source, sourcePlaylistId, fullPlaylist) { + if (typeof mirrorPlaylist !== 'function' || !fullPlaylist?.tracks) { + return; + } + mirrorPlaylist(source, sourcePlaylistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ + track_name: t.name, + artist_name: (t.artists && t.artists[0]) + ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) + : '', + album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', + duration_ms: t.duration_ms || 0, + image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] + ? t.album.images[0].url + : null, + source_track_id: t.id || t.spotify_track_id || '', + })), { + description: fullPlaylist.description, + owner: fullPlaylist.owner, + image_url: fullPlaylist.image_url, + }); +} + +/** @deprecated Use mirrorPlaylistTracksForSource */ +function mirrorSpotifyPlaylistTracks(playlistId, fullPlaylist) { + mirrorPlaylistTracksForSource('spotify', playlistId, fullPlaylist); +} + +async function fetchAndCachePlaylistTracks(cacheKey, fetchUrl, mirrorSource, mirrorSourceId) { + const response = await fetch(fetchUrl); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) { + throw new Error(fullPlaylist.error); + } + playlistTrackCache[cacheKey] = fullPlaylist.tracks; + mirrorPlaylistTracksForSource(mirrorSource, mirrorSourceId, fullPlaylist); + return fullPlaylist; +} + +async function fetchAndCacheSpotifyPlaylistTracks(playlistId) { + return fetchAndCachePlaylistTracks( + playlistId, + `/api/spotify/playlist/${playlistId}`, + 'spotify', + playlistId, + ); +} + +async function fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, deezerPlaylistId) { + return fetchAndCachePlaylistTracks( + arlPlaylistId, + `/api/deezer/arl-playlist/${deezerPlaylistId}`, + 'deezer', + deezerPlaylistId, + ); +} + +/** + * Footer actions for Spotify/Deezer playlist details modals. + * Standalone SoulSync has no media-server playlist sync — show Mirrored + re-analysis instead. + */ +function playlistModalDownloadSyncFooterHtml(playlistId, options = {}) { + const { + hasCompletedProcess = false, + isSyncing = false, + source = 'spotify', + closeBeforeDownload = false, + } = options; + const openDm = closeBeforeDownload + ? `closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')` + : `openDownloadMissingModal('${playlistId}')`; + const downloadBtns = hasCompletedProcess + ? ` + ` + : ``; + + if (_isSoulsyncStandalone) { + return `${downloadBtns} + `; + } + + return `${downloadBtns} + + `; +} + +async function restartPlaylistDownloadMissing(playlistId) { + const proc = activeDownloadProcesses[playlistId]; + if (proc?.poller) { + clearInterval(proc.poller); + } + if (proc?.modalElement) { + proc.modalElement.remove(); + } + delete activeDownloadProcesses[playlistId]; + if (typeof closePlaylistDetailsModal === 'function') { + closePlaylistDetailsModal(); + } + if (typeof closeDeezerArlPlaylistDetailsModal === 'function') { + closeDeezerArlPlaylistDetailsModal(); + } + await openDownloadMissingModal(playlistId); +} + +async function navigateToMirroredPlaylist(playlistRef, source = 'spotify') { + if (typeof navigateToPage === 'function') { + navigateToPage('sync'); + } + const mirroredBtn = document.querySelector('.sync-tab-button[data-tab="mirrored"]'); + if (mirroredBtn) { + mirroredBtn.click(); + } + try { + const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source); + const res = await fetch( + `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}` + ); + const data = await res.json(); + if (!data.found || !data.playlist?.id) { + if (typeof loadMirroredPlaylists === 'function') { + await loadMirroredPlaylists(); + } + showToast('Playlist not mirrored yet — open it once from Sync to create the mirror.', 'warning'); + return; + } + if (typeof loadMirroredPlaylists === 'function') { + await loadMirroredPlaylists(); + } + if (typeof openMirroredPlaylistModal === 'function') { + await openMirroredPlaylistModal(data.playlist.id); + } + } catch (err) { + showToast(`Could not open mirrored playlist: ${err.message}`, 'error'); + } +} + async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlistName, spotifyTracks, album, artist, showLoadingOverlayParam = true, contextType = 'artist_album') { if (showLoadingOverlayParam) { showLoadingOverlay('Loading album...'); @@ -3200,6 +3516,7 @@ async function fetchAndUpdateServiceStatus() { _isSoulsyncStandalone = isSoulsyncStandalone2; document.querySelectorAll('.sync-to-server-btn, [id$="-sync-btn"], [onclick*="startPlaylistSync"], [onclick*="syncPlaylistToServer"], [onclick*="startDecadeSync"]').forEach(btn => { if (btn.id === 'stats-sync-btn') return; // React stats page owns this control now. + if (btn.classList.contains('soulsync-standalone-action')) return; if (isSoulsyncStandalone2) { btn.dataset.hiddenByStandalone = '1'; btn.style.display = 'none'; diff --git a/webui/static/style.css b/webui/static/style.css index 2324a5c3..de95020b 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12088,6 +12088,21 @@ body.helper-mode-active #dashboard-activity-feed:hover { line-height: 1.3; } +.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-toggle input { + margin: 0; +} + .auto-sync-scheduled-timing { display: flex; flex-wrap: wrap; @@ -18728,6 +18743,24 @@ body.helper-mode-active #dashboard-activity-feed:hover { .playlist-modal-footer-right { display: flex; gap: 12px; + flex-wrap: wrap; + justify-content: flex-end; +} + +.playlist-modal-organize-toggle { + display: flex; + align-items: center; + gap: 8px; + font-size: 13px; + color: rgba(255, 255, 255, 0.85); + cursor: pointer; + user-select: none; + max-width: 100%; +} + +.playlist-modal-organize-toggle input { + margin: 0; + flex-shrink: 0; } .playlist-modal-btn { diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index 0490076f..a87eeff1 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -2438,6 +2438,11 @@ async function loadDeezerArlPlaylists() { throw new Error(error.error || 'Failed to fetch Deezer playlists'); } deezerArlPlaylists = await response.json(); + if (typeof invalidatePlaylistTrackCache === 'function') { + invalidatePlaylistTrackCache(); + } else { + playlistTrackCache = {}; + } renderDeezerArlPlaylists(); deezerArlPlaylistsLoaded = true; @@ -2525,25 +2530,25 @@ async function openDeezerArlPlaylistDetailsModal(event, playlistId) { showLoadingOverlay(`Loading playlist: ${playlist.name}...`); try { - if (playlistTrackCache[arlPlaylistId]) { + const playlistMeta = { ...playlist, track_count: playlist.track_count ?? playlist.tracks?.length }; + const cacheStale = typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(arlPlaylistId, playlistMeta); + if (playlistTrackCache[arlPlaylistId] && !cacheStale) { const fullPlaylist = { ...playlist, id: arlPlaylistId, tracks: playlistTrackCache[arlPlaylistId] }; showDeezerArlPlaylistDetailsModal(fullPlaylist, playlistId); } else { - const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - - playlistTrackCache[arlPlaylistId] = fullPlaylist.tracks; - - // Auto-mirror - mirrorPlaylist('deezer', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ - track_name: t.name, - artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', - album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', - duration_ms: t.duration_ms || 0, - source_track_id: t.id || '' - })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); - + if (cacheStale && typeof invalidatePlaylistTrackCache === 'function') { + invalidatePlaylistTrackCache(arlPlaylistId); + } + const fullPlaylist = typeof fetchAndCacheDeezerArlPlaylistTracks === 'function' + ? await fetchAndCacheDeezerArlPlaylistTracks(arlPlaylistId, playlistId) + : await (async () => { + const response = await fetch(`/api/deezer/arl-playlist/${playlistId}`); + const data = await response.json(); + if (data.error) throw new Error(data.error); + playlistTrackCache[arlPlaylistId] = data.tracks; + return data; + })(); showDeezerArlPlaylistDetailsModal({ ...fullPlaylist, id: arlPlaylistId }, playlistId); } } catch (error) { @@ -2608,15 +2613,20 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
`; @@ -2633,6 +2643,9 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) { } modal.style.display = 'flex'; + if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal(playlistId, 'deezer'); + } } function closeDeezerArlPlaylistDetailsModal() { diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index e0dbceb2..77da6514 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -1610,6 +1610,11 @@ async function loadSpotifyPlaylists() { throw new Error(error.error || 'Failed to fetch playlists'); } spotifyPlaylists = await response.json(); + if (typeof invalidatePlaylistTrackCache === 'function') { + invalidatePlaylistTrackCache(); + } else { + playlistTrackCache = {}; + } renderSpotifyPlaylists(); spotifyPlaylistsLoaded = true; @@ -1833,35 +1838,35 @@ async function openPlaylistDetailsModal(event, playlistId) { showLoadingOverlay(`Loading playlist: ${playlist.name}...`); try { - // --- CACHING LOGIC START --- - if (playlistTrackCache[playlistId]) { + const cacheStale = typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(playlistId, playlist); + if (playlistTrackCache[playlistId] && !cacheStale) { console.log(`Cache HIT for playlist ${playlistId}. Using cached tracks.`); - // Use the cached tracks instead of fetching const fullPlaylist = { ...playlist, tracks: playlistTrackCache[playlistId] }; showPlaylistDetailsModal(fullPlaylist); } else { - console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); - // Fetch from the server if not in cache - const response = await fetch(`/api/spotify/playlist/${playlistId}`); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - - // Store the fetched tracks in the cache - playlistTrackCache[playlistId] = fullPlaylist.tracks; + if (cacheStale) { + console.log(`Cache STALE for playlist ${playlistId} — refetching tracks.`); + if (typeof invalidatePlaylistTrackCache === 'function') { + invalidatePlaylistTrackCache(playlistId); + } else { + delete playlistTrackCache[playlistId]; + } + } else { + console.log(`Cache MISS for playlist ${playlistId}. Fetching from server...`); + } + const fullPlaylist = typeof fetchAndCacheSpotifyPlaylistTracks === 'function' + ? await fetchAndCacheSpotifyPlaylistTracks(playlistId) + : await (async () => { + const response = await fetch(`/api/spotify/playlist/${playlistId}`); + const data = await response.json(); + if (data.error) throw new Error(data.error); + playlistTrackCache[playlistId] = data.tracks; + return data; + })(); console.log(`Cached ${fullPlaylist.tracks.length} tracks for playlist ${playlistId}.`); - - // Auto-mirror this Spotify playlist - mirrorPlaylist('spotify', playlistId, fullPlaylist.name, fullPlaylist.tracks.map(t => ({ - track_name: t.name, artist_name: (t.artists && t.artists[0]) ? (typeof t.artists[0] === 'object' ? t.artists[0].name : t.artists[0]) : '', - album_name: t.album ? (typeof t.album === 'object' ? t.album.name : t.album) : '', - duration_ms: t.duration_ms || 0, - image_url: t.album && typeof t.album === 'object' && t.album.images && t.album.images[0] ? t.album.images[0].url : null, - source_track_id: t.id || t.spotify_track_id || '' - })), { description: fullPlaylist.description, owner: fullPlaylist.owner, image_url: fullPlaylist.image_url }); - showPlaylistDetailsModal(fullPlaylist); } - // --- CACHING LOGIC END --- } catch (error) { showToast(`Error: ${error.message}`, 'error'); @@ -1929,22 +1934,27 @@ function showPlaylistDetailsModal(playlist) { `; modal.style.display = 'flex'; + if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal(playlist.id, 'spotify'); + } } function closePlaylistDetailsModal() { @@ -2184,19 +2194,36 @@ async function openDownloadMissingModal(playlistId) { showLoadingOverlay('Loading playlist...'); // **NEW**: Check if a process is already active for this playlist - if (activeDownloadProcesses[playlistId]) { + const playlistMeta = spotifyPlaylists.find(p => p.id === playlistId); + const processStale = typeof isPlaylistDownloadProcessStale === 'function' + ? isPlaylistDownloadProcessStale(playlistId, playlistMeta) + : (typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(playlistId, playlistMeta)); + + if (activeDownloadProcesses[playlistId] && !processStale) { console.log(`Modal for ${playlistId} already exists. Showing it.`); - closePlaylistDetailsModal(); // Close playlist details modal even when reusing existing modal + closePlaylistDetailsModal(); const process = activeDownloadProcesses[playlistId]; if (process.modalElement) { - // Show helpful message if it's a completed process if (process.status === 'complete') { - showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); + showToast('Showing previous results. Use "Download Missing (New)" for a fresh run.', 'info'); } process.modalElement.style.display = 'flex'; } hideLoadingOverlay(); - return; // Don't create a new one + return; + } + if (processStale && activeDownloadProcesses[playlistId]) { + if (typeof restartPlaylistDownloadMissing === 'function') { + await restartPlaylistDownloadMissing(playlistId); + hideLoadingOverlay(); + return; + } + if (typeof clearPlaylistDownloadProcess === 'function') { + clearPlaylistDownloadProcess(playlistId); + } else if (typeof invalidatePlaylistTrackCache === 'function') { + invalidatePlaylistTrackCache(playlistId); + } } console.log(`📥 Opening Download Missing Tracks modal for playlist: ${playlistId}`); @@ -2210,16 +2237,29 @@ async function openDownloadMissingModal(playlistId) { } let tracks = playlistTrackCache[playlistId]; - if (!tracks) { + const needFreshTracks = !tracks || ( + typeof playlistTrackCacheIsStale === 'function' + && playlistTrackCacheIsStale(playlistId, playlist) + ); + if (needFreshTracks) { try { - const fetchUrl = playlistId.startsWith('deezer_arl_') - ? `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}` - : `/api/spotify/playlist/${playlistId}`; - const response = await fetch(fetchUrl); - const fullPlaylist = await response.json(); - if (fullPlaylist.error) throw new Error(fullPlaylist.error); - tracks = fullPlaylist.tracks; - playlistTrackCache[playlistId] = tracks; + if (playlistId.startsWith('deezer_arl_')) { + const fetchUrl = `/api/deezer/arl-playlist/${playlistId.replace('deezer_arl_', '')}`; + const response = await fetch(fetchUrl); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + tracks = fullPlaylist.tracks; + playlistTrackCache[playlistId] = tracks; + } else if (typeof fetchAndCacheSpotifyPlaylistTracks === 'function') { + const fullPlaylist = await fetchAndCacheSpotifyPlaylistTracks(playlistId); + tracks = fullPlaylist.tracks; + } else { + const response = await fetch(`/api/spotify/playlist/${playlistId}`); + const fullPlaylist = await response.json(); + if (fullPlaylist.error) throw new Error(fullPlaylist.error); + tracks = fullPlaylist.tracks; + playlistTrackCache[playlistId] = tracks; + } } catch (error) { showToast(`Failed to fetch tracks: ${error.message}`, 'error'); hideLoadingOverlay(); @@ -2335,10 +2375,7 @@ async function openDownloadMissingModal(playlistId) { Force Download All - + ` - : ``; + : ``; if (_isSoulsyncStandalone) { return `${downloadBtns} @@ -1290,6 +1325,9 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId); + } if (showLoadingOverlayParam) { hideLoadingOverlay(); } diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index b5f2612b..01aab02a 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -3554,6 +3554,13 @@ const _RESULT_DISPLAY_MAP = { { key: 'wishlist_queued', label: 'Wishlist Queued' }, { key: 'duration_seconds', label: 'Duration (s)' }, ], + 'sync_playlist': [ + { key: 'matched_tracks', label: 'In Library' }, + { key: 'failed_tracks', label: 'Missing' }, + { key: 'wishlist_added_count', label: 'Added to Wishlist', hideZero: true }, + { key: 'total_tracks', label: 'Total Tracks' }, + { key: 'synced_tracks', label: 'On Server Playlist', hideZero: true }, + ], }; function _renderResultStats(resultJson, actionType) { diff --git a/webui/static/style.css b/webui/static/style.css index de95020b..d40d055a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -18471,8 +18471,36 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-discovery-modal .modal-footer-left { display: flex; + flex-direction: column; + align-items: stretch; + gap: 14px; + flex: 1; + min-width: 0; +} + +.youtube-discovery-modal .modal-footer-organize { + flex: none; + width: 100%; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.youtube-discovery-modal .modal-footer-organize .playlist-modal-organize-toggle { + width: 100%; + font-size: 14px; +} + +.youtube-discovery-modal .modal-footer-actions { + display: flex; + flex-wrap: wrap; gap: 12px; align-items: center; + width: 100%; +} + +.youtube-discovery-modal .modal-footer-actions .modal-info { + flex: 1 1 100%; + margin-bottom: 4px; } .youtube-discovery-modal .modal-footer-right { diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index a87eeff1..69dd0cef 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -1233,10 +1233,7 @@ function updateTidalModalButtons(urlHash, phase) { const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (!modal) return; - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } + setDiscoveryModalFooterActions(urlHash, phase); } async function startTidalDownloadMissing(urlHash) { @@ -1314,7 +1311,7 @@ async function startTidalDownloadMissing(urlHash) { } } -async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) { +async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks, options = {}) { showLoadingOverlay('Loading Tidal playlist...'); // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { @@ -1326,6 +1323,10 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, } process.modalElement.style.display = 'flex'; } + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId); + } + hideLoadingOverlay(); return; } @@ -1464,10 +1465,12 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, Force Download All - `} @@ -9569,6 +9584,48 @@ function openYouTubeDiscoveryModal(urlHash) { } console.log('✨ Created new modal with current state'); + if (isSpotifyPublic && state.spotify_public_playlist_id && typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal( + `spotify_public_${state.spotify_public_playlist_id}`, + 'spotify_public', + ); + } + } +} + +function discoveryModalOrganizeFooterHtml(state) { + if (!state || typeof playlistOrganizeToggleHtml !== 'function') { + return ''; + } + if (state.is_spotify_public_playlist && state.spotify_public_playlist_id) { + const ref = `spotify_public_${state.spotify_public_playlist_id}`; + return ``; + } + return ''; +} + +function buildDiscoveryModalFooterLeftHtml(urlHash, phase, state) { + const organize = discoveryModalOrganizeFooterHtml(state); + const actions = getModalActionButtons(urlHash, phase, state); + return `${organize}`; +} + +function setDiscoveryModalFooterActions(urlHash, phase, state = null) { + if (!state) { + state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + } + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + const footerLeft = modal?.querySelector('.modal-footer-left'); + if (!footerLeft) { + return; + } + footerLeft.innerHTML = buildDiscoveryModalFooterLeftHtml(urlHash, phase, state); + if (state?.is_spotify_public_playlist && state.spotify_public_playlist_id + && typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal( + `spotify_public_${state.spotify_public_playlist_id}`, + 'spotify_public', + ); } } @@ -9650,7 +9707,12 @@ function getModalActionButtons(urlHash, phase, state = null) { } else if (isDeezer) { buttons += ``; } else if (isSpotifyPublic) { - buttons += ``; + const folderArg = _isSoulsyncStandalone ? ', true' : ''; + const label = _isSoulsyncStandalone + ? '📁 Download to Playlist Folder' + : '🔍 Download Missing Tracks'; + const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : ''; + buttons += ``; } else if (isITunesLink) { buttons += ``; } else if (isBeatport) { @@ -9819,7 +9881,12 @@ function getModalActionButtons(urlHash, phase, state = null) { } else if (isQobuz) { syncCompleteButtons += ``; } else if (isSpotifyPublic) { - syncCompleteButtons += ``; + const folderArg = _isSoulsyncStandalone ? ', true' : ''; + const label = _isSoulsyncStandalone + ? '📁 Download to Playlist Folder' + : '🔍 Download Missing Tracks'; + const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : ''; + syncCompleteButtons += ``; } else if (isITunesLink) { syncCompleteButtons += ``; } else if (isBeatport) { @@ -9875,7 +9942,12 @@ function getModalActionButtons(urlHash, phase, state = null) { } else if (isDeezer) { dlCompleteButtons += ``; } else if (isSpotifyPublic) { - dlCompleteButtons += ``; + const folderArg = _isSoulsyncStandalone ? ', true' : ''; + const label = _isSoulsyncStandalone + ? '📁 Download to Playlist Folder' + : '🔍 Download Missing Tracks'; + const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : ''; + dlCompleteButtons += ``; } else if (isITunesLink) { dlCompleteButtons += ``; } else if (isBeatport) { @@ -10118,18 +10190,17 @@ function updateYouTubeDiscoveryModal(urlHash, status) { const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; if (state && state.phase === 'discovering') { state.phase = 'discovered'; - const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); - if (actionButtonsContainer) { - actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); - console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`); - } + setDiscoveryModalFooterActions(urlHash, 'discovered', state); + console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`); const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`); if (descEl) descEl.textContent = 'Discovery complete! View the results below.'; } else if (state && state.phase === 'discovered') { // Already discovered — ensure buttons are correct (e.g. after rehydration) - const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); + const actionButtonsContainer = document.querySelector( + `#youtube-discovery-modal-${urlHash} .modal-footer-actions`, + ); if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) { - actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); + setDiscoveryModalFooterActions(urlHash, 'discovered', state); } } } @@ -10626,7 +10697,7 @@ function updateYouTubeModalButtons(urlHash, phase) { const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + setDiscoveryModalFooterActions(urlHash, phase); } } diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index 77da6514..6af72167 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -2210,6 +2210,9 @@ async function openDownloadMissingModal(playlistId) { } process.modalElement.style.display = 'flex'; } + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(playlistId); + } hideLoadingOverlay(); return; } @@ -2375,7 +2378,9 @@ async function openDownloadMissingModal(playlistId) { Force Download All - + ${typeof downloadMissingModalOrganizeCheckboxHtml === 'function' + ? downloadMissingModalOrganizeCheckboxHtml(playlistId) + : ``}