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()

View File

@@ -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