From 779d729a08bbab3a9ccf45d91d12b8caaf94dc59 Mon Sep 17 00:00:00 2001 From: BoulderBadgeDad Date: Fri, 29 May 2026 09:16:47 -0700 Subject: [PATCH] Fix: Spotify playlist sync shows only 100 tracks (#736) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Reported by @Yug1900: a Spotify playlist's overview shows the correct count (e.g. 203) but the sync modal lists only 100, and only 100 would sync. Root cause in /api/spotify/playlist/ (the Spotify-tab track fetch): owned playlists fetch + paginate fine (sp.next loops over all pages). But when the authenticated playlist_items call FAILS (intermittently, often a 403 on followed / not-owned playlists) it fell straight back to the public embed scraper, which is hard-capped at ~100 tracks and has no pagination — and the result was returned as if complete. The overview total is correct because it comes from the playlist *metadata* listing, which succeeds independently. Fixes (additive — the owned/working path is unchanged): - Retry the items fetch once before resorting to the embed scraper, so a transient failure no longer silently truncates a large playlist. - Guard against silent truncation: compare fetched count against the playlist's known total; if short (or via the capped embed fallback), log a warning and return `incomplete: true` + `expected_total` instead of presenting a partial list as complete. No behavior change when the fetch succeeds in full (incomplete=false, extra fields ignored by the frontend). Lets a future UI tweak surface "got 100 of 203 — retry" rather than silently dropping tracks. --- web_server.py | 45 ++++++++++++++++++++++++++++++++++++++------- 1 file changed, 38 insertions(+), 7 deletions(-) diff --git a/web_server.py b/web_server.py index 17c4322e..27bca092 100644 --- a/web_server.py +++ b/web_server.py @@ -18956,11 +18956,24 @@ def get_playlist_tracks(playlist_id): # Fetch all tracks with full album data tracks = [] - try: - results = spotify_client._get_playlist_items_page(playlist_id, limit=100) - except Exception as items_err: - # 403 on followed playlists — try the public embed scraper as fallback - logger.warning(f"Playlist items fetch failed ({items_err}), trying public embed scraper") + # The items endpoint intermittently fails for followed playlists — retry + # once before resorting to the (≈100-capped) public embed scraper, so a + # transient failure doesn't silently truncate a large playlist. + results = None + items_err = None + for _attempt in range(2): + try: + results = spotify_client._get_playlist_items_page(playlist_id, limit=100) + break + except Exception as _e: + items_err = _e + if _attempt == 0: + logger.warning(f"Playlist items fetch failed ({_e}); retrying once") + time.sleep(0.5) + if results is None: + # Both attempts failed (often a 403 on followed playlists) — fall back + # to the public embed scraper as a last resort (capped at ~100 tracks). + logger.warning(f"Playlist items unavailable after retry ({items_err}), trying public embed scraper") try: from core.spotify_public_scraper import scrape_spotify_embed embed_data = scrape_spotify_embed('playlist', playlist_id) @@ -18986,7 +18999,10 @@ def get_playlist_tracks(playlist_id): 'track_count': len(tracks), 'image_url': playlist_data['images'][0]['url'] if playlist_data.get('images') else None, 'snapshot_id': playlist_data.get('snapshot_id', ''), - 'tracks': tracks + 'tracks': tracks, + # Embed scrape is capped at ~100 — mark as incomplete. + 'incomplete': True, + 'expected_total': (playlist_data.get('tracks') or {}).get('total'), } return jsonify(playlist_dict) except Exception as scrape_err: @@ -19014,6 +19030,19 @@ def get_playlist_tracks(playlist_id): else: results = None + # Guard against silent truncation: Spotify occasionally returns a short + # first page (next=None) for some playlists, so the loop above can end + # early. Compare against the playlist's known total and flag it rather + # than presenting a partial list as complete (bug #736). + expected_total = (playlist_data.get('tracks') or {}).get('total') + incomplete = bool(expected_total) and len(tracks) < expected_total + if incomplete: + logger.warning( + f"Playlist {playlist_id} ('{playlist_data.get('name')}'): fetched " + f"{len(tracks)}/{expected_total} tracks — Spotify returned incomplete " + f"items data (flagged, not silently truncated)" + ) + # Convert playlist to dict playlist_dict = { 'id': playlist_data['id'], @@ -19025,7 +19054,9 @@ def get_playlist_tracks(playlist_id): 'track_count': len(tracks), 'image_url': playlist_data['images'][0]['url'] if playlist_data.get('images') else None, 'snapshot_id': playlist_data.get('snapshot_id', ''), - 'tracks': tracks + 'tracks': tracks, + 'incomplete': incomplete, + 'expected_total': expected_total, } return jsonify(playlist_dict) except Exception as e: