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
- `}