You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
Both flowsheet write paths fire LML metadata enrichment as a floating Promise after the row is inserted/upserted, via the shared fireAndForgetMetadataForRow in apps/backend/services/metadata/enrichment.service.ts:
Each path also synchronously emits a liveFs SSE refetch event before the enrichment promise resolves. Connected SSE consumers receive the refetch signal before the metadata UPDATE lands, so they pull the row with artwork_url, spotify_url, artist_bio, etc. all still null. ~1–2s later (bounded — see below) the enrichment UPDATE commits, but no second SSE fires, so consumers never see the metadata until a user-driven refetch.
Latency bound on enrichment
Worst-case wall-clock from row insert → enrichment UPDATE committed:
LML lookup is hard-bounded by TIMEOUT_MS = 5000 in apps/backend/services/lml/lml.client.ts (AbortController). On timeout, lmlFetch throws LmlClientError(504).
The UPDATE flowsheet WHERE id=… is bounded by DB_STATEMENT_TIMEOUT_MS=5000 and runs in well under 100ms in practice (PK lookup, single row).
Total p100 ≈ 5.1s. Typical happy path is 200–800ms.
On the LML-timeout path, metadata.service.ts:fetchMetadata still returns a partial result (synthesized YouTube/Bandcamp/Soundcloud search URLs) and an UPDATE still happens — but artwork_url, spotify_url, apple_music_url, discogs_url, release_year, artist_bio, artist_wikipedia_url all stay null. So a "second emit on UPDATE-resolved" gated only on UPDATE success would fire even when no user-visible field changed, triggering a wasted refetch. The second emit should be gated on at least one user-visible field becoming non-null.
User-visible impact
iOS users see new tracks appear with no album art for the first ~1–2s after they begin playing. The art never appears for that entry until the user pulls to refresh, or until a new track gets added and triggers its own refetch. dj-site users on the now-playing surface see the same thing.
Affected client paths
iOS has two reader modes selectable in-app: a tubafrenzy-source mode (today's default, 100% of users) and a Backend-Service-source mode (rolling out, 0% today, scaling up). Old/stuck app versions will remain on tubafrenzy mode indefinitely, so the tubafrenzy-mode fix has to be production-grade and stay maintained — it isn't throwaway alongside tubafrenzy.
dj-site
Does not subscribe to backend's SSE. Uses RTK Query with a 60-second polling interval on the flowsheet and now-playing surfaces (src/hooks/flowsheetHooks.ts:67, :75, :243; src/widgets/NowPlaying/index.tsx:43). The adding DJ's screen renders new entries from the addEntry HTTP response (which carries no artwork yet on the free-form path), then waits up to 60s for the next poll to fetch the enriched row. A second serverEventsMgr.broadcast from fireAndForgetMetadataForRow.then() reaches no consumer here today — worth emitting as zero-cost future-proofing for an eventual dj-site SSE migration, but it isn't what closes the user-visible bug. Closing it on dj-site is out of scope for this issue (see Deferred).
iOS — tubafrenzy mode (100% today + long tail forever)
Reads through apps/backend/services/playlist-proxy.service.ts, which:
Subscribes to tubafrenzy's SSE at /playlists/recentStream.
Maintains an in-memory store of the current playlist.
On every created / updated event, calls enrichSinglePlaycut() which does ONE SELECT flowsheet.artwork_url WHERE … and caches the result.
Never re-enriches an entry once it's been processed.
The tubafrenzy created event arrives ~simultaneously with backend's INSERT (or, on the webhook path, tubafrenzy is the originator), so the proxy's read loses the race against fireAndForgetMetadataForRow's UPDATE essentially every time, caches undefined, and serves undefined until the entry rolls off the 200-entry window.
A backend liveFs SSE broadcast does not reach this proxy — they're independent SSE pipelines. But the proxy is in-process with the enrichment service, so the fix is an in-process call from fireAndForgetMetadataForRow.then() into the proxy's "re-enrich one row" path, keyed by lookup key (artist+album).
iOS — Backend-Service mode (0% today, scaling up)
Hits a backend REST endpoint that reads flowsheet directly. No proxy cache to invalidate; freshness is bounded by whatever pull cadence the iOS app uses, or unbounded if the app fetches once and caches client-side. iOS does not currently subscribe to backend liveFs SSE.
The right BS-mode fix is for iOS to subscribe to liveFs (same wire as dj-site), at which point the second-emit fix below covers BS-mode iOS for free with no new backend work. That iOS-side subscription is out of scope for this issue — see Deferred.
Proposed fix surfaces
Status update 2026-04-28: order has shifted. Surface 2 is now the load-bearing first step. Surface 1 is implemented in #643 but held in draft, gated on the library-side artwork backfill #637 — without that, denormalization writes ≈zero rows in practice (population baseline: 0.2%, see issuecomment-4338014422). Surface 3 remains deferred until BS-mode iOS rollout warrants it.
Three layers. Original ordering preserved below as written; current sequencing is 2 → #637 backfill → 1 → 3.
1. Bin-pick denormalization (highest leverage; eliminates the race on the common path on every client mode at once)
flowsheet.controller.ts:addEntry's bin-pick branch (body.album_id !== undefined) already has the album row in hand via getAlbumFromDB, but that helper at flowsheet.service.ts:601 does not select library.artwork_url — even though the column exists (migration 0045). Add artwork_url (and any other denormalizable columns LML would otherwise re-fetch) to the SELECT, stamp them onto the flowsheet row at INSERT time, and bin-picks no longer have a metadata race for those fields. Free-form entries (no album_id) still need LML and still need the second-emit fix.
This is the smallest patch with the largest in-show win, because most adds during a show are bin-picks. Precondition: library-side artwork backfill #637 (population is 0.2% today — #643 is held in draft pending that). The column exists; population is a separate question worth answering with one query before this lands.
2. Second emit at UPDATE resolution (covers dj-site + tubafrenzy-mode iOS)
Inject a callback into fireAndForgetMetadataForRow that fires after the UPDATE commits with at least one user-visible field non-null. The callsite (in app.ts or wherever routes are mounted) wires that callback to two consumers:
playlist-proxy.service.ts.reEnrich(lookupKey) — re-runs enrichSinglePlaycut for any in-memory entries with a matching artist+album lookup key. This is the load-bearing consumer today — it covers 100% of iOS users (tubafrenzy mode) plus the long-tail of stuck old-version installs.
serverEventsMgr.broadcast(Topics.liveFs, { type: 'metadata-updated', payload: { flowsheetId, fields: { artwork_url, spotify_url, ... } } }) — zero-cost future-proofing. No consumer today (dj-site polls, BS-mode iOS isn't subscribed yet); reaches dj-site / iOS once they migrate to SSE.
A single notify point drives both consumers. No CDC/WebSocket/SSE-round-trip is needed for the proxy — they're in the same process.
The richer payload (type='metadata-updated', flowsheetId, fields) lets dj-site patch one row instead of doing a full RTK Query refetch, and is strictly better than a generic refetch event for both consumers.
3. iOS BS-mode SSE subscription (deferred)
When BS-mode rolls out to non-trivial traffic, iOS should subscribe to liveFs and consume the same metadata-updated events as dj-site. Tracked separately — see Deferred.
Alternatives considered and rejected
CDC subscription: the proxy could subscribe to onCdcEvent (in-process, exposed by shared/database/src/cdc-listener.ts) and re-enrich on flowsheet UPDATE. Works, but reaches over the enrichment service and ties the proxy to the CDC pipeline for a problem that has a more direct fix.
Periodic polling: trivially safe, ugly, never want to ship.
Reorder the broadcast to fire only after enrichment: eliminates the race but trades it for an availability problem. If LML is slow or down, tracks don't appear in dj-site / iOS until LML times out (5s) — strictly worse than "appears instantly with null artwork."
Acceptance criteria
Unit test: fireAndForgetMetadataForRow invokes the resolved-callback after the UPDATE commits, with the set of fields that became non-null. Mock the callback and assert it's called once on enrichment success with non-empty fields.
Unit test: callback NOT invoked on the LML-no-match path that yields zero user-visible fields, on the error path, or when LML returns metadata identical to what's already on the row.
Integration test: with the playlist-proxy connected to a stub tubafrenzy SSE, a created event followed by a backend INSERT + enrichment UPDATE results in the proxy's in-memory store reflecting post-enrichment artwork within 1s of the UPDATE (i.e., the in-process notify ran).
Smoke test on prod, tubafrenzy-mode iOS only (100% of today's iOS users): live show, new track plays, iOS app shows artwork within ≤3s of the LML-success path without manual refresh. LML-timeout path: the row appears immediately and the artwork-empty state is acceptable (no second emit, no refetch storm).
Bin-pick denormalization (separable from above): getAlbumFromDB returns artwork_url, the bin-pick INSERT writes it to flowsheet, and a unit test confirms flowsheet.artwork_url is non-null at the moment addEntry returns when the album row has it.
WXYC/dj-site — consumes backend liveFs SSE on the now-playing / flowsheet surfaces. Will get the richer metadata-updated payload for free; can be patched to apply a one-row update instead of a full refetch.
WXYC/wxyc-ios-64 — tubafrenzy-mode reads through apps/backend/services/playlist-proxy.service.ts; BS-mode hits backend REST directly (no SSE today). The deferred BS-mode SSE work lands in this repo.
apps/backend/services/playlist-proxy.service.ts — enrichSinglePlaycut is the iOS-tubafrenzy-mode fix surface.
apps/backend/services/metadata/enrichment.service.ts — fireAndForgetMetadataForRow is where the resolved-callback gets injected.
apps/backend/services/flowsheet.service.ts — getAlbumFromDB is the bin-pick denormalization surface.
Context
Both flowsheet write paths fire LML metadata enrichment as a floating Promise after the row is inserted/upserted, via the shared
fireAndForgetMetadataForRowinapps/backend/services/metadata/enrichment.service.ts:apps/backend/controllers/flowsheet.controller.ts(dj-siteaddEntry)apps/backend/routes/internal.route.ts— tubafrenzy webhook, gated onxmax=0so only fresh inserts enrich (Trigger LML metadata enrichment for tubafrenzy webhook upserts #627, merged 2026-04-28)Each path also synchronously emits a
liveFsSSE refetch event before the enrichment promise resolves. Connected SSE consumers receive the refetch signal before the metadata UPDATE lands, so they pull the row withartwork_url,spotify_url,artist_bio, etc. all still null. ~1–2s later (bounded — see below) the enrichment UPDATE commits, but no second SSE fires, so consumers never see the metadata until a user-driven refetch.Latency bound on enrichment
Worst-case wall-clock from row insert → enrichment UPDATE committed:
TIMEOUT_MS = 5000inapps/backend/services/lml/lml.client.ts(AbortController). On timeout,lmlFetchthrowsLmlClientError(504).UPDATE flowsheet WHERE id=…is bounded byDB_STATEMENT_TIMEOUT_MS=5000and runs in well under 100ms in practice (PK lookup, single row).On the LML-timeout path,
metadata.service.ts:fetchMetadatastill returns a partial result (synthesized YouTube/Bandcamp/Soundcloud search URLs) and an UPDATE still happens — butartwork_url,spotify_url,apple_music_url,discogs_url,release_year,artist_bio,artist_wikipedia_urlall stay null. So a "second emit on UPDATE-resolved" gated only on UPDATE success would fire even when no user-visible field changed, triggering a wasted refetch. The second emit should be gated on at least one user-visible field becoming non-null.User-visible impact
iOS users see new tracks appear with no album art for the first ~1–2s after they begin playing. The art never appears for that entry until the user pulls to refresh, or until a new track gets added and triggers its own refetch. dj-site users on the now-playing surface see the same thing.
Affected client paths
iOS has two reader modes selectable in-app: a tubafrenzy-source mode (today's default, 100% of users) and a Backend-Service-source mode (rolling out, 0% today, scaling up). Old/stuck app versions will remain on tubafrenzy mode indefinitely, so the tubafrenzy-mode fix has to be production-grade and stay maintained — it isn't throwaway alongside tubafrenzy.
dj-site
Does not subscribe to backend's SSE. Uses RTK Query with a 60-second polling interval on the flowsheet and now-playing surfaces (
src/hooks/flowsheetHooks.ts:67,:75,:243;src/widgets/NowPlaying/index.tsx:43). The adding DJ's screen renders new entries from the addEntry HTTP response (which carries no artwork yet on the free-form path), then waits up to 60s for the next poll to fetch the enriched row. A secondserverEventsMgr.broadcastfromfireAndForgetMetadataForRow.then()reaches no consumer here today — worth emitting as zero-cost future-proofing for an eventual dj-site SSE migration, but it isn't what closes the user-visible bug. Closing it on dj-site is out of scope for this issue (see Deferred).iOS — tubafrenzy mode (100% today + long tail forever)
Reads through
apps/backend/services/playlist-proxy.service.ts, which:/playlists/recentStream.created/updatedevent, callsenrichSinglePlaycut()which does ONESELECT flowsheet.artwork_url WHERE …and caches the result.The tubafrenzy
createdevent arrives ~simultaneously with backend's INSERT (or, on the webhook path, tubafrenzy is the originator), so the proxy's read loses the race againstfireAndForgetMetadataForRow's UPDATE essentially every time, cachesundefined, and servesundefineduntil the entry rolls off the 200-entry window.A backend
liveFsSSE broadcast does not reach this proxy — they're independent SSE pipelines. But the proxy is in-process with the enrichment service, so the fix is an in-process call fromfireAndForgetMetadataForRow.then()into the proxy's "re-enrich one row" path, keyed by lookup key (artist+album).iOS — Backend-Service mode (0% today, scaling up)
Hits a backend REST endpoint that reads
flowsheetdirectly. No proxy cache to invalidate; freshness is bounded by whatever pull cadence the iOS app uses, or unbounded if the app fetches once and caches client-side. iOS does not currently subscribe to backendliveFsSSE.The right BS-mode fix is for iOS to subscribe to
liveFs(same wire as dj-site), at which point the second-emit fix below covers BS-mode iOS for free with no new backend work. That iOS-side subscription is out of scope for this issue — see Deferred.Proposed fix surfaces
Three layers. Original ordering preserved below as written; current sequencing is 2 → #637 backfill → 1 → 3.
1. Bin-pick denormalization (highest leverage; eliminates the race on the common path on every client mode at once)
flowsheet.controller.ts:addEntry's bin-pick branch (body.album_id !== undefined) already has the album row in hand viagetAlbumFromDB, but that helper atflowsheet.service.ts:601does not selectlibrary.artwork_url— even though the column exists (migration 0045). Addartwork_url(and any other denormalizable columns LML would otherwise re-fetch) to the SELECT, stamp them onto the flowsheet row at INSERT time, and bin-picks no longer have a metadata race for those fields. Free-form entries (noalbum_id) still need LML and still need the second-emit fix.This is the smallest patch with the largest in-show win, because most adds during a show are bin-picks. Precondition: library-side artwork backfill #637 (population is 0.2% today — #643 is held in draft pending that). The column exists; population is a separate question worth answering with one query before this lands.
2. Second emit at UPDATE resolution (covers dj-site + tubafrenzy-mode iOS)
Inject a callback into
fireAndForgetMetadataForRowthat fires after the UPDATE commits with at least one user-visible field non-null. The callsite (inapp.tsor wherever routes are mounted) wires that callback to two consumers:playlist-proxy.service.ts.reEnrich(lookupKey)— re-runsenrichSinglePlaycutfor any in-memory entries with a matching artist+album lookup key. This is the load-bearing consumer today — it covers 100% of iOS users (tubafrenzy mode) plus the long-tail of stuck old-version installs.serverEventsMgr.broadcast(Topics.liveFs, { type: 'metadata-updated', payload: { flowsheetId, fields: { artwork_url, spotify_url, ... } } })— zero-cost future-proofing. No consumer today (dj-site polls, BS-mode iOS isn't subscribed yet); reaches dj-site / iOS once they migrate to SSE.A single notify point drives both consumers. No CDC/WebSocket/SSE-round-trip is needed for the proxy — they're in the same process.
The richer payload (
type='metadata-updated',flowsheetId,fields) lets dj-site patch one row instead of doing a full RTK Query refetch, and is strictly better than a genericrefetchevent for both consumers.3. iOS BS-mode SSE subscription (deferred)
When BS-mode rolls out to non-trivial traffic, iOS should subscribe to
liveFsand consume the samemetadata-updatedevents as dj-site. Tracked separately — see Deferred.Alternatives considered and rejected
onCdcEvent(in-process, exposed byshared/database/src/cdc-listener.ts) and re-enrich on flowsheet UPDATE. Works, but reaches over the enrichment service and ties the proxy to the CDC pipeline for a problem that has a more direct fix.Acceptance criteria
fireAndForgetMetadataForRowinvokes the resolved-callback after the UPDATE commits, with the set of fields that became non-null. Mock the callback and assert it's called once on enrichment success with non-empty fields.createdevent followed by a backend INSERT + enrichment UPDATE results in the proxy's in-memory store reflecting post-enrichment artwork within 1s of the UPDATE (i.e., the in-process notify ran).getAlbumFromDBreturnsartwork_url, the bin-pick INSERT writes it to flowsheet, and a unit test confirmsflowsheet.artwork_urlis non-null at the momentaddEntryreturns when the album row has it.Deferred (not in this issue)
liveFssubscription — tracked at Subscribe to Backend-Service liveFs SSE for metadata updates (BS-mode) wxyc-ios-64#227 (gated on BS-mode rollout).Out of scope
xmax=0gate in Trigger LML metadata enrichment for tubafrenzy webhook upserts #627).Cross-repo links
WXYC/dj-site— consumes backendliveFsSSE on the now-playing / flowsheet surfaces. Will get the richermetadata-updatedpayload for free; can be patched to apply a one-row update instead of a full refetch.WXYC/wxyc-ios-64— tubafrenzy-mode reads throughapps/backend/services/playlist-proxy.service.ts; BS-mode hits backend REST directly (no SSE today). The deferred BS-mode SSE work lands in this repo.apps/backend/services/playlist-proxy.service.ts—enrichSinglePlaycutis the iOS-tubafrenzy-mode fix surface.apps/backend/services/metadata/enrichment.service.ts—fireAndForgetMetadataForRowis where the resolved-callback gets injected.apps/backend/services/flowsheet.service.ts—getAlbumFromDBis the bin-pick denormalization surface.