Fix watchlist scan false failures, Spotify backfill, and wishlist remove

Watchlist scanner: empty discography (no new releases in lookback) was
treated as API failure, causing "Failed to get artist discography" for
artists like Kendrick Lamar who simply had no recent releases. Now
distinguishes None (API failure → try next source) from [] (success,
no new tracks). Spotify backfill now uses the authenticated client
instance instead of creating a fresh unauthenticated one.

Wishlist nebula: album remove now sends album_name (API updated to
accept album_name as fallback alongside album_id). Track remove
re-renders the nebula after deletion. Toned down processing pulse
animation.

Updated test to verify fallback triggers on API failure (None), not
on empty results.
This commit is contained in:
Broque Thomas 2026-04-16 18:06:45 -07:00
parent f59c564382
commit 09d358ef69
4 changed files with 40 additions and 10 deletions

View file

@ -653,7 +653,10 @@ class WatchlistScanner:
last_scan_timestamp,
lookback_days=watchlist_artist.lookback_days,
)
if not albums:
# albums can be None (API failure) or empty list (no new releases).
# None means this source failed — try next source.
# Empty list means success — artist has no new releases in the lookback window.
if albums is None:
return None
image_url = self._get_artist_image_for_source(watchlist_artist, source, client, artist_id)
@ -1410,8 +1413,11 @@ class WatchlistScanner:
_skip['max_pages'] = _max_pages
albums = client.get_artist_albums(artist_id, album_type='album,single', limit=50, **_skip)
if albums is None:
logger.warning(f"API failure fetching albums for artist {artist_id}")
return None
if not albums:
logger.warning(f"No albums found for artist {artist_id}")
logger.debug(f"No albums found for artist {artist_id}")
return []
# Add small delay after fetching artist discography to be extra safe
@ -1581,7 +1587,11 @@ class WatchlistScanner:
def _match_to_spotify(self, artist_name: str) -> Optional[str]:
"""Match artist name to Spotify ID using fuzzy name comparison."""
try:
client = get_client_for_source('spotify')
# Use the authenticated spotify_client passed to the scanner,
# not get_client_for_source which creates a fresh unauthenticated instance
client = self.spotify_client
if not client or not client.is_spotify_authenticated():
client = get_client_for_source('spotify')
if not client:
return None

View file

@ -419,12 +419,15 @@ def test_get_artist_discography_for_watchlist_prefers_primary_source(monkeypatch
assert spotify_client.album_calls == []
def test_get_artist_discography_for_watchlist_falls_back_when_primary_empty(monkeypatch):
def test_get_artist_discography_for_watchlist_falls_back_when_primary_fails(monkeypatch):
"""When the primary source API fails (returns None), fall back to next source."""
monkeypatch.setattr(watchlist_scanner_module, "time", types.SimpleNamespace(sleep=lambda *_args, **_kwargs: None))
monkeypatch.setattr(watchlist_scanner_module, "get_primary_source", lambda: "deezer")
monkeypatch.setattr(watchlist_scanner_module, "get_source_priority", lambda primary: [primary, "spotify", "itunes"])
# Deezer client returns None from get_artist_albums (API failure)
deezer_client = _FakeSourceClient(artist_id="dz-artist", albums=[], image_url="https://example.com/deezer.jpg")
deezer_client.get_artist_albums = lambda *args, **kwargs: None # Simulate API failure
spotify_album = types.SimpleNamespace(id="sp-album", name="Spotify Album", release_date=None)
spotify_client = _FakeSourceClient(artist_id="sp-artist", albums=[spotify_album], image_url="https://example.com/spotify.jpg")
@ -448,7 +451,7 @@ def test_get_artist_discography_for_watchlist_falls_back_when_primary_empty(monk
assert result.source == "spotify"
assert result.artist_id == "sp-artist"
assert result.albums and result.albums[0].id == "sp-album"
assert deezer_client.album_calls
# Spotify client should have been called as fallback
assert spotify_client.album_calls

View file

@ -25024,9 +25024,10 @@ def remove_album_from_wishlist():
data = request.get_json()
album_id = data.get('album_id')
album_name_filter = data.get('album_name')
if not album_id:
return jsonify({"success": False, "error": "No album_id provided"}), 400
if not album_id and not album_name_filter:
return jsonify({"success": False, "error": "No album_id or album_name provided"}), 400
wishlist_service = get_wishlist_service()
all_tracks = wishlist_service.get_wishlist_tracks_for_download(profile_id=get_current_profile_id())
@ -25065,8 +25066,18 @@ def remove_album_from_wishlist():
track_album_id = re.sub(r'[^a-zA-Z0-9\s_-]', '', custom_id) # Remove special chars
track_album_id = re.sub(r'\s+', '_', track_album_id).lower() # Replace spaces & lowercase
# Match by album ID
if track_album_id == album_id:
# Match by album ID or album name
matched = False
if album_id and track_album_id == album_id:
matched = True
elif album_name_filter:
track_album_name = album_data.get('name', '')
if isinstance(spotify_data.get('album'), str):
track_album_name = spotify_data['album']
if track_album_name and track_album_name.lower().strip() == album_name_filter.lower().strip():
matched = True
if matched:
spotify_track_id = track.get('spotify_track_id') or track.get('id')
if spotify_track_id:
tracks_to_remove.append(spotify_track_id)

View file

@ -40711,7 +40711,13 @@ async function _removeWishlistTrack(trackId) {
try {
const res = await fetch('/api/wishlist/remove-track', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ spotify_track_id: trackId }) });
const data = await res.json();
if (data.success) { const p = document.querySelector(`.wl-single-pill[data-track-id="${trackId}"]`); if (p) p.remove(); showToast('Removed', 'success'); await updateWishlistCount(); }
if (data.success) {
showToast('Removed', 'success');
await updateWishlistCount();
// Re-render nebula to reflect removal
wishlistPageState.isInitialized = false;
await initializeWishlistPage();
}
} catch (err) { showToast('Error: ' + err.message, 'error'); }
}