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