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;

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