Skip to content

feat(api): add Discover Weekly mix endpoint - #1025

Merged
dylanjeffers merged 1 commit into
mainfrom
feat/discover-weekly
Aug 24, 2026
Merged

feat(api): add Discover Weekly mix endpoint#1025
dylanjeffers merged 1 commit into
mainfrom
feat/discover-weekly

Conversation

@dylanjeffers

Copy link
Copy Markdown
Contributor

Adds GET /v1/users/:id/discover-weekly — a fixed-size, taste-matched track mix: tracks the listener hasn't heard, weighted toward artists they don't already follow.

Why it isn't a stored playlist

Audius playlists are on-chain entities, so minting one per user per week isn't viable. Instead the query is deterministic given (user_id, iso_year, iso_week), and a hashtextextended seed on that tuple rotates the mix weekly without random(). Cached 6h; entries are immutable for their key, so the TTL only bounds memory.

Why it isn't the For You feed

Reuses /feed/for-you's genre-affinity and engagement terms, but:

  • inverts the in-network weight (followed 0.70 vs unfollowed 1.25)
  • hard-excludes played/saved rather than soft-penalizing
  • drops the recency half-life
  • caps one track per artist (For You allows 3)

The product difference is artifact-vs-feed, not ranking quality.

Known limitations

  • Mid-week drift. The played-exclusion has no upper time bound, so the mix can shrink as the listener plays through it, once the 6h cache expires. Fixing it properly needs a stored track list per (user, year, week) — which also gives the mix a URL. Follow-up.
  • Not profiled against production-scale data. The query pulls 700 candidates and joins against plays/saves/follows plus two aggregate tables — the shape that caused the upstream timeouts in perf(for-you): cap my_saved_artists to 200 most-recent #805/perf(for-you): bound my_artist_affinity and follow_set by recency #806. Every user-side CTE is bounded the same way those fixes bounded them, but this wants an EXPLAIN ANALYZE against a replica with a heavy listener before it takes real traffic.

Tests

5 new tests covering every filter, the one-per-artist cap, followed-artist demotion, cold start, and week-to-week rotation. Full suite green.

🤖 Generated with Claude Code

GET /v1/users/:id/discover-weekly returns a fixed-size, taste-matched
track mix: tracks the listener hasn't heard, weighted toward artists they
don't already follow.
Deliberately not a stored playlist. Audius playlists are on-chain
entities, so minting one per user per week isn't viable; instead the
query is deterministic given (user_id, iso_year, iso_week) and a
hashtextextended seed on that tuple rotates the mix weekly without
random(). Cached 6h -- entries are immutable for their key, so the TTL
only bounds memory.
Reuses the For You feed's genre-affinity and engagement terms but
inverts the in-network weight (followed 0.70 vs unfollowed 1.25),
hard-excludes played/saved rather than soft-penalizing, drops the recency
half-life, and caps one track per artist. The product difference from
/feed/for-you is artifact-vs-feed, not ranking quality.
Known limitation: the played-exclusion has no upper time bound, so the
mix can shrink mid-week as the listener plays through it once the cache
expires. Fixing that properly needs a stored track list per
(user, year, week), which also gives the mix a URL. Tracked as follow-up.
Not yet profiled against production-scale data.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
@dylanjeffers
dylanjeffers merged commit d64c3bb into mainAug 24, 2026
2 checks passed
@dylanjeffers
dylanjeffers deleted the feat/discover-weekly branch August 24, 2026 20:28
dylanjeffers added a commit that referenced this pull request Aug 24, 2026
`/users/:id/discover-weekly` (shipped in #1025) returns an **empty mix
for every user** in production. Verified against prod across six
accounts — all returned `{"data": []}`.
## Root cause
Two bugs compounded.
`track_trending_scores` holds two populations:
| rows | read by | median track age |
|---|---|---|
| genre-carrying | `/tracks/trending`, `/tracks/trending/underground` |
**~4 days** (max 12) |
| null/empty genre | this endpoint, and `/feed/for-you` | **~5–6 years**
|
The candidate CTEs matched only the null-genre rows, so every candidate
was ancient — then the 365-day age cutoff in the filter stage dropped
all of them. Empty result, every time.
Measured on prod: the 18 out-of-network tracks in deadmau5's for-you
feed ranged from **1822 to 2175 days old**, while `/tracks/trending`
top-100 ranged 0–10 days.
## Fix
Drop the genre predicate from both candidate CTEs, so they read the live
rows the same way the trending endpoints do. The age cutoff stays but is
no longer load-bearing.
## Why no test caught it
Every existing test seeded score rows without a genre, so the fixtures
only ever exercised the stale-population path. Adds one that seeds a
genre-carrying row — the shape that actually reaches the query in
production.
## Related, not fixed here
`/users/:id/feed/for-you` uses the same `genre IS NULL OR genre = ''`
predicate on its trending and underground candidate sources ([lines 327
and
346](https://github.com/AudiusProject/api/blob/main/api/v1_users_feed_for_you.go#L327)).
It doesn't return empty because it has in-network and playlist sources
to fall back on, but its trending candidates are very likely the same
5–6-year-old tracks — being ranked by a scorer with a 48-hour recency
half-life. Worth a separate look.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
dylanjeffers added a commit to AudiusProject/apps that referenced this pull request Aug 24, 2026
Wires `GET /users/:id/discover-weekly` into the Explore page on web and
native, rendered as a collection card so the mix reads like a playlist.
Depends on [api#1025](AudiusProject/api#1025)
and [api#1026](AudiusProject/api#1026) — both
merged and live in production.
## What's here
- `useDiscoverWeekly` — a plain `useQuery`, not infinite. The mix is a
fixed-size artifact, not a lineup you scroll; there is no page 2.
- `sdk.users.getDiscoverWeekly` — hand-written pending the next SDK
regen, same as `getSuggestedFollows` in #14562, since `npm run gen`
pulls the spec from a running node.
- Web section (desktop + mobile web) and a parallel native section,
since `packages/mobile` composes its own Explore screen.
## Three decisions worth reviewing
**The artwork is a checked-in asset.** The mix has no `playlist_id` to
hang cover art on — Audius playlists are on-chain entities and the mix
is computed per request. Styled to sit alongside the Hot & New playlist
art.
**Clicking plays instead of navigating.** No permalink to navigate to,
so the card queues all 30 starting at the top. Closest thing to playlist
behavior without a route.
**No section heading or Carousel.** One card, so the scroll affordance
is dead weight and a heading would repeat the card's own title.
## Known gaps
- **The card has playlist affordances without playlist substance** —
can't be favorited, reposted, shared, or linked to. Closing that needs
the stored track-list per `(user, year, week)`, which would also fix the
mid-week drift noted in api#1025.
- **Not visually verified in the running app** at time of opening — the
section is signed-in-only.
## Verification
`tsc` and eslint clean across web, mobile, and sdk. The one `common`
error (`getDiscoverWeekly` not on `UsersApi`) is the known SDK-dist
parity issue — the built `dist/index.d.ts` types `sdk.users` as the
generated class, so the already-merged `useSuggestedFollows` produces an
identical error. Resolves on the next regen.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
---------
Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
Sign up for freeto join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant

@dylanjeffers