#897: surface the ignore-list on the wishlist page + stop blocking manual re-adds

Two issues behind #897:

1) Discoverability — the "Ignored" management modal (view/un-ignore/clear-all,
   shipped with #874) was only reachable from the wishlist *overview modal*
   footer, which most users never open. Add the same button to the wishlist
   page toolbar next to Cleanup / Clear All, wired to openWishlistIgnoreModal().

2) Manual re-add silently blocked (carlosjfcasero) — the album-modal "add to
   wishlist" endpoint passes source_type=album, but the ignore gate only
   bypasses+clears for source_type=manual, so re-adding a previously-cancelled
   track failed. We cannot just send manual: source_type drives Albums/Singles
   categorisation and repair_worker legitimately uses album too. Thread an
   explicit user_initiated flag (db.add_to_wishlist -> service -> album route)
   that bypasses+clears the ignore while preserving the real source_type.

Regression test pins both: an automatic source_type=album add stays blocked,
the user_initiated add goes through, clears the ignore, and keeps source_type=album.
This commit is contained in:
BoulderBadgeDad 2026-06-21 20:21:44 -07:00
parent 1c93a5640d
commit 15ea87a154
5 changed files with 41 additions and 2 deletions

View file

@ -501,6 +501,10 @@ def add_album_track_to_wishlist(
source_type=source_type,
source_context=enhanced_source_context,
profile_id=runtime.profile_id,
# Explicit user click in the album modal — must bypass + clear the
# ignore-list, even if the user previously cancelled this track
# (otherwise the add is silently dropped — carlosjfcasero, #897).
user_initiated=True,
)
if success:

View file

@ -100,6 +100,7 @@ class WishlistService:
source_type: str = "manual",
source_context: Dict[str, Any] = None,
profile_id: int = 1,
user_initiated: bool = False,
) -> bool:
"""
Directly add a track to the wishlist.
@ -110,6 +111,8 @@ class WishlistService:
source_type: Source type ('playlist', 'album', 'manual')
source_context: Additional context information
profile_id: Profile to add to
user_initiated: True for an explicit user add bypasses + clears the
ignore-list while keeping the real source_type (#874/#897).
"""
if track_data is None:
track_data = spotify_track_data
@ -124,6 +127,7 @@ class WishlistService:
source_type=source_type,
source_info=source_context or {},
profile_id=profile_id,
user_initiated=user_initiated,
)
def add_spotify_track_to_wishlist(

View file

@ -8925,8 +8925,15 @@ class MusicDatabase:
source_info: Dict[str, Any] = None,
profile_id: int = 1,
track_data: Dict[str, Any] = None,
user_initiated: bool = False,
) -> bool:
"""Add a failed track to the wishlist for retry"""
"""Add a failed track to the wishlist for retry.
``user_initiated`` marks an explicit user add (e.g. the library album
"add to wishlist" modal). Like ``source_type == 'manual'`` it bypasses
the ignore-list gate AND clears any stale ignore but unlike changing
``source_type`` it preserves the real provenance ('album'), which the
wishlist categorisation (Albums vs Singles) relies on (#874/#897)."""
try:
if track_data is not None and spotify_track_data is None:
spotify_track_data = track_data
@ -8956,7 +8963,7 @@ class MusicDatabase:
# clear any stale ignore so it sticks. Fail-open: any error here
# must never block a legitimate wishlist add.
try:
if source_type == 'manual':
if source_type == 'manual' or user_initiated:
self.remove_from_wishlist_ignore(track_id, profile_id=profile_id)
elif self.is_track_ignored(track_id, profile_id=profile_id):
logger.info("Skipping wishlist add — track is on the ignore-list (#874): %s", track_id)

View file

@ -146,6 +146,26 @@ def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db):
assert db.is_track_ignored("t1") is False
def test_user_initiated_add_bypasses_and_clears_keeping_source_type(db):
# #897 / carlosjfcasero: a user manually adds an album track they had
# previously cancelled. It must bypass the gate AND clear the ignore — but
# WITHOUT pretending to be source_type='manual' (the album modal sends
# source_type='album', which the Albums/Singles categorisation relies on,
# and which an automatic path like repair_worker also legitimately uses).
track = _track("t7")
db.add_to_wishlist_ignore("t7", "Owned Song", "Owned Artist", REASON_CANCELLED)
# An automatic 'album' add (e.g. repair_worker) is still correctly blocked.
assert db.add_to_wishlist(track, source_type="album") is False
assert db.is_track_ignored("t7") is True
# The explicit user click (user_initiated) goes through and clears the ignore,
# while the stored source_type stays 'album'.
assert db.add_to_wishlist(track, source_type="album", user_initiated=True) is True
assert db.is_track_ignored("t7") is False
# Provenance preserved: the stored row is still source_type='album', NOT 'manual'.
row = next(r for r in db.get_wishlist_tracks() if str(r.get("spotify_track_id")) == "t7")
assert row.get("source_type") == "album"
def test_gate_failopen_when_ignore_table_errors(db, monkeypatch):
# If the ignore check raises, the add must still succeed (never block).
monkeypatch.setattr(db, "is_track_ignored", lambda *a, **k: (_ for _ in ()).throw(RuntimeError("boom")))

View file

@ -7320,6 +7320,10 @@
</div>
</div>
<div class="wishlist-page-header-right">
<button class="btn btn--secondary" onclick="openWishlistIgnoreModal()" title="Tracks you removed or cancelled — auto-skipped until they expire. Un-ignore to allow auto-download again.">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="10"/><line x1="4.93" y1="4.93" x2="19.07" y2="19.07"/></svg>
Ignored
</button>
<button class="btn btn--secondary" onclick="cleanupWishlistOverview()">
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h18"/><path d="M8 6V4h8v2"/></svg>
Cleanup