Replace mode (default) deletes + recreates the server playlist every sync,
which wipes its custom image, description, and identity. Add an opt-in
'reconcile' sync mode that edits the existing playlist in place — adds the
tracks now in the source, removes the ones gone — without destroying the
object, so the user's custom art/description survive.
- Pure planner plan_playlist_reconcile(current, desired) -> {add, remove}.
- Per-client reconcile_playlist: Plex addItems/removeItems on the same object;
Navidrome Subsonic updatePlaylist delta (songIdToAdd / descending
songIndexToRemove); Jellyfin add + remove-by-PlaylistItemId on /Playlists/{id}/Items.
- sync_service: reconcile branch with a replace FALLBACK (if a server's in-place
edit is unavailable/fails, sync still succeeds destructively — logged loudly).
- Default stays 'replace' (no behavior change). New Settings > Playlist sync mode
picker (replace/reconcile/append) backed by playlist_sync.mode; per-request
sync_mode still overrides.
- Reconcile skips the post-sync source-image push so a custom poster isn't
re-clobbered (the bug).
Tests: planner (add/remove/dedupe/order/empty) + reconcile-or-replace dispatch
(success / false-fallback / exception-fallback / no-method). Per-server in-place
API calls need dev validation against real Plex/Jellyfin/Navidrome.
NOTE: opt-in only; default behavior unchanged.
67 lines
2.4 KiB
Python
67 lines
2.4 KiB
Python
"""sync_mode='reconcile' dispatch + fallback (#792).
|
|
|
|
_reconcile_or_replace tries the client's in-place reconcile and falls back to
|
|
the destructive replace only when reconcile is unavailable or fails, so a sync
|
|
always succeeds while preferring the non-destructive path.
|
|
"""
|
|
|
|
import sys
|
|
import types
|
|
|
|
# Stub optional Spotify dep so services.sync_service imports in the test env.
|
|
if 'spotipy' not in sys.modules:
|
|
sp = types.ModuleType('spotipy'); oa = types.ModuleType('spotipy.oauth2')
|
|
sp.Spotify = type('S', (), {}); oa.SpotifyOAuth = oa.SpotifyClientCredentials = type('O', (), {})
|
|
sp.oauth2 = oa; sys.modules['spotipy'] = sp; sys.modules['spotipy.oauth2'] = oa
|
|
|
|
from services.sync_service import PlaylistSyncService
|
|
|
|
|
|
def _service():
|
|
return PlaylistSyncService.__new__(PlaylistSyncService)
|
|
|
|
|
|
class _Client:
|
|
def __init__(self, reconcile=None):
|
|
self._reconcile = reconcile
|
|
self.reconcile_calls = []
|
|
self.replace_calls = []
|
|
if reconcile is not None:
|
|
def reconcile_playlist(name, tracks):
|
|
self.reconcile_calls.append(name)
|
|
if isinstance(reconcile, Exception):
|
|
raise reconcile
|
|
return reconcile
|
|
self.reconcile_playlist = reconcile_playlist
|
|
|
|
def update_playlist(self, name, tracks):
|
|
self.replace_calls.append(name)
|
|
return True
|
|
|
|
|
|
def test_reconcile_success_does_not_fall_back():
|
|
c = _Client(reconcile=True)
|
|
assert _service()._reconcile_or_replace(c, 'P', []) is True
|
|
assert c.reconcile_calls == ['P']
|
|
assert c.replace_calls == [] # never recreated
|
|
|
|
|
|
def test_reconcile_false_falls_back_to_replace():
|
|
c = _Client(reconcile=False)
|
|
assert _service()._reconcile_or_replace(c, 'P', []) is True
|
|
assert c.reconcile_calls == ['P']
|
|
assert c.replace_calls == ['P'] # fell back so the sync still happens
|
|
|
|
|
|
def test_reconcile_exception_falls_back_to_replace():
|
|
c = _Client(reconcile=RuntimeError('boom'))
|
|
assert _service()._reconcile_or_replace(c, 'P', []) is True
|
|
assert c.reconcile_calls == ['P']
|
|
assert c.replace_calls == ['P']
|
|
|
|
|
|
def test_client_without_reconcile_uses_replace():
|
|
c = _Client(reconcile=None) # no reconcile_playlist attribute
|
|
assert not hasattr(c, 'reconcile_playlist')
|
|
assert _service()._reconcile_or_replace(c, 'P', []) is True
|
|
assert c.replace_calls == ['P']
|