diff --git a/services/sync_service.py b/services/sync_service.py index c1213f1a..077af59a 100644 --- a/services/sync_service.py +++ b/services/sync_service.py @@ -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". diff --git a/tests/test_sync_dedupe.py b/tests/test_sync_dedupe.py new file mode 100644 index 00000000..386322ea --- /dev/null +++ b/tests/test_sync_dedupe.py @@ -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([]) == []