Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything manually added to the server-side playlist.
The fix adds a per-sync mode picker next to the Sync button on the
playlist details modal — Replace (default, current delete-recreate
behavior) or Append only (preserves existing tracks, only adds new
ones). Useful when the source platform caps playlist size and the
user is manually building beyond it on the server.
Implementation:
* New `append_to_playlist(name, tracks)` method on Plex / Jellyfin /
Navidrome clients. Each uses the server's NATIVE append API:
- Plex: `existing_playlist.addItems(new_tracks)`
- Jellyfin: `POST /Playlists/<id>/Items?Ids=...&UserId=...`
- Navidrome: Subsonic `updatePlaylist?songIdToAdd=...`
Falls back to `create_playlist` when the playlist doesn't exist
yet (first sync). No delete-recreate, no backup playlist created
(preserves playlist creation date + metadata + non-soulsync-managed
tracks).
* Dedup-by-server-native-id (ratingKey for Plex, GUID for Jellyfin,
song-id for Navidrome) — never re-adds a track already on the
playlist. Server-native identity, not fuzzy title+artist match,
so it can't false-collide.
* `sync_service.sync_playlist` accepts `sync_mode='replace'|'append'`
kwarg. Single if/else branch dispatches to `append_to_playlist` or
`update_playlist`. Threaded through `core/discovery/sync.run_sync_task`
and the `/api/sync/start` HTTP handler. Validation on the API rejects
unknown mode strings (defaults to 'replace').
* Frontend: per-playlist `<select id="sync-mode-${id}">` rendered next
to the Sync button in both modal renderers (sync-spotify.js for
Spotify playlists, sync-services.js for Deezer ARL playlists).
`startPlaylistSync` reads the select at click time; missing select
(other callers like discover.js) defaults to 'replace' so backward
compat preserved without per-call-site updates.
* SoulSync standalone has no playlist methods at all and the modal
hides the Sync button entirely on it via `_isSoulsyncStandalone` —
dispatch never reaches that path, no defensive fallback needed.
15 new tests pin per-server append behavior:
- missing playlist → create_playlist delegation
- dedup filtering (existing IDs skipped, only new tracks added)
- empty new-track set short-circuits without API call
- failure paths return False without raising
- contract listing (KNOWN_PER_SERVER_METHODS includes
'append_to_playlist'; Plex / Jellyfin / Navidrome all implement)
Plus tests/discovery/test_discovery_sync.py fake `sync_playlist`
fixture got `sync_mode='replace'` default to match the new signature
(was breaking after the kwarg add; now passing).
WHATS_NEW entry under new '2.6.0' block (hidden by
`_getLatestWhatsNewVersion` until next release bump).
Closes CJFC discord request.
470 lines
16 KiB
Python
470 lines
16 KiB
Python
"""Tests for core/discovery/sync.py — playlist sync background worker."""
|
|
|
|
from __future__ import annotations
|
|
|
|
import threading
|
|
from dataclasses import dataclass
|
|
|
|
import pytest
|
|
|
|
from core.discovery import sync as ds
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Fakes
|
|
# ---------------------------------------------------------------------------
|
|
|
|
@dataclass
|
|
class _FakeSyncResult:
|
|
matched_tracks: int = 5
|
|
failed_tracks: int = 1
|
|
synced_tracks: int = 4
|
|
total_tracks: int = 6
|
|
match_details: list = None
|
|
|
|
def __post_init__(self):
|
|
if self.match_details is None:
|
|
self.match_details = []
|
|
|
|
|
|
class _FakeMediaClient:
|
|
def __init__(self, connected=True):
|
|
self._connected = connected
|
|
|
|
def is_connected(self):
|
|
return self._connected
|
|
|
|
|
|
class _FakeMediaServerEngine:
|
|
"""Stand-in for MediaServerEngine — only the bits SyncDeps needs."""
|
|
def __init__(self, plex=None, jellyfin=None, navidrome=None):
|
|
self._clients = {'plex': plex, 'jellyfin': jellyfin, 'navidrome': navidrome}
|
|
|
|
def client(self, name):
|
|
return self._clients.get(name)
|
|
|
|
|
|
class _FakeSyncService:
|
|
def __init__(self, *, media_client=None, server_type='plex',
|
|
sync_result=None, raise_on_sync=None,
|
|
spotify_client=True, plex_client=True, jellyfin_client=True):
|
|
self._media_client = media_client
|
|
self._server_type = server_type
|
|
self._sync_result = sync_result or _FakeSyncResult()
|
|
self._raise_on_sync = raise_on_sync
|
|
self.spotify_client = object() if spotify_client else None
|
|
# The sync_service exposes the engine so the discovery worker
|
|
# can introspect per-server clients via self._engine.client(name).
|
|
self._engine = _FakeMediaServerEngine(
|
|
plex=object() if plex_client else None,
|
|
jellyfin=object() if jellyfin_client else None,
|
|
)
|
|
self.progress_callback = None
|
|
self.progress_playlist_name = None
|
|
self.cleared_callbacks = []
|
|
|
|
def _get_active_media_client(self):
|
|
return (self._media_client, self._server_type)
|
|
|
|
def set_progress_callback(self, cb, playlist_name):
|
|
self.progress_callback = cb
|
|
self.progress_playlist_name = playlist_name
|
|
|
|
def clear_progress_callback(self, playlist_name):
|
|
self.cleared_callbacks.append(playlist_name)
|
|
|
|
async def sync_playlist(self, playlist, download_missing=False, profile_id=1, sync_mode='replace'):
|
|
if self._raise_on_sync:
|
|
raise self._raise_on_sync
|
|
return self._sync_result
|
|
|
|
async def _find_track_in_media_server(self, spotify_track):
|
|
return None, 0.0
|
|
|
|
|
|
class _FakeConfig:
|
|
def __init__(self, server='plex'):
|
|
self._server = server
|
|
|
|
def get_active_media_server(self):
|
|
return self._server
|
|
|
|
|
|
class _FakePlex:
|
|
def __init__(self):
|
|
self.image_calls = []
|
|
|
|
def set_playlist_image(self, name, url):
|
|
self.image_calls.append((name, url))
|
|
return True
|
|
|
|
|
|
class _FakeJellyfin:
|
|
def __init__(self):
|
|
self.image_calls = []
|
|
|
|
def set_playlist_image(self, name, url):
|
|
self.image_calls.append((name, url))
|
|
return True
|
|
|
|
|
|
class _FakeAutomationEngine:
|
|
def __init__(self):
|
|
self.events = []
|
|
|
|
def emit(self, event_type, data):
|
|
self.events.append((event_type, data))
|
|
|
|
|
|
def _run_async_sync(coro):
|
|
"""Run a coroutine to completion using a new event loop."""
|
|
import asyncio
|
|
loop = asyncio.new_event_loop()
|
|
try:
|
|
return loop.run_until_complete(coro)
|
|
finally:
|
|
loop.close()
|
|
|
|
|
|
def _build_deps(
|
|
*,
|
|
sync_service=None,
|
|
config=None,
|
|
plex=None,
|
|
jellyfin=None,
|
|
automation=None,
|
|
sync_states=None,
|
|
sync_lock=None,
|
|
record_sync_history_start=None,
|
|
update_automation_progress=None,
|
|
update_and_save_sync_status=None,
|
|
run_async=None,
|
|
):
|
|
return ds.SyncDeps(
|
|
config_manager=config or _FakeConfig(),
|
|
sync_service=sync_service or _FakeSyncService(media_client=_FakeMediaClient()),
|
|
media_server_engine=_FakeMediaServerEngine(
|
|
plex=plex or _FakePlex(),
|
|
jellyfin=jellyfin or _FakeJellyfin(),
|
|
),
|
|
automation_engine=automation or _FakeAutomationEngine(),
|
|
run_async=run_async or _run_async_sync,
|
|
record_sync_history_start=record_sync_history_start or (lambda **kw: None),
|
|
update_automation_progress=update_automation_progress or (lambda *a, **kw: None),
|
|
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(),
|
|
)
|
|
|
|
|
|
def _track(name='Song', artists=None, album='Album', track_id='id1'):
|
|
return {
|
|
'id': track_id,
|
|
'name': name,
|
|
'artists': artists or ['Artist'],
|
|
'album': album,
|
|
'duration_ms': 1000,
|
|
}
|
|
|
|
|
|
@pytest.fixture
|
|
def patched_db(monkeypatch):
|
|
"""Stubs database access — never hits a real DB."""
|
|
class _StubDB:
|
|
def __init__(self):
|
|
self.completion_calls = []
|
|
self.track_results_calls = []
|
|
|
|
def update_sync_history_completion(self, batch_id, matched, synced, failed):
|
|
self.completion_calls.append((batch_id, matched, synced, failed))
|
|
|
|
def update_sync_history_track_results(self, batch_id, results_json):
|
|
self.track_results_calls.append((batch_id, results_json))
|
|
return True
|
|
|
|
def refresh_sync_history_entry(self, *args):
|
|
pass
|
|
|
|
def get_sync_history_entry(self, entry_id):
|
|
return None
|
|
|
|
def read_sync_match_cache(self, sp_id, server):
|
|
return None
|
|
|
|
stub = _StubDB()
|
|
monkeypatch.setattr('database.music_database.MusicDatabase', lambda: stub)
|
|
return stub
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# History recording
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_records_sync_history_for_new_sync(patched_db):
|
|
"""Non-resync playlist_id triggers record_sync_history_start callback."""
|
|
history_calls = []
|
|
deps = _build_deps(record_sync_history_start=lambda **kw: history_calls.append(kw))
|
|
|
|
ds.run_sync_task('p1', 'My Playlist', [_track()], deps=deps)
|
|
|
|
assert len(history_calls) == 1
|
|
assert history_calls[0]['playlist_id'] == 'p1'
|
|
assert history_calls[0]['playlist_name'] == 'My Playlist'
|
|
assert history_calls[0]['source_page'] == 'sync'
|
|
|
|
|
|
def test_resync_skips_history_record(patched_db):
|
|
"""Re-sync playlist_id (resync_<id>_<ts>) skips record_sync_history_start."""
|
|
history_calls = []
|
|
deps = _build_deps(record_sync_history_start=lambda **kw: history_calls.append(kw))
|
|
|
|
ds.run_sync_task('resync_42_1234', 'Replayed', [_track()], deps=deps)
|
|
|
|
assert history_calls == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Setup error path
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_setup_error_marks_state_error(patched_db, monkeypatch):
|
|
"""Exception during track conversion → sync_states[id] = 'error'."""
|
|
states = {}
|
|
|
|
# Force SpotifyTrack constructor to raise to trigger setup error path
|
|
class BoomSpotifyTrack:
|
|
def __init__(self, **kw):
|
|
raise ValueError("boom!")
|
|
|
|
monkeypatch.setattr(ds, 'SpotifyTrack', BoomSpotifyTrack)
|
|
deps = _build_deps(sync_states=states)
|
|
|
|
ds.run_sync_task('pX', 'Playlist X', [_track()], deps=deps)
|
|
|
|
assert states['pX']['status'] == 'error'
|
|
assert 'boom!' in states['pX']['error']
|
|
|
|
|
|
def test_setup_error_with_automation_id_updates_progress(patched_db, monkeypatch):
|
|
"""Setup error with automation_id calls update_automation_progress with status=error."""
|
|
auto_calls = []
|
|
|
|
class BoomSpotifyTrack:
|
|
def __init__(self, **kw):
|
|
raise ValueError("setup boom")
|
|
|
|
monkeypatch.setattr(ds, 'SpotifyTrack', BoomSpotifyTrack)
|
|
deps = _build_deps(update_automation_progress=lambda *a, **kw: auto_calls.append((a, kw)))
|
|
|
|
ds.run_sync_task('pY', 'PY', [_track()], automation_id='auto-1', deps=deps)
|
|
|
|
assert any(kw.get('status') == 'error' for _, kw in auto_calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync service errors
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_no_sync_service_marks_error(patched_db):
|
|
"""sync_service None → caught by outer except, sync_states marked error."""
|
|
states = {}
|
|
deps = _build_deps(sync_states=states)
|
|
deps.sync_service = None # explicit override past the default fallback
|
|
|
|
ds.run_sync_task('pZ', 'PZ', [_track()], deps=deps)
|
|
|
|
assert states['pZ']['status'] == 'error'
|
|
|
|
|
|
def test_sync_playlist_exception_marks_error(patched_db):
|
|
"""sync_playlist raising propagates → sync_states marked error."""
|
|
states = {}
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(),
|
|
raise_on_sync=RuntimeError("network down"))
|
|
deps = _build_deps(sync_service=svc, sync_states=states)
|
|
|
|
ds.run_sync_task('pErr', 'PErr', [_track()], deps=deps)
|
|
|
|
assert states['pErr']['status'] == 'error'
|
|
assert 'network down' in states['pErr']['error']
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Successful sync
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_successful_sync_marks_state_finished(patched_db):
|
|
"""Successful sync transitions sync_states to 'finished' with result_dict."""
|
|
states = {}
|
|
result = _FakeSyncResult(matched_tracks=10, total_tracks=12, synced_tracks=10, failed_tracks=2)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, sync_states=states)
|
|
|
|
ds.run_sync_task('pOK', 'POK', [_track()], deps=deps)
|
|
|
|
assert states['pOK']['status'] == 'finished'
|
|
assert states['pOK']['progress']['matched_tracks'] == 10
|
|
|
|
|
|
def test_unmatched_tracks_summary_added_to_state(patched_db):
|
|
"""match_details with not_found entries → unmatched_tracks summary on result_dict."""
|
|
states = {}
|
|
md = [
|
|
{'name': 'Lost1', 'artist': 'A', 'image_url': '', 'status': 'not_found'},
|
|
{'name': 'Found1', 'artist': 'B', 'status': 'matched'},
|
|
{'name': 'Lost2', 'artist': 'C', 'image_url': '', 'status': 'not_found'},
|
|
]
|
|
result = _FakeSyncResult(match_details=md)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, sync_states=states)
|
|
|
|
ds.run_sync_task('pU', 'PU', [_track()], deps=deps)
|
|
|
|
unmatched = states['pU']['progress'].get('unmatched_tracks', [])
|
|
assert len(unmatched) == 2
|
|
assert unmatched[0]['name'] == 'Lost1'
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Playlist image upload
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_playlist_image_uploaded_to_plex(patched_db):
|
|
"""Plex active server + image_url + synced > 0 → plex_client.set_playlist_image called."""
|
|
plex = _FakePlex()
|
|
cfg = _FakeConfig(server='plex')
|
|
result = _FakeSyncResult(synced_tracks=5)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, plex=plex, config=cfg)
|
|
|
|
ds.run_sync_task('pImg', 'PImg', [_track()],
|
|
playlist_image_url='https://img/x.png', deps=deps)
|
|
|
|
assert plex.image_calls == [('PImg', 'https://img/x.png')]
|
|
|
|
|
|
def test_playlist_image_uploaded_to_jellyfin(patched_db):
|
|
"""Jellyfin/Emby active → jellyfin_client.set_playlist_image."""
|
|
jf = _FakeJellyfin()
|
|
cfg = _FakeConfig(server='jellyfin')
|
|
result = _FakeSyncResult(synced_tracks=3)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, jellyfin=jf, config=cfg)
|
|
|
|
ds.run_sync_task('pJF', 'PJF', [_track()],
|
|
playlist_image_url='https://img/y.png', deps=deps)
|
|
|
|
assert jf.image_calls == [('PJF', 'https://img/y.png')]
|
|
|
|
|
|
def test_no_image_upload_when_zero_synced(patched_db):
|
|
"""synced_tracks == 0 → no playlist image upload."""
|
|
plex = _FakePlex()
|
|
result = _FakeSyncResult(synced_tracks=0)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, plex=plex)
|
|
|
|
ds.run_sync_task('pNoImg', 'PNoImg', [_track()],
|
|
playlist_image_url='https://img/z.png', deps=deps)
|
|
|
|
assert plex.image_calls == []
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Automation engine
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_automation_engine_emits_playlist_synced(patched_db):
|
|
"""Successful sync emits 'playlist_synced' event on automation_engine."""
|
|
ae = _FakeAutomationEngine()
|
|
result = _FakeSyncResult(matched_tracks=7, total_tracks=8, synced_tracks=7, failed_tracks=1)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc, automation=ae)
|
|
|
|
ds.run_sync_task('pE', 'PE', [_track()], deps=deps)
|
|
|
|
assert any(evt == 'playlist_synced' for evt, _ in ae.events)
|
|
|
|
|
|
def test_automation_progress_finished_called(patched_db):
|
|
"""automation_id provided + sync OK → update_automation_progress called with status=finished."""
|
|
auto_calls = []
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc,
|
|
update_automation_progress=lambda *a, **kw: auto_calls.append(kw))
|
|
|
|
ds.run_sync_task('pA', 'PA', [_track()], automation_id='auto-99', deps=deps)
|
|
|
|
assert any(kw.get('status') == 'finished' for kw in auto_calls)
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync history persistence
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_sync_history_completion_saved(patched_db):
|
|
"""Successful sync calls update_sync_history_completion on the DB."""
|
|
result = _FakeSyncResult(matched_tracks=4, synced_tracks=4, failed_tracks=0)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pHist', 'PHist', [_track()], deps=deps)
|
|
|
|
assert len(patched_db.completion_calls) == 1
|
|
bid, matched, synced, failed = patched_db.completion_calls[0]
|
|
assert matched == 4 and synced == 4 and failed == 0
|
|
|
|
|
|
def test_match_details_persisted_to_track_results(patched_db):
|
|
"""match_details on result → update_sync_history_track_results called with JSON."""
|
|
md = [{'name': 'T1', 'status': 'matched'}]
|
|
result = _FakeSyncResult(match_details=md)
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient(), sync_result=result)
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pMD', 'PMD', [_track()], deps=deps)
|
|
|
|
assert len(patched_db.track_results_calls) == 1
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Sync status save (smart-skip hash)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_update_and_save_sync_status_called(patched_db):
|
|
"""update_and_save_sync_status called with a tracks_hash for smart-skip."""
|
|
save_calls = []
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc,
|
|
update_and_save_sync_status=lambda *a, **kw: save_calls.append((a, kw)))
|
|
|
|
ds.run_sync_task('pSS', 'PSS', [_track(track_id='abc'), _track(track_id='def')], deps=deps)
|
|
|
|
assert len(save_calls) == 1
|
|
args, kwargs = save_calls[0]
|
|
assert kwargs.get('tracks_hash') # md5 hash present
|
|
|
|
|
|
# ---------------------------------------------------------------------------
|
|
# Cleanup (finally)
|
|
# ---------------------------------------------------------------------------
|
|
|
|
def test_finally_clears_progress_callback(patched_db):
|
|
"""finally block clears sync_service progress callback."""
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pCB', 'PCB', [_track()], deps=deps)
|
|
|
|
# Both the explicit clear (after run_async) and the finally block run
|
|
assert 'PCB' in svc.cleared_callbacks
|
|
|
|
|
|
def test_finally_drops_original_tracks_map(patched_db):
|
|
"""finally block deletes _original_tracks_map attribute when present."""
|
|
svc = _FakeSyncService(media_client=_FakeMediaClient())
|
|
deps = _build_deps(sync_service=svc)
|
|
|
|
ds.run_sync_task('pTM', 'PTM', [_track()], deps=deps)
|
|
|
|
assert not hasattr(svc, '_original_tracks_map')
|