fix(api): a track with no track_cid is not streamable, plus a repair job - #1032
fix(api): a track with no track_cid is not streamable, plus a repair job#1032dylanjeffers wants to merge 2 commits into
Conversation
An upload whose track_cid never made it onto the track entity has audio sitting on the content node that no reader can address. The stream link was already left nil for these rows, and /stream already 404s, but is_streamable kept reporting true - so every client believed the track was healthy. The player spins on a dead URL, and mobile's share-to-story feeds that URL to ffmpeg, which fails with a generic "Sorry, something went wrong" instead of saying the track has no audio. Split the predicate in two. IsAudioAllowed keeps the old meaning - the track is not deleted and its owner is still active - and gates downloads and previews. IsStreamable now also requires a cid to stream. Downloads deliberately stay on IsAudioAllowed: they fall back to orig_file_cid, which a row missing its track_cid still has, so losing is_streamable must not cost the artist their downloads. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
track_cid is taken verbatim from the uploader's metadata at index time. The client is supposed to poll the content node until the transcode finishes and then include the resulting cid when it writes the track; when that handshake falls through, the track is indexed with a NULL track_cid. The audio is fine and sitting on the content node - there is just no cid on the row pointing at it. The track looks normal, collects favorites and reposts, and never records a single play. The content node still holds the upload record keyed by the track's audio_upload_id, so reconcile from there, the same way RepairAudioAnalysesJob recovers bpm / musical_key. Unlike that job, this one requires two distinct nodes to agree on the cid before writing it. bpm fills in a display field; track_cid decides which bytes every listener receives for the track. Upload records are replicated across mirrors, so agreement is cheap, and it means a single misbehaving or stale node cannot repoint a track's audio on its own. Tracks that cannot reach quorum, or whose nodes disagree, are logged and left for the next pass rather than repaired on one node's word. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Blast radius, measured — and it's two failure modes, not oneI sampled the 250 most recent public tracks via 3 of 250 (1.2%) have no Checking each against 5 content nodes for the upload record changes the picture:
So there are two distinct ways a track ends up dead, and this job only fixes the first:
Two things worth flagging out of that:
At ~1.2% of uploads, if that rate is representative this is on the order of several dead tracks a day, and it's been running at least since Aug 31. I could not query prod directly to confirm the historical total — SELECT count(*) FILTER (WHERE audio_upload_id IS NOT NULL) AS repairable_candidates,
count(*) AS total
FROM tracks
WHERE is_current AND NOT is_delete AND track_cid IS NULL; |
…4584) ## What happened [Michael reported](https://audius-internal.slack.com/archives/CA80RCL77/p1788456292567669) that one specific link failed with "Sorry, something went wrong" when sharing to an Instagram story, while every other link worked. That track has no audio. `track_cid` is null on the indexed row, so the stream endpoint 404s. Share-to-story builds a video out of the track's audio — it resolves the stream URL and passes it to ffmpeg as an input (`-i ${streamMp3Url}`). ffmpeg gets a 404 JSON body instead of an mp3, exits non-zero, and `handleError` shows the generic toast with no hint that the track itself is broken. Every other link he tried worked because those tracks had audio. ## What this changes **Don't offer the story platforms for a track with nothing to play.** AudiusProject/api#1032 makes the API report `is_streamable: false` for these rows, so `isShareableTrack` now excludes them — better than failing 60% of the way through a progress drawer. The check is an explicit `!== false` because not every track source populates the field, and an absent one must not hide the share options. **Guard the stream-URL step.** `signGatedContentRequest` and `getTrackStreamUrl` were wrapped in nothing at all, and `handleShare` doesn't catch either — so a rejection there (a failed signature, an SDK that never initialized) escaped as an unhandled promise rejection: no toast, and the progress drawer left spinning with no way out but backing out of it. Now it goes through `handleError` like every other step, and the analytics error carries `Error at resolve stream url step` so the failing step is identifiable. The two comment updates in `packages/common` bring the `is_streamable` docs in line with its widened meaning — it now also covers an upload indexed without its `track_cid`, not just deleted/deactivated. ## Worth knowing `isTrackUnavailable` reads the same flag, so once the API change lands, these tracks will render the "no longer available" screen on the track page instead of a dead player. That's intended, and transient for tracks the repair job in AudiusProject/api#1032 can fix. Mobile ships via OTA and iOS OTA has been broken since the 1.5.186 build failed, so this is likely to reach iOS well after the server-side fixes land. The repair job closes the loop regardless of client version. ## Testing Lint clean on all four files; typecheck clean on the changed files. Not exercised on a device — the share-to-story path needs a real Instagram hand-off to test end to end. 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
## The root cause behind the dead-track incident [Michael reported](https://audius-internal.slack.com/archives/CA80RCL77/p1788456292567669) that one link failed when sharing to an Instagram story. The track behind it has no audio at all: `track_cid` is null on the indexed row, `/stream` 404s, and share-to-story hands that URL to ffmpeg, which fails. AudiusProject/api#1032 makes the API honest about it and repairs the rows; #14584 stops mobile offering a story for a track it cannot build a video from. **This PR is why the rows exist in the first place.** `pollProcessingStatus` returned the moment a storage node reported `status: 'done'`: ```ts if (resp?.status === 'done') { return resp } ``` It never checked that the result it is polling *for* is on the response. Upload rows replicate across storage nodes and `getProcessingStatus` talks to whichever node `storageNodeSelector` hands back — falling over to others on error — so a mirror can legitimately answer `done` from a row it has not finished catching up on, with an empty `results` map. `populateTrackMetadataWithUploadResponseV2` then does: ```ts trackCid: audioResponse.results['320'], ``` which is `undefined`, and the track entity is written without a cid. Nothing errors. The upload reports success, the track page loads, the artwork renders, people favorite and repost it — and there is no cid on the row pointing at the audio, so it can never be played and never records a play. That matches the failing track exactly: `duration`, `bpm`, `musical_key`, `orig_file_cid` and `audio_upload_id` were all populated from the upload response, so the response was there and carried `probe` and `audio_analysis_results` — only `results['320']` was missing. The result: **0 plays against 18 favorites and 16 reposts.** ## The change Require the `'320'` result before treating an audio poll as finished. A node that really is done will have it on the next pass three seconds later. An upload genuinely stuck in that state now times out with an error naming the cause — `Upload reported done but no transcode result appeared within...` — instead of silently publishing unplayable audio, which is a strictly better failure: the artist finds out at upload time rather than never. Image templates are untouched; they have no `'320'` to wait for. ## Testing `Storage.test.ts`: a node reporting done with no results keeps polling and picks up the cid on the retry; a response that already has the cid returns on the first call with no extra poll; image templates return immediately on an empty results map. All 5 tests in the file pass. Not reproduced against a live node — the race needs replication lag between storage nodes to trigger. ## Related - AudiusProject/api#1032 — `is_streamable` honesty plus a repair job for rows already in this state - #14584 — mobile stops offering share-to-story for tracks with no audio 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
What happened
Michael reported that one specific link failed with "Sorry, something went wrong" when sharing to an Instagram story, while every other link worked.
That track has no audio.
track_cidis null on the indexed row, so the stream endpoint 404s:Mobile's share-to-story builds a video out of the track's audio: it resolves the stream URL and hands it to ffmpeg as an input. ffmpeg gets a 404 JSON body instead of an mp3, exits non-zero, and the user gets the generic toast.
Storage did its job — the mediorum upload record for this track is
status: done,transcode_progress: 1, with a valid 320kbps cid. The track entity was written 89 seconds later without that cid, and nothing backfills it server-side.The worse part is that the track is completely unplayable: 0 plays against 18 favorites and 16 reposts since Aug 31. The artist has exactly one track and it's dead, and nothing in the product tells them, because
is_streamablestill returnstrue.What this changes
is_streamablestops lying. The stream link was already left nil for cidless rows and/streamalready 404s — only the flag disagreed. Split in two:IsAudioAllowedkeeps the old meaning (not deleted, owner still active) and gates downloads and previews;IsStreamablenow also requires a cid to stream.Downloads deliberately stay on
IsAudioAllowed. A download falls back toorig_file_cid, which a row missing itstrack_cidstill has, so losingis_streamablemust not cost the artist their downloads.v1_track_download.goswitched to the new guard for that reason; the playlist m3u8 builder correctly stays onIsStreamable, since it needs a stream URL.A repair job for the rows already in this state.
jobs/repair_track_cids.gois modeled on the existingRepairAudioAnalysesJob: find current, undeleted tracks with a NULLtrack_cidand anaudio_upload_id, ask content nodes for the upload record, writeresults["320"]. Runs every 15 minutes.One deliberate departure from the job it copies: two distinct nodes must agree on the cid before it is written. bpm fills in a display field;
track_ciddecides which bytes every listener receives for the track. Upload records are replicated across mirrors, so agreement is cheap to obtain, and it means a single stale or misbehaving node cannot repoint a track's audio on its own. Tracks that can't reach quorum, or whose nodes disagree, are logged and left for the next pass. The write also re-checkstrack_cid IS NULL, so a real indexer write always wins a race.Worth knowing before merging
This widens what
is_streamable: falsemeans, and the clients already act on it —isTrackUnavailablefeeds the web and mobile track pages, so cidless tracks will render the "no longer available" screen instead of a dead player. I think that's the right call, but it's a visible change beyond the reported bug. For repairable tracks it's transient until the job runs.This limits the damage; it does not stop it happening again. The origin — why the upload client wrote the track entity 89s after transcode finished without attaching the cid — is client-side and not addressed here.
Testing
./api/...,./jobs/and./indexer/...all green. New coverage: a cidless track reportsis_streamable: falsewith a null stream, the same track is still downloadable viaorig_file_cid, quorum repairs, a single node cannot repair, disagreeing nodes leave the row alone, unreachable nodes don't block a repair the reachable ones agree on, andapplyTrackCidnever overwrites an existing cid.Related
🤖 Generated with Claude Code