From 0b1fdba2a15be094779dcfedb688e2b3a343a266 Mon Sep 17 00:00:00 2001 From: kekkokk Date: Thu, 4 Jun 2026 00:08:53 +0200 Subject: [PATCH] Fix standalone mirrored playlist sync and post-sync downloads. SoulSync standalone matches library tracks without Plex fetchItem, reports missing counts correctly, and skips server playlist writes. Automation re-syncs when the mirror grows; after sync finishes, starts organize download (organize-by-playlist) or wishlist processing. UI: Spotify URL playlist-folder controls, organize toggle layout in the discovery modal, reload organize preference when reopening Download Missing. Co-authored-by: Cursor --- core/automation/handlers/sync_playlist.py | 40 ++++- core/discovery/sync.py | 118 +++++++++++++- services/sync_service.py | 180 ++++++++++++--------- tests/automation/test_handlers_playlist.py | 97 ++++++++++- tests/discovery/test_discovery_sync.py | 65 ++++++++ web_server.py | 5 +- webui/static/downloads.js | 3 + webui/static/shared-helpers.js | 58 +++++-- webui/static/stats-automations.js | 7 + webui/static/style.css | 28 ++++ webui/static/sync-services.js | 123 +++++++++++--- webui/static/sync-spotify.js | 9 +- 12 files changed, 609 insertions(+), 124 deletions(-) diff --git a/core/automation/handlers/sync_playlist.py b/core/automation/handlers/sync_playlist.py index 631a546f..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) diff --git a/core/discovery/sync.py b/core/discovery/sync.py index 2af7ad27..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): @@ -476,9 +560,21 @@ def run_sync_task( 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: @@ -497,12 +593,28 @@ def run_sync_task( 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/services/sync_service.py b/services/sync_service.py index 2a3488c7..e8d04e65 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, @@ -551,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)) @@ -618,27 +646,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_handlers_playlist.py b/tests/automation/test_handlers_playlist.py index 9cd5f2e1..a690d7b6 100644 --- a/tests/automation/test_handlers_playlist.py +++ b/tests/automation/test_handlers_playlist.py @@ -770,6 +770,7 @@ class TestSyncPlaylist: def test_unchanged_since_last_sync_returns_skipped(self): discovered_track = { + 'source_track_id': 'spot-1', 'extra_data': json.dumps({ 'discovered': True, 'matched_data': { @@ -789,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( @@ -800,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/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/web_server.py b/web_server.py index 538f2591..fc717498 100644 --- a/web_server.py +++ b/web_server.py @@ -19017,7 +19017,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 @@ -23631,6 +23631,9 @@ 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, ) diff --git a/webui/static/downloads.js b/webui/static/downloads.js index dd1df920..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; } diff --git a/webui/static/shared-helpers.js b/webui/static/shared-helpers.js index 6ce927e2..72c8a4dc 100644 --- a/webui/static/shared-helpers.js +++ b/webui/static/shared-helpers.js @@ -965,15 +965,41 @@ 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, "\\'"); @@ -1000,11 +1026,12 @@ function isPlaylistOrganizeEnabled(playlistRef) { return downloadMissingCb ? downloadMissingCb.checked : false; } -async function fetchMirroredOrganizePreference(playlistRef, source = 'spotify') { +async function fetchMirroredOrganizePreference(playlistRef, source = null) { try { - const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source); + const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); + const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); const res = await fetch( - `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}` + `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}` ); const data = await res.json(); return !!(data.found && data.playlist?.organize_by_playlist); @@ -1014,11 +1041,12 @@ async function fetchMirroredOrganizePreference(playlistRef, source = 'spotify') } } -async function setMirroredOrganizePreference(playlistRef, enabled, source = 'spotify') { +async function setMirroredOrganizePreference(playlistRef, enabled, source = null) { try { - const resolveRef = normalizePlaylistOrganizeRef(playlistRef, source); + const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); + const resolveRef = normalizePlaylistOrganizeRef(playlistRef, resolvedSource); const res = await fetch( - `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(source)}` + `/api/mirrored-playlists/resolve?ref=${encodeURIComponent(resolveRef)}&source=${encodeURIComponent(resolvedSource)}` ); const data = await res.json(); if (!data.found || !data.playlist?.id) { @@ -1041,8 +1069,9 @@ async function setMirroredOrganizePreference(playlistRef, enabled, source = 'spo } } -async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = 'spotify') { - const enabled = await fetchMirroredOrganizePreference(playlistRef, source); +async function loadPlaylistOrganizePreferenceIntoModal(playlistRef, source = null) { + const resolvedSource = playlistOrganizeSourceForRef(playlistRef, source); + const enabled = await fetchMirroredOrganizePreference(playlistRef, resolvedSource); syncPlaylistOrganizeCheckboxes(playlistRef, enabled); } @@ -1054,10 +1083,15 @@ async function onPlaylistOrganizePreferenceChange(playlistRef, enabled, source = } } -async function applyMirroredOrganizePreference(playlistRef, source = 'spotify') { +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) { @@ -1209,10 +1243,11 @@ function playlistModalDownloadSyncFooterHtml(playlistId, 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} @@ -1290,6 +1325,9 @@ async function openDownloadMissingModalForArtistAlbum(virtualPlaylistId, playlis showToast('Showing previous results. Close this modal to start a new analysis.', 'info'); } process.modalElement.style.display = 'flex'; + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId); + } if (showLoadingOverlayParam) { hideLoadingOverlay(); } diff --git a/webui/static/stats-automations.js b/webui/static/stats-automations.js index b5f2612b..01aab02a 100644 --- a/webui/static/stats-automations.js +++ b/webui/static/stats-automations.js @@ -3554,6 +3554,13 @@ const _RESULT_DISPLAY_MAP = { { key: 'wishlist_queued', label: 'Wishlist Queued' }, { key: 'duration_seconds', label: 'Duration (s)' }, ], + 'sync_playlist': [ + { key: 'matched_tracks', label: 'In Library' }, + { key: 'failed_tracks', label: 'Missing' }, + { key: 'wishlist_added_count', label: 'Added to Wishlist', hideZero: true }, + { key: 'total_tracks', label: 'Total Tracks' }, + { key: 'synced_tracks', label: 'On Server Playlist', hideZero: true }, + ], }; function _renderResultStats(resultJson, actionType) { diff --git a/webui/static/style.css b/webui/static/style.css index de95020b..d40d055a 100644 --- a/webui/static/style.css +++ b/webui/static/style.css @@ -18471,8 +18471,36 @@ body.helper-mode-active #dashboard-activity-feed:hover { .youtube-discovery-modal .modal-footer-left { display: flex; + flex-direction: column; + align-items: stretch; + gap: 14px; + flex: 1; + min-width: 0; +} + +.youtube-discovery-modal .modal-footer-organize { + flex: none; + width: 100%; + padding-bottom: 12px; + border-bottom: 1px solid rgba(255, 255, 255, 0.1); +} + +.youtube-discovery-modal .modal-footer-organize .playlist-modal-organize-toggle { + width: 100%; + font-size: 14px; +} + +.youtube-discovery-modal .modal-footer-actions { + display: flex; + flex-wrap: wrap; gap: 12px; align-items: center; + width: 100%; +} + +.youtube-discovery-modal .modal-footer-actions .modal-info { + flex: 1 1 100%; + margin-bottom: 4px; } .youtube-discovery-modal .modal-footer-right { diff --git a/webui/static/sync-services.js b/webui/static/sync-services.js index a87eeff1..69dd0cef 100644 --- a/webui/static/sync-services.js +++ b/webui/static/sync-services.js @@ -1233,10 +1233,7 @@ function updateTidalModalButtons(urlHash, phase) { const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); if (!modal) return; - const footerLeft = modal.querySelector('.modal-footer-left'); - if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); - } + setDiscoveryModalFooterActions(urlHash, phase); } async function startTidalDownloadMissing(urlHash) { @@ -1314,7 +1311,7 @@ async function startTidalDownloadMissing(urlHash) { } } -async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks) { +async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, spotifyTracks, options = {}) { showLoadingOverlay('Loading Tidal playlist...'); // Check if a process is already active for this virtual playlist if (activeDownloadProcesses[virtualPlaylistId]) { @@ -1326,6 +1323,10 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, } process.modalElement.style.display = 'flex'; } + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(virtualPlaylistId); + } + hideLoadingOverlay(); return; } @@ -1464,10 +1465,12 @@ async function openDownloadMissingModalForTidal(virtualPlaylistId, playlistName, Force Download All - `} @@ -9569,6 +9584,48 @@ function openYouTubeDiscoveryModal(urlHash) { } console.log('✨ Created new modal with current state'); + if (isSpotifyPublic && state.spotify_public_playlist_id && typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal( + `spotify_public_${state.spotify_public_playlist_id}`, + 'spotify_public', + ); + } + } +} + +function discoveryModalOrganizeFooterHtml(state) { + if (!state || typeof playlistOrganizeToggleHtml !== 'function') { + return ''; + } + if (state.is_spotify_public_playlist && state.spotify_public_playlist_id) { + const ref = `spotify_public_${state.spotify_public_playlist_id}`; + return ``; + } + return ''; +} + +function buildDiscoveryModalFooterLeftHtml(urlHash, phase, state) { + const organize = discoveryModalOrganizeFooterHtml(state); + const actions = getModalActionButtons(urlHash, phase, state); + return `${organize}`; +} + +function setDiscoveryModalFooterActions(urlHash, phase, state = null) { + if (!state) { + state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; + } + const modal = document.getElementById(`youtube-discovery-modal-${urlHash}`); + const footerLeft = modal?.querySelector('.modal-footer-left'); + if (!footerLeft) { + return; + } + footerLeft.innerHTML = buildDiscoveryModalFooterLeftHtml(urlHash, phase, state); + if (state?.is_spotify_public_playlist && state.spotify_public_playlist_id + && typeof loadPlaylistOrganizePreferenceIntoModal === 'function') { + void loadPlaylistOrganizePreferenceIntoModal( + `spotify_public_${state.spotify_public_playlist_id}`, + 'spotify_public', + ); } } @@ -9650,7 +9707,12 @@ function getModalActionButtons(urlHash, phase, state = null) { } else if (isDeezer) { buttons += ``; } else if (isSpotifyPublic) { - buttons += ``; + const folderArg = _isSoulsyncStandalone ? ', true' : ''; + const label = _isSoulsyncStandalone + ? '📁 Download to Playlist Folder' + : '🔍 Download Missing Tracks'; + const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : ''; + buttons += ``; } else if (isITunesLink) { buttons += ``; } else if (isBeatport) { @@ -9819,7 +9881,12 @@ function getModalActionButtons(urlHash, phase, state = null) { } else if (isQobuz) { syncCompleteButtons += ``; } else if (isSpotifyPublic) { - syncCompleteButtons += ``; + const folderArg = _isSoulsyncStandalone ? ', true' : ''; + const label = _isSoulsyncStandalone + ? '📁 Download to Playlist Folder' + : '🔍 Download Missing Tracks'; + const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : ''; + syncCompleteButtons += ``; } else if (isITunesLink) { syncCompleteButtons += ``; } else if (isBeatport) { @@ -9875,7 +9942,12 @@ function getModalActionButtons(urlHash, phase, state = null) { } else if (isDeezer) { dlCompleteButtons += ``; } else if (isSpotifyPublic) { - dlCompleteButtons += ``; + const folderArg = _isSoulsyncStandalone ? ', true' : ''; + const label = _isSoulsyncStandalone + ? '📁 Download to Playlist Folder' + : '🔍 Download Missing Tracks'; + const extraClass = _isSoulsyncStandalone ? ' soulsync-standalone-action' : ''; + dlCompleteButtons += ``; } else if (isITunesLink) { dlCompleteButtons += ``; } else if (isBeatport) { @@ -10118,18 +10190,17 @@ function updateYouTubeDiscoveryModal(urlHash, status) { const state = listenbrainzPlaylistStates[urlHash] || youtubePlaylistStates[urlHash]; if (state && state.phase === 'discovering') { state.phase = 'discovered'; - const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); - if (actionButtonsContainer) { - actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); - console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`); - } + setDiscoveryModalFooterActions(urlHash, 'discovered', state); + console.log(`✨ Updated action buttons for completed discovery: ${urlHash}`); const descEl = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-description`); if (descEl) descEl.textContent = 'Discovery complete! View the results below.'; } else if (state && state.phase === 'discovered') { // Already discovered — ensure buttons are correct (e.g. after rehydration) - const actionButtonsContainer = document.querySelector(`#youtube-discovery-modal-${urlHash} .modal-footer-left`); + const actionButtonsContainer = document.querySelector( + `#youtube-discovery-modal-${urlHash} .modal-footer-actions`, + ); if (actionButtonsContainer && actionButtonsContainer.querySelector('.modal-info')) { - actionButtonsContainer.innerHTML = getModalActionButtons(urlHash, 'discovered', state); + setDiscoveryModalFooterActions(urlHash, 'discovered', state); } } } @@ -10626,7 +10697,7 @@ function updateYouTubeModalButtons(urlHash, phase) { const footerLeft = modal.querySelector('.modal-footer-left'); if (footerLeft) { - footerLeft.innerHTML = getModalActionButtons(urlHash, phase); + setDiscoveryModalFooterActions(urlHash, phase); } } diff --git a/webui/static/sync-spotify.js b/webui/static/sync-spotify.js index 77da6514..6af72167 100644 --- a/webui/static/sync-spotify.js +++ b/webui/static/sync-spotify.js @@ -2210,6 +2210,9 @@ async function openDownloadMissingModal(playlistId) { } process.modalElement.style.display = 'flex'; } + if (typeof refreshOrganizePreferenceForDownloadModal === 'function') { + await refreshOrganizePreferenceForDownloadModal(playlistId); + } hideLoadingOverlay(); return; } @@ -2375,7 +2378,9 @@ async function openDownloadMissingModal(playlistId) { Force Download All - + ${typeof downloadMissingModalOrganizeCheckboxHtml === 'function' + ? downloadMissingModalOrganizeCheckboxHtml(playlistId) + : ``}