#874: wishlist ignore-list — stop auto-retrying removed/cancelled tracks

A user who removes a wishlist track, or cancels an in-flight wishlist
download, would have it re-added on the next auto cycle (watchlist scan,
failed-track capture, or the cancel handler's own re-add), so the same
release downloaded -> failed/cancelled -> re-queued forever.

Adds a TTL'd skip-gate (30 days), softer than the blocklist: it expires
so the track is reconsidered later, and never blocks a manual
force-download — only the automatic re-queue.

- core/wishlist/ignore.py: pure TTL/normalization/display logic + a
  best-effort orchestrator (no DB handle, caller passes now).
- database/music_database.py: migration-safe wishlist_ignore table +
  add/check/remove/list(+purge)/clear methods, and the gate in
  add_to_wishlist beside the blocklist guard. Fail-open throughout — an
  ignore error can never block a legitimate add; a manual add bypasses
  the gate AND clears the ignore.
- routes.py: user remove (single/album/batch) records an ignore. Hooked
  at the route layer, NOT the DB remove, so success-cleanup never
  ignores (regression-tested).
- web_server.py: cancel now ignores + removes from the wishlist instead
  of re-adding for endless retry; three /api/wishlist/ignore-list*
  endpoints.
- downloads.js: 'Ignored' modal (view / un-ignore / clear all).
- 13 tests: pure logic, DB seam, gate (block/bypass/fail-open),
  route wiring, and the success-cleanup-does-not-ignore regression.
This commit is contained in:
BoulderBadgeDad 2026-06-15 22:50:39 -07:00
parent 46be97b195
commit 48e86a1a58
6 changed files with 755 additions and 19 deletions

162
core/wishlist/ignore.py Normal file
View file

@ -0,0 +1,162 @@
"""Wishlist ignore-list — a TTL'd skip-gate for the wishlist (#874).
When a user removes a track from the wishlist or cancels an in-flight
wishlist download, SoulSync would otherwise re-add it on the next
automatic cycle (watchlist scan, failed-track capture, or the cancel
handler's own re-add), so the same release downloads → fails/cancels →
re-queues forever. The ignore list records the user's "stop
auto-grabbing this" intent, and the wishlist *add* path checks it,
skipping automatic re-adds until the entry ages out.
It is deliberately softer than the blocklist:
- it **expires** after ``IGNORE_TTL_DAYS`` so the track is re-attempted
again later rather than banned forever, and
- it **never blocks a manual force-download** only the automatic
re-queue. (Manual downloads don't go through ``add_to_wishlist`` at
all, and an explicit manual *add* both bypasses the gate and clears
any existing ignore for the track.)
This module is pure decision logic no database handle and no clock of
its own; the caller passes ``now``. The SQL lives in ``MusicDatabase``
as a thin wrapper around these helpers, which keeps the TTL / id-matching
rules unit-testable without a database.
"""
from __future__ import annotations
from datetime import datetime, timedelta
from typing import Any, Dict, Iterable, List, Optional, Set, Tuple
IGNORE_TTL_DAYS = 30
# Recognised reasons (free-text tolerated; these are the canonical two).
REASON_REMOVED = "removed"
REASON_CANCELLED = "cancelled"
def normalize_ignore_id(track_id: Any) -> str:
"""Canonical key for a wishlist track id.
The wishlist stores some ids as a composite ``<track_id>::<album_id>``
(when ``wishlist.allow_duplicate_tracks`` is on). The add-path gate
keys on the bare track id (``spotify_track_data['id']``), so we strip
the ``::album`` suffix here so an ignore recorded from a composite-id
wishlist row still matches the bare-id add attempt, and vice-versa.
Returns ``''`` for falsy/blank input.
"""
s = str(track_id or "").strip()
if not s:
return ""
return s.split("::", 1)[0]
def _parse_ts(value: Any) -> Optional[datetime]:
"""Best-effort parse of a sqlite TIMESTAMP / ISO string / datetime."""
if isinstance(value, datetime):
return value
if not value:
return None
s = str(value).strip()
for fmt in (
"%Y-%m-%d %H:%M:%S",
"%Y-%m-%dT%H:%M:%S",
"%Y-%m-%d %H:%M:%S.%f",
"%Y-%m-%dT%H:%M:%S.%f",
):
try:
return datetime.strptime(s, fmt)
except ValueError:
continue
try:
return datetime.fromisoformat(s)
except ValueError:
return None
def is_expired(created_at: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS) -> bool:
"""True when an ignore entry created at ``created_at`` has aged past TTL.
Fail-SAFE in the gate's favour: an unparseable/blank timestamp is
treated as **expired** (returns True) so a corrupt row can never wedge
a track out of the wishlist permanently the worst case is the
ignore silently lapses and the track becomes eligible again.
"""
created = _parse_ts(created_at)
if created is None:
return True
return now >= created + timedelta(days=ttl_days)
def active_ignored_ids(
rows: Iterable[Dict[str, Any]], now: datetime, ttl_days: int = IGNORE_TTL_DAYS
) -> Set[str]:
"""Set of normalized track ids whose ignore entry is still within TTL."""
out: Set[str] = set()
for row in rows or []:
tid = normalize_ignore_id(row.get("track_id"))
if tid and not is_expired(row.get("created_at"), now, ttl_days):
out.add(tid)
return out
def is_ignored(
rows: Iterable[Dict[str, Any]], track_id: Any, now: datetime, ttl_days: int = IGNORE_TTL_DAYS
) -> bool:
"""Whether ``track_id`` matches an in-TTL entry among ``rows``."""
key = normalize_ignore_id(track_id)
if not key:
return False
return key in active_ignored_ids(rows, now, ttl_days)
def extract_display(data: Any) -> Tuple[str, str]:
"""Pull a (track_name, artist_name) pair from a Spotify-shaped dict.
Tolerates ``artists`` as a list of dicts or bare strings, and missing
fields. Used to give ignore-list rows a human label for the UI.
Returns ``('', '')`` when nothing usable is present.
"""
if not isinstance(data, dict):
return "", ""
name = str(data.get("name") or "").strip()
artist = ""
artists = data.get("artists") or []
if isinstance(artists, list) and artists:
first = artists[0]
if isinstance(first, dict):
artist = str(first.get("name") or "").strip()
else:
artist = str(first or "").strip()
return name, artist
def ignore_wishlist_track(
database: Any,
profile_id: int,
track_id: Any,
reason: str,
spotify_data: Any = None,
) -> bool:
"""Record an ignore entry for a wishlist track. Best-effort; never raises.
Copies the track's display name/artist from ``spotify_data`` when
provided (callers should capture it BEFORE removing the wishlist row,
since the row may be gone afterwards); otherwise leaves them blank.
Returns True when an entry was written.
"""
key = normalize_ignore_id(track_id)
if not key or database is None:
return False
name, artist = extract_display(spotify_data or {})
try:
return bool(
database.add_to_wishlist_ignore(
key,
track_name=name,
artist_name=artist,
reason=reason or REASON_REMOVED,
profile_id=profile_id,
)
)
except Exception:
return False

View file

@ -310,12 +310,30 @@ def remove_track_from_wishlist(
if not spotify_track_id:
return {"success": False, "error": "No spotify_track_id provided"}, 400
success = get_wishlist_service().remove_track_from_wishlist(
service = get_wishlist_service()
_db = getattr(service, "database", None)
# #874: capture the track's display info BEFORE removal (the row is
# gone afterwards) so the ignore-list entry carries a human label.
_ignore_data = None
try:
if _db is not None:
_ignore_data = _db.get_wishlist_spotify_data(
spotify_track_id, profile_id=runtime.profile_id)
except Exception:
_ignore_data = None
success = service.remove_track_from_wishlist(
spotify_track_id,
profile_id=runtime.profile_id,
)
if success:
# #874: a user-initiated remove means "stop auto-requeuing this".
# Record a TTL'd ignore so the watchlist/auto-processor doesn't
# re-add it. Best-effort — never fails the remove.
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
ignore_wishlist_track(_db, runtime.profile_id,
spotify_track_id, REASON_REMOVED, spotify_data=_ignore_data)
runtime.logger.info("Successfully removed track from wishlist: %s", spotify_track_id)
return {"success": True, "message": "Track removed from wishlist"}, 200
@ -358,13 +376,20 @@ def remove_album_from_wishlist(
if matched:
spotify_track_id = track.get("track_id") or track.get("spotify_track_id") or track.get("id")
if spotify_track_id:
tracks_to_remove.append(spotify_track_id)
# Keep the loaded spotify_data alongside the id so the #874
# ignore entry can be labelled without a second DB read.
tracks_to_remove.append((spotify_track_id, spotify_data))
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
_db = getattr(wishlist_service, "database", None)
removed_count = 0
album_remove_pid = runtime.profile_id
for spotify_track_id in tracks_to_remove:
for spotify_track_id, track_spotify_data in tracks_to_remove:
if wishlist_service.remove_track_from_wishlist(spotify_track_id, profile_id=album_remove_pid):
removed_count += 1
# #874: user removed the whole album → ignore each track.
ignore_wishlist_track(_db, album_remove_pid,
spotify_track_id, REASON_REMOVED, spotify_data=track_spotify_data)
if removed_count > 0:
runtime.logger.info("Successfully removed %s tracks from album %s", removed_count, album_id)
@ -390,11 +415,22 @@ def remove_batch_from_wishlist(
if not spotify_track_ids or not isinstance(spotify_track_ids, list):
return {"success": False, "error": "Missing or invalid spotify_track_ids"}, 400
from core.wishlist.ignore import ignore_wishlist_track, REASON_REMOVED
service = get_wishlist_service()
_db = getattr(service, "database", None)
removed = 0
pid = runtime.profile_id
for track_id in spotify_track_ids:
if get_wishlist_service().remove_track_from_wishlist(track_id, profile_id=pid):
# Capture label before the row is deleted (#874).
_data = None
try:
if _db is not None:
_data = _db.get_wishlist_spotify_data(track_id, profile_id=pid)
except Exception:
_data = None
if service.remove_track_from_wishlist(track_id, profile_id=pid):
removed += 1
ignore_wishlist_track(_db, pid, track_id, REASON_REMOVED, spotify_data=_data)
runtime.logger.info("Batch removed %s track(s) from wishlist", removed)
return {

View file

@ -344,7 +344,29 @@ class MusicDatabase:
source_info TEXT -- JSON of source context (playlist name, album info, etc.)
)
""")
# Wishlist ignore-list (#874): a TTL'd skip-gate. When a user
# removes a track from the wishlist or cancels an in-flight
# wishlist download, the track is recorded here so the automatic
# re-add paths (watchlist scan, failed-track capture, cancel
# re-add) skip it until the entry ages out (see core.wishlist.
# ignore.IGNORE_TTL_DAYS). Softer than `blocklist`: it expires
# and never blocks a manual force-download. Keyed on the bare
# track id; unique per (profile, track) so re-ignoring refreshes.
cursor.execute("""
CREATE TABLE IF NOT EXISTS wishlist_ignore (
id INTEGER PRIMARY KEY AUTOINCREMENT,
profile_id INTEGER NOT NULL DEFAULT 1,
track_id TEXT NOT NULL,
track_name TEXT DEFAULT '',
artist_name TEXT DEFAULT '',
reason TEXT DEFAULT 'removed', -- 'removed' | 'cancelled'
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
UNIQUE(profile_id, track_id)
)
""")
cursor.execute("CREATE INDEX IF NOT EXISTS idx_wishlist_ignore_profile ON wishlist_ignore (profile_id, track_id)")
# Watchlist table for storing artists to monitor for new releases
cursor.execute("""
CREATE TABLE IF NOT EXISTS watchlist_artists (
@ -8851,6 +8873,21 @@ class MusicDatabase:
_blocked[0], _blocked[1])
return False
# Ignore-list guard (#874): a user who removed or cancelled this
# track asked us to stop AUTO-requeuing it — every automatic
# re-add funnels through here, so one check covers them all.
# A *manual* add is explicit user intent → bypass the gate AND
# clear any stale ignore so it sticks. Fail-open: any error here
# must never block a legitimate wishlist add.
try:
if source_type == 'manual':
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)
return False
except Exception as _ignore_exc:
logger.debug("Wishlist ignore-list check skipped (fail-open): %s", _ignore_exc)
from core.library import manual_library_match as _mlm
if _mlm.get_match_for_track(self, profile_id, spotify_track_data):
logger.info(
@ -8995,7 +9032,147 @@ class MusicDatabase:
except Exception as e:
logger.error(f"Error removing track from wishlist: {e}")
return False
# ── Wishlist ignore-list (#874) ──────────────────────────────────────
# A TTL'd skip-gate consulted by add_to_wishlist so user-removed /
# user-cancelled tracks are not auto-re-queued. All methods fail-open
# (an error here must never block a legitimate wishlist add).
def add_to_wishlist_ignore(self, track_id: str, track_name: str = "",
artist_name: str = "", reason: str = "removed",
profile_id: int = 1) -> bool:
"""Record (or refresh) an ignore entry for a wishlist track id.
Keyed on the bare track id; UNIQUE(profile_id, track_id) means a
repeat ignore replaces the row and so refreshes its TTL clock.
"""
from core.wishlist.ignore import normalize_ignore_id
key = normalize_ignore_id(track_id)
if not key:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
INSERT OR REPLACE INTO wishlist_ignore
(profile_id, track_id, track_name, artist_name, reason, created_at)
VALUES (?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
""", (profile_id, key, track_name or "", artist_name or "", reason or "removed"))
conn.commit()
logger.info("Added track to wishlist ignore-list (%s): '%s' [%s]",
reason or "removed", track_name or key, key)
return True
except Exception as e:
logger.error("Error adding to wishlist ignore-list: %s", e)
return False
def is_track_ignored(self, track_id: str, profile_id: int = 1,
ttl_days: Optional[int] = None) -> bool:
"""Whether ``track_id`` has a non-expired ignore entry. Fail-open False."""
from core.wishlist.ignore import normalize_ignore_id, is_expired, IGNORE_TTL_DAYS
ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days
key = normalize_ignore_id(track_id)
if not key:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT created_at FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
(profile_id, key))
row = cursor.fetchone()
if not row:
return False
return not is_expired(row["created_at"], datetime.now(), ttl)
except Exception as e:
logger.debug("is_track_ignored failed open: %s", e)
return False
def remove_from_wishlist_ignore(self, track_id: str, profile_id: int = 1) -> bool:
"""Un-ignore a track (manual override / UI action). Returns True if a row went."""
from core.wishlist.ignore import normalize_ignore_id
key = normalize_ignore_id(track_id)
if not key:
return False
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
(profile_id, key))
conn.commit()
return cursor.rowcount > 0
except Exception as e:
logger.error("Error removing from wishlist ignore-list: %s", e)
return False
def get_wishlist_ignore(self, profile_id: int = 1,
ttl_days: Optional[int] = None) -> List[Dict[str, Any]]:
"""Active (non-expired) ignore entries, newest first; purges lapsed rows."""
from core.wishlist.ignore import is_expired, IGNORE_TTL_DAYS
ttl = IGNORE_TTL_DAYS if ttl_days is None else ttl_days
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT track_id, track_name, artist_name, reason, created_at "
"FROM wishlist_ignore WHERE profile_id = ? ORDER BY created_at DESC",
(profile_id,))
rows = cursor.fetchall()
now = datetime.now()
active, expired_ids = [], []
for r in rows:
if is_expired(r["created_at"], now, ttl):
expired_ids.append(r["track_id"])
else:
active.append({
"track_id": r["track_id"],
"track_name": r["track_name"] or "",
"artist_name": r["artist_name"] or "",
"reason": r["reason"] or "removed",
"created_at": r["created_at"],
})
# Opportunistic housekeeping so the table can't grow unbounded.
if expired_ids:
cursor.executemany(
"DELETE FROM wishlist_ignore WHERE profile_id = ? AND track_id = ?",
[(profile_id, tid) for tid in expired_ids])
conn.commit()
return active
except Exception as e:
logger.error("Error reading wishlist ignore-list: %s", e)
return []
def clear_wishlist_ignore(self, profile_id: int = 1) -> int:
"""Drop every ignore entry for a profile. Returns rows removed."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM wishlist_ignore WHERE profile_id = ?", (profile_id,))
conn.commit()
return cursor.rowcount
except Exception as e:
logger.error("Error clearing wishlist ignore-list: %s", e)
return 0
def get_wishlist_spotify_data(self, track_id: str, profile_id: int = 1) -> Dict[str, Any]:
"""Parsed ``spotify_data`` for a wishlist row, or {}. Used to label an
ignore entry with the track's name/artist before the row is removed."""
try:
with self._get_connection() as conn:
cursor = conn.cursor()
cursor.execute(
"SELECT spotify_data FROM wishlist_tracks WHERE spotify_track_id = ? AND profile_id = ?",
(track_id, profile_id))
row = cursor.fetchone()
if not row or not row["spotify_data"]:
return {}
data = json.loads(row["spotify_data"])
return data if isinstance(data, dict) else {}
except Exception as e:
logger.debug("get_wishlist_spotify_data failed: %s", e)
return {}
def get_wishlist_tracks(self, limit: Optional[int] = None, profile_id: int = 1,
offset: int = 0, category: Optional[str] = None) -> List[Dict[str, Any]]:
"""Get tracks in the wishlist for the given profile, ordered by date added

View file

@ -0,0 +1,190 @@
"""#874 — wishlist ignore-list: TTL skip-gate for user-removed/cancelled tracks.
Two layers:
* pure logic (core.wishlist.ignore) TTL, id-normalization, display extract
* DB seam (MusicDatabase on a temp db) add/check/remove/list/clear, the
add_to_wishlist gate, the manual-add bypass, and the regression that the
success-cleanup removal path does NOT ignore.
"""
from datetime import datetime, timedelta
import pytest
from core.wishlist.ignore import (
IGNORE_TTL_DAYS,
REASON_CANCELLED,
REASON_REMOVED,
active_ignored_ids,
extract_display,
is_expired,
is_ignored,
normalize_ignore_id,
)
from database.music_database import MusicDatabase
# ── pure logic ──────────────────────────────────────────────────────────
def test_normalize_strips_composite_album_suffix():
assert normalize_ignore_id("track123::album456") == "track123"
assert normalize_ignore_id(" track123 ") == "track123"
assert normalize_ignore_id("") == ""
assert normalize_ignore_id(None) == ""
def test_is_expired_true_past_ttl_false_within():
now = datetime(2026, 6, 15, 12, 0, 0)
fresh = (now - timedelta(days=5)).strftime("%Y-%m-%d %H:%M:%S")
stale = (now - timedelta(days=40)).strftime("%Y-%m-%d %H:%M:%S")
assert is_expired(fresh, now) is False
assert is_expired(stale, now) is True
def test_is_expired_unparseable_is_treated_expired_fail_open():
# Corrupt timestamp must lapse (never wedge a track out of the wishlist).
assert is_expired("not-a-date", datetime(2026, 6, 15)) is True
assert is_expired("", datetime(2026, 6, 15)) is True
assert is_expired(None, datetime(2026, 6, 15)) is True
def test_is_ignored_matches_composite_and_bare_ids():
now = datetime(2026, 6, 15, 12, 0, 0)
created = (now - timedelta(days=1)).strftime("%Y-%m-%d %H:%M:%S")
rows = [{"track_id": "abc", "created_at": created}]
# Stored bare, queried with composite (and vice-versa) — both match.
assert is_ignored(rows, "abc::album9", now) is True
rows2 = [{"track_id": "abc", "created_at": created}]
assert is_ignored(rows2, "abc", now) is True
assert is_ignored(rows2, "different", now) is False
def test_active_ignored_ids_drops_expired():
now = datetime(2026, 6, 15, 12, 0, 0)
fresh = (now - timedelta(days=2)).strftime("%Y-%m-%d %H:%M:%S")
stale = (now - timedelta(days=99)).strftime("%Y-%m-%d %H:%M:%S")
rows = [
{"track_id": "keep", "created_at": fresh},
{"track_id": "drop", "created_at": stale},
]
assert active_ignored_ids(rows, now) == {"keep"}
def test_extract_display_handles_dict_and_string_artists():
assert extract_display({"name": "Song", "artists": [{"name": "A"}]}) == ("Song", "A")
assert extract_display({"name": "Song", "artists": ["B"]}) == ("Song", "B")
assert extract_display({}) == ("", "")
assert extract_display(None) == ("", "")
# ── DB seam (temp database — never the live db) ─────────────────────────
@pytest.fixture
def db(tmp_path):
return MusicDatabase(str(tmp_path / "m.db"))
def _track(track_id="t1", name="Some Song", artist="Some Artist", album_id="alb1"):
return {
"id": track_id,
"name": name,
"artists": [{"name": artist}],
"album": {"id": album_id, "name": "Some Album", "images": []},
}
def test_add_check_remove_roundtrip(db):
assert db.is_track_ignored("t1") is False
assert db.add_to_wishlist_ignore("t1", "Song", "Artist", REASON_REMOVED) is True
assert db.is_track_ignored("t1") is True
# Composite id of the same base track is also considered ignored.
assert db.is_track_ignored("t1::albX") is True
assert db.remove_from_wishlist_ignore("t1") is True
assert db.is_track_ignored("t1") is False
def test_get_and_clear_ignore_list(db):
db.add_to_wishlist_ignore("a", "SongA", "ArtA", REASON_REMOVED)
db.add_to_wishlist_ignore("b", "SongB", "ArtB", REASON_CANCELLED)
entries = db.get_wishlist_ignore()
assert {e["track_id"] for e in entries} == {"a", "b"}
assert any(e["reason"] == "cancelled" for e in entries)
assert db.clear_wishlist_ignore() == 2
assert db.get_wishlist_ignore() == []
def test_expired_entry_is_not_active_and_gets_purged(db):
db.add_to_wishlist_ignore("old", "Old", "Art", REASON_REMOVED)
# Backdate it well past the TTL.
with db._get_connection() as conn:
conn.execute(
"UPDATE wishlist_ignore SET created_at = ? WHERE track_id = ?",
((datetime.now() - timedelta(days=IGNORE_TTL_DAYS + 5)).strftime("%Y-%m-%d %H:%M:%S"), "old"),
)
conn.commit()
assert db.is_track_ignored("old") is False # lapsed
assert db.get_wishlist_ignore() == [] # and purged on read
with db._get_connection() as conn:
remaining = conn.execute("SELECT COUNT(*) c FROM wishlist_ignore").fetchone()["c"]
assert remaining == 0
# ── the gate: add_to_wishlist honours the ignore-list ───────────────────
def test_gate_blocks_auto_readd_but_manual_bypasses_and_clears(db):
track = _track("t1")
# Auto add works first time.
assert db.add_to_wishlist(track, source_type="playlist") is True
# User removes + ignores it.
db.remove_from_wishlist("t1")
db.add_to_wishlist_ignore("t1", "Some Song", "Some Artist", REASON_REMOVED)
# Auto re-add (watchlist / failed-capture / cancel) is now blocked.
assert db.add_to_wishlist(track, source_type="playlist") is False
assert db.is_track_ignored("t1") is True
# A MANUAL add bypasses the gate AND clears the ignore so it sticks.
assert db.add_to_wishlist(track, source_type="manual") is True
assert db.is_track_ignored("t1") is False
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")))
assert db.add_to_wishlist(_track("t9"), source_type="playlist") is True
def test_route_remove_track_records_ignore(db, monkeypatch):
# Pin the route → ignore wiring (not just the DB layer): a user-initiated
# remove via the route function must drop the row AND ignore the track.
import types
from core.wishlist import routes as routes_module
db.add_to_wishlist(_track("rt1", name="Route Song", artist="Route Artist"),
source_type="playlist")
class _Svc:
database = db
def remove_track_from_wishlist(self, tid, profile_id=1):
return db.remove_from_wishlist(tid, profile_id=profile_id)
monkeypatch.setattr(routes_module, "get_wishlist_service", lambda: _Svc())
runtime = types.SimpleNamespace(profile_id=1, logger=routes_module.module_logger)
payload, status = routes_module.remove_track_from_wishlist(runtime, "rt1")
assert status == 200
assert db.is_track_ignored("rt1") is True
# The ignore carries the captured label.
entry = db.get_wishlist_ignore()[0]
assert entry["track_name"] == "Route Song"
assert entry["reason"] == REASON_REMOVED
def test_regression_success_cleanup_does_not_ignore(db):
# The post-download success path calls remove_from_wishlist directly — it
# must NOT add anything to the ignore-list (only user remove/cancel do).
track = _track("t5")
assert db.add_to_wishlist(track, source_type="playlist") is True
db.remove_from_wishlist("t5") # simulate success cleanup
assert db.is_track_ignored("t5") is False # NOT ignored
# And so a later legitimate auto-add still works.
assert db.add_to_wishlist(track, source_type="playlist") is True

View file

@ -16819,6 +16819,45 @@ def remove_batch_from_wishlist():
logger.error(f"Error batch removing from wishlist: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/ignore-list', methods=['GET'])
def get_wishlist_ignore_list():
"""#874: active (non-expired) wishlist ignore entries for this profile."""
try:
runtime = _build_wishlist_route_runtime()
entries = get_wishlist_service().database.get_wishlist_ignore(profile_id=runtime.profile_id)
from core.wishlist.ignore import IGNORE_TTL_DAYS
return jsonify({"success": True, "entries": entries, "ttl_days": IGNORE_TTL_DAYS})
except Exception as e:
logger.error(f"Error reading wishlist ignore-list: {e}")
return jsonify({"success": False, "error": str(e), "entries": []}), 500
@app.route('/api/wishlist/ignore-list/remove', methods=['POST'])
def remove_from_wishlist_ignore_list():
"""#874: un-ignore a track so it can be auto-acquired again."""
try:
data = request.get_json() or {}
track_id = data.get('track_id') or data.get('spotify_track_id')
if not track_id:
return jsonify({"success": False, "error": "No track_id provided"}), 400
runtime = _build_wishlist_route_runtime()
ok = get_wishlist_service().database.remove_from_wishlist_ignore(
track_id, profile_id=runtime.profile_id)
return jsonify({"success": True, "removed": ok})
except Exception as e:
logger.error(f"Error removing from wishlist ignore-list: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/wishlist/ignore-list/clear', methods=['POST'])
def clear_wishlist_ignore_list():
"""#874: clear the entire wishlist ignore-list for this profile."""
try:
runtime = _build_wishlist_route_runtime()
count = get_wishlist_service().database.clear_wishlist_ignore(profile_id=runtime.profile_id)
return jsonify({"success": True, "cleared": count})
except Exception as e:
logger.error(f"Error clearing wishlist ignore-list: {e}")
return jsonify({"success": False, "error": str(e)}), 500
@app.route('/api/add-album-to-wishlist', methods=['POST'])
def add_album_track_to_wishlist():
"""Endpoint to add a single track from an album to the wishlist."""
@ -18956,26 +18995,44 @@ def _check_batch_completion_v2(batch_id):
def _add_cancelled_task_to_wishlist(task):
"""
Helper function to add cancelled task to wishlist.
Separated for clarity and error isolation.
"""Handle a user-cancelled download's wishlist state.
#874: a manual cancel means "stop auto-retrying this release". The
previous behaviour re-added the cancelled track to the wishlist, which
the auto-processor then re-downloaded re-cancelled re-added,
forever. Instead we record a TTL'd ignore entry (so the watchlist /
auto-processor skip it) and drop it from the active wishlist. The user
can still force-download it manually (manual paths bypass the gate),
and the ignore lapses after core.wishlist.ignore.IGNORE_TTL_DAYS so it
is reconsidered later. Error-isolated; never raises into the caller.
"""
if not task:
return
try:
from core.wishlist_service import get_wishlist_service
from core.wishlist.ignore import extract_display, REASON_CANCELLED
wishlist_service = get_wishlist_service()
payload = _build_cancelled_task_wishlist_payload(task, profile_id=get_current_profile_id())
success = wishlist_service.add_spotify_track_to_wishlist(**payload)
if success:
logger.info(f"[Atomic Cancel] Added '{task.get('track_info', {}).get('name')}' to wishlist")
else:
logger.error(f"[Atomic Cancel] Failed to add '{task.get('track_info', {}).get('name')}' to wishlist")
profile_id = get_current_profile_id()
track_info = task.get('track_info', {}) or {}
track_id = track_info.get('id')
name = track_info.get('name')
if not track_id:
logger.warning("[Atomic Cancel] No track id on cancelled task — cannot ignore '%s'", name)
return
disp_name, disp_artist = extract_display(track_info)
wishlist_service.database.add_to_wishlist_ignore(
track_id, track_name=disp_name, artist_name=disp_artist,
reason=REASON_CANCELLED, profile_id=profile_id)
# Drop it from the active wishlist so the auto-processor stops
# re-attempting it; the gate then blocks any automatic re-add.
wishlist_service.remove_track_from_wishlist(track_id, profile_id=profile_id)
logger.info("[Atomic Cancel] Ignored (TTL) + removed from wishlist: '%s'", name or track_id)
except Exception as e:
logger.error(f"[Atomic Cancel] Critical error adding to wishlist: {e}")
logger.error(f"[Atomic Cancel] Critical error handling cancelled task: {e}")
@app.route('/api/playlists/<batch_id>/cancel_batch', methods=['POST'])
def cancel_batch(batch_id):

View file

@ -1186,6 +1186,9 @@ async function openWishlistOverviewModal() {
<button class="playlist-modal-btn playlist-modal-btn-warning" onclick="cleanupWishlistOverview()">
🧹 Cleanup Wishlist
</button>
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="openWishlistIgnoreModal()" title="Tracks you removed or cancelled — auto-skipped until they expire">
🚫 Ignored
</button>
</div>
<div class="playlist-modal-footer-right">
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistOverviewModal()">Close</button>
@ -1312,6 +1315,117 @@ function closeWishlistOverviewModal() {
console.log('✅ Modal closed');
}
// ── #874: Wishlist ignore-list ("Ignored") modal ────────────────────────
// Tracks the user removed from the wishlist or cancelled mid-download are
// auto-skipped (not re-queued) until they expire. This modal lets the user
// see what's currently ignored and lift the skip (un-ignore / clear all).
async function openWishlistIgnoreModal() {
let modal = document.getElementById('wishlist-ignore-modal');
if (modal) modal.remove();
modal = document.createElement('div');
modal.id = 'wishlist-ignore-modal';
modal.className = 'modal-overlay';
modal.style.cssText = 'display:flex;position:fixed;inset:0;z-index:10050;align-items:center;justify-content:center;background:rgba(0,0,0,0.6);';
modal.innerHTML = `
<div class="playlist-modal-content" style="max-width:560px;width:90%;max-height:80vh;display:flex;flex-direction:column;">
<div class="playlist-modal-header">
<h2 style="margin:0;">🚫 Ignored Tracks</h2>
<button class="playlist-modal-btn playlist-modal-btn-secondary" onclick="closeWishlistIgnoreModal()">Close</button>
</div>
<p style="opacity:0.7;font-size:13px;margin:8px 16px 0;">Removed or cancelled tracks are skipped by auto-download until they expire. Un-ignore to allow auto-download again (you can always download manually).</p>
<div id="wishlist-ignore-list" class="playlist-tracks-scroll" style="flex:1;overflow-y:auto;padding:12px 16px;">
<div class="loading-indicator">Loading...</div>
</div>
<div class="playlist-modal-footer">
<div class="playlist-modal-footer-left">
<button id="wishlist-ignore-clear-btn" class="playlist-modal-btn playlist-modal-btn-danger" onclick="clearWishlistIgnoreList()" style="display:none;">Clear All</button>
</div>
</div>
</div>`;
document.body.appendChild(modal);
modal.addEventListener('click', (e) => { if (e.target === modal) closeWishlistIgnoreModal(); });
await loadWishlistIgnoreList();
}
function closeWishlistIgnoreModal() {
const modal = document.getElementById('wishlist-ignore-modal');
if (modal) modal.remove();
}
async function loadWishlistIgnoreList() {
const list = document.getElementById('wishlist-ignore-list');
const clearBtn = document.getElementById('wishlist-ignore-clear-btn');
if (!list) return;
try {
const resp = await fetch('/api/wishlist/ignore-list');
const data = await resp.json();
const entries = (data && data.entries) || [];
if (clearBtn) clearBtn.style.display = entries.length ? '' : 'none';
if (!entries.length) {
list.innerHTML = '<div class="playlist-empty-state" style="text-align:center;opacity:0.6;padding:30px 0;">🎉<br><br>Nothing ignored.</div>';
return;
}
const ttl = (data && data.ttl_days) || 30;
list.innerHTML = entries.map(e => {
const title = escapeHtml(e.track_name || e.track_id || 'Unknown');
const artist = escapeHtml(e.artist_name || '');
const reason = e.reason === 'cancelled' ? 'Cancelled' : 'Removed';
const tid = escapeHtml(String(e.track_id || ''));
return `<div class="wishlist-ignore-row" style="display:flex;align-items:center;gap:10px;padding:8px 4px;border-bottom:1px solid rgba(255,255,255,0.06);">
<div style="flex:1;min-width:0;">
<div style="font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${title}</div>
<div style="opacity:0.6;font-size:12px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;">${artist}${artist ? ' · ' : ''}${reason} · skips ${ttl}d</div>
</div>
<button class="playlist-modal-btn playlist-modal-btn-secondary" style="flex-shrink:0;" data-track-id="${tid}" onclick="unignoreWishlistTrack(this.dataset.trackId)">Un-ignore</button>
</div>`;
}).join('');
} catch (err) {
list.innerHTML = '<div class="playlist-empty-state" style="text-align:center;opacity:0.6;padding:30px 0;">Error loading ignored tracks</div>';
}
}
async function unignoreWishlistTrack(trackId) {
if (!trackId) return;
try {
const resp = await fetch('/api/wishlist/ignore-list/remove', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ track_id: trackId }),
});
const data = await resp.json();
if (data && data.success) {
showToast('Track un-ignored — it can be auto-downloaded again.', 'success');
await loadWishlistIgnoreList();
} else {
showToast(`Un-ignore failed: ${(data && data.error) || 'unknown'}`, 'error');
}
} catch (err) {
showToast(`Un-ignore failed: ${err.message}`, 'error');
}
}
async function clearWishlistIgnoreList() {
if (!await showConfirmDialog({
title: 'Clear Ignored List',
message: 'Allow all currently-ignored tracks to be auto-downloaded again?',
confirmText: 'Clear All',
cancelText: 'Cancel',
})) return;
try {
const resp = await fetch('/api/wishlist/ignore-list/clear', { method: 'POST' });
const data = await resp.json();
if (data && data.success) {
showToast(`Cleared ${data.cleared || 0} ignored track(s).`, 'success');
await loadWishlistIgnoreList();
} else {
showToast(`Clear failed: ${(data && data.error) || 'unknown'}`, 'error');
}
} catch (err) {
showToast(`Clear failed: ${err.message}`, 'error');
}
}
async function cleanupWishlistOverview() {
console.log('🧹 cleanupWishlistOverview() called');