diff --git a/backend/app/services/recap.py b/backend/app/services/recap.py index 2da341af..0a154176 100644 --- a/backend/app/services/recap.py +++ b/backend/app/services/recap.py @@ -30,6 +30,9 @@ TOP_N = 5 TOP_MOOD_TAGS = 3 +# `mood_tags` also holds genre, instrumentation and energy tags. Counting those gave a year of +# Interpol and Boards of Canada as "blues, rock, country" (measured on the NAS, 2026-10-30). +_MOOD_CATEGORY = "mood" _COMPLETED = "completed" _ERRORED = "errored" @@ -45,6 +48,8 @@ class RecapTrack: artist: str | None plays: int minutes: float + # Internal: lets the track show its artist's canonical spelling. Not in the response. + artist_id: UUID | None = None @dataclass @@ -178,7 +183,10 @@ async def compute_recap( minutes = r.played_seconds / 60 track = tracks.setdefault( - r.track_id, RecapTrack(id=r.track_id, title=r.title, artist=r.artist, plays=0, minutes=0) + r.track_id, RecapTrack( + id=r.track_id, title=r.title, artist=r.artist, plays=0, minutes=0, + artist_id=r.canonical_artist_id, + ), ) track.plays += 1 track.minutes += minutes @@ -232,7 +240,9 @@ async def compute_recap( play_counts = Counter(r.track_id for r in completed) recap.sound = await _sound(db, play_counts) - recap.first_listens = await _first_listens(db, profile_id, start, {r.track_id for r in rows}) + recap.first_listens = await _first_listens( + db, profile_id, start, {r.track_id for r in completed} + ) recap.favorites_added = await db.scalar( select(func.count()).where( ProfileFavorite.profile_id == profile_id, @@ -244,13 +254,22 @@ async def compute_recap( async def _canonical_names(db: AsyncSession, recap: Recap) -> None: - """Show the canonical spelling, not whichever tag happened to be played first.""" - artist_ids = [a.id for a in recap.top_artists if a.id is not None] + """Show the canonical spelling, not whichever tag happened to be played first. + + Tracks as well as artists: otherwise one slide says "Boards Of Canada" and the next + "Boards of Canada". + """ + artist_ids = {a.id for a in recap.top_artists} | {t.artist_id for t in recap.top_tracks} + artist_ids.discard(None) if artist_ids: rows = await db.execute(select(Artist.id, Artist.name).where(Artist.id.in_(artist_ids))) names: dict[UUID, str] = {row.id: row.name for row in rows} for artist in recap.top_artists: - artist.name = names.get(artist.id, artist.name) + if artist.id is not None: + artist.name = names.get(artist.id, artist.name) + for track in recap.top_tracks: + if track.artist_id is not None: + track.artist = names.get(track.artist_id, track.artist) if recap.top_album is not None and recap.top_album.id is not None: name = await db.scalar(select(Album.name).where(Album.id == recap.top_album.id)) recap.top_album.name = name or recap.top_album.name @@ -284,7 +303,7 @@ async def _sound(db: AsyncSession, play_counts: Counter[UUID]) -> RecapSound: if a.key: keys[a.key] += weight for tag in a.mood_tags or []: - if tag.get("tag"): + if tag.get("tag") and tag.get("category") == _MOOD_CATEGORY: tags[tag["tag"]] += weight if keys: sound.key = keys.most_common(1)[0][0] @@ -295,7 +314,10 @@ async def _sound(db: AsyncSession, play_counts: Counter[UUID]) -> RecapSound: async def _first_listens( db: AsyncSession, profile_id: UUID, start: datetime, heard: set[UUID] ) -> int: - """Tracks heard in the window that had never been heard before it. + """Tracks played through in the window that had never been heard before it. + + `heard` is the window's completed tracks, so this is a subset of `unique_tracks`. Counting + skips too gave 1,457 first listens against 1,322 tracks on the NAS, which reads as an error. "Before" reaches back past the trustworthy cutoff on purpose: a play in July is unreliable about how much was heard, not about whether the track was heard at all. diff --git a/backend/tests/test_recap.py b/backend/tests/test_recap.py index 07311e66..269d02ec 100644 --- a/backend/tests/test_recap.py +++ b/backend/tests/test_recap.py @@ -166,6 +166,22 @@ async def test_artists_are_grouped_by_canonical_artist_across_tracks(async_db): assert recap.unique_artists == 2 +@pytest.mark.asyncio +async def test_a_top_track_shows_its_artists_canonical_spelling(async_db): + """Not "Boards Of Canada" on one slide and "Boards of Canada" on the next.""" + profile = await insert_test_profile(async_db) + await insert_test_track(async_db, title="Roygbiv", artist="Boards of Canada", album="A") + track = await insert_test_track( + async_db, title="All Reason Departs", artist="Boards Of Canada", album="B" + ) + await _plays(async_db, profile, track, 2) + await async_db.commit() + + recap = await compute_recap(async_db, profile.id, 2026, UTC, now=NOW) + + assert recap.top_tracks[0].artist == recap.top_artists[0].name == "Boards of Canada" + + # ── Time, sound, firsts (point 6) ────────────────────────────────────── @@ -193,11 +209,18 @@ async def test_sound_is_weighted_by_how_often_each_track_was_played(async_db): quiet = await insert_test_track(async_db, title="Quiet") await insert_test_analysis(async_db, loud.id, { "bpm": 140.0, "energy": 0.9, "key": "A minor", - "mood_tags": [{"tag": "aggressive", "category": "mood", "confidence": 0.4}], + "mood_tags": [ + {"tag": "aggressive", "category": "mood", "confidence": 0.4}, + # Outnumbers both moods; a genre is not a mood, so it must not be counted. + {"tag": "blues", "category": "genre", "confidence": 0.5}, + ], }) await insert_test_analysis(async_db, quiet.id, { "bpm": 70.0, "energy": 0.1, "key": "C major", - "mood_tags": [{"tag": "calm", "category": "mood", "confidence": 0.4}], + "mood_tags": [ + {"tag": "calm", "category": "mood", "confidence": 0.4}, + {"tag": "blues", "category": "genre", "confidence": 0.5}, + ], }) await _plays(async_db, profile, loud, 3) await _plays(async_db, profile, quiet, 1) @@ -228,6 +251,22 @@ async def test_a_first_listen_is_a_track_never_heard_before_the_window(async_db) assert recap.first_listens == 1 +@pytest.mark.asyncio +async def test_a_track_only_skipped_is_not_a_first_listen(async_db): + """First listens are a subset of the tracks played, never more of them.""" + profile = await insert_test_profile(async_db) + played = await insert_test_track(async_db, title="Played") + skipped = await insert_test_track(async_db, title="Skipped") + await _plays(async_db, profile, played, 1) + await _plays(async_db, profile, skipped, 1, outcome="skipped", played_seconds=10) + await async_db.commit() + + recap = await compute_recap(async_db, profile.id, 2026, UTC, now=NOW) + + assert recap.first_listens == 1 + assert recap.first_listens <= recap.unique_tracks + + @pytest.mark.asyncio async def test_favourites_added_in_the_window_are_counted(async_db): profile = await insert_test_profile(async_db) diff --git a/docs/decisions/ADR-0156-a-recap-is-a-summary-of-a-window-of-play-events.md b/docs/decisions/ADR-0156-a-recap-is-a-summary-of-a-window-of-play-events.md index 8020dd40..731cac10 100644 --- a/docs/decisions/ADR-0156-a-recap-is-a-summary-of-a-window-of-play-events.md +++ b/docs/decisions/ADR-0156-a-recap-is-a-summary-of-a-window-of-play-events.md @@ -10,6 +10,16 @@ Implementation: (`compute_recap`, `recap_window`, `RECAP_MIN_PLAYS`), `backend/app/api/routes/recap.py`, and `tests/test_recap.py`, which has 18 tests. Removing the cutoff or the `errored` filter fails 7 of them. The change is additive and the contract stays at version 1. +- **First run against the NAS, 2026-10-30** (#421 deployed). The totals matched a SQL count + exactly: 2,066 plays and 9,773.2 minutes since August 1. Three defects were found and fixed: + - `first_listens` counted skipped tracks too, giving 1,457 against 1,322 `unique_tracks`. It now + counts only tracks played through, so it is a subset of `unique_tracks`. + - Point 6's "mood tags" counted every category in `mood_tags`, so the year read as "blues, rock, + country". Only `category == "mood"` is counted now. + - Top tracks showed the file's artist tag, not the canonical artist name. +- **Point 6's raw medians are not yet meaningful on their own.** The whole library's median energy + is 0.83, valence 0.85 and danceability 0.15, so "0.85 energy" means "typical for this library". + A client must not present these values as absolutes. Extends [ADR-0004](ADR-0004-listening-feedback-is-event-sourced.md)