Skip to content

fix(api): a track with no track_cid is not streamable, plus a repair job - #1032

Open
dylanjeffers wants to merge 2 commits into
mainfrom
fix/no-audio-not-streamable
Open

fix(api): a track with no track_cid is not streamable, plus a repair job#1032
dylanjeffers wants to merge 2 commits into
mainfrom
fix/no-audio-not-streamable

Conversation

@dylanjeffers

@dylanjeffers dylanjeffers commented Sep 3, 2026

Copy link
Copy Markdown
Contributor

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_cid is null on the indexed row, so the stream endpoint 404s:

curl -sL "https://api.audius.co/v1/tracks/70YW9Og/stream?app_name=test"
{"code":404,"error":"track audio is unavailable"}

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_streamable still returns true.

What this changes

is_streamable stops lying. The stream link was already left nil for cidless rows and /stream already 404s — only the flag disagreed. Split in two: IsAudioAllowed keeps the old meaning (not deleted, owner still active) and gates downloads and previews; IsStreamable now also requires a cid to stream.

Downloads deliberately stay on IsAudioAllowed. A download falls 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. v1_track_download.go switched to the new guard for that reason; the playlist m3u8 builder correctly stays on IsStreamable, since it needs a stream URL.

A repair job for the rows already in this state. jobs/repair_track_cids.go is modeled on the existing RepairAudioAnalysesJob: find current, undeleted tracks with a NULL track_cid and an audio_upload_id, ask content nodes for the upload record, write results["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_cid decides 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-checks track_cid IS NULL, so a real indexer write always wins a race.

Worth knowing before merging

This widens what is_streamable: false means, and the clients already act on it — isTrackUnavailable feeds 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 reports is_streamable: false with a null stream, the same track is still downloadable via orig_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, and applyTrackCid never overwrites an existing cid.

Related

🤖 Generated with Claude Code

dylanjeffers and others added 2 commits September 3, 2026 14:41
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>
@dylanjeffers

Copy link
Copy Markdown
Contributor Author

Blast radius, measured — and it's two failure modes, not one

I sampled the 250 most recent public tracks via /v1/tracks/latest (which doesn't filter on track_cid, so it's an unbiased sample of recent uploads). That covers a 9-hour window of uploads today.

3 of 250 (1.2%) have no track_cid. All three 404 on /stream, all three are 3.5–9 hours old — well past any in-flight window — and all three have 0 plays.

Checking each against 5 content nodes for the upload record changes the picture:

track node status agreeing on a cid repairable by this job
70YW9Og (the reported one) done ×5 5/5, identical
4x1VEWb done ×5 5/5, identical
9mmd0zj busy ×5 none
4kxMyRZ busy ×5 none

So there are two distinct ways a track ends up dead, and this job only fixes the first:

  1. Transcode finished, cid never reached the track row. Repairable — the cid exists and every node agrees on it.
  2. Transcode stalled and never finished. 9mmd0zj sat at transcode_progress: 0 since 17:57; 4kxMyRZ reached 91.8% and stopped at 13:08. No error, no error_count, rows untouched for hours. There is no cid anywhere to repair to, and the job correctly leaves these alone.

Two things worth flagging out of that:

  • The quorum design validated cleanly against production — 5/5 node agreement on both repairable uploads, so requiring 2 costs nothing in practice.
  • Mode 2 is a separate bug and probably the more serious one. A stalled transcode leaves no error anywhere, and a track entity got written regardless — the SDK poll should have thrown rather than publishing. Relatedly, Storage.ts guards its stall detector with if (resp.transcode_progress), so a transcode stuck at exactly 0 never trips the no-progress timeout at all. That's not addressed by this PR or fix(sdk): don't accept a transcode as done without its cid apps#14585; it needs its own look at mediorum.

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 — gcloud needs an interactive reauth. The query worth running is:

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;

dylanjeffers added a commit to AudiusProject/apps that referenced this pull request Sep 9, 2026
…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>
dylanjeffers added a commit to AudiusProject/apps that referenced this pull request Sep 9, 2026
## 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>
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.

1 participant