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..509c25ef 100644 --- a/core/automation/handlers/sync_playlist.py +++ b/core/automation/handlers/sync_playlist.py @@ -145,13 +145,36 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str tracks_hash = hashlib.md5(track_ids_str.encode()).hexdigest() sync_id_key = f"auto_mirror_{playlist_id}" + # Full mirror identity (every source_track_id on the playlist). tracks_hash + # only covers tracks_json — if a new mirror row is skipped (no discovery / + # no source id), tracks_hash stays identical to the pre-add sync and we + # used to no-op with "unchanged" while the new song never hit wishlist. + mirror_ids_str = ','.join( + sorted(t.get('source_track_id', '') or '' for t in tracks if t.get('source_track_id')) + ) + mirror_tracks_hash = hashlib.md5(mirror_ids_str.encode()).hexdigest() if mirror_ids_str else '' + + event_data = config.get('_event_data') or {} + try: + tracks_added = int(event_data.get('added') or 0) + except (TypeError, ValueError): + tracks_added = 0 + force_sync = tracks_added > 0 or skipped_count > 0 + try: sync_statuses = deps.load_sync_status_file() last_status = sync_statuses.get(sync_id_key, {}) last_hash = last_status.get('tracks_hash', '') + last_mirror_hash = last_status.get('mirror_tracks_hash', '') last_matched = last_status.get('matched_tracks', -1) - if last_hash == tracks_hash and last_matched >= len(tracks_json): + mirror_changed = bool(mirror_tracks_hash) and mirror_tracks_hash != last_mirror_hash + if ( + not force_sync + and not mirror_changed + and last_hash == tracks_hash + and last_matched >= len(tracks_json) + ): # Exact same tracks, all matched last time — nothing to do. deps.update_progress( auto_id, @@ -162,6 +185,21 @@ def auto_sync_playlist(config: Dict[str, Any], deps: AutomationDeps) -> Dict[str 'status': 'skipped', 'reason': f'All {len(tracks_json)} tracks unchanged since last sync', } + if force_sync and last_hash == tracks_hash and last_matched >= len(tracks_json): + deps.update_progress( + auto_id, + log_line=( + f'Forcing sync: playlist changed ({tracks_added} added) or ' + f'{skipped_count} track(s) need discovery' + ), + log_type='info', + ) + elif mirror_changed: + deps.update_progress( + auto_id, + log_line='Mirror track list changed — running sync', + log_type='info', + ) except Exception as e: deps.logger.debug("mirror sync last-status read: %s", e) @@ -180,9 +218,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..c3b513ce 100644 --- a/core/discovery/sync.py +++ b/core/discovery/sync.py @@ -47,6 +47,90 @@ class SyncDeps: update_and_save_sync_status: Callable sync_states: dict sync_lock: Any # threading.Lock + # Optional: post-sync download follow-up for mirrored-playlist automations. + process_wishlist_automatically: Callable[..., Any] | None = None + run_playlist_organize_download: Callable[..., Any] | None = None + is_wishlist_actually_processing: Callable[[], bool] | None = None + + +def _post_sync_automation_followup( + deps: SyncDeps, + *, + automation_id: str, + playlist_id: str, + skip_wishlist_add: bool, + result: Any, +) -> None: + """Queue downloads after an automation sync finishes. + + Sync Playlist runs in a background thread and returns immediately, so a + separate scheduled "Process Wishlist" action often runs on an empty wishlist. + Organize-by-playlist skips sync-time wishlist adds and expects a folder + download batch instead — that only ran in Playlist Pipeline before this hook. + """ + if not automation_id or not str(playlist_id).startswith('auto_mirror_'): + return + try: + mirrored_id = int(str(playlist_id).replace('auto_mirror_', '', 1)) + except ValueError: + return + + failed = int(getattr(result, 'failed_tracks', 0) or 0) + wishlist_added = int(getattr(result, 'wishlist_added_count', 0) or 0) + + if skip_wishlist_add: + org_fn = deps.run_playlist_organize_download + if failed <= 0: + return + if not org_fn: + logger.warning( + "Organize-by-playlist sync left %s missing tracks but organize download is unavailable", + failed, + ) + deps.update_automation_progress( + automation_id, + log_line=f'{failed} missing — enable Playlist Pipeline or disable Organize by Playlist', + log_type='warning', + ) + return + org_result = org_fn(mirrored_playlist_id=mirrored_id, automation_id=automation_id) + status = org_result.get('status', 'unknown') if isinstance(org_result, dict) else 'unknown' + reason = org_result.get('reason', '') if isinstance(org_result, dict) else '' + log_type = 'success' if status == 'started' else 'warning' + detail = f' ({reason})' if reason and status != 'started' else '' + deps.update_automation_progress( + automation_id, + log_line=f'Organize download {status} for {failed} missing track(s){detail}', + log_type=log_type, + ) + return + + if wishlist_added <= 0: + if failed > 0: + deps.update_automation_progress( + automation_id, + log_line=f'{failed} missing but none added to wishlist — check logs', + log_type='warning', + ) + return + + proc_fn = deps.process_wishlist_automatically + if not proc_fn: + return + is_busy = deps.is_wishlist_actually_processing + if is_busy and is_busy(): + deps.update_automation_progress( + automation_id, + log_line=f'Added {wishlist_added} to wishlist; download worker already running', + log_type='info', + ) + return + proc_fn(automation_id=automation_id) + deps.update_automation_progress( + automation_id, + log_line=f'Started wishlist download for {wishlist_added} track(s)', + log_type='success', + ) async def _database_only_find_track(spotify_track, candidate_pool=None): @@ -133,7 +217,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 +454,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)) @@ -459,9 +560,21 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p matched = getattr(result, 'matched_tracks', 0) total = getattr(result, 'total_tracks', 0) failed = getattr(result, 'failed_tracks', 0) + wishlist_added = getattr(result, 'wishlist_added_count', 0) or 0 deps.update_automation_progress(automation_id, status='finished', progress=100, phase='Sync complete', - log_line=f'Done: {matched}/{total} matched, {failed} failed', log_type='success') + log_line=( + f'Done: {matched}/{total} in library, {failed} missing' + + (f', {wishlist_added} added to wishlist' if wishlist_added else '') + ), + log_type='success') + _post_sync_automation_followup( + deps, + automation_id=automation_id, + playlist_id=playlist_id, + skip_wishlist_add=skip_wishlist_add, + result=result, + ) # Emit playlist_synced event for automation engine try: @@ -480,12 +593,28 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p import hashlib as _hl _track_ids_str = ','.join(sorted(t.get('id', '') for t in tracks_json)) _tracks_hash = _hl.md5(_track_ids_str.encode()).hexdigest() + _mirror_tracks_hash = None + if str(playlist_id).startswith('auto_mirror_'): + try: + _mp_id = int(str(playlist_id).replace('auto_mirror_', '', 1)) + from database.music_database import MusicDatabase + _mtracks = MusicDatabase().get_mirrored_playlist_tracks(_mp_id) + _mids = ','.join( + sorted(t.get('source_track_id', '') or '' for t in _mtracks if t.get('source_track_id')) + ) + _mirror_tracks_hash = _hl.md5(_mids.encode()).hexdigest() if _mids else '' + except Exception as e: + logger.debug("mirror_tracks_hash for sync status: %s", e) snapshot_id = getattr(playlist, 'snapshot_id', None) - deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id, + _status_kwargs = dict( matched_tracks=getattr(result, 'matched_tracks', 0), total_tracks=getattr(result, 'total_tracks', 0), discovered_tracks=len(tracks_json), - tracks_hash=_tracks_hash) + tracks_hash=_tracks_hash, + ) + if _mirror_tracks_hash is not None: + _status_kwargs['mirror_tracks_hash'] = _mirror_tracks_hash + deps.update_and_save_sync_status(playlist_id, playlist_name, playlist.owner, snapshot_id, **_status_kwargs) except Exception as e: logger.error(f"SYNC FAILED for {playlist_id}: {e}") 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 4263ba5a..dc6c9bf4 100644 --- a/core/imports/pipeline.py +++ b/core/imports/pipeline.py @@ -590,6 +590,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 fa187d74..b421adfa 100644 --- a/database/music_database.py +++ b/database/music_database.py @@ -574,6 +574,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) @@ -1111,6 +1112,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: @@ -12414,7 +12428,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 [] @@ -12441,11 +12459,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 58ddfa18..072185d4 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -115,6 +115,12 @@ class PlaylistSyncService: logger.error("Navidrome client not provided to sync service") return None, "navidrome" return client, "navidrome" + elif active_server == "soulsync": + client = self._media_client('soulsync') + if not client: + logger.error("SoulSync library client not provided to sync service") + return None, "soulsync" + return client, "soulsync" else: # Default to Plex client = self._media_client('plex') if profile_id and client: @@ -292,63 +298,92 @@ class PlaylistSyncService: return self._create_error_result(playlist.name, ["Sync cancelled"]) media_client, server_type = self._get_active_media_client() - self._update_progress(playlist.name, f"Creating/updating {server_type.title()} playlist", "", 80, 5, 4, - total_tracks=total_tracks, - matched_tracks=len(matched_tracks), - failed_tracks=len(unmatched_tracks)) - - # Get the actual media server track objects - media_tracks = [r.plex_track for r in matched_tracks if r.plex_track] # plex_track is a generic name here - logger.info(f"Creating playlist with {len(media_tracks)} matched tracks") + unmatched_count = len(unmatched_tracks) - # Validate that all tracks have proper ratingKey attributes for playlist creation - valid_tracks = [] - for i, track in enumerate(media_tracks): - if track and hasattr(track, 'ratingKey'): - valid_tracks.append(track) - logger.debug(f"Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})") - else: - logger.warning(f"Track {i+1} invalid for playlist: {track} (type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})") - - logger.info(f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid {server_type.title()} objects with ratingKeys") - - # Deduplicate by ratingKey — media servers silently drop duplicates, - # so count should reflect what actually ends up in the playlist - seen_keys = set() - deduped_tracks = [] - for t in valid_tracks: - if t.ratingKey not in seen_keys: - seen_keys.add(t.ratingKey) - deduped_tracks.append(t) - if len(deduped_tracks) < len(valid_tracks): - logger.info(f"Deduplicated {len(valid_tracks) - len(deduped_tracks)} duplicate ratingKeys ({len(valid_tracks)} → {len(deduped_tracks)} tracks)") - - # Use the deduplicated tracks for the sync - plex_tracks = deduped_tracks - - # Use active media server for playlist sync - media_client, server_type = self._get_active_media_client() - if not media_client: - logger.error("No active media client available for playlist sync") - sync_success = False - else: - logger.info( - f"Syncing playlist '{playlist.name}' to {server_type.upper()} server " - f"(mode: {sync_mode})" + # SoulSync standalone has no server playlists — only library match + + # wishlist for missing files. Previously we fell through to Plex here, + # showed "Creating/updating Plex playlist", playlist write failed, and + # failed_tracks was computed as total - 0 synced (= entire playlist). + if server_type == 'soulsync': + self._update_progress( + playlist.name, + "Standalone: library check complete", + "", + 80, 5, 4, + total_tracks=total_tracks, + matched_tracks=len(matched_tracks), + failed_tracks=unmatched_count, ) - # sync_mode == 'append' preserves user-added tracks on the server - # playlist (CJFC discord report) — never deletes, only adds new - # ones via the per-server `append_to_playlist`. The sync UI - # hides the Sync button entirely on SoulSync standalone (which - # has no playlist methods), so every client that reaches this - # point implements both methods. - if sync_mode == 'append': - sync_success = media_client.append_to_playlist(playlist.name, valid_tracks) + sync_success = True + synced_tracks = len(matched_tracks) + failed_tracks = unmatched_count + logger.info( + f"Standalone playlist sync '{playlist.name}': " + f"{len(matched_tracks)} in library, {unmatched_count} missing" + ) + else: + self._update_progress( + playlist.name, + f"Creating/updating {server_type.title()} playlist", + "", + 80, 5, 4, + total_tracks=total_tracks, + matched_tracks=len(matched_tracks), + failed_tracks=unmatched_count, + ) + + # Get the actual media server track objects + media_tracks = [r.plex_track for r in matched_tracks if r.plex_track] + logger.info(f"Creating playlist with {len(media_tracks)} matched tracks") + + # Validate that all tracks have proper ratingKey attributes for playlist creation + valid_tracks = [] + for i, track in enumerate(media_tracks): + if track and hasattr(track, 'ratingKey'): + valid_tracks.append(track) + logger.debug(f"Track {i+1} valid for playlist: '{track.title}' (ratingKey: {track.ratingKey})") + else: + logger.warning( + f"Track {i+1} invalid for playlist: {track} " + f"(type: {type(track)}, has ratingKey: {hasattr(track, 'ratingKey') if track else 'N/A'})" + ) + + logger.info( + f"Playlist validation: {len(valid_tracks)}/{len(media_tracks)} tracks are valid " + f"{server_type.title()} objects with ratingKeys" + ) + + # Deduplicate by ratingKey — media servers silently drop duplicates + seen_keys = set() + deduped_tracks = [] + for t in valid_tracks: + if t.ratingKey not in seen_keys: + seen_keys.add(t.ratingKey) + deduped_tracks.append(t) + if len(deduped_tracks) < len(valid_tracks): + logger.info( + f"Deduplicated {len(valid_tracks) - len(deduped_tracks)} duplicate ratingKeys " + f"({len(valid_tracks)} → {len(deduped_tracks)} tracks)" + ) + + plex_tracks = deduped_tracks + + if not media_client: + logger.error("No active media client available for playlist sync") + sync_success = False else: - sync_success = media_client.update_playlist(playlist.name, valid_tracks) - - synced_tracks = len(plex_tracks) if sync_success else 0 - failed_tracks = len(playlist.tracks) - synced_tracks - downloaded_tracks + logger.info( + f"Syncing playlist '{playlist.name}' to {server_type.upper()} server " + f"(mode: {sync_mode})" + ) + if sync_mode == 'append': + sync_success = media_client.append_to_playlist(playlist.name, valid_tracks) + else: + sync_success = media_client.update_playlist(playlist.name, valid_tracks) + + synced_tracks = len(plex_tracks) if sync_success else 0 + # Not in library (for wishlist), not "total minus playlist size". + failed_tracks = unmatched_count self._update_progress(playlist.name, "Sync completed", "", 100, 5, 5, total_tracks=total_tracks, @@ -357,8 +392,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: @@ -548,20 +586,13 @@ class PlaylistSyncService: server_track_id = cached['server_track_id'] db_track_check = cache_db.get_track_by_id(server_track_id) if db_track_check: - if server_type == "jellyfin": - class JellyfinTrackFromCache: + if server_type in ("jellyfin", "navidrome", "soulsync"): + class DbTrackFromCache: def __init__(self, db_t): self.ratingKey = db_t.id self.title = db_t.title self.id = db_t.id - actual_track = JellyfinTrackFromCache(db_track_check) - elif server_type == "navidrome": - class NavidromeTrackFromCache: - def __init__(self, db_t): - self.ratingKey = db_t.id - self.title = db_t.title - self.id = db_t.id - actual_track = NavidromeTrackFromCache(db_track_check) + actual_track = DbTrackFromCache(db_track_check) else: try: actual_track = media_client.server.fetchItem(int(server_track_id)) @@ -633,27 +664,19 @@ class PlaylistSyncService: # Fetch the actual track object from active media server using the database track ID try: - if server_type == "jellyfin": - # For Jellyfin, create a track object from database info (Jellyfin doesn't have fetchItem) - class JellyfinTrackFromDB: - def __init__(self, db_track): - self.ratingKey = db_track.id - self.title = db_track.title - self.id = db_track.id - - actual_track = JellyfinTrackFromDB(db_track) - logger.debug(f"Created Jellyfin track object for '{db_track.title}' (ID: {actual_track.ratingKey})") - return actual_track, confidence - elif server_type == "navidrome": - # For Navidrome, create a track object from database info (similar to Jellyfin) - class NavidromeTrackFromDB: + if server_type in ("jellyfin", "navidrome", "soulsync"): + # DB-backed servers — no remote fetchItem (SoulSync standalone included). + class DbTrackFromDB: def __init__(self, db_track): self.ratingKey = db_track.id self.title = db_track.title self.id = db_track.id - actual_track = NavidromeTrackFromDB(db_track) - logger.debug(f"Created Navidrome track object for '{db_track.title}' (ID: {actual_track.ratingKey})") + actual_track = DbTrackFromDB(db_track) + logger.debug( + f"Created {server_type} track object for '{db_track.title}' " + f"(ID: {actual_track.ratingKey})" + ) return actual_track, confidence else: # For Plex, use the original fetchItem approach 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..a690d7b6 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,8 +739,38 @@ 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 = { + 'source_track_id': 'spot-1', 'extra_data': json.dumps({ 'discovered': True, 'matched_data': { @@ -758,7 +790,11 @@ class TestSyncPlaylist: import hashlib expected_hash = hashlib.md5('spot-1'.encode()).hexdigest() sync_statuses = { - 'auto_mirror_1': {'tracks_hash': expected_hash, 'matched_tracks': 1} + 'auto_mirror_1': { + 'tracks_hash': expected_hash, + 'mirror_tracks_hash': expected_hash, + 'matched_tracks': 1, + } } deps = _build_deps( @@ -769,6 +805,96 @@ class TestSyncPlaylist: assert result['status'] == 'skipped' assert 'unchanged' in result['reason'] + def test_playlist_changed_event_bypasses_unchanged_skip(self): + discovered_track = { + 'source_track_id': 'spot-1', + 'extra_data': json.dumps({ + 'discovered': True, + 'matched_data': { + 'id': 'spot-1', 'name': 'T', 'artists': [{'name': 'X'}], + 'album': {'name': 'A'}, 'duration_ms': 0, + }, + }), + 'artist_name': 'X', + } + db = _StubDB( + playlists=[{'id': 1, 'name': 'P'}], + playlist_tracks={1: [discovered_track]}, + ) + import hashlib + expected_hash = hashlib.md5('spot-1'.encode()).hexdigest() + sync_statuses = { + 'auto_mirror_1': { + 'tracks_hash': expected_hash, + 'mirror_tracks_hash': expected_hash, + 'matched_tracks': 1, + } + } + sync_calls: List[tuple] = [] + deps = _build_deps( + get_database=lambda: db, + load_sync_status_file=lambda: sync_statuses, + run_sync_task=lambda *a, **k: sync_calls.append((a, k)), + ) + result = auto_sync_playlist( + {'playlist_id': '1', '_event_data': {'added': '1', 'playlist_id': '1'}}, + deps, + ) + assert result['status'] == 'started' + import time + for _ in range(50): + if sync_calls: + break + time.sleep(0.01) + assert len(sync_calls) == 1 + + def test_new_mirror_row_with_skipped_track_bypasses_unchanged_skip(self): + """New playlist row without discovery must not reuse the old tracks_hash skip.""" + discovered_track = { + 'source_track_id': 'spot-1', + 'extra_data': json.dumps({ + 'discovered': True, + 'matched_data': { + 'id': 'spot-1', 'name': 'T', 'artists': [{'name': 'X'}], + 'album': {'name': 'A'}, 'duration_ms': 0, + }, + }), + 'artist_name': 'X', + } + db = _StubDB( + playlists=[{'id': 1, 'name': 'P'}], + playlist_tracks={1: [discovered_track, {}]}, # second row not syncable + ) + import hashlib + old_hash = hashlib.md5('spot-1'.encode()).hexdigest() + new_mirror_hash = hashlib.md5('spot-1,spot-new'.encode()).hexdigest() + sync_statuses = { + 'auto_mirror_1': { + 'tracks_hash': old_hash, + 'mirror_tracks_hash': old_hash, + 'matched_tracks': 1, + } + } + # Give the new mirror row a source id so mirror hash changes. + db.playlist_tracks[1][1]['source_track_id'] = 'spot-new' + + sync_calls: List[tuple] = [] + deps = _build_deps( + get_database=lambda: db, + load_sync_status_file=lambda: sync_statuses, + run_sync_task=lambda *a, **k: sync_calls.append((a, k)), + ) + result = auto_sync_playlist({'playlist_id': '1'}, deps) + assert result['status'] == 'started' + assert result['skipped_tracks'] == '1' + import time + for _ in range(50): + if sync_calls: + break + time.sleep(0.01) + assert len(sync_calls) == 1 + assert new_mirror_hash != old_hash + # ─── playlist_pipeline ─────────────────────────────────────────────── 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/discovery/test_discovery_sync.py b/tests/discovery/test_discovery_sync.py index 1ac44d76..2a123804 100644 --- a/tests/discovery/test_discovery_sync.py +++ b/tests/discovery/test_discovery_sync.py @@ -20,6 +20,7 @@ class _FakeSyncResult: failed_tracks: int = 1 synced_tracks: int = 4 total_tracks: int = 6 + wishlist_added_count: int = 0 match_details: list = None def __post_init__(self): @@ -139,6 +140,9 @@ def _build_deps( update_automation_progress=None, update_and_save_sync_status=None, run_async=None, + process_wishlist_automatically=None, + run_playlist_organize_download=None, + is_wishlist_actually_processing=None, ): return ds.SyncDeps( config_manager=config or _FakeConfig(), @@ -154,6 +158,9 @@ def _build_deps( update_and_save_sync_status=update_and_save_sync_status or (lambda *a, **kw: None), sync_states=sync_states if sync_states is not None else {}, sync_lock=sync_lock or threading.Lock(), + process_wishlist_automatically=process_wishlist_automatically, + run_playlist_organize_download=run_playlist_organize_download, + is_wishlist_actually_processing=is_wishlist_actually_processing, ) @@ -445,6 +452,64 @@ def test_update_and_save_sync_status_called(patched_db): assert kwargs.get('tracks_hash') # md5 hash present +# --------------------------------------------------------------------------- +# Post-sync automation follow-up +# --------------------------------------------------------------------------- + +def test_post_sync_triggers_wishlist_processor_for_mirror_automation(patched_db): + wishlist_calls = [] + result = _FakeSyncResult( + matched_tracks=5, + failed_tracks=2, + wishlist_added_count=2, + total_tracks=7, + ) + svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result) + deps = _build_deps( + sync_service=svc, + process_wishlist_automatically=lambda **kw: wishlist_calls.append(kw), + is_wishlist_actually_processing=lambda: False, + ) + + ds.run_sync_task( + 'auto_mirror_42', + 'Mirror', + [_track()], + automation_id='auto-1', + deps=deps, + ) + + assert len(wishlist_calls) == 1 + assert wishlist_calls[0]['automation_id'] == 'auto-1' + + +def test_post_sync_starts_organize_download_when_skip_wishlist_add(patched_db): + org_calls = [] + result = _FakeSyncResult( + matched_tracks=50, + failed_tracks=10, + total_tracks=60, + ) + svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result) + deps = _build_deps( + sync_service=svc, + run_playlist_organize_download=lambda **kw: org_calls.append(kw) or {'status': 'started'}, + ) + + ds.run_sync_task( + 'auto_mirror_7', + 'Organized', + [_track()], + automation_id='auto-2', + deps=deps, + skip_wishlist_add=True, + ) + + assert len(org_calls) == 1 + assert org_calls[0]['mirrored_playlist_id'] == 7 + assert org_calls[0]['automation_id'] == 'auto-2' + + # --------------------------------------------------------------------------- # Cleanup (finally) # --------------------------------------------------------------------------- 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 389d311a..144436e4 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, @@ -18750,6 +18752,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() @@ -18960,7 +18979,7 @@ def _update_and_save_sync_status(playlist_id, playlist_name, playlist_owner, sna 'last_synced': now.isoformat() } # Store match counts and track hash for smart-skip on scheduled syncs - for key in ('matched_tracks', 'total_tracks', 'discovered_tracks', 'tracks_hash'): + for key in ('matched_tracks', 'total_tracks', 'discovered_tracks', 'tracks_hash', 'mirror_tracks_hash'): if key in kwargs: status[key] = kwargs[key] sync_statuses[playlist_id] = status @@ -23575,14 +23594,44 @@ def _build_sync_deps(): update_and_save_sync_status=_update_and_save_sync_status, sync_states=sync_states, sync_lock=sync_lock, + process_wishlist_automatically=_process_wishlist_automatically, + run_playlist_organize_download=_run_playlist_organize_download, + is_wishlist_actually_processing=is_wishlist_actually_processing, ) -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, ) @@ -31655,6 +31704,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 d3a53042..1461c559 100644 --- a/webui/static/core.js +++ b/webui/static/core.js @@ -534,6 +534,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..c124206e 100644 --- a/webui/static/downloads.js +++ b/webui/static/downloads.js @@ -429,6 +429,9 @@ async function openDownloadMissingModalForYouTube(virtualPlaylistId, playlistNam } process.modalElement.style.display = 'flex'; } + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId); + } hideLoadingOverlay(); // Hide overlay when reopening existing modal return; } @@ -2449,8 +2452,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 +4429,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..72c8a4dc 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -961,6 +961,357 @@ function setAlbumDownloadingStatus(albumId, downloaded = 0, total = 0) { // in artists.js but used broadly across the app. // ---------------------------------------------------------------------------- +function playlistDetailsOrganizeCheckboxId(playlistRef) { + return `playlist-organize-${playlistRef}`; +} + +/** Infer mirrored-playlist API source from a UI playlist / virtual id. */ +function playlistOrganizeSourceForRef(playlistRef, explicitSource = null) { + if (explicitSource) { + return explicitSource; + } + const ref = String(playlistRef || ''); + if (ref.startsWith('spotify_public_')) { + return 'spotify_public'; + } + if (ref.startsWith('deezer_arl_')) { + return 'deezer'; + } + return 'spotify'; +} + +/** 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); + } + if (source === 'spotify_public' && ref.startsWith('spotify_public_')) { + return ref.slice('spotify_public_'.length); + } + return ref; +} + +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) { + 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 = 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); + } catch (err) { + console.debug('Could not load organize-by-playlist preference:', err); + return false; + } +} + +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 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 = null) { + const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); + const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource); + 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 = null) { + await loadPlaylistOrganizePreferenceIntoModal(playlistRef, source); +} + +/** Re-sync organize toggles when re-showing an existing Download Missing modal. */ +async function refreshOrganizePreferenceForDownloadModal(playlistRef, source = null) { + await applyMirroredOrganizePreference(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 dmLabel = _isSoulsyncStandalone ? '📁 Download to Playlist Folder' : '📥 Download Missing Tracks'; + 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...'); @@ -974,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(); } @@ -3200,6 +3554,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/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 ddce9efb..c148b9ff 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -12103,6 +12103,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; @@ -18471,8 +18486,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 { @@ -18743,6 +18786,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..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 - `}
- - - + + `; @@ -2633,6 +2656,9 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) { } modal.style.display = 'flex'; + if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal(playlistId, 'deezer'); + } } function closeDeezerArlPlaylistDetailsModal() { @@ -3596,7 +3622,7 @@ function updateDeezerModalButtons(urlHash, phase) { const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + setDiscoveryModalFooterActions(urlHash, phase); } } @@ -5621,7 +5647,7 @@ function updateBeatportModalButtons(urlHash, phase) { const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + setDiscoveryModalFooterActions(urlHash, phase); } } @@ -7582,11 +7608,11 @@ function updateSpotifyPublicModalButtons(urlHash, phase) { const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + setDiscoveryModalFooterActions(urlHash, phase); } } -async function startSpotifyPublicDownloadMissing(urlHash) { +async function startSpotifyPublicDownloadMissing(urlHash, forcePlaylistFolder = false) { try { console.log('🔍 Starting download missing tracks for Spotify public playlist:', urlHash); @@ -7648,7 +7674,9 @@ async function startSpotifyPublicDownloadMissing(urlHash) { discoveryModal.classList.add('hidden'); } - await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks); + await openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks, { + forcePlaylistFolder: !!forcePlaylistFolder, + }); } catch (error) { console.error('Error starting Spotify public download missing:', error); @@ -8606,7 +8634,7 @@ function updateITunesLinkModalButtons(urlHash, phase) { const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + setDiscoveryModalFooterActions(urlHash, phase); } } @@ -9431,7 +9459,7 @@ function openYouTubeDiscoveryModal(urlHash) { `; modal.style.display = 'flex'; + if (typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal(playlist.id, 'spotify'); + } } function closePlaylistDetailsModal() { @@ -2184,19 +2194,39 @@ 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'; } + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(playlistId); + } 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 +2240,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 +2378,9 @@ async function openDownloadMissingModal(playlistId) { Force Download All - + ${typeof downloadMissingModalOrganizeCheckboxHtml === 'function' + ? downloadMissingModalOrganizeCheckboxHtml(playlistId) + : ``}