#905 (part 1): push the deduped track list to the media server, not the raw matched list

sync_playlist computed deduped_tracks but the dispatch (append/reconcile/replace) sent the raw
valid_tracks — so a library track matched by more than one source entry was pushed multiple
times each sync. Extracted _dedupe_by_rating_key (tested) and routed all three modes through it.

This fixes the WITHIN-sync duplication. The cross-sync growth reporters describe (Navidrome
playlist doubling every resync) is a separate server-push issue still under diagnosis.
This commit is contained in:
BoulderBadgeDad 2026-06-23 14:39:29 -07:00
parent 9d16abf952
commit 10a1e1337f
2 changed files with 67 additions and 15 deletions

View file

@ -37,6 +37,23 @@ def _plex_track_file(plex_track) -> str:
return ''
def _dedupe_by_rating_key(tracks: list) -> list:
"""Drop repeated media-server tracks (same ratingKey), preserving first-seen
order. The same library track can match more than one source entry (or appear
twice in the source), and pushing the dupes made reconcile/replace re-add the
track every sync part of the #905 doubling. The sync dispatch MUST send this
deduped list, not the raw matched list."""
seen = set()
out = []
for t in tracks:
key = getattr(t, 'ratingKey', None)
if key is None or key in seen:
continue
seen.add(key)
out.append(t)
return out
def reresolve_manual_match_live_plex(cache_db, media_client, m, *, profile_id,
source_track_id, server_source):
"""Re-resolve a manual match whose stored Plex ratingKey went stale.
@ -438,21 +455,16 @@ class PlaylistSyncService:
f"{server_type.title()} objects with ratingKeys"
)
# Deduplicate by ratingKey — media servers silently drop duplicates
seen_keys = set()
deduped_tracks = []
for t in valid_tracks:
if t.ratingKey not in seen_keys:
seen_keys.add(t.ratingKey)
deduped_tracks.append(t)
if len(deduped_tracks) < len(valid_tracks):
# Deduplicate by ratingKey — media servers silently drop duplicates,
# and pushing dupes made every sync re-add the same track (#905). The
# dispatch below sends THIS deduped list, never the raw `valid_tracks`.
plex_tracks = _dedupe_by_rating_key(valid_tracks)
if len(plex_tracks) < len(valid_tracks):
logger.info(
f"Deduplicated {len(valid_tracks) - len(deduped_tracks)} duplicate ratingKeys "
f"({len(valid_tracks)}{len(deduped_tracks)} tracks)"
f"Deduplicated {len(valid_tracks) - len(plex_tracks)} duplicate ratingKeys "
f"({len(valid_tracks)}{len(plex_tracks)} tracks)"
)
plex_tracks = deduped_tracks
if not media_client:
logger.error("No active media client available for playlist sync")
sync_success = False
@ -462,11 +474,11 @@ class PlaylistSyncService:
f"(mode: {sync_mode})"
)
if sync_mode == 'append':
sync_success = media_client.append_to_playlist(playlist.name, valid_tracks)
sync_success = media_client.append_to_playlist(playlist.name, plex_tracks)
elif sync_mode == 'reconcile':
sync_success = self._reconcile_or_replace(media_client, playlist.name, valid_tracks)
sync_success = self._reconcile_or_replace(media_client, playlist.name, plex_tracks)
else:
sync_success = media_client.update_playlist(playlist.name, valid_tracks)
sync_success = media_client.update_playlist(playlist.name, plex_tracks)
synced_tracks = len(plex_tracks) if sync_success else 0
# Not in library (for wishlist), not "total minus playlist size".

40
tests/test_sync_dedupe.py Normal file
View file

@ -0,0 +1,40 @@
"""Sync must push a ratingKey-deduped track list (#905).
The dedup was computed but the dispatch sent the raw matched list, so a track
matched by more than one source entry got pushed multiple times and on
reconcile/replace that re-added it every sync (playlists doubling). This guards
the pure dedup helper the dispatch now uses.
"""
from __future__ import annotations
from services.sync_service import _dedupe_by_rating_key
class _T:
def __init__(self, rk):
self.ratingKey = rk
def test_removes_duplicate_rating_keys_preserving_order():
a, b, c = _T('1'), _T('2'), _T('3')
dup_b = _T('2')
out = _dedupe_by_rating_key([a, b, c, dup_b, a])
assert [t.ratingKey for t in out] == ['1', '2', '3']
assert out[1] is b # first-seen object kept, not the later duplicate
def test_no_duplicates_is_identity():
items = [_T('1'), _T('2'), _T('3')]
assert _dedupe_by_rating_key(items) == items
def test_drops_tracks_without_rating_key():
class _NoKey:
pass
out = _dedupe_by_rating_key([_T('1'), _NoKey(), _T('2')])
assert [t.ratingKey for t in out] == ['1', '2']
def test_empty():
assert _dedupe_by_rating_key([]) == []