design: sync unliked tracks — remove from show and re-compact positions

Nick is the host; if he unlikes a track, the service should respect
that and remove it from the show playlist. Positions re-compact after
removal. The tracks table retains the record for historical reference.

Made-with: Cursor
This commit is contained in:
cottongin
2026-03-12 01:13:19 -04:00
parent 7eaf036d4b
commit 22f6b5cbca
2 changed files with 97 additions and 4 deletions

View File

@@ -579,6 +579,30 @@ def test_set_show_tracks_preserves_existing_positions(db):
assert tracks[1]["position"] == 2
def test_set_show_tracks_removes_unliked(db):
"""Tracks no longer in the likes list are removed and positions re-compact."""
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
show = db.get_or_create_show(week_start, week_end)
t1 = _make_track(1, "2026-03-14T01:00:00+00:00", title="First")
t2 = _make_track(2, "2026-03-14T02:00:00+00:00", title="Second")
t3 = _make_track(3, "2026-03-14T03:00:00+00:00", title="Third")
db.upsert_track(t1)
db.upsert_track(t2)
db.upsert_track(t3)
db.set_show_tracks(show.id, [t1.id, t2.id, t3.id])
# Nick unlikes track 2
db.set_show_tracks(show.id, [t1.id, t3.id])
tracks = db.get_show_tracks(show.id)
assert len(tracks) == 2
assert tracks[0]["track_id"] == 1
assert tracks[0]["position"] == 1
assert tracks[1]["track_id"] == 3
assert tracks[1]["position"] == 2 # re-compacted from 3
def test_get_show_track_by_position(db):
week_start = datetime(2026, 3, 13, 2, 0, 0, tzinfo=timezone.utc)
week_end = datetime(2026, 3, 20, 2, 0, 0, tzinfo=timezone.utc)
@@ -821,7 +845,12 @@ Add these methods to the `Database` class in `src/ntr_fetcher/db.py`:
return dict(row) if row else None
def set_show_tracks(self, show_id: int, track_ids: list[int]) -> None:
"""Set show tracks, preserving positions of already-assigned tracks."""
"""Sync show tracks to match track_ids list.
- Preserves positions of already-assigned tracks.
- Appends new tracks after the current max position.
- Removes tracks no longer in track_ids and re-compacts positions.
"""
conn = self._connect()
existing = conn.execute(
"SELECT track_id, position FROM show_tracks WHERE show_id = ? ORDER BY position",
@@ -829,6 +858,17 @@ Add these methods to the `Database` class in `src/ntr_fetcher/db.py`:
).fetchall()
existing_map = {row["track_id"]: row["position"] for row in existing}
track_id_set = set(track_ids)
# Remove tracks that are no longer liked
removed = [tid for tid in existing_map if tid not in track_id_set]
for tid in removed:
conn.execute(
"DELETE FROM show_tracks WHERE show_id = ? AND track_id = ?",
(show_id, tid),
)
# Add new tracks at the end
max_pos = max(existing_map.values()) if existing_map else 0
for track_id in track_ids:
if track_id not in existing_map:
@@ -837,6 +877,19 @@ Add these methods to the `Database` class in `src/ntr_fetcher/db.py`:
"INSERT OR IGNORE INTO show_tracks (show_id, track_id, position) VALUES (?, ?, ?)",
(show_id, track_id, max_pos),
)
# Re-compact positions if anything was removed
if removed:
rows = conn.execute(
"SELECT track_id FROM show_tracks WHERE show_id = ? ORDER BY position",
(show_id,),
).fetchall()
for i, row in enumerate(rows, start=1):
conn.execute(
"UPDATE show_tracks SET position = ? WHERE show_id = ? AND track_id = ?",
(i, show_id, row["track_id"]),
)
conn.commit()
conn.close()
@@ -1418,6 +1471,46 @@ async def test_poll_once_fetches_and_stores():
assert call_args[0][1] == [1, 2] # track_ids in order
@pytest.mark.asyncio
async def test_poll_once_removes_unliked_tracks():
"""When a track disappears from likes, it should be removed from the show."""
mock_sc = AsyncMock()
mock_sc.resolve_user.return_value = 206979918
# First poll: two tracks
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
_make_track(2, "2026-03-14T02:00:00+00:00"),
]
mock_db = MagicMock()
mock_show = MagicMock()
mock_show.id = 1
mock_db.get_or_create_show.return_value = mock_show
poller = Poller(
db=mock_db,
soundcloud=mock_sc,
soundcloud_user="nicktherat",
show_day=2,
show_hour=22,
poll_interval=3600,
)
await poller.poll_once()
# Second poll: Nick unliked track 2
mock_sc.fetch_likes.return_value = [
_make_track(1, "2026-03-14T01:00:00+00:00"),
]
mock_db.reset_mock()
mock_db.get_or_create_show.return_value = mock_show
await poller.poll_once()
call_args = mock_db.set_show_tracks.call_args
assert call_args[0][1] == [1] # only track 1 remains
@pytest.mark.asyncio
async def test_poll_once_full_refresh():
mock_sc = AsyncMock()