Server playlist sync: append mode (preserve user-added tracks)

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.
pull/548/head
Broque Thomas 4 days ago
parent 1d6e213b16
commit 6fe85f2f37

@ -49,7 +49,7 @@ class SyncDeps:
sync_lock: Any # threading.Lock
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None):
def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', deps: SyncDeps = None, sync_mode: str = 'replace'):
"""The actual sync function that runs in the background thread."""
sync_states = deps.sync_states
sync_lock = deps.sync_lock
@ -359,7 +359,7 @@ def run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, p
sync_service._skip_wishlist = is_wing_it
# Run the sync (this is a blocking call within this thread)
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id))
result = deps.run_async(sync_service.sync_playlist(playlist, download_missing=False, profile_id=profile_id, sync_mode=sync_mode))
# Clear progress callback immediately to prevent race condition where a
# late-firing progress callback overwrites the "finished" state below

@ -1533,6 +1533,78 @@ class JellyfinClient(MediaServerClient):
logger.debug(f"Could not set playlist poster for '{playlist_name}': {e}")
return False
def append_to_playlist(self, playlist_name: str, tracks) -> bool:
"""Append tracks to an existing playlist (creates it if missing).
Differs from `update_playlist`: never deletes existing tracks,
never recreates the playlist, no backup. Used by sync mode
'append' so user-added tracks on the server playlist survive
re-syncing the source. Dedupe-by-Id ensures we don't re-add
tracks the playlist already contains."""
if not self.ensure_connection():
return False
try:
existing_playlist = self.get_playlist_by_name(playlist_name)
if not existing_playlist:
logger.info(
f"Jellyfin append: playlist '{playlist_name}' doesn't exist yet — "
f"creating with {len(tracks)} tracks"
)
return self.create_playlist(playlist_name, tracks)
playlist_id = existing_playlist.id
existing_tracks = self.get_playlist_tracks(playlist_id)
existing_ids = {
str(t.id) for t in existing_tracks if hasattr(t, 'id') and t.id
}
new_track_ids = []
for t in tracks:
tid = None
if hasattr(t, 'id'):
tid = str(t.id) if t.id else None
elif isinstance(t, dict):
tid = str(t.get('Id') or t.get('id') or '')
if tid and tid not in existing_ids and self._is_valid_guid(tid):
new_track_ids.append(tid)
if not new_track_ids:
logger.info(
f"Jellyfin append: no new tracks to add to '{playlist_name}' "
f"(all matched tracks already present)"
)
return True
import requests
batch_size = 100
total_added = 0
for i in range(0, len(new_track_ids), batch_size):
batch = new_track_ids[i:i + batch_size]
add_url = f"{self.base_url}/Playlists/{playlist_id}/Items"
add_params = {'Ids': ','.join(batch), 'UserId': self.user_id}
resp = requests.post(
add_url, params=add_params,
headers={'X-Emby-Token': self.api_key}, timeout=30,
)
if resp.status_code in (200, 204):
total_added += len(batch)
else:
logger.error(
f"Jellyfin append batch failed: HTTP {resp.status_code} - "
f"{resp.text[:200]}"
)
return False
logger.info(
f"Jellyfin append: added {total_added} new tracks to '{playlist_name}' "
f"(skipped {len(tracks) - total_added} already present or invalid)"
)
return True
except Exception as e:
logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}")
return False
def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist"""
if not self.ensure_connection():

@ -97,6 +97,7 @@ KNOWN_PER_SERVER_METHODS = (
'get_library_stats',
'create_playlist',
'update_playlist',
'append_to_playlist',
'copy_playlist',
'get_all_playlists',
'get_playlist_by_name',

@ -974,6 +974,73 @@ class NavidromeClient(MediaServerClient):
matches.append(playlist)
return matches
def append_to_playlist(self, playlist_name: str, tracks) -> bool:
"""Append tracks to an existing playlist (creates it if missing).
Differs from `update_playlist`: never deletes existing tracks,
never recreates the playlist, no backup. Used by sync mode
'append' so user-added tracks on the server playlist survive
re-syncing the source. Dedupe-by-id ensures we don't re-add
tracks the playlist already contains."""
if not self.ensure_connection():
return False
try:
existing_playlists = self.get_playlists_by_name(playlist_name)
if not existing_playlists:
logger.info(
f"Navidrome append: playlist '{playlist_name}' doesn't exist yet — "
f"creating with {len(tracks)} tracks"
)
return self.create_playlist(playlist_name, tracks)
primary = existing_playlists[0]
existing_tracks = self.get_playlist_tracks(primary.id)
existing_ids = {
str(t.id) for t in existing_tracks if hasattr(t, 'id') and t.id
}
new_track_ids = []
for t in tracks:
tid = None
if hasattr(t, 'ratingKey'):
tid = str(t.ratingKey)
elif hasattr(t, 'id'):
tid = str(t.id) if t.id else None
elif isinstance(t, dict):
tid = str(t.get('id') or '')
if tid and tid not in existing_ids:
new_track_ids.append(tid)
if not new_track_ids:
logger.info(
f"Navidrome append: no new tracks to add to '{playlist_name}' "
f"(all matched tracks already present)"
)
return True
# Subsonic updatePlaylist: `songIdToAdd` accepts repeated values
# (requests serializes list values as repeated query/form params).
params = {
'playlistId': primary.id,
'songIdToAdd': new_track_ids,
}
response = self._make_request('updatePlaylist', params)
if response and response.get('status') == 'ok':
logger.info(
f"Navidrome append: added {len(new_track_ids)} new tracks to "
f"'{playlist_name}' (skipped {len(tracks) - len(new_track_ids)} "
f"already present)"
)
return True
logger.error(
f"Failed to append to Navidrome playlist '{playlist_name}'"
)
return False
except Exception as e:
logger.error(f"Error appending to Navidrome playlist '{playlist_name}': {e}")
return False
def update_playlist(self, playlist_name: str, tracks) -> bool:
"""Update an existing playlist or create it if it doesn't exist. Handles duplicates."""
if not self.ensure_connection():

@ -620,6 +620,53 @@ class PlexClient(MediaServerClient):
logger.error(f"Error copying playlist '{source_name}' to '{target_name}': {e}")
return False
def append_to_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
"""Append tracks to an existing playlist (creates it if missing).
Differs from `update_playlist`: never deletes existing tracks,
never recreates the playlist, no backup. Used by sync mode
'append' so user-added tracks on the server playlist survive
re-syncing the source. Dedupe-by-ratingKey ensures we don't
re-add tracks the playlist already contains."""
if not self.ensure_connection():
return False
try:
try:
existing_playlist = self.server.playlist(playlist_name)
except NotFound:
logger.info(
f"Plex append: playlist '{playlist_name}' doesn't exist yet — "
f"creating with {len(tracks)} tracks"
)
return self.create_playlist(playlist_name, tracks)
existing_keys = {
str(t.ratingKey) for t in existing_playlist.items()
if hasattr(t, 'ratingKey')
}
new_tracks = [
t for t in tracks
if hasattr(t, 'ratingKey') and str(t.ratingKey) not in existing_keys
]
if not new_tracks:
logger.info(
f"Plex append: no new tracks to add to '{playlist_name}' "
f"(all {len(tracks)} matched-tracks already present)"
)
return True
existing_playlist.addItems(new_tracks)
logger.info(
f"Plex append: added {len(new_tracks)} new tracks to '{playlist_name}' "
f"(skipped {len(tracks) - len(new_tracks)} already present)"
)
return True
except Exception as e:
logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}")
return False
def update_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
if not self.ensure_connection():
return False

@ -171,7 +171,7 @@ class PlaylistSyncService:
failed_tracks=failed_tracks
))
async def sync_playlist(self, playlist: SpotifyPlaylist, download_missing: bool = False, profile_id: int = None) -> SyncResult:
async def sync_playlist(self, playlist: SpotifyPlaylist, download_missing: bool = False, profile_id: int = None, sync_mode: str = 'replace') -> SyncResult:
self._active_profile_id = profile_id
# Check if THIS specific playlist is already syncing
if playlist.name in self.syncing_playlists:
@ -314,9 +314,20 @@ class PlaylistSyncService:
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")
# THE FIX: Ensure we are passing the correct, native track objects to the client
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
logger.info(
f"Syncing playlist '{playlist.name}' to {server_type.upper()} server "
f"(mode: {sync_mode})"
)
# 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)
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

@ -73,7 +73,7 @@ class _FakeSyncService:
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):
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

@ -0,0 +1,333 @@
"""Pin server-playlist sync 'append' mode behavior.
Discord report (CJFC, 2026-04-26): syncing a Spotify playlist to the
server overwrote anything the user had manually added to the server-
side playlist. The fix adds a per-sync mode toggle:
- 'replace' (default, current behavior) delete + recreate
- 'append' keep existing tracks, only add new ones
Each server client (Plex / Jellyfin / Navidrome) gets a new
`append_to_playlist(name, tracks)` method that:
- Falls back to `create_playlist` when the playlist doesn't exist yet
- Fetches existing track IDs and dedupes incoming tracks against them
- Uses the server's NATIVE append API (no delete-recreate)
`sync_service.sync_playlist` accepts `sync_mode` and dispatches to
`append_to_playlist` when set to 'append'. Falls back to
`update_playlist` (replace semantics) when the client doesn't
implement append (e.g. SoulSync standalone has no playlist methods
at all).
These tests pin:
- Per-server append: missing playlist create_playlist delegation
- Per-server append: existing IDs filtered out (no double-adds)
- Per-server append: empty new-track set short-circuits without API call
- Per-server append: failure paths return False without raising
- sync_service dispatch: mode='append' calls append_to_playlist
- sync_service dispatch: mode='replace' calls update_playlist (default)
- sync_service dispatch: missing append_to_playlist method falls back to update_playlist
"""
from __future__ import annotations
from types import SimpleNamespace
from unittest.mock import MagicMock, patch
import pytest
# ---------------------------------------------------------------------------
# Plex append_to_playlist
# ---------------------------------------------------------------------------
from core.plex_client import PlexClient
def _make_plex_client():
client = PlexClient.__new__(PlexClient)
client.server = MagicMock()
client.music_library = MagicMock()
client._all_libraries_mode = False
client._connection_attempted = True
client._is_connecting = False
client._last_connection_check = 0
client._connection_check_interval = 30
return client
class TestPlexAppendToPlaylist:
def test_falls_back_to_create_when_playlist_missing(self):
"""Reporter's playlist may not exist on the server yet (first
sync). Append mode should create it instead of erroring."""
from plexapi.exceptions import NotFound
client = _make_plex_client()
client.server.playlist = MagicMock(side_effect=NotFound("not found"))
new_tracks = [SimpleNamespace(ratingKey='100'), SimpleNamespace(ratingKey='101')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'create_playlist', return_value=True) as mock_create:
result = client.append_to_playlist("Test Playlist", new_tracks)
assert result is True
mock_create.assert_called_once_with("Test Playlist", new_tracks)
def test_filters_out_already_present_tracks(self):
"""Reporter's exact case: server playlist has tracks A, B
already; sync brings A, B, C. Only C should be added.
Existing tracks must NOT be re-added (would create
duplicates)."""
client = _make_plex_client()
existing_playlist = MagicMock()
existing_playlist.items = MagicMock(return_value=[
SimpleNamespace(ratingKey='100'), # track A
SimpleNamespace(ratingKey='101'), # track B
])
existing_playlist.addItems = MagicMock()
client.server.playlist = MagicMock(return_value=existing_playlist)
incoming = [
SimpleNamespace(ratingKey='100'), # already present
SimpleNamespace(ratingKey='101'), # already present
SimpleNamespace(ratingKey='102'), # NEW — only this should be added
]
with patch.object(client, 'ensure_connection', return_value=True):
result = client.append_to_playlist("Test Playlist", incoming)
assert result is True
# Only the new track passed to addItems
called_with = existing_playlist.addItems.call_args[0][0]
assert len(called_with) == 1
assert called_with[0].ratingKey == '102'
def test_short_circuits_when_all_tracks_already_present(self):
"""All incoming tracks already on the playlist → no API call,
return True (no-op success)."""
client = _make_plex_client()
existing_playlist = MagicMock()
existing_playlist.items = MagicMock(return_value=[
SimpleNamespace(ratingKey='100'),
SimpleNamespace(ratingKey='101'),
])
existing_playlist.addItems = MagicMock()
client.server.playlist = MagicMock(return_value=existing_playlist)
incoming = [SimpleNamespace(ratingKey='100'), SimpleNamespace(ratingKey='101')]
with patch.object(client, 'ensure_connection', return_value=True):
result = client.append_to_playlist("Test Playlist", incoming)
assert result is True
existing_playlist.addItems.assert_not_called()
def test_returns_false_when_not_connected(self):
"""Defensive: ensure_connection False → return False, no API
call. Caller treats as a normal failure."""
client = _make_plex_client()
with patch.object(client, 'ensure_connection', return_value=False):
result = client.append_to_playlist("Test Playlist", [
SimpleNamespace(ratingKey='100'),
])
assert result is False
def test_swallows_exceptions_returns_false(self):
"""Plex SDK errors mid-append shouldn't crash the sync — log
+ return False so the caller can fall back."""
client = _make_plex_client()
client.server.playlist = MagicMock(side_effect=RuntimeError("plex down"))
with patch.object(client, 'ensure_connection', return_value=True):
result = client.append_to_playlist("Test Playlist", [
SimpleNamespace(ratingKey='100'),
])
assert result is False
# ---------------------------------------------------------------------------
# Jellyfin append_to_playlist
# ---------------------------------------------------------------------------
from core.jellyfin_client import JellyfinClient
def _make_jellyfin_client():
client = JellyfinClient.__new__(JellyfinClient)
client.base_url = "http://jellyfin.local"
client.api_key = "fake-api-key"
client.user_id = "user-123"
return client
class TestJellyfinAppendToPlaylist:
def test_falls_back_to_create_when_playlist_missing(self):
client = _make_jellyfin_client()
new_tracks = [SimpleNamespace(id='item-100')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=None), \
patch.object(client, 'create_playlist', return_value=True) as mock_create:
result = client.append_to_playlist("Test", new_tracks)
assert result is True
mock_create.assert_called_once_with("Test", new_tracks)
def test_filters_out_already_present_tracks(self):
"""Reporter's exact case for Jellyfin — only new GUIDs go in."""
client = _make_jellyfin_client()
existing_playlist = SimpleNamespace(id='pl-1')
existing_tracks = [
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000001'),
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000002'),
]
incoming = [
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000001'), # present
SimpleNamespace(id='aaaaaaaa-bbbb-cccc-dddd-000000000003'), # NEW
]
captured_post_params = {}
def fake_post(url, params=None, headers=None, timeout=None):
captured_post_params['url'] = url
captured_post_params['ids'] = params['Ids']
return SimpleNamespace(status_code=204, text='')
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post', side_effect=fake_post):
result = client.append_to_playlist("Test", incoming)
assert result is True
# Only the NEW track id should have been POSTed
assert captured_post_params['ids'] == 'aaaaaaaa-bbbb-cccc-dddd-000000000003'
def test_short_circuits_when_no_new_tracks(self):
client = _make_jellyfin_client()
existing_playlist = SimpleNamespace(id='pl-1')
existing_tracks = [SimpleNamespace(id='guid-1')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post') as mock_post:
result = client.append_to_playlist("Test", [SimpleNamespace(id='guid-1')])
assert result is True
mock_post.assert_not_called()
def test_returns_false_on_post_error(self):
client = _make_jellyfin_client()
existing_playlist = SimpleNamespace(id='pl-1')
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlist_by_name', return_value=existing_playlist), \
patch.object(client, 'get_playlist_tracks', return_value=[]), \
patch.object(client, '_is_valid_guid', return_value=True), \
patch('core.jellyfin_client.requests.post',
return_value=SimpleNamespace(status_code=500, text='server error')):
result = client.append_to_playlist("Test", [SimpleNamespace(id='new-guid')])
assert result is False
# ---------------------------------------------------------------------------
# Navidrome append_to_playlist
# ---------------------------------------------------------------------------
from core.navidrome_client import NavidromeClient
def _make_navidrome_client():
client = NavidromeClient.__new__(NavidromeClient)
client.base_url = "http://navidrome.local"
client.username = "user"
client.password = "pass"
return client
class TestNavidromeAppendToPlaylist:
def test_falls_back_to_create_when_playlist_missing(self):
client = _make_navidrome_client()
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlists_by_name', return_value=[]), \
patch.object(client, 'create_playlist', return_value=True) as mock_create:
result = client.append_to_playlist("Test", [SimpleNamespace(id='song-1')])
assert result is True
mock_create.assert_called_once()
def test_filters_out_already_present_tracks_and_calls_subsonic(self):
client = _make_navidrome_client()
existing_playlists = [SimpleNamespace(id='pl-1', title='Test')]
existing_tracks = [SimpleNamespace(id='100'), SimpleNamespace(id='101')]
incoming = [
SimpleNamespace(id='100'), # present
SimpleNamespace(id='102'), # NEW
SimpleNamespace(id='103'), # NEW
]
captured = {}
def fake_make_request(endpoint, params=None):
captured['endpoint'] = endpoint
captured['params'] = params
return {'status': 'ok'}
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlists_by_name', return_value=existing_playlists), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \
patch.object(client, '_make_request', side_effect=fake_make_request):
result = client.append_to_playlist("Test", incoming)
assert result is True
assert captured['endpoint'] == 'updatePlaylist'
assert captured['params']['playlistId'] == 'pl-1'
# Only NEW song IDs in songIdToAdd, not already-present ones
assert sorted(captured['params']['songIdToAdd']) == ['102', '103']
def test_short_circuits_when_no_new_tracks(self):
client = _make_navidrome_client()
existing_playlists = [SimpleNamespace(id='pl-1', title='Test')]
existing_tracks = [SimpleNamespace(id='100')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlists_by_name', return_value=existing_playlists), \
patch.object(client, 'get_playlist_tracks', return_value=existing_tracks), \
patch.object(client, '_make_request') as mock_req:
result = client.append_to_playlist("Test", [SimpleNamespace(id='100')])
assert result is True
mock_req.assert_not_called()
def test_falls_back_when_subsonic_returns_failed(self):
client = _make_navidrome_client()
existing_playlists = [SimpleNamespace(id='pl-1', title='Test')]
with patch.object(client, 'ensure_connection', return_value=True), \
patch.object(client, 'get_playlists_by_name', return_value=existing_playlists), \
patch.object(client, 'get_playlist_tracks', return_value=[]), \
patch.object(client, '_make_request', return_value=None):
# _make_request returns None when Subsonic returns 'failed' status
result = client.append_to_playlist("Test", [SimpleNamespace(id='new-1')])
assert result is False
# ---------------------------------------------------------------------------
# Contract pinning — append_to_playlist is in KNOWN_PER_SERVER_METHODS
# ---------------------------------------------------------------------------
def test_append_to_playlist_listed_in_contract():
"""If a future refactor drops `append_to_playlist` from the
contract's KNOWN_PER_SERVER_METHODS list, the conformance test
won't catch it (those are advisory-only). This test is the
explicit pin that the method is part of the recognized
per-server playlist surface."""
from core.media_server.contract import KNOWN_PER_SERVER_METHODS
assert 'append_to_playlist' in KNOWN_PER_SERVER_METHODS
def test_each_client_implements_append_to_playlist():
"""Pin: Plex / Jellyfin / Navidrome all have the method (at the
class level instance state isn't required for this check).
SoulSync standalone is intentionally excluded it has no
playlist methods at all per the contract notes."""
assert hasattr(PlexClient, 'append_to_playlist')
assert hasattr(JellyfinClient, 'append_to_playlist')
assert hasattr(NavidromeClient, 'append_to_playlist')

@ -23836,10 +23836,11 @@ def _build_sync_deps():
)
def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url=''):
def _run_sync_task(playlist_id, playlist_name, tracks_json, automation_id=None, profile_id=1, playlist_image_url='', sync_mode='replace'):
return _discovery_sync.run_sync_task(
playlist_id, playlist_name, tracks_json, automation_id, profile_id, playlist_image_url,
_build_sync_deps(),
sync_mode=sync_mode,
)
@ -23855,14 +23856,22 @@ def start_playlist_sync():
playlist_name = data.get('playlist_name')
tracks_json = data.get('tracks') # Pass the full track list
playlist_image_url = data.get('image_url', '')
# 'replace' (default) deletes the server playlist and recreates it from
# the source. 'append' preserves user-added tracks already on the server
# playlist — only adds tracks that aren't there yet. Per-server clients
# implement append via native add APIs (Plex addItems, Jellyfin POST
# /Playlists/<id>/Items, Navidrome updatePlaylist?songIdToAdd=...).
sync_mode = data.get('sync_mode', 'replace')
if sync_mode not in ('replace', 'append'):
sync_mode = 'replace'
if not all([playlist_id, playlist_name, tracks_json]):
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
# Add activity for sync start
add_activity_item("", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks", "Now")
add_activity_item("", "Spotify Sync Started", f"'{playlist_name}' - {len(tracks_json)} tracks ({sync_mode})", "Now")
logger.info(f"Starting playlist sync for '{playlist_name}' with {len(tracks_json)} tracks")
logger.info(f"Starting playlist sync for '{playlist_name}' with {len(tracks_json)} tracks (mode: {sync_mode})")
logger.debug(f"Request parsed at {time.strftime('%H:%M:%S')} (took {(time.time()-request_start_time)*1000:.1f}ms)")
with sync_lock:
@ -23875,7 +23884,7 @@ def start_playlist_sync():
# Submit the task to the thread pool (capture profile_id while still in request context)
_sync_profile_id = get_current_profile_id()
thread_submit_time = time.time()
future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json, None, _sync_profile_id, playlist_image_url)
future = sync_executor.submit(_run_sync_task, playlist_id, playlist_name, tracks_json, None, _sync_profile_id, playlist_image_url, sync_mode)
active_sync_workers[playlist_id] = future
thread_submit_duration = (time.time() - thread_submit_time) * 1000
logger.info(f"⏱️ [TIMING] Thread submitted at {time.strftime('%H:%M:%S')} (took {thread_submit_duration:.1f}ms)")

@ -4098,9 +4098,21 @@ async function cancelTrackDownload(playlistId, trackIndex) {
}
// Find and REPLACE the old startPlaylistSyncFromModal function
async function startPlaylistSync(playlistId) {
async function startPlaylistSync(playlistId, syncModeOverride = null) {
const startTime = Date.now();
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId}`);
// Sync mode: prefer explicit override (e.g. from automation/discover code paths
// that don't render the modal selector), else read the per-playlist <select>
// rendered next to the Sync button, else default 'replace' to preserve
// historical behavior for any caller that hasn't been updated yet.
let syncMode = syncModeOverride;
if (!syncMode) {
const modeSelect = document.getElementById(`sync-mode-${playlistId}`);
syncMode = (modeSelect && modeSelect.value) || 'replace';
}
if (syncMode !== 'replace' && syncMode !== 'append') {
syncMode = 'replace';
}
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode})`);
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
if (!playlist) {
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
@ -4160,7 +4172,8 @@ async function startPlaylistSync(playlistId) {
playlist_id: playlist.id,
playlist_name: playlist.name,
tracks: tracks, // Send the full track list
image_url: playlist.image_url || ''
image_url: playlist.image_url || '',
sync_mode: syncMode
})
});

@ -3413,6 +3413,11 @@ function closeHelperSearch() {
// projects that span multiple commits before shipping. Strip the flag at
// release time and add a real `date:` line at the top of the version block.
const WHATS_NEW = {
'2.6.0': [
// --- post-release patch work on the 2.6.0 line — entries hidden by _getLatestWhatsNewVersion until the build version bumps ---
{ date: 'Unreleased — 2.6.0 patch work' },
{ title: 'Server Playlist Sync: Append Mode (Stop Overwriting User-Added Tracks)', desc: 'discord report (cjfc, 2026-04-26): syncing a spotify playlist to your server overwrote anything you\'d manually added to the server-side playlist. now there\'s a per-sync mode picker next to the Sync button on the playlist details modal: "Replace" (default, current behavior — delete + recreate) or "Append only" (preserve existing, only add tracks not already there). useful when the source platform caps playlist size (spotify 100-track limit) and you\'re manually building beyond it on the server. each server client (plex / jellyfin / navidrome) gets a new `append_to_playlist(name, tracks)` method that uses the server\'s native append api — plex `addItems`, jellyfin `POST /Playlists/<id>/Items`, navidrome subsonic `updatePlaylist?songIdToAdd=...`. no delete-recreate, no backup playlist created in append mode (preserves playlist creation date + metadata + non-soulsync-managed tracks). dedup-by-id ensures we never add a track that\'s already on the playlist (matched by ratingKey for plex, jellyfin guid id for jellyfin, song id for navidrome — server-native identity, not fuzzy title+artist match). falls back to `create_playlist` when the playlist doesn\'t exist yet (first sync). sync_service dispatches via the new mode flag through /api/sync/start; soulsync standalone has no playlist methods at all so the dispatch falls back to update_playlist with a warning log when append is requested against it. 15 new tests pin: missing playlist → create delegation, dedup filtering (existing ids skipped), short-circuit on no-new-tracks (no api call), failure paths return False without raising, contract listing for each server client.', page: 'sync' },
],
'2.5.0': [
// --- May 10, 2026 — 2.5.0 release ---
{ date: 'May 10, 2026 — 2.5.0 release' },

@ -15537,6 +15537,24 @@ body.helper-mode-active #dashboard-activity-feed:hover {
box-shadow: 0 6px 20px rgba(var(--accent-rgb), 0.4);
}
/* Sync mode picker Replace vs Append, sits left of the Sync button.
Sized to match the modal-btn height so it doesn't break footer alignment. */
.playlist-modal-sync-mode {
background: #2a2a2a;
color: #ffffff;
border: 1px solid #555555;
border-radius: 6px;
padding: 8px 10px;
font-size: 13px;
cursor: pointer;
margin-right: 4px;
}
.playlist-modal-sync-mode:hover {
border-color: #777777;
background: #333333;
}
.playlist-modal-btn-tertiary {
background: #404040;
color: #ffffff;

@ -1683,6 +1683,10 @@ function showDeezerArlPlaylistDetailsModal(playlist, originalDeezerPlaylistId) {
<button class="playlist-modal-btn playlist-modal-btn-tertiary" onclick="closeDeezerArlPlaylistDetailsModal(); openDownloadMissingModal('${playlistId}')">
${hasCompletedProcess ? '📊 View Download Results' : '📥 Download Missing Tracks'}
</button>
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
<option value="replace" selected>Replace</option>
<option value="append">Append only</option>
</select>
<button id="sync-btn-${playlistId}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlistId}')" ${isSyncing ? 'disabled' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
</div>
</div>

@ -1935,6 +1935,10 @@ function showPlaylistDetailsModal(playlist) {
? '📊 View Download Results'
: '📥 Download Missing Tracks'}
</button>
<select id="sync-mode-${playlist.id}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)" ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>
<option value="replace" selected>Replace</option>
<option value="append">Append only</option>
</select>
<button id="sync-btn-${playlist.id}" class="playlist-modal-btn playlist-modal-btn-primary" onclick="startPlaylistSync('${playlist.id}')" ${isSyncing ? 'disabled' : ''} ${_isSoulsyncStandalone ? 'style="display:none"' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>
</div>
</div>

Loading…
Cancel
Save