Add skip tracking, compressed heatmap, listening log, docs, tests, and OpenAI support

Major changes:
- Add skip tracking: poll currently-playing every 15s, detect skips (<30s listened)
- Add listening-log and sessions API endpoints
- Fix ReccoBeats client to extract spotify_id from href response
- Compress heatmap from 24 hours to 6 x 4-hour blocks
- Add OpenAI support in narrative service (use max_completion_tokens for new models)
- Add ListeningLog component with timeline and list views
- Update all frontend components to use real data (album art, play counts)
- Add docker-compose external network (dockernet) support
- Add comprehensive documentation (API, DATA_MODEL, ARCHITECTURE, FRONTEND)
- Add unit tests for ingest and API endpoints
This commit is contained in:
bnair123
2025-12-30 00:15:01 +04:00
parent faee830545
commit 887e78bf47
26 changed files with 1942 additions and 662 deletions

View File

@@ -0,0 +1,113 @@
import pytest
from unittest.mock import MagicMock, AsyncMock, patch
from datetime import datetime, timedelta
from app.ingest import PlaybackTracker, finalize_track
class TestPlaybackTracker:
def test_initial_state(self):
tracker = PlaybackTracker()
assert tracker.current_track_id is None
assert tracker.track_start_time is None
assert tracker.accumulated_listen_ms == 0
assert tracker.last_progress_ms == 0
assert tracker.is_paused is False
class TestFinalizeTrack:
def test_finalize_creates_play_history_when_not_exists(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
tracker = PlaybackTracker()
tracker.current_track_id = "track123"
tracker.track_start_time = datetime(2024, 1, 1, 10, 0, 0)
tracker.accumulated_listen_ms = 60000
finalize_track(mock_db, tracker)
mock_db.add.assert_called_once()
mock_db.commit.assert_called_once()
assert tracker.current_track_id is None
assert tracker.accumulated_listen_ms == 0
def test_finalize_marks_skip_when_under_30s(self):
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = None
tracker = PlaybackTracker()
tracker.current_track_id = "track123"
tracker.track_start_time = datetime(2024, 1, 1, 10, 0, 0)
tracker.accumulated_listen_ms = 15000
finalize_track(mock_db, tracker)
call_args = mock_db.add.call_args[0][0]
assert call_args.skipped is True
def test_finalize_updates_existing_play(self):
mock_existing = MagicMock()
mock_existing.listened_ms = None
mock_db = MagicMock()
mock_db.query.return_value.filter.return_value.first.return_value = (
mock_existing
)
tracker = PlaybackTracker()
tracker.current_track_id = "track123"
tracker.track_start_time = datetime(2024, 1, 1, 10, 0, 0)
tracker.accumulated_listen_ms = 120000
finalize_track(mock_db, tracker)
assert mock_existing.listened_ms == 120000
assert mock_existing.skipped is False
mock_db.commit.assert_called_once()
class TestReccoBeatsClient:
@pytest.mark.asyncio
async def test_extracts_spotify_id_from_href(self):
from app.services.reccobeats_client import ReccoBeatsClient
with patch("httpx.AsyncClient") as mock_client:
mock_response = MagicMock()
mock_response.status_code = 200
mock_response.json.return_value = {
"content": [
{
"id": "uuid-here",
"href": "https://open.spotify.com/track/abc123xyz",
"energy": 0.8,
"valence": 0.6,
}
]
}
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
return_value=mock_response
)
client = ReccoBeatsClient()
result = await client.get_audio_features(["abc123xyz"])
assert len(result) == 1
assert result[0]["spotify_id"] == "abc123xyz"
assert result[0]["energy"] == 0.8
@pytest.mark.asyncio
async def test_returns_empty_on_error(self):
from app.services.reccobeats_client import ReccoBeatsClient
with patch("httpx.AsyncClient") as mock_client:
mock_client.return_value.__aenter__.return_value.get = AsyncMock(
side_effect=Exception("Network error")
)
client = ReccoBeatsClient()
result = await client.get_audio_features(["test123"])
assert result == []