Skip to content

Public archive renders 354 DJ identities for 311 DJs: case-fold duplicates, multi-token co-host composites, 96 nameless shows, and 41 raw HTML entities #2402

Description

@jakebromberg

Revised 2026-09-09. The original body claimed a stored-vs-served discrepancy that does not exist, and understated three of the four defects. Both corrected below; the investigation that produced the corrections is in this comment. Figures are now from a 40-week scan rather than a 12-week one.

Problem

The DJ identity served on the public playlist archive renders 354 distinct strings for 311 distinct DJs. One DJ appears as two different people, co-hosted shows attribute to a single fabricated composite handle, 4% of shows are nameless, and a cohort of shows renders raw HTML numeric character references as literal text. None of this blocked the tubafrenzy cutover; all of it makes the archive — and any roster query built on it — quietly wrong.

This became listener-visible on 2026-09-08, when WXYC/website#233 restored wxyc.org/playlists/archive (~1,546 req/day plus its ~1,906/day drill-in) and wxyc.org/airplay-search (~181/day). Both render this data.

Measured 2026-09-09 against production

Via GET https://api.wxyc.org/flowsheet/range over 40 consecutive 7-day windows, 2025-12-03 → 2026-09-09 PT — 2,399 shows, 354 distinct non-null dj_name values. Reproducible with the script in "Notes for implementer". These figures postdate the #2281 scrub of 2026-09-08.

1. Case-fold duplicates — 38 collisions across 354 names. Same DJ, two identities. Three of the 38 are three-way rather than two-way. A twelve-week slice of the same method gives 17 collisions across 177 names, so this scales with the window rather than being a fixed backlog. Examples:

annie / Annie                      DJ Diggler / DJ DIGGLER
augustine / Augustine              dj eureka! / DJ Eureka!
dj almost famous / DJ Almost Famous    Dj Made to Measure / DJ Made to Measure
DJ Beaux / DJ BEAUX                dj mothership / DJ Mothership
DJ blue / DJ Blue                  dj ovni / DJ OVNI
Dj Derpa / DJ Derpa                DJ shadowboxer / DJ Shadowboxer

A listener browsing the archive sees DJ Diggler and DJ DIGGLER as two DJs, and a roster query counting distinct DJs over-counts by 38. This is also what made the pre-cutover accountless-DJ cohort measurement need hand-deduplication.

2. Co-host shows collapse two DJs into one string, using four different join tokens. 20 distinct composite strings across 34 shows in the 40-week scan:

Join token distinct composites
and 7
& 6
+ 4
, 3

Commas are the minority form — an earlier version of this ticket treated this as a comma problem, and a splitter written to that assumption would miss most of the cohort. Upstream since 2025-01-01 the ratio is 42 ampersand-joined shows against 15 comma-joined.

The same pair is not stable across shows. Dj double hockey sticks & dj palomita (5 shows) and dj palomita + dj double hockey sticks (6 shows) are the same two people under a different token and a different order. Splitting on all four tokens is necessary but not sufficient; member order has to be canonicalized before pairs will match each other.

Neither DJ in a composite is findable by searching their own name, and each composite is a phantom entry in every distinct-name list.

3. Nameless shows — 96 of 2,399 (4.0%). dj_name is null on the show record, so the archive renders a show with no attribution. The twelve-week slice gives 15 of 695, so this is a rate rather than a backlog — any acceptance criterion should name a window or say "all history".

4. A real HTML-entity cohort — 41 shows, and it is upstream. DJ_HANDLE values carrying numeric character references, e.g. DJ 4𝄽. They run 2012-04 to 2022-07 PT and stop dead there, which reads like a pre-UTF-8 JSP form encoding non-Latin-1 input on submit. These pass through to the current wire unchanged — GET /flowsheet/range for 2022-07-10 → 07-17 returns shows 62312 and 62359 with dj_name = DJ 4𝄽, so wxyc.org/playlists/archive renders the literal entity text today. Finite, frozen, and the only place in the DJ-name data where HTML entities actually exist.

Investigated and closed: there is no stored-vs-served discrepancy

An earlier version of this ticket asserted that dj pipe dreams & dj slime and a Dj digitalprincess whitespace variant appeared in the stored column but not in the served output. That was wrong on both counts.

Checked 2026-09-09 against tubafrenzy DJ_HANDLE (as hex), GET /flowsheet/range over 40 weeks, and flowsheet.dj_name via a byte-equality dj:"…" search. All three agree. The & was an escaping artifact of the runbook write-up this ticket was drawn from — the stored and served value is dj pipe dreams & dj slime with a bare ampersand, and the show (1951243, 2026-08-30) is inside the original twelve-week window and is served; the earlier claim that the API returned dj pipe dreams for it was a scan-pattern error, not a data finding. Dj digitalprincess has exactly one byte form across all 28 upstream shows, all 16 served shows, and all 45 stored entry rows for her two most recent shows — one classic-origin, one dj-site-native.

shows.legacy_dj_name is a verbatim copy of the upstream DJ_HANDLE and is authoritative for the legacy cohort. The sharpest evidence is a corrupted row: upstream has exactly two mojibake handles in its whole history (two consecutive shows on 2025-11-17 where Dj Fabergé is stored as Dj Faberg + a double-encoded U+FFFD), and /flowsheet/range serves them with those exact bytes while the preceding show that day carries the correct value. Backend reproduces upstream faithfully enough to preserve a defect it could have papered over.

One residual, stated rather than hidden: if the Dj digitalprincess observation came from an aggregated query rather than the per-show one, a byte difference could still hide in auth_user.dj_name, the one column whose whitespace the API trims away on every read path. Closing that needs a single query against Backend Postgres, which was not reachable: SELECT id, dj_name, length(dj_name), md5(dj_name) FROM auth_user WHERE dj_name ILIKE '%digitalprincess%';

Why this is a bounded, closing window

The upstream writer is dead. The tubafrenzy flowsheet webhook — sole filler of shows.legacy_dj_name per #1858 — was disabled 2026-09-07 19:50 PDT as Phase 6a step E4 (WXYC/wiki#88). No new rows of this shape can be created. This is a finite, frozen cohort, not an ongoing leak.

But the ground truth has a shelf life. The only source that can disambiguate a co-host composite or a case variant is tubafrenzy's wxycmusic MySQL, queryable until Phase 6b (WXYC/wiki#94, dateless) and after that surviving only as the archived dump from #1543.

Desired end state

The archive renders one identity per DJ. Case variants of one handle resolve to one displayed name, co-hosted shows either attribute to both DJs or are explicitly marked as co-hosted rather than carrying a fabricated composite handle, nameless shows carry their upstream handle or a deliberate label, and the 41 numeric-character-reference handles render as the characters they encode.

Part of this is a policy question, not a repair. Case-folding DJ blue to DJ Blue picks a winner among a DJ's own self-typed spellings. Deciding which spelling is canonical — most recent, most frequent, or the account's dj_name where one exists — should be settled before the migration, not inside it.

Where

  • shows.legacy_dj_name, shows.dj_name, flowsheet.dj_name (Backend Postgres, wxyc_schema)
  • resolveShowDjName / resolveDjDisplayNameshared/database/src/dj-name.ts
  • resolveShowapps/backend/routes/internal.route.ts (writes COALESCE(auth_user.dj_name, shows.legacy_dj_name) onto marker rows)
  • buildDjNameMatchapps/backend/services/search.service.ts (a quoted dj:"…" term compiles to byte equality against flowsheet.dj_name, which is the probe used above)
  • Read paths: GET /flowsheet/range, GET /flowsheet/playlist, GET /flowsheet/search
  • Consumers: wxyc.org/playlists/archive, wxyc.org/airplay-search, the iOS/Android v2 playlist
  • Ground truth: FLOWSHEET_RADIO_SHOW_PROD.DJ_HANDLE in tubafrenzy wxycmusic
  • Regenerating queries: plans/cutover-gate.sql, plans/cutover-roster.sql

Constraints

Acceptance criteria

Restructured 2026-09-10 per the three-treatments decision: case variants fold with no writes, the 41 entities are repaired, composites are deferred. There is deliberately no canonical-spelling criterion.

Case variants — fold for aggregation, write nothing

The 41 numeric character references — repair

  • All 41 upstream DJ_HANDLE values carrying numeric character references render as the characters they encode, verified against two known shows (62312, 62359).
  • Both shows.legacy_dj_name and flowsheet.dj_name are updated, or the archive listing and search will disagree about the same DJ.
  • A SELECT with the same WHERE clause confirms the row count before the UPDATE.
  • The write respects the /recentEntries 2 s bridge budget — sampled during the run, not only before and after. At 41 shows this should be trivial, which is itself worth confirming rather than assuming.

Cross-cutting

Explicitly out of scope

Notes for implementer

The scan that produced the figures above (no auth required; /flowsheet/range rejects windows wider than ~7 days with a 400):

NOW=$(python3 -c "print(int(__import__('time').time()*1000))")
for w in $(seq 1 40); do
  E=$((NOW - (w-1)*7*86400000)); S=$((E - 7*86400000))
  curl -s "https://api.wxyc.org/flowsheet/range?start=$S&end=$E" \
    | jq -r '.shows[]? | "\(.id)\t\(.dj_name // "NULL")"'
done > djname-scan.txt

Then group by unicodedata.normalize("NFKC", " ".join(n.split())).casefold() and report groups of size > 1. Detect composites on all of &, , , and, + — a comma-only pattern finds 3 of the 20.

Worth checking whether wxyc.org/airplay-search's DJ-name search is case-sensitive — if it is, these variants also split search results, compounding the problem on the surface that was just restored. #2398 reports exact-mode search using = where the rest of the system folds, and #2400 reports every dj_name search being a full seq scan; both are adjacent to this on the same restored pages.

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 workingtubafrenzyTouches WXYC/tubafrenzy (webhook, legacy mirror, flowsheet ETL, reconcile)

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions