Skip to content

GET /library/info reports plays from the dead library.plays column: album detail shows "0 plays" for a release search reports 135 #2397

Description

@jakebromberg

Problem

Kate Bailey, on the station Slack (2026-09-08):

Ok another small thing -- in the card catalog, it always seems to say 0 plays on the release popup window, e.g. dj.wxyc.org/dashboard/album/56696 (this release has 135 plays in search results)

The album detail modal reports 0 plays for every release. The same release in the search results reports its real count. This is BS#1489's defect, unfixed on a second endpoint.

Root cause

library.plays is a dead physical column that nothing maintains — it is 0 for every row. The real per-album count lives in the album_plays materialized view. BS#1489 fixed GET /library/query by joining the MV and sourcing the value from it:

const albumPlaysJoin = sql`LEFT JOIN ${album_plays} ON ${album_plays.album_id} = ${library_artist_view.id}`;
const playsColumn = sql`COALESCE(${album_plays.plays}, 0)`;

// BS#1489: `library_artist_view.plays` projects the physical `library.plays`
// column, which nothing maintains — it is 0 for every row, so `sort=plays`
// ordered a constant (silently a no-op, falling through to the secondary
// tiebreak). The real per-album play count lives in the `album_plays` MV
// (migration 0059) — the same live `COUNT(*)` over flowsheet track entries the
// catalog export and the tsvector ranker read. Scope a LEFT JOIN to it into the
// search query and source `plays` from it, rather than redefining the view (the
// view is read by several other code paths; this keeps the blast radius on the
// one surface with the bug). The join is 1:1 (unique index on
// `album_plays.album_id`), so it never changes the result set or `COUNT(*)`;
// COALESCE 0 covers never-played albums, which have no MV row.
const albumPlaysJoin = sql`LEFT JOIN ${album_plays} ON ${album_plays.album_id} = ${library_artist_view.id}`;
const playsColumn = sql`COALESCE(${album_plays.plays}, 0)`;

getAlbumFromDB — the query behind GET /library/info — was not part of that change and still projects the dead column. Note it feeds five endpoints, not one: GET /library/info (library.controller.ts:1645), PATCH /library/:id (:1896, :1938), markMissing (:1996) and markFound (:2006). All five ship plays: 0 today, and all five are fixed by the same edit:

plays: library.plays,

Route:

library_route.get('/info', requirePermissions({ catalog: ['read'] }), libraryController.getAlbum);

LIBRARY_VIEW_PROJECTION carries the same plays: library.plays — at library.service.ts:1763, not 3202 as this ticket first said. Leave it alone for this fix (investigated; see the comment below). It is a hand-written base-table mirror, not the view definition, and changing it means widening a satisfies Record<keyof …, Column> constraint that rejects an SQL expression, making the album_plays join unconditional, adding it to LIBRARY_VIEW_JOINS_RAW (which has none), and re-verifying the CTA arm's prepended joins. That is a different and larger change than the reported bug, and BS#1489 made exactly this argument in-repo (library-search.service.ts:100-103).

Desired end state

GET /library/info?album_id=56696 reports the same play count the search results report for the same release. No client change: dj-site already renders album.plays ?? 0 and will show the real number once the endpoint sends one.

Suggested approach

Add the album_plays LEFT JOIN to getAlbumFromDB and project COALESCE(album_plays.plays, 0) in place of library.plays. The join is on album_plays.album_id = library.id and is a left join on a unique key, so it cannot change the row count. getAlbumByLegacyId calls straight through to getAlbumFromDB, so the legacy-keyed permalink front door is fixed by the same edit.

Check the MV's refresh cadence (album-plays-refresh.service.ts) and state it in the PR — the detail modal will be as fresh as the search results and no fresher, which is fine, but it should be a known property rather than a surprise.

Constraints

  • Widening the value is not a contract change, and the spec is already on the fix's side: AlbumDetail.plays is declared at wxyc-shared/api.yaml:2591-2593 as "Station play count for this release" — optional, non-nullable, no bounds. Shipping 0 is the implementation violating that description; shipping the real count brings it into compliance. There is no runtime validator in either package to trip.
  • Consumers are safe. Only dj-site reads plays off a detail response — AlbumCard.tsx:226, {album.plays ?? 0} plays, no zero-branch and no sentinel. iOS and Android have zero readers (wxyc-ios-64 declares the field on generated models nothing references; the other three repos have no matches). The zero-special-casing that does exist (Results/Result.tsx:168,173, MobileResult.tsx:51) is on the search path and is not fed by /library/info.
  • Read-only change. No migration, no write path.

Acceptance criteria

  • GET /library/info?album_id=<id> and GET /library/query return the same plays for the same release.
  • Verified against release 56696 specifically (135 plays per Kate's report).
  • GET /library/info?legacy_release_id=<id> fixed by the same change (it routes through getAlbumFromDB).
  • Test covering a release with a non-zero count and one with none (COALESCE → 0, not null).
  • Decision recorded on whether LIBRARY_VIEW_PROJECTION.plays is changed or deliberately left. Investigated: leave it, track separately. See above.
  • AlbumCard.tsx:226 hardcodes the plural, so a genuine count of 1 renders "1 plays" — unreachable today, reachable the moment this ships. Fix it in dj-site alongside, or file it; do not let the first correct value be a grammar bug.
  • Note in the PR that this also repairs a pre-existing dj-site defect: patchSearchCaches.ts:137-146 splices the album-detail row wholesale into the search cache (:187-189), so classifying rotation from the modal currently inserts a plays: 0 row into a search page — rendering at a wrong sort slot. The existing.plays ?? updated.plays guard at patchSearchResult.ts:59 does not cover that path.
  • Confirm the refresh job is actually running in prod. It isalbum-plays-refresh last ran 2026-09-08 18:14 PDT, 32 min before the check. The modal will be at most ~1h stale and exactly as stale as the search results it is compared against. The design flaw stands (first refresh at +1 interval, not at boot, and nothing reads cronjob_runs) but is not currently mis-serving anyone — worth a separate hardening ticket, not a blocker here.

Related

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    bugSomething isn't working

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions