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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user