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,5 @@
import pytest
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))

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 == []

View File

@@ -0,0 +1,49 @@
import pytest
from unittest.mock import MagicMock, patch, AsyncMock
from datetime import datetime
@pytest.fixture
def mock_db():
return MagicMock()
class TestSnapshotsEndpoint:
def test_snapshots_endpoint_exists(self, mock_db):
with patch("app.main.SessionLocal", return_value=mock_db):
from fastapi.testclient import TestClient
from app.main import app
mock_db.query.return_value.order_by.return_value.limit.return_value.all.return_value = []
with TestClient(app) as client:
response = client.get("/snapshots?limit=1")
assert response.status_code == 200
class TestListeningLogEndpoint:
def test_listening_log_endpoint_exists(self, mock_db):
with patch("app.main.SessionLocal", return_value=mock_db):
from fastapi.testclient import TestClient
from app.main import app
mock_db.query.return_value.options.return_value.filter.return_value.order_by.return_value.limit.return_value.all.return_value = []
with TestClient(app) as client:
response = client.get("/listening-log?days=7&limit=100")
assert response.status_code == 200
class TestSessionsEndpoint:
def test_sessions_endpoint_exists(self, mock_db):
with patch("app.main.SessionLocal", return_value=mock_db):
from fastapi.testclient import TestClient
from app.main import app
mock_db.query.return_value.options.return_value.filter.return_value.order_by.return_value.all.return_value = []
with TestClient(app) as client:
response = client.get("/sessions?days=7")
assert response.status_code == 200
data = response.json()
assert "session_list" in data

View File

@@ -1,155 +0,0 @@
import os
import json
# import pytest <-- Removed
from datetime import datetime, timedelta
from sqlalchemy import create_engine
from sqlalchemy.orm import sessionmaker
from backend.app.models import Base, PlayHistory, Track, Artist
from backend.app.services.stats_service import StatsService
# Setup Test Database
# @pytest.fixture <-- Removed
def db_session():
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
yield session
session.close()
def seed_data(db):
"""
Seeds the database with specific patterns to verify metrics.
Pattern:
- High Energy/Happy Session (Morning)
- Low Energy/Sad Session (Night)
- Skips
- Repeats
"""
# 1. Create Artists
a1 = Artist(id="a1", name="The Hype Men", genres=["pop", "dance"])
a2 = Artist(id="a2", name="Sad Bois", genres=["indie", "folk"])
a3 = Artist(id="a3", name="Mozart", genres=["classical"])
db.add_all([a1, a2, a3])
# 2. Create Tracks
# High Energy, High Valence, Fast
t1 = Track(
id="t1", name="Party Anthem", album="Hype Vol 1", duration_ms=180000,
popularity=80, energy=0.9, valence=0.9, danceability=0.8, tempo=140.0, acousticness=0.1, instrumentalness=0.0,
key=0, mode=1 # C Major
)
t1.artists.append(a1)
# Low Energy, Low Valence, Slow
t2 = Track(
id="t2", name="Rainy Day", album="Sad Vol 1", duration_ms=240000,
popularity=20, energy=0.2, valence=0.1, danceability=0.3, tempo=80.0, acousticness=0.9, instrumentalness=0.0,
key=9, mode=0 # A Minor
)
t2.artists.append(a2)
# Classical (Instrumental)
t3 = Track(
id="t3", name="Symphony 40", album="Classics", duration_ms=300000,
popularity=50, energy=0.4, valence=0.5, danceability=0.1, tempo=110.0, acousticness=0.8, instrumentalness=0.9,
key=5, mode=0
)
t3.artists.append(a3)
db.add_all([t1, t2, t3])
db.commit()
# 3. Create History
base_time = datetime(2023, 11, 1, 8, 0, 0) # Morning
plays = []
# SESSION 1: Morning Hype (3 plays of t1)
# 08:00
plays.append(PlayHistory(track_id="t1", played_at=base_time, context_uri="spotify:playlist:morning"))
# 08:04 (4 min gap)
plays.append(PlayHistory(track_id="t1", played_at=base_time + timedelta(minutes=4), context_uri="spotify:playlist:morning"))
# 08:08
plays.append(PlayHistory(track_id="t1", played_at=base_time + timedelta(minutes=8), context_uri="spotify:playlist:morning"))
# GAP > 20 mins -> New Session
# SESSION 2: Night Sadness (t2, t2, t3)
# 22:00
night_time = datetime(2023, 11, 1, 22, 0, 0)
plays.append(PlayHistory(track_id="t2", played_at=night_time, context_uri="spotify:album:sad"))
# SKIP SIMULATION: t2 played at 22:00, next play at 22:00:20 (20s later).
# Duration is 240s. 20s < 230s. This is a skip.
# But wait, logic says "boredom skip".
# If I play t2 at 22:00.
# And play t3 at 22:00:40.
# Diff = 40s. 40 < (240 - 10). Yes, Skip.
plays.append(PlayHistory(track_id="t3", played_at=night_time + timedelta(seconds=40), context_uri="spotify:album:sad"))
# Finish t3 (5 mins)
plays.append(PlayHistory(track_id="t3", played_at=night_time + timedelta(seconds=40) + timedelta(minutes=5, seconds=10), context_uri="spotify:album:sad"))
db.add_all(plays)
db.commit()
def test_stats_generation(db_session):
seed_data(db_session)
stats_service = StatsService(db_session)
start = datetime(2023, 11, 1, 0, 0, 0)
end = datetime(2023, 11, 2, 0, 0, 0)
report = stats_service.generate_full_report(start, end)
print("\n--- GENERATED REPORT ---")
print(json.dumps(report, indent=2, default=str))
print("------------------------\n")
# Assertions
# 1. Volume
assert report["volume"]["total_plays"] == 6
assert report["volume"]["unique_tracks"] == 3
# Top track should be t1 (3 plays)
assert report["volume"]["top_tracks"][0]["name"] == "Party Anthem"
# 2. Time
# 3 plays in morning (8am), 3 plays at night (22pm)
assert report["time_habits"]["part_of_day"]["morning"] == 3
assert report["time_habits"]["part_of_day"]["night"] == 0 # 22:00 is "evening" in buckets (18-23)
assert report["time_habits"]["part_of_day"]["evening"] == 3
# 3. Sessions
# Should be 2 sessions (gap between 08:08 and 22:00)
assert report["sessions"]["count"] == 2
# 4. Skips
# 1 skip detected (t2 -> t3 gap was 40s vs 240s duration)
assert report["skips"]["total_skips"] == 1
# 5. Vibe & Clustering
# Should have cluster info
assert "clusters" in report["vibe"]
# Check harmonic
assert report["vibe"]["harmonic_profile"]["major_pct"] > 0
# Check tempo zones (t1=140=Hype, t2=80=Chill, t3=110=Groove)
# 3x t1 (Hype), 1x t2 (Chill), 2x t3 (Groove)
# Total 6. Hype=0.5, Chill=0.17, Groove=0.33
zones = report["vibe"]["tempo_zones"]
assert zones["hype"] == 0.5
# 6. Context
# Morning = Playlist (3), Night = Album (3) -> 50/50
assert report["context"]["type_breakdown"]["playlist"] == 0.5
assert report["context"]["type_breakdown"]["album"] == 0.5
if __name__ == "__main__":
# Manually run if executed as script
engine = create_engine("sqlite:///:memory:")
Base.metadata.create_all(engine)
Session = sessionmaker(bind=engine)
session = Session()
test_stats_generation(session)