LB manager: cascade-delete mirrored rows when LB cache prunes

ListenBrainz auto-rotates the user's "For You" playlists weekly:
"Weekly Jams for X, week of 2026-05-25 Mon" gets a fresh MBID
every Monday, and the prior week's playlist gets dropped from
ListenBrainz's API after ~25 weeks. The LB manager already mirrors
that retention policy in ``_cleanup_old_playlists`` (keeps the 25
most-recent per category). The Sync-tab auto-mirror flow, though,
created a ``mirrored_playlists`` row for each unique MBID — so the
user's Mirrored tab would accumulate 100+ dead Weekly Jams /
Weekly Exploration rows per year, each pointing at an LB playlist
the cache had already pruned.

Fix: when LB manager removes a cached LB playlist (either via the
periodic ``_cleanup_old_playlists`` rotation or an explicit
``delete_cached_playlist`` call), also delete the matching
``mirrored_playlists`` row + its tracks. Downloaded tracks stay
in the library — only the mirror row + track refs go.

- New ``_cascade_delete_mirrored_for_mbids(cursor, mbids, source)``
  helper runs in the same transaction as the LB cache delete so
  the two stay consistent.
- ``_cleanup_old_playlists`` now selects ``playlist_mbid`` alongside
  ``id`` from the stale rows + passes the mbids through the cascade
  helper before committing.
- ``delete_cached_playlist`` looks up the playlist's type first
  (so it knows whether to target ``source='listenbrainz'`` or
  ``source='lastfm'`` mirrored rows), then cascades.

Cleanup is best-effort: any cascade error logs a warning but
doesn't roll back the LB cache delete itself. Losing the
cache→mirror link in a rare edge case is preferable to crashing
the LB update loop.
This commit is contained in:
Broque Thomas 2026-05-26 15:12:56 -07:00
parent f521be7720
commit 6198fc37d8

View file

@ -344,13 +344,15 @@ class ListenBrainzManager:
try:
# Get IDs of playlists to delete (all except keep_count most recent)
cursor.execute("""
SELECT id FROM listenbrainz_playlists
SELECT id, playlist_mbid FROM listenbrainz_playlists
WHERE playlist_type = ? AND profile_id = ?
ORDER BY last_updated DESC
LIMIT -1 OFFSET ?
""", (playlist_type, self.profile_id, keep_count))
old_playlist_ids = [row[0] for row in cursor.fetchall()]
stale_rows = cursor.fetchall()
old_playlist_ids = [row[0] for row in stale_rows]
old_mbids = [row[1] for row in stale_rows if row[1]]
if old_playlist_ids:
# Delete tracks for old playlists
@ -362,12 +364,66 @@ class ListenBrainzManager:
logger.info(f"Removed {len(old_playlist_ids)} old {playlist_type} playlists")
# Cascade delete: matching mirrored_playlists rows go too.
# LB Weekly Jams / Weekly Exploration get new MBIDs every
# week — without this, the user accumulates dead mirror
# rows that point at LB playlists the cache already pruned.
# Downloaded tracks stay in the library; only the mirror
# row + its track refs are removed.
if old_mbids:
mirror_source = (
'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz'
)
self._cascade_delete_mirrored_for_mbids(cursor, old_mbids, mirror_source)
except Exception as e:
logger.error(f"Error cleaning up {playlist_type} playlists: {e}")
conn.commit()
conn.close()
def _cascade_delete_mirrored_for_mbids(self, cursor, mbids, source):
"""Delete mirrored_playlists rows whose source_playlist_id matches
any of ``mbids`` for this profile + source.
Runs on the same cursor as the caller so the cleanup lands in
the same transaction. Silent on failure (cleanup is best-effort
losing the cache-prune-mirror link in rare edge cases is
preferable to crashing the LB update loop)."""
if not mbids:
return
try:
placeholders = ','.join('?' * len(mbids))
# Find matching mirror IDs first so we can delete tracks +
# row in two well-defined steps. ``mirrored_playlist_tracks``
# has no ON DELETE CASCADE constraint enforced unless PRAGMA
# foreign_keys is on, so do it explicitly.
cursor.execute(
f"""
SELECT id FROM mirrored_playlists
WHERE source = ? AND profile_id = ?
AND source_playlist_id IN ({placeholders})
""",
(source, self.profile_id, *mbids),
)
mirror_ids = [row[0] for row in cursor.fetchall()]
if not mirror_ids:
return
mid_ph = ','.join('?' * len(mirror_ids))
cursor.execute(
f"DELETE FROM mirrored_playlist_tracks WHERE playlist_id IN ({mid_ph})",
mirror_ids,
)
cursor.execute(
f"DELETE FROM mirrored_playlists WHERE id IN ({mid_ph})",
mirror_ids,
)
logger.info(
f"Cascade-removed {len(mirror_ids)} stale {source} mirrored playlists"
)
except Exception as exc:
logger.warning(f"Cascade delete of mirrored {source} rows failed: {exc}")
def save_lastfm_radio_playlist(self, seed_track: str, seed_artist: str, similar_tracks: List[Dict]) -> str:
"""
Persist a Last.fm similar-tracks playlist to the DB under playlist_type='lastfm_radio'.
@ -500,6 +556,21 @@ class ListenBrainzManager:
"""Delete a cached playlist and its tracks (CASCADE handles tracks via FK)"""
conn = self._get_db_connection()
cursor = conn.cursor()
# Figure out the source flavor before deleting the row — the
# cascade below needs to know whether the matching mirror is
# ``source='listenbrainz'`` or ``source='lastfm'``.
playlist_type = ''
try:
cursor.execute(
"SELECT playlist_type FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?",
(playlist_mbid, self.profile_id),
)
row = cursor.fetchone()
playlist_type = row[0] if row else ''
except Exception:
pass
# Delete tracks first (SQLite FK CASCADE requires PRAGMA foreign_keys=ON)
cursor.execute("""
DELETE FROM listenbrainz_tracks WHERE playlist_id IN (
@ -510,6 +581,12 @@ class ListenBrainzManager:
"DELETE FROM listenbrainz_playlists WHERE playlist_mbid = ? AND profile_id = ?",
(playlist_mbid, self.profile_id)
)
# Cascade the delete into mirrored_playlists so the user's
# Mirrored tab doesn't accumulate dead LB rows.
mirror_source = 'lastfm' if playlist_type == 'lastfm_radio' else 'listenbrainz'
self._cascade_delete_mirrored_for_mbids(cursor, [playlist_mbid], mirror_source)
conn.commit()
conn.close()