diff --git a/docs/plans/2026-03-12-ntr-fetcher-implementation.md b/docs/plans/2026-03-12-ntr-fetcher-implementation.md index bb8783c..e7cf2dc 100644 --- a/docs/plans/2026-03-12-ntr-fetcher-implementation.md +++ b/docs/plans/2026-03-12-ntr-fetcher-implementation.md @@ -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() diff --git a/docs/plans/2026-03-12-ntr-soundcloud-fetcher-design.md b/docs/plans/2026-03-12-ntr-soundcloud-fetcher-design.md index fb0cfb9..cecce10 100644 --- a/docs/plans/2026-03-12-ntr-soundcloud-fetcher-design.md +++ b/docs/plans/2026-03-12-ntr-soundcloud-fetcher-design.md @@ -68,7 +68,7 @@ Join table linking tracks to shows with position. Position assignment: likes sorted by `liked_at` ascending (oldest first), positions assigned 1, 2, 3... New likes mid-week get the next position; existing positions never shift. -Once a track is assigned a position in a show, it stays even if Nick unlikes it. Admin endpoints exist for manual corrections. +If Nick unlikes a track, the poller removes it from the show and re-compacts positions (e.g. if track at position 2 is removed, position 3 becomes 2). The `tracks` table retains the record for historical reference, but the `show_tracks` link is deleted. ## API Endpoints @@ -135,7 +135,7 @@ Admin endpoints are protected by a bearer token (`NTR_ADMIN_TOKEN`). Read endpoi 1. Compute current week's boundary window (Wednesday 22:00 ET -> next Wednesday 22:00 ET, converted to UTC accounting for DST via `zoneinfo`). 2. Ensure a `shows` row exists for this window. 3. Fetch likes from SoundCloud. -4. Upsert new tracks, assign positions, update `show_tracks`. +4. Sync the show playlist: upsert new tracks, remove unliked tracks, re-compact positions. 5. Sleep for the configured interval. ### Incremental fetching @@ -167,7 +167,7 @@ Each SoundCloud HTTP call: 3 attempts, exponential backoff (2s, 4s, 8s). 401s tr | Network timeout | Same as 5xx | | `client_id` extraction failure | Log error, skip tick, retry next hour | | Poller task crash | Supervisor restarts after 30s backoff | -| Nick unlikes a track | Track stays in show — positions are stable | +| Nick unlikes a track | Track removed from show, positions re-compacted | ## Project Structure