Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
36 changes: 29 additions & 7 deletions backend/app/services/recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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,
Expand All @@ -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
Expand Down Expand Up @@ -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]
Expand All @@ -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.
Expand Down
43 changes: 41 additions & 2 deletions backend/tests/test_recap.py
Original file line number Diff line number Diff line change
Expand Up @@ -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) ──────────────────────────────────────


Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
Loading