Merge pull request #794 from Nezreka/feat/playlist-reconcile-sync
Feat/playlist reconcile sync
This commit is contained in:
commit
2d443986e7
14 changed files with 455 additions and 16 deletions
|
|
@ -620,7 +620,13 @@ class ConfigManager:
|
|||
"embed_tags": True
|
||||
},
|
||||
"playlist_sync": {
|
||||
"create_backup": True
|
||||
"create_backup": True,
|
||||
# How a re-sync writes to the server playlist:
|
||||
# replace — delete + recreate (default; today's behavior)
|
||||
# reconcile — edit in place (add/remove delta), preserving the
|
||||
# playlist's custom image, description, and identity (#792)
|
||||
# append — only add new tracks, never remove
|
||||
"mode": "replace"
|
||||
},
|
||||
"settings": {
|
||||
"audio_quality": "flac"
|
||||
|
|
|
|||
|
|
@ -508,7 +508,13 @@ def run_sync_task(
|
|||
# don't want persisted to app.log.
|
||||
_synced = getattr(result, 'synced_tracks', 0)
|
||||
logger.info(f"[PLAYLIST IMAGE] has_image={bool(playlist_image_url)}, synced_tracks={_synced}")
|
||||
if playlist_image_url and _synced > 0:
|
||||
# In reconcile mode the whole point (#792) is to NOT touch the playlist's
|
||||
# existing image — pushing the source image here would re-clobber a
|
||||
# user's custom poster every sync, the exact bug reconcile fixes. So skip
|
||||
# the auto image push for reconcile (the playlist keeps its own art).
|
||||
if sync_mode == 'reconcile':
|
||||
logger.info("[PLAYLIST IMAGE] reconcile mode — preserving existing playlist image")
|
||||
elif playlist_image_url and _synced > 0:
|
||||
try:
|
||||
active_server = deps.config_manager.get_active_media_server()
|
||||
logger.info(f"[PLAYLIST IMAGE] active_server={active_server}")
|
||||
|
|
|
|||
|
|
@ -1605,6 +1605,80 @@ class JellyfinClient(MediaServerClient):
|
|||
logger.error(f"Error appending to Jellyfin playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def reconcile_playlist(self, playlist_name: str, tracks) -> bool:
|
||||
"""In-place reconcile (#792): add missing + remove gone on the existing
|
||||
playlist (POST/DELETE /Playlists/{id}/Items) so its poster, name, and Id
|
||||
survive — no delete/recreate. Removal needs each entry's PlaylistItemId,
|
||||
which we fetch from /Playlists/{id}/Items. Creates the playlist if
|
||||
missing. Returns False so the caller can fall back to replace."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
import requests
|
||||
from core.sync.playlist_edit import plan_playlist_reconcile
|
||||
existing = self.get_playlist_by_name(playlist_name)
|
||||
if not existing:
|
||||
logger.info(f"Jellyfin reconcile: '{playlist_name}' doesn't exist — creating")
|
||||
return self.create_playlist(playlist_name, tracks)
|
||||
|
||||
playlist_id = existing.id
|
||||
# Entries carry both the track Id and the PlaylistItemId (entry id);
|
||||
# removal is by EntryIds, not track Ids.
|
||||
entries = [] # list of (track_id, entry_id) in playlist order
|
||||
resp = self._make_request(f'/Playlists/{playlist_id}/Items', {'UserId': self.user_id})
|
||||
if resp:
|
||||
for item in resp.get('Items', []):
|
||||
tid = str(item.get('Id') or '')
|
||||
eid = str(item.get('PlaylistItemId') or '')
|
||||
if tid:
|
||||
entries.append((tid, eid))
|
||||
|
||||
current_ids = [tid for tid, _ in entries]
|
||||
desired_ids = []
|
||||
for t in tracks:
|
||||
tid = (str(t.id) if hasattr(t, 'id') and t.id
|
||||
else str(t.get('Id') or t.get('id') or '') if isinstance(t, dict) else '')
|
||||
if tid and self._is_valid_guid(tid):
|
||||
desired_ids.append(tid)
|
||||
|
||||
plan = plan_playlist_reconcile(current_ids, desired_ids)
|
||||
hdr = {'X-Emby-Token': self.api_key}
|
||||
|
||||
if plan['add']:
|
||||
for i in range(0, len(plan['add']), 100):
|
||||
batch = plan['add'][i:i + 100]
|
||||
r = requests.post(
|
||||
f"{self.base_url}/Playlists/{playlist_id}/Items",
|
||||
params={'Ids': ','.join(batch), 'UserId': self.user_id},
|
||||
headers=hdr, timeout=30,
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
logger.error(f"Jellyfin reconcile add failed: HTTP {r.status_code}")
|
||||
return False
|
||||
|
||||
if plan['remove']:
|
||||
remove_set = set(plan['remove'])
|
||||
entry_ids = [eid for tid, eid in entries if tid in remove_set and eid]
|
||||
for i in range(0, len(entry_ids), 100):
|
||||
batch = entry_ids[i:i + 100]
|
||||
r = requests.delete(
|
||||
f"{self.base_url}/Playlists/{playlist_id}/Items",
|
||||
params={'EntryIds': ','.join(batch)},
|
||||
headers=hdr, timeout=30,
|
||||
)
|
||||
if r.status_code not in (200, 204):
|
||||
logger.error(f"Jellyfin reconcile remove failed: HTTP {r.status_code}")
|
||||
return False
|
||||
|
||||
logger.info(
|
||||
f"Jellyfin reconcile '{playlist_name}': +{len(plan['add'])} / "
|
||||
f"-{len(plan['remove'])} (playlist preserved)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconciling 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():
|
||||
|
|
|
|||
|
|
@ -1124,6 +1124,60 @@ class NavidromeClient(MediaServerClient):
|
|||
logger.error(f"Error appending to Navidrome playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def reconcile_playlist(self, playlist_name: str, tracks) -> bool:
|
||||
"""In-place reconcile (#792): add missing + remove gone via Subsonic
|
||||
updatePlaylist (songIdToAdd / songIndexToRemove), keeping the existing
|
||||
playlist object so its comment/identity survive — no delete/recreate.
|
||||
Creates the playlist if missing. Returns False so the caller can fall
|
||||
back to replace on any failure."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
from core.sync.playlist_edit import plan_playlist_reconcile
|
||||
existing_playlists = self.get_playlists_by_name(playlist_name)
|
||||
if not existing_playlists:
|
||||
logger.info(f"Navidrome reconcile: '{playlist_name}' doesn't exist — creating")
|
||||
return self.create_playlist(playlist_name, tracks)
|
||||
|
||||
primary = existing_playlists[0]
|
||||
existing_tracks = self.get_playlist_tracks(primary.id)
|
||||
current_ids = [str(t.id) for t in existing_tracks if getattr(t, 'id', None)]
|
||||
desired_ids = []
|
||||
for t in tracks:
|
||||
tid = (str(t.ratingKey) if hasattr(t, 'ratingKey')
|
||||
else str(t.id) if getattr(t, 'id', None)
|
||||
else str(t.get('id', '')) if isinstance(t, dict) else '')
|
||||
if tid:
|
||||
desired_ids.append(tid)
|
||||
|
||||
plan = plan_playlist_reconcile(current_ids, desired_ids)
|
||||
if not plan['add'] and not plan['remove']:
|
||||
return True
|
||||
|
||||
params = {'playlistId': primary.id}
|
||||
if plan['add']:
|
||||
params['songIdToAdd'] = plan['add']
|
||||
if plan['remove']:
|
||||
# Indices into the CURRENT list; remove descending so earlier
|
||||
# removals don't shift the indices of later ones.
|
||||
remove_set = set(plan['remove'])
|
||||
params['songIndexToRemove'] = sorted(
|
||||
(i for i, cid in enumerate(current_ids) if cid in remove_set),
|
||||
reverse=True,
|
||||
)
|
||||
response = self._make_request('updatePlaylist', params)
|
||||
if response and response.get('status') == 'ok':
|
||||
logger.info(
|
||||
f"Navidrome reconcile '{playlist_name}': +{len(plan['add'])} / "
|
||||
f"-{len(plan['remove'])} (playlist preserved)"
|
||||
)
|
||||
return True
|
||||
logger.error(f"Navidrome reconcile failed for '{playlist_name}'")
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconciling 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():
|
||||
|
|
|
|||
|
|
@ -667,6 +667,53 @@ class PlexClient(MediaServerClient):
|
|||
logger.error(f"Error appending to Plex playlist '{playlist_name}': {e}")
|
||||
return False
|
||||
|
||||
def reconcile_playlist(self, playlist_name: str, tracks: List[TrackInfo]) -> bool:
|
||||
"""In-place reconcile (#792): bring an existing playlist's tracks to match
|
||||
``tracks`` by adding the missing ones and removing the gone ones, WITHOUT
|
||||
deleting/recreating the playlist — so its custom poster, summary, and
|
||||
ratingKey survive the sync. Creates the playlist if it doesn't exist.
|
||||
|
||||
Returns False on any failure so the caller can fall back to replace."""
|
||||
if not self.ensure_connection():
|
||||
return False
|
||||
try:
|
||||
from core.sync.playlist_edit import plan_playlist_reconcile
|
||||
try:
|
||||
existing = self.server.playlist(playlist_name)
|
||||
except NotFound:
|
||||
logger.info(f"Plex reconcile: '{playlist_name}' doesn't exist — creating")
|
||||
return self.create_playlist(playlist_name, tracks)
|
||||
|
||||
current_items = [i for i in existing.items() if hasattr(i, 'ratingKey')]
|
||||
current_ids = [str(i.ratingKey) for i in current_items]
|
||||
desired_ids = [str(t.ratingKey) for t in tracks if hasattr(t, 'ratingKey')]
|
||||
plan = plan_playlist_reconcile(current_ids, desired_ids)
|
||||
|
||||
add_set, remove_set = set(plan['add']), set(plan['remove'])
|
||||
to_add = [t for t in tracks if hasattr(t, 'ratingKey') and str(t.ratingKey) in add_set]
|
||||
to_remove = [i for i in current_items if str(i.ratingKey) in remove_set]
|
||||
|
||||
if to_add:
|
||||
existing.addItems(to_add)
|
||||
if to_remove:
|
||||
try:
|
||||
existing.removeItems(to_remove)
|
||||
except (AttributeError, TypeError):
|
||||
# Older plexapi: no batch removeItems — drop one at a time.
|
||||
for it in to_remove:
|
||||
try:
|
||||
existing.removeItem(it)
|
||||
except Exception as one_err:
|
||||
logger.debug("Plex reconcile: removeItem failed: %s", one_err)
|
||||
logger.info(
|
||||
f"Plex reconcile '{playlist_name}': +{len(to_add)} / -{len(to_remove)} "
|
||||
f"(playlist preserved)"
|
||||
)
|
||||
return True
|
||||
except Exception as e:
|
||||
logger.error(f"Error reconciling 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
|
||||
|
|
|
|||
|
|
@ -71,4 +71,64 @@ def remove_one_occurrence(
|
|||
return ids, False
|
||||
|
||||
|
||||
__all__ = ["plan_playlist_add", "remove_one_occurrence"]
|
||||
def plan_playlist_reconcile(
|
||||
current_ids: List[str],
|
||||
desired_ids: List[str],
|
||||
) -> dict:
|
||||
"""Plan an in-place reconcile of a server playlist toward a desired tracklist.
|
||||
|
||||
Used by ``sync_mode='reconcile'`` (#792): instead of deleting + recreating
|
||||
the playlist (which destroys its custom image, description, and identity),
|
||||
the caller keeps the existing playlist object and applies only the delta —
|
||||
adding the tracks that are missing and removing the ones no longer in the
|
||||
source. Pure, no I/O.
|
||||
|
||||
Returns ``{'add': [...], 'remove': [...]}`` (both lists of string ids):
|
||||
- ``add`` — desired ids not currently present, in desired order.
|
||||
- ``remove`` — current ids no longer desired (each occurrence kept once;
|
||||
duplicates of a still-desired id are left for the caller's
|
||||
dedupe to handle, never mass-removed).
|
||||
|
||||
Order-preserving and duplicate-safe: a desired id already present is not
|
||||
re-added; a current id that's still desired is not removed even if it
|
||||
appears more than once.
|
||||
"""
|
||||
desired = [str(t) for t in desired_ids]
|
||||
current = [str(t) for t in current_ids]
|
||||
current_set = set(current)
|
||||
desired_set = set(desired)
|
||||
add = [d for d in desired if d not in current_set]
|
||||
# Preserve order of removal as it appears in the current list; one entry per
|
||||
# id (the caller maps ids back to concrete playlist entries to delete).
|
||||
seen_remove = set()
|
||||
remove = []
|
||||
for c in current:
|
||||
if c not in desired_set and c not in seen_remove:
|
||||
seen_remove.add(c)
|
||||
remove.append(c)
|
||||
return {"add": add, "remove": remove}
|
||||
|
||||
|
||||
VALID_SYNC_MODES = ("replace", "append", "reconcile")
|
||||
|
||||
|
||||
def normalize_sync_mode(requested, configured, default: str = "replace") -> str:
|
||||
"""Resolve the effective playlist sync mode.
|
||||
|
||||
An explicit per-request value wins; otherwise the configured default
|
||||
(Settings > Playlist sync mode); anything unrecognized falls back to
|
||||
``default``. Keeping ``reconcile`` in ``VALID_SYNC_MODES`` is load-bearing —
|
||||
a validation list that omits it silently downgrades reconcile to replace,
|
||||
which is exactly the #792 regression this helper exists to prevent.
|
||||
"""
|
||||
mode = (requested or "") or (configured or "") or default
|
||||
return mode if mode in VALID_SYNC_MODES else default
|
||||
|
||||
|
||||
__all__ = [
|
||||
"plan_playlist_add",
|
||||
"remove_one_occurrence",
|
||||
"plan_playlist_reconcile",
|
||||
"normalize_sync_mode",
|
||||
"VALID_SYNC_MODES",
|
||||
]
|
||||
|
|
|
|||
|
|
@ -195,6 +195,26 @@ class PlaylistSyncService:
|
|||
failed_tracks=failed_tracks
|
||||
))
|
||||
|
||||
def _reconcile_or_replace(self, client, playlist_name: str, tracks) -> bool:
|
||||
"""Reconcile mode (#792): edit the playlist in place (add/remove delta,
|
||||
preserving its image + description + identity). If the client lacks
|
||||
reconcile or it fails, fall back to the destructive replace so the sync
|
||||
still succeeds — logged loudly so a failing in-place edit is diagnosable.
|
||||
"""
|
||||
fn = getattr(client, 'reconcile_playlist', None)
|
||||
if fn is None:
|
||||
return client.update_playlist(playlist_name, tracks)
|
||||
try:
|
||||
if fn(playlist_name, tracks):
|
||||
return True
|
||||
logger.warning(
|
||||
"Reconcile sync failed for '%s' — falling back to replace "
|
||||
"(playlist will be recreated this once)", playlist_name)
|
||||
except Exception as e:
|
||||
logger.warning(
|
||||
"Reconcile sync errored for '%s' (%s) — falling back to replace", playlist_name, e)
|
||||
return client.update_playlist(playlist_name, tracks)
|
||||
|
||||
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
|
||||
|
|
@ -378,6 +398,8 @@ class PlaylistSyncService:
|
|||
)
|
||||
if sync_mode == 'append':
|
||||
sync_success = media_client.append_to_playlist(playlist.name, valid_tracks)
|
||||
elif sync_mode == 'reconcile':
|
||||
sync_success = self._reconcile_or_replace(media_client, playlist.name, valid_tracks)
|
||||
else:
|
||||
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
|
||||
|
||||
|
|
|
|||
67
tests/sync/test_reconcile_or_replace.py
Normal file
67
tests/sync/test_reconcile_or_replace.py
Normal file
|
|
@ -0,0 +1,67 @@
|
|||
"""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']
|
||||
|
|
@ -2,7 +2,12 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from core.sync.playlist_edit import plan_playlist_add, remove_one_occurrence
|
||||
from core.sync.playlist_edit import (
|
||||
normalize_sync_mode,
|
||||
plan_playlist_add,
|
||||
plan_playlist_reconcile,
|
||||
remove_one_occurrence,
|
||||
)
|
||||
|
||||
|
||||
# ── plan_playlist_add: link must not duplicate ────────────────────────────
|
||||
|
|
@ -78,3 +83,72 @@ def test_remove_single_occurrence():
|
|||
def test_remove_stringifies():
|
||||
new_ids, removed = remove_one_occurrence([1, 2, 2, 3], 2)
|
||||
assert removed and new_ids == ["1", "2", "3"]
|
||||
|
||||
|
||||
# ── plan_playlist_reconcile: in-place delta (#792) ────────────────────────
|
||||
|
||||
def test_reconcile_adds_new_keeps_existing():
|
||||
# 4-track playlist + a 5th in source → add only the 5th, remove nothing.
|
||||
plan = plan_playlist_reconcile(['a', 'b', 'c', 'd'], ['a', 'b', 'c', 'd', 'e'])
|
||||
assert plan == {'add': ['e'], 'remove': []}
|
||||
|
||||
|
||||
def test_reconcile_removes_gone():
|
||||
# Source dropped 'b' → remove it, add nothing.
|
||||
plan = plan_playlist_reconcile(['a', 'b', 'c'], ['a', 'c'])
|
||||
assert plan == {'add': [], 'remove': ['b']}
|
||||
|
||||
|
||||
def test_reconcile_add_and_remove_together():
|
||||
plan = plan_playlist_reconcile(['a', 'b', 'c'], ['a', 'c', 'd', 'e'])
|
||||
assert plan['add'] == ['d', 'e'] # desired order preserved
|
||||
assert plan['remove'] == ['b']
|
||||
|
||||
|
||||
def test_reconcile_noop_when_identical():
|
||||
assert plan_playlist_reconcile(['a', 'b'], ['a', 'b']) == {'add': [], 'remove': []}
|
||||
|
||||
|
||||
def test_reconcile_empty_desired_removes_all():
|
||||
assert plan_playlist_reconcile(['a', 'b'], []) == {'add': [], 'remove': ['a', 'b']}
|
||||
|
||||
|
||||
def test_reconcile_empty_current_adds_all():
|
||||
assert plan_playlist_reconcile([], ['a', 'b']) == {'add': ['a', 'b'], 'remove': []}
|
||||
|
||||
|
||||
def test_reconcile_stringifies_ids():
|
||||
plan = plan_playlist_reconcile([1, 2], [2, 3])
|
||||
assert plan == {'add': ['3'], 'remove': ['1']}
|
||||
|
||||
|
||||
def test_reconcile_duplicate_still_desired_not_removed():
|
||||
# 'a' appears twice and is still desired → never queued for removal;
|
||||
# a gone id is listed once even if it had duplicates.
|
||||
plan = plan_playlist_reconcile(['a', 'a', 'b', 'b'], ['a'])
|
||||
assert plan == {'add': [], 'remove': ['b']}
|
||||
|
||||
|
||||
# ── normalize_sync_mode: reconcile must survive resolution (#792 regression) ──
|
||||
|
||||
def test_normalize_keeps_reconcile_from_config():
|
||||
# The bug: a validation list omitting 'reconcile' downgraded the configured
|
||||
# setting to 'replace'. No per-request override → configured value wins.
|
||||
assert normalize_sync_mode(None, 'reconcile') == 'reconcile'
|
||||
assert normalize_sync_mode('', 'reconcile') == 'reconcile'
|
||||
|
||||
|
||||
def test_normalize_request_overrides_config():
|
||||
assert normalize_sync_mode('append', 'reconcile') == 'append'
|
||||
assert normalize_sync_mode('reconcile', 'replace') == 'reconcile'
|
||||
|
||||
|
||||
def test_normalize_falls_back_for_unknown():
|
||||
assert normalize_sync_mode('bogus', 'replace') == 'replace'
|
||||
assert normalize_sync_mode(None, None) == 'replace'
|
||||
assert normalize_sync_mode(None, 'also-bogus') == 'replace'
|
||||
|
||||
|
||||
def test_normalize_all_real_modes_pass_through():
|
||||
for m in ('replace', 'append', 'reconcile'):
|
||||
assert normalize_sync_mode(m, 'replace') == m
|
||||
|
|
|
|||
|
|
@ -21053,10 +21053,17 @@ def _get_source_playlist_states(states, error_label, info_log_label=None):
|
|||
def _submit_sync_task(sync_playlist_id, playlist_name, spotify_tracks, playlist_image_url):
|
||||
"""Submit a sync to the shared executor (closes over sync_executor /
|
||||
_run_sync_task / get_current_profile_id so the lifted start_sync helper
|
||||
stays free of those globals)."""
|
||||
stays free of those globals).
|
||||
|
||||
Used by ALL per-source discovery syncs (Spotify-Public/Tidal/Deezer/Qobuz/
|
||||
YouTube/iTunes-link/ListenBrainz/Beatport). These have no per-request mode
|
||||
selector, so honor the configured default (Settings > Playlist sync mode) —
|
||||
otherwise they always ran 'replace' regardless of the setting (#792)."""
|
||||
from core.sync.playlist_edit import normalize_sync_mode
|
||||
_mode = normalize_sync_mode(None, config_manager.get('playlist_sync.mode', 'replace'))
|
||||
return sync_executor.submit(
|
||||
_run_sync_task, sync_playlist_id, playlist_name, spotify_tracks,
|
||||
None, get_current_profile_id(), playlist_image_url,
|
||||
None, get_current_profile_id(), playlist_image_url, _mode,
|
||||
)
|
||||
|
||||
|
||||
|
|
@ -23546,9 +23553,11 @@ def start_playlist_sync():
|
|||
# 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'
|
||||
# Per-request sync_mode wins; otherwise use the configured default
|
||||
# (Settings > Playlist sync mode). Default 'replace' keeps today's behavior.
|
||||
from core.sync.playlist_edit import normalize_sync_mode
|
||||
sync_mode = normalize_sync_mode(data.get('sync_mode'),
|
||||
config_manager.get('playlist_sync.mode', 'replace'))
|
||||
|
||||
if not all([playlist_id, playlist_name, tracks_json]):
|
||||
return jsonify({"success": False, "error": "Missing playlist_id, name, or tracks."}), 400
|
||||
|
|
|
|||
|
|
@ -6112,6 +6112,18 @@
|
|||
<div class="settings-group" data-stg="library">
|
||||
<h3>Playlist Sync Settings</h3>
|
||||
|
||||
<div class="form-group">
|
||||
<label>Sync mode:</label>
|
||||
<select id="playlist-sync-mode" class="form-select">
|
||||
<option value="replace">Replace — recreate the playlist each sync (default)</option>
|
||||
<option value="reconcile">Reconcile — edit in place, keep image & description</option>
|
||||
<option value="append">Append — only add new tracks, never remove</option>
|
||||
</select>
|
||||
<div class="help-text">
|
||||
"Replace" deletes and recreates the server playlist every sync, which wipes its custom image/description. "Reconcile" updates the same playlist in place (adds new tracks, removes ones no longer in the source) so your custom image and description survive. "Append" only ever adds.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label class="checkbox-label">
|
||||
<input type="checkbox" id="create-backup" checked>
|
||||
|
|
|
|||
|
|
@ -4401,12 +4401,15 @@ async function startPlaylistSync(playlistId, syncModeOverride = null) {
|
|||
let syncMode = syncModeOverride;
|
||||
if (!syncMode) {
|
||||
const modeSelect = document.getElementById(`sync-mode-${playlistId}`);
|
||||
syncMode = (modeSelect && modeSelect.value) || 'replace';
|
||||
// Empty value = "use the Settings default" — send nothing so the
|
||||
// backend falls back to playlist_sync.mode (#792). Don't hardcode
|
||||
// 'replace' here or it shadows the global setting.
|
||||
syncMode = (modeSelect && modeSelect.value) || '';
|
||||
}
|
||||
if (syncMode !== 'replace' && syncMode !== 'append') {
|
||||
syncMode = 'replace';
|
||||
if (syncMode && !['replace', 'reconcile', 'append'].includes(syncMode)) {
|
||||
syncMode = '';
|
||||
}
|
||||
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode})`);
|
||||
console.log(`🚀 [${new Date().toTimeString().split(' ')[0]}] Starting sync for playlist: ${playlistId} (mode: ${syncMode || 'default(setting)'})`);
|
||||
const playlist = spotifyPlaylists.find(p => p.id === playlistId);
|
||||
if (!playlist) {
|
||||
console.error(`❌ Could not find playlist data for ID: ${playlistId}`);
|
||||
|
|
|
|||
|
|
@ -1175,6 +1175,8 @@ async function loadSettingsData() {
|
|||
|
||||
// Populate Playlist Sync settings
|
||||
document.getElementById('create-backup').checked = settings.playlist_sync?.create_backup !== false;
|
||||
const _syncModeEl = document.getElementById('playlist-sync-mode');
|
||||
if (_syncModeEl) _syncModeEl.value = settings.playlist_sync?.mode || 'replace';
|
||||
|
||||
// Populate Post-Download Conversion settings
|
||||
document.getElementById('downsample-hires').checked = settings.lossy_copy?.downsample_hires === true;
|
||||
|
|
@ -2938,7 +2940,8 @@ async function saveSettings(quiet = false) {
|
|||
allow_duplicate_tracks: document.getElementById('allow-duplicate-tracks').checked
|
||||
},
|
||||
playlist_sync: {
|
||||
create_backup: document.getElementById('create-backup').checked
|
||||
create_backup: document.getElementById('create-backup').checked,
|
||||
mode: document.getElementById('playlist-sync-mode')?.value || 'replace'
|
||||
},
|
||||
content_filter: {
|
||||
allow_explicit: document.getElementById('allow-explicit').checked
|
||||
|
|
|
|||
|
|
@ -1265,8 +1265,10 @@ function playlistModalDownloadSyncFooterHtml(playlistId, options = {}) {
|
|||
}
|
||||
|
||||
return `${downloadBtns}
|
||||
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Replace overwrites the server playlist; Append only adds new tracks (preserves user-added)">
|
||||
<option value="replace" selected>Replace</option>
|
||||
<select id="sync-mode-${playlistId}" class="playlist-modal-sync-mode" title="Default uses your Settings > Playlist sync mode. Replace overwrites the server playlist; Reconcile edits it in place (keeps custom image/description); Append only adds new tracks.">
|
||||
<option value="" selected>Sync mode: default</option>
|
||||
<option value="replace">Replace</option>
|
||||
<option value="reconcile">Reconcile (keep image/desc)</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' : ''}>${isSyncing ? '⏳ Syncing...' : 'Sync Playlist'}</button>`;
|
||||
|
|
|
|||
Loading…
Reference in a new issue