Fix #792: reconcile sync mode was clamped back to 'replace' in the backend

start_playlist_sync validated the resolved mode with 'if sync_mode not in
(replace, append): sync_mode = replace' — a pre-existing clamp two lines below
the config read I added, which silently downgraded a configured 'reconcile' to
'replace'. So config=reconcile resolved correctly then got clobbered, and every
sync ran replace regardless of the setting (and incognito didn't help — it's
backend, not cache).

Replace the hand-rolled clamp with a pure normalize_sync_mode(requested,
configured) helper (VALID_SYNC_MODES includes reconcile) so the resolution is
testable and can't silently drop a mode again. Regression tests cover
reconcile-from-config, request-overrides-config, and unknown->replace.
This commit is contained in:
BoulderBadgeDad 2026-06-04 15:57:40 -07:00
parent b105372d70
commit 65c9948c78
3 changed files with 52 additions and 4 deletions

View file

@ -109,4 +109,26 @@ def plan_playlist_reconcile(
return {"add": add, "remove": remove}
__all__ = ["plan_playlist_add", "remove_one_occurrence", "plan_playlist_reconcile"]
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",
]

View file

@ -3,6 +3,7 @@
from __future__ import annotations
from core.sync.playlist_edit import (
normalize_sync_mode,
plan_playlist_add,
plan_playlist_reconcile,
remove_one_occurrence,
@ -126,3 +127,28 @@ def test_reconcile_duplicate_still_desired_not_removed():
# 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

View file

@ -23548,9 +23548,9 @@ def start_playlist_sync():
# /Playlists/<id>/Items, Navidrome updatePlaylist?songIdToAdd=...).
# Per-request sync_mode wins; otherwise use the configured default
# (Settings > Playlist sync mode). Default 'replace' keeps today's behavior.
sync_mode = data.get('sync_mode') or config_manager.get('playlist_sync.mode', 'replace')
if sync_mode not in ('replace', 'append'):
sync_mode = 'replace'
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