Skip to content

fix(playlist): decouple guess autocomplete pool from track playability (closes #22) - #23

Merged
Privex-chat merged 2 commits into
mainfrom
fix/autocomplete-pool-desync
Jul 29, 2026
Merged

fix(playlist): decouple guess autocomplete pool from track playability (closes #22)#23
Privex-chat merged 2 commits into
mainfrom
fix/autocomplete-pool-desync

Conversation

@Privex-chat

@Privex-chat Privex-chat commented Jul 29, 2026

Copy link
Copy Markdown
Owner

Closes #22.

The bug

On a large playlist most tracks can't be guessed at all. The dropdown is the only submission path, so the player is forced to skip and lose the round.

The reporter flagged the pool/answer desync as a "secondary concern" they hadn't reproduced. It is actually the primary cause, and it isn't a rare race — it's the default path for any playlist over ~100 tracks:

  1. A full fetch stores every track in playlist_tracks, but only the ~100 previews Spotify's playlist embed returns are resolved at that moment.
  2. fetch_complete is computed from the stored track list, so it's TRUE ("we have the whole playlist") while ~90% of previews are still pending — server.py:943.
  3. HomePage bailed out of its progress poll on exactly that flag (HomePage.js:182), conflating "we have the whole track list" with "every preview resolved". So: no progress bar, and freshestPlaylist() never re-fetched before starting.
  4. The background fill kept resolving previews, so /sessions/start drew answers from a live pool several times larger than the client's snapshot.

The server already knew — it returns pending_preview_retry: 850 and a warning string. HomePage never rendered either.

The fix

Make the pool structurally a superset of the answer set, rather than one that happens to match when the clocks agree.

  • load_playlist returns every track linked to the playlist. Playability is reported as counts only (total_tracks, pending_preview_retry, pool_size) — never per track. Answers are still drawn only from playable tracks; that filter stays where it belongs.
  • Both Spotify fetch paths emit the same shape via _public_track, and _finalize_and_save always serves the DB read-back — so there is exactly one builder for the user-facing pool and it can't differ by which path served the request.
  • HomePage keys its poll off the exact pending count the API returns instead of inferring it from totals that don't line up past the fetch cap.

Two omissions from the response are deliberate: preview_url (the CDN URL identifies a clip by comparing bytes) and any per-track playable flag (only playable tracks can be answers, so it would let a client filter the pool down to candidates). Neither was read by any client code.

Verification

Read-only queries against the production DB, plus a replay of the real GamePage dropdown logic over a real pool and real server-drawn answers.

Invariant — across the 40 largest cached playlists, answer candidates falling outside the new pool: 0. Across those same playlists, 5,400 tracks can currently become an answer while absent from an old-style pool (worst case 638 of 1500 — 43%).

End-to-end, playlist 61gF4ehWZJT8A4tnTV3Kxa (1251 tracks, 588 playable at session start, 96 in the client's snapshot):

pool answers typeable
old (snapshot from load time) 3 / 25
new (whole playlist) 25 / 25

That 88% failure rate matches the reporter's experience. Typing half a title surfaces the answer 25/25.

Supporting changes

Needed because the pool roughly doubled and would otherwise regress latency and Spotify egress:

  • Ranked dropdown (exact > prefix > word-start > substring; title over artist) instead of the first 7 found. Without it the exact title loses its slot to incidental substring hits on a large pool — same symptom, different cause. Unit-tested.
  • /playlist returns stored album art inline — the whole-pool art prefetch drops from 13 requests to 0 on that playlist.
  • /tracks/art is now a pure DB read. It used to scrape one Spotify embed page per missing track, which at pool scale is ~1000 scrapes per game start, competing with the preview fill for the same endpoint and inviting the 429 that stalls previews. The fill now harvests cover art from the embed page it already loads, so misses resolve on their own.
  • save_album_art_to_db does one batched UPDATE instead of one per track.
  • Use the existing Fisher-Yates shuffleArray; sort() with a random comparator is not a uniform shuffle (the correct helper was already defined and unused).
  • Show the playable/total split pre-game, counted against the pool actually held rather than Spotify's stated total, so it never promises tracks we don't have.

Net payload: +36 KB gzipped for 2.1× the pool, against 13 fewer sequential round-trips.

Notes for review

  • craco build clean; 32 frontend tests pass (5 new); matching.py self-check passes.
  • The pool ⊇ answers invariant lives in SQL across two modules, so it needs a live DB to test. There's no backend integration-test harness in the repo, so it's verified by the read-only queries above rather than by a committed test — worth adding a harness separately.
  • The progress bar's denominator still comes from Spotify's stated total, so it can't reach 100% on playlists past the 1500-track metadata cap. Pre-existing and cosmetic; belongs with a fix for that cap.
  • Not addressed here, filed for later batches: sessions.py hand-rolls artist matching instead of using matching.py (breaks every feat./& collab in artist mode), the round-audio prefetch starts the next round's scoring timer on the reveal screen, and ALLOW_LEGACY_SCORE_SUBMIT still defaults on.

🤖 Generated with Claude Code

Summary by CodeRabbit

  • New Features

    • Playlist results now include the full guessable track pool, with separate counts for playable tracks and previews still being processed.
    • Album artwork and track previews can be filled in automatically in the background.
    • Guess suggestions are now ranked by relevance, with improved limits and ordering.
    • Home screen messaging now clearly distinguishes playable tracks from total guessable tracks.
    • Added updated translations for these playlist status messages.
  • Documentation

    • Clarified playlist availability counts, preview processing, and album-art behavior in the API documentation.

Closes #22.

On a large playlist most tracks could not be guessed at all. The dropdown is
the only submission path, so the player was forced to skip and lose the round.

Root cause is a desync between two differently-timed queries, and it is not a
rare race — it is the default path for any playlist over ~100 tracks:

  1. A full fetch stores every track in playlist_tracks, but only the ~100
     previews the playlist embed returns are resolved at that moment.
  2. fetch_complete is computed from the stored track list, so it is TRUE
     ("we have the whole playlist") while ~90% of previews are still pending.
  3. HomePage bailed out of its progress poll on exactly that flag, conflating
     "we have the whole track list" with "every preview resolved". No progress
     bar, and freshestPlaylist() never re-fetched before starting.
  4. The background fill kept resolving previews, so /sessions/start drew
     answers from a live pool several times larger than the client's snapshot.

Measured on a real cached playlist (1251 tracks, 588 playable at session
start, 96 in the client's snapshot): 3 of 25 server-drawn answers were
typeable. After this change, 25 of 25.

The pool is now structurally a superset of the answer set rather than one that
happens to match when the clocks agree:

- load_playlist returns every track linked to the playlist. Playability is
  reported as counts only (total_tracks, pending_preview_retry, pool_size),
  never per track, and answers are still drawn only from playable tracks.
  Verified across the 40 largest cached playlists: 0 answer candidates fall
  outside the new pool.
- Both Spotify fetch paths emit that same shape via _public_track, and
  _finalize_and_save now always serves the DB read-back, so there is exactly
  one builder for the user-facing pool.
- HomePage keys its poll off the exact pending count the API returns instead
  of inferring it from totals that don't line up past the fetch cap.

Two omissions from the response are deliberate: preview_url (the CDN URL
identifies a clip by comparing bytes) and any per-track playable flag (only
playable tracks can be answers, so it would let a client filter the pool down
to candidates). Neither was read by any client code.

Supporting changes, needed because the pool grew ~2x and would otherwise
regress latency and Spotify egress:

- Rank dropdown matches (exact > prefix > word-start > substring, title over
  artist) instead of taking the first 7 found. Without this the exact title
  loses its slot to incidental substring hits on a large pool — the same
  symptom, different cause. Covered by unit tests.
- /playlist returns stored album art inline, so the whole-pool art prefetch
  drops from 13 requests to 0 on that playlist.
- /tracks/art is a pure DB read. It used to scrape one Spotify embed page per
  missing track, which at pool scale meant ~1000 scrapes per game start,
  competing with the preview fill for the same endpoint. The fill now harvests
  cover art from the embed page it already loads, so misses resolve on their
  own.
- save_album_art_to_db does one batched UPDATE instead of one per track.
- Use the existing Fisher-Yates shuffleArray for the pool; sort() with a
  random comparator is not a uniform shuffle.
- Report the playable/total split in the pre-game UI, counted against the pool
  actually held rather than Spotify's stated total, so it never promises
  tracks we don't have.

Net payload on that playlist: +36 KB gzipped for 2.1x the pool, against 13
fewer sequential round-trips.

Co-Authored-By: Claude <noreply@anthropic.com>
@vercel

vercel Bot commented Jul 29, 2026

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
audyn Ready Ready Preview Jul 29, 2026 10:00pm

@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

Warning

Review limit reached

@Privex-chat, you've reached your PR review limit, so we couldn't start this review.

Next review available in: 41 minutes

Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available.
You're only billed for reviews past your plan's rate limits ($0.25/file).

How can I continue?

After more reviews become available, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews.

How do review limits work?

CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability.

For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window.

Please refer docs for additional details.

Review details
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 5e16f85f-d9fe-45f5-9359-fd2d7ee8c723

📥 Commits

Reviewing files that changed from the base of the PR and between 437ec9c and 23434c5.

📒 Files selected for processing (1)
  • backend/server.py
📝 Walkthrough

Walkthrough

The playlist API now returns the complete guessable track pool while separating playable and pending-preview counts. Preview recovery also backfills album art. The frontend ranks autocomplete suggestions and displays localized playable-versus-total playlist information.

Changes

Playlist pool and guessing flow

Layer / File(s) Summary
Playlist pool contract and response shaping
backend/db_playlists.py, backend/server.py
Playlist responses retain tracks without previews, omit per-track preview fields, and separate pool_size, playable total_tracks, and pending retries.
Preview and album-art recovery
backend/server.py, backend/db_playlists.py
Preview scraping extracts cover art, persists recovered states and art, and updates the art cache through batched database writes.
Ranked autocomplete matching
frontend/src/lib/search.js, frontend/src/lib/search.test.js, frontend/src/pages/GamePage.js
Artist and track suggestions use ranked matching, shuffled tie ordering, and a maximum suggestion count.
Playability UI contract
frontend/src/pages/HomePage.js, frontend/src/i18n/*, docs/api.md
Polling uses pending preview retries, the UI shows playable-versus-pool counts, and documentation and translations describe the updated API semantics.

Estimated code review effort: 4 (Complex) | ~45 minutes

Sequence Diagram(s)

sequenceDiagram
  participant HomePage
  participant PlaylistAPI
  participant PreviewFill
  participant Database
  HomePage->>PlaylistAPI: request playlist pool and counts
  PlaylistAPI->>Database: load all playlist tracks
  Database-->>PlaylistAPI: tracks, playable count, pending retries
  PlaylistAPI-->>HomePage: playlist response
  HomePage->>PreviewFill: poll while retries remain
  PreviewFill->>Database: save recovered previews and album art
Loading

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Out of Scope Changes check ⚠️ Warning The PR also changes album-art backfill, /tracks/art lookup behavior, and batched art updates, which are outside #22. Split the album-art and /tracks/art work into a separate PR, or add those requirements to the linked issue scope.
Docstring Coverage ⚠️ Warning Docstring coverage is 57.89% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly describes the main fix: decoupling the guess pool from preview playability.
Linked Issues check ✅ Passed The playlist now returns the full guess pool while preserving playable-only answer selection and preview retry tracking.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/autocomplete-pool-desync

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
backend/db_playlists.py (1)

47-115: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Use the shared retry cap in the pending-count queries.

backend/db_playlists.py now uses MAX_PREVIEW_RETRIES, but the direct “pending” queries in backend/server.py (/playlist-status and /playlist/.../wait-for-previews) still hard-code t.preview_retry_count < 5. If the retry limit is tuned in backend/preview_store.py, those endpoints can return a different pending value than pending_preview_retry, creating pool/count desync for clients.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/db_playlists.py` around lines 47 - 115, Update the direct
pending-count queries in the /playlist-status and
/playlist/.../wait-for-previews handlers in backend/server.py to use the shared
MAX_PREVIEW_RETRIES value instead of the hard-coded retry limit. Ensure both
endpoints apply the same cap as the pending_count calculation in the
playlist-loading flow, preserving synchronized pending and pool counts when the
cap changes.
🧹 Nitpick comments (1)
backend/server.py (1)

236-267: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Consider a short negative-cache TTL for art misses.

A cache miss that's also a DB miss triggers a get_album_art_from_db query every call, with no negative caching. During an active preview fill on a large playlist, the frontend polls status/art repeatedly while many tracks are still missing art, so the same not-yet-found IDs can hit the DB on every poll cycle until they resolve.

Caching a brief "not found yet" sentinel (short TTL) alongside the existing positive cache would cut this repeated DB traffic without delaying art appearance meaningfully.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@backend/server.py` around lines 236 - 267, Add a short-lived negative-cache
sentinel to fetch_album_art_for_tracks for track IDs absent from both art_cache
and get_album_art_from_db, and skip those IDs on subsequent calls until the
sentinel expires. Preserve existing positive-cache behavior and ensure
discovered URLs replace any negative entries so art appears promptly.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@backend/server.py`:
- Around line 641-679: Guard the result of parse_embed_next_data in
_scrape_one_preview before accessing nested .get() calls. Treat a falsy or
malformed parse result as a per-track failure and return the existing ("fail",
tid, "", "") tuple, ensuring the preview fill continues without raising;
preserve the current successful extraction behavior for valid embed data.

---

Outside diff comments:
In `@backend/db_playlists.py`:
- Around line 47-115: Update the direct pending-count queries in the
/playlist-status and /playlist/.../wait-for-previews handlers in
backend/server.py to use the shared MAX_PREVIEW_RETRIES value instead of the
hard-coded retry limit. Ensure both endpoints apply the same cap as the
pending_count calculation in the playlist-loading flow, preserving synchronized
pending and pool counts when the cap changes.

---

Nitpick comments:
In `@backend/server.py`:
- Around line 236-267: Add a short-lived negative-cache sentinel to
fetch_album_art_for_tracks for track IDs absent from both art_cache and
get_album_art_from_db, and skip those IDs on subsequent calls until the sentinel
expires. Preserve existing positive-cache behavior and ensure discovered URLs
replace any negative entries so art appears promptly.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 890f8d3b-4bd5-44a7-9637-e84f12b76a12

📥 Commits

Reviewing files that changed from the base of the PR and between 9e36621 and 437ec9c.

📒 Files selected for processing (12)
  • backend/db_playlists.py
  • backend/server.py
  • docs/api.md
  • frontend/src/i18n/en.js
  • frontend/src/i18n/es.js
  • frontend/src/i18n/hi.js
  • frontend/src/i18n/ja.js
  • frontend/src/i18n/ko.js
  • frontend/src/lib/search.js
  • frontend/src/lib/search.test.js
  • frontend/src/pages/GamePage.js
  • frontend/src/pages/HomePage.js

Comment thread backend/server.py
…a fill

Addresses CodeRabbit review on #23.

The nested `.get()` chain over the embed payload could raise AttributeError,
and because it sits inside an asyncio.gather fan-out without
return_exceptions, that raise discarded the whole chunk's results: none of
mark_recovered/mark_unavailable/mark_failed ran, so the offending track's retry
counter never advanced and it could knock out the same playlist's fill on every
subsequent attempt. Credit to the review for tracing that consequence.

The suggested patch (guard `if not next_data`) closes only part of it. Falsy
payloads were already safe — `{}` chains fine. The actual vectors are:

  - truthy non-dict from json.loads: `"str"`, `42`, `true`
  - an explicit null at an intermediate level, e.g. `{"props": null}`, because
    `.get(k, {})` returns the default only for a MISSING key, not a null value
  - a non-dict `entity`, which survives the chain and then raises downstream in
    `.get("audioPreview")` / _extract_cover_art

Of ten payload shapes, the current chain raises on six; the suggested guard
fixes two of those six.

So the fix is type-checked at every hop instead:

- _embed_entity() walks to the entity node and always returns a dict.
- _extract_cover_art() uses `or {}` / `or []` and skips non-dict sources, since
  a null coverArt or sources would otherwise raise on the next access.
- The parse/extract in _scrape_one_preview is wrapped so the function is total
  rather than total-by-inspection.
- gather() takes return_exceptions=True with the exceptions filtered and
  logged, per the reviewer's offer. Redundant with a total scraper, but this is
  the exact point where a raise turns into silent loss of persisted work, so
  the structural guard is worth its three lines.

Verified: a raising task now costs one track instead of all 48 in its chunk.

Also from the review: /playlist-status and /playlist/{id}/wait-for-previews
hard-coded `preview_retry_count < 5` while load_playlist uses
MAX_PREVIEW_RETRIES. #23 made that consequential — the client now starts
polling off load_playlist's pending count and reads progress off
/playlist-status's, so a change to the constant would leave the progress bar
unable to finish. Both now take the shared constant. (sessions.py has the same
literal; left alone to avoid churn against the open #24.)

Adds a `python server.py` self-check covering all ten payload shapes and the
cover-art edge cases, in the same style as matching.py and sessions.py. uvicorn
imports the module and never runs __main__. Confirmed the check fails when the
falsiness-only guard is substituted.

Declined the negative-cache nitpick for /tracks/art: #23 makes that path cold
(album art now ships inline with /playlist, so the endpoint is only hit for
tracks art hasn't reached yet), a DB miss is one indexed lookup, and a negative
sentinel would actively delay art that the preview fill is concurrently
backfilling into art_cache.

Co-Authored-By: Claude <noreply@anthropic.com>
@Privex-chat

Copy link
Copy Markdown
Owner Author

Worked through all three. Two were valid and are fixed in 23434c5; the nitpick I'm declining with reasoning.


1. Unguarded .get() chain in _scrape_one_previewvalid, fixed (and the suggested patch was incomplete)

Your impact analysis was better than mine. I'd only seen "the fill aborts"; you traced it to the part that actually matters — gather without return_exceptions discards the chunk, so no mark_recovered/mark_unavailable/mark_failed runs, the offending track's retry counter never advances, and it knocks out the same playlist on every subsequent fill. A permanently stuck pool, which would quietly undermine this PR's whole premise.

But the proposed if not next_data: return ("fail", ...) closes only part of it. Falsy payloads were already safe{}.get("props", {}) chains fine all the way down. The real vectors are elsewhere. I enumerated ten payload shapes:

payload current chain if not next_data guard now
valid ok ok ok
{} ok ok ok
null AttributeError ok ok
[] AttributeError ok ok
"unexpected" AttributeError AttributeError ok
42 AttributeError AttributeError ok
{"props":null} AttributeError AttributeError ok
{"props":{"pageProps":{"state":null}}} AttributeError AttributeError ok
entity: null ok → non-dict ok → non-dict ok
entity: [] ok → non-dict ok → non-dict ok

Six of ten raise today; the suggested guard fixes two of those six. The misses are (a) truthy non-dicts from json.loads, and (b) an explicit null at an intermediate level.get(k, {}) applies its default only when the key is missing, not when its value is null. The last two rows don't raise in the chain but return a non-dict that raises downstream in .get("audioPreview") / _extract_cover_art.

So it's type-checked at every hop instead:

def _embed_entity(next_data) -> dict:
    node = next_data
    for key in _EMBED_ENTITY_PATH:
        if not isinstance(node, dict):
            return {}
        node = node.get(key)
    return node if isinstance(node, dict) else {}

Plus _extract_cover_art hardened for null coverArt/sources and non-dict entries, and the parse/extract wrapped so _scrape_one_preview is total by construction rather than total-by-inspection.

And yes to your return_exceptions=True offer. Strictly redundant with a total scraper, but this fan-out is the precise point where a raise converts into silent loss of already-completed work, so the structural guard earns its three lines. Verified the difference directly — with a deliberately raising task in a 5-task chunk:

OLD: whole chunk lost -> RuntimeError
     -> mark_recovered/mark_failed never run; retry counters never advance
NEW: 4/5 results survived; recovered=['t1','t2','t3','t4']

2. Hard-coded retry cap in /playlist-status and /wait-for-previewsvalid, fixed

Both now take MAX_PREVIEW_RETRIES (already imported). This mattered more than it looks, and specifically because of this PR: the client now triggers its poll off load_playlist's pending_preview_retry (which uses the constant) and reads progress off /playlist-status's pending (which didn't), so bumping the constant would have left the progress bar unable to finish.

sessions.py:185 carries the same literal — left alone deliberately, since #24 is open against that file and this isn't worth the cross-PR churn. Tracked for that batch.

3. Negative-cache sentinel for fetch_album_art_for_tracksdeclining

Three reasons:

  • The path is now cold. This PR ships album art inline with /playlist, so GamePage only calls /tracks/art for tracks art hasn't reached yet. Measured on a real 1251-track playlist: 1251/1251 had art inline, so the call count went 13 → 0. The optimisation targets requests that mostly no longer happen.
  • A miss costs one indexed lookupWHERE track_id = ANY($1), ≤100 ids, once per chunk per game.
  • It would fight the fix. The preview fill now backfills cover art into both the DB and art_cache as it scrapes. A negative sentinel would suppress lookups for exactly the tracks that are actively resolving, delaying art rather than speeding anything up. The "a miss just means not fetched yet and resolves on its own" property is deliberate.

Happy to revisit if the endpoint ever shows up hot in practice.


Validation

  • New python server.py self-check covers all ten payload shapes above plus the cover-art edge cases (null coverArt, null sources, non-dict entries, the 200–400px pick, and the fallback when nothing is in range). Same __main__ convention as matching.py; uvicorn imports the module and never runs it.
  • Confirmed the check has teeth: substituting the falsiness-only guard makes it fail with AttributeError: 'str' object has no attribute 'get'; it passes with the type-checked version.
  • matching.py self-check passes, 32 frontend tests pass, craco build compiles clean.

Also picked up the RUF002 en-dash in that docstring. Leaving the two except Exception blocks (BLE001) as-is: catching broadly is the intended contract here — "any problem with this one track means mark it failed and move on" — and third-party HTML failure modes aren't enumerable. There's no ruff/lint config in the repo, so this isn't an enforced rule.

@Privex-chat
Privex-chat merged commit 246cf50 into main Jul 29, 2026
3 checks passed
Privex-chat added a commit that referenced this pull request Jul 31, 2026
…or docs

Addresses CodeRabbit review on #24.

reveal construction (line 313)
------------------------------
_is_guess_correct reads `entry.get("artist") or ""`, then seven lines later the
same dict was subscripted directly as entry["name"] / entry["artist"]. Two
functions reading one dict disagreeing about whether its keys are guaranteed is
the inconsistency worth removing — my own diff introduced it.

To be accurate about severity: the KeyError is not reachable today. Every entry
start_session writes carries name and artist, sourced from tracks columns that
are NOT NULL DEFAULT '', and sessions expire after 2h so no older JSONB shape
survives. This is consistency, not a live fix. It is still worth doing: `entry`
is deserialized JSONB whose shape has changed before (see _META_KEYS), and the
failure mode would be a 500 on a guess the player already committed to.

Extracted _reveal_for(tid, entry) rather than inlining .get() calls, so the
logic is testable without a database — same reasoning that produced
_is_guess_correct in this PR. Response contract is unchanged: the same four
keys, in the same shape the frontend reads.

The review asked for an endpoint-level test. Declining that specific form:
there is no pytest, no test database and no CI in this repo, so it would mean
standing up the first backend HTTP test harness inside a review fixup, which is
a scope decision for the owner rather than something to smuggle in here. The
extracted helper is covered by the existing python sessions.py self-check
instead, at the layer the bug would live in — full entry, empty entry,
all-null-values entry, and partial entry. Confirmed the check fails with
KeyError: 'name' when the direct subscripts are restored.

separator docstring (line 80)
-----------------------------
The list rendered `×` and `x` adjacent to each other. They are two different
rules in _ARTIST_SEP — the multiplication sign splits anywhere, the letter x
only at word boundaries — so a reader who cannot tell them apart loses the
point of the sentence. Spelled the symbols out in words and pointed at
matching._ARTIST_SEP for the exact pattern. The regex itself keeps the literal
`×`; it is functional.

also
----
Followed through on the MAX_PREVIEW_RETRIES consistency I said I would pick up
in this batch when it came up on #23: start_session's pending_count was the
third site hard-coding `preview_retry_count < 5`. It feeds the client's
"waiting for previews" retry, so it has to mean the same thing as the preview
store and /playlist-status.

Co-Authored-By: Claude <noreply@anthropic.com>
Sign up for free to 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.

Tracks without a Spotify preview are missing from the guess autocomplete, making them impossible to answer

1 participant