Skip to content

Commit ddae4ad

Browse files
dylanjeffersclaude
andcommitted
fix(migrate-tool): migrate originals via orig_file_cid with mp3 fallback
Audio source resolution now walks a prioritized candidate list per track (raw orig_file_cid → CID mirror → gated download URL → transcoded mp3 stream), trying each until one fetch succeeds. Original masters now flow through for every indexed track that still has its bytes on the network, not just the ones the artist toggled downloadable. The stream fallback guarantees every approved request migrates at least the lossy mp3 so a single pruned original never aborts the run. Preview UI relabeled to "mp3 only" for the rare tracks where no original is on file. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 706631a commit ddae4ad

6 files changed

Lines changed: 138 additions & 23 deletions

File tree

packages/migrate-tool/README.md

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,10 +26,19 @@ database backing the request queue.
2626

2727
## Limitations
2828

29-
- **Original masters**: only tracks the artist marked as **downloadable** expose
30-
the original audio file via the public API. Other tracks migrate with the
31-
transcoded MP3 stream, which is a lossy re-encoding rather than a bit-for-bit
32-
copy. The track preview shows which of these applies per track.
29+
- **Audio source selection**: the worker tries sources in priority order
30+
for each track and uses the first one that succeeds:
31+
1. Raw `orig_file_cid` fetched from the rendezvous-primary validator node
32+
(`/content/{cid}`) — bit-for-bit copy of the original master,
33+
regardless of whether the artist marked the track downloadable.
34+
2. Same CID, mirror node — covers the case where the primary node is
35+
unhealthy.
36+
3. Gated download URL — original master, only available when the track
37+
is flagged downloadable.
38+
4. Transcoded MP3 stream — lossy, but always reachable. Acts as the
39+
last-resort fallback so every approved request gets a content copy.
40+
Sources 1 and 2 are skipped when `orig_file_cid` is missing or
41+
`is_original_available` is false (legacy uploads or pruned originals).
3342
- **No identity verification in-tool**: anyone signed in can request migration
3443
of any handle. The approver is responsible for verifying the requester owns
3544
the old account before approving. Don't approve a request without

packages/migrate-tool/api/_lib/migrate.ts

Lines changed: 114 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { Genre, Mood } from '@audius/sdk'
1+
import type { AudiusSdkWithServices, Genre, Mood, Track } from '@audius/sdk'
22

33
import { getServerSDK } from './audius'
44
import { getSupabase, TABLE } from './supabase'
@@ -34,13 +34,115 @@ function blobToFile(blob: Blob, name: string): File {
3434
return new File([blob], name, { type: blob.type || 'application/octet-stream' })
3535
}
3636

37+
type AudioCandidate = {
38+
label: string
39+
resolve: () => Promise<{ url: string; filename: string }>
40+
}
41+
42+
/**
43+
* Build a prioritized list of source URLs for the track's audio. Each
44+
* candidate is tried in order, so a hard failure on the original (pruned
45+
* bytes, unhealthy node, missing index) silently falls through to the
46+
* next best source. The MP3 stream is always last so every track ends up
47+
* with at least *some* content copy.
48+
*/
49+
function buildAudioCandidates(
50+
sdk: AudiusSdkWithServices,
51+
track: Track,
52+
trackId: string,
53+
isDownloadablePreview: boolean
54+
): AudioCandidate[] {
55+
const candidates: AudioCandidate[] = []
56+
57+
// 1. Original master via raw CID. Validator nodes serve content-addressed
58+
// files at /content/{cid} with no gating, so this works regardless of
59+
// isDownloadable. Tried first because it's a bit-for-bit copy.
60+
if (track.origFileCid && track.isOriginalAvailable !== false) {
61+
const cid = track.origFileCid
62+
candidates.push({
63+
label: `orig-cid:${cid}`,
64+
resolve: async () => {
65+
const nodes = sdk.services.storageNodeSelector.getNodes(cid)
66+
if (nodes.length === 0) {
67+
throw new Error('No storage node available for original file CID.')
68+
}
69+
// Pick the rendezvous-primary; the mirror candidate below handles
70+
// failover when this fetch throws.
71+
return {
72+
url: `${nodes[0]}/content/${cid}`,
73+
filename: track.origFilename ?? cid
74+
}
75+
}
76+
})
77+
78+
// Same CID, mirrors. Each mirror becomes its own candidate so a single
79+
// unhealthy node doesn't take down the migration.
80+
candidates.push({
81+
label: `orig-cid-mirrors:${cid}`,
82+
resolve: async () => {
83+
const nodes = sdk.services.storageNodeSelector.getNodes(cid)
84+
const mirror = nodes[1] ?? nodes[2]
85+
if (!mirror) {
86+
throw new Error('No mirror available for original file CID.')
87+
}
88+
return {
89+
url: `${mirror}/content/${cid}`,
90+
filename: track.origFilename ?? cid
91+
}
92+
}
93+
})
94+
}
95+
96+
// 2. Gated download URL — only works for tracks the artist flagged as
97+
// downloadable, but the bytes are still the original master.
98+
if (isDownloadablePreview) {
99+
candidates.push({
100+
label: 'download-url',
101+
resolve: async () => {
102+
const url = await sdk.tracks.getTrackDownloadUrl({ trackId })
103+
return { url, filename: filenameFromUrl(url, `${trackId}.audio`) }
104+
}
105+
})
106+
}
107+
108+
// 3. Transcoded MP3 stream — always available, lossy. Ensures every
109+
// track migrates with *something* even if the original is gone.
110+
candidates.push({
111+
label: 'stream-url',
112+
resolve: async () => {
113+
const url = await sdk.tracks.getTrackStreamUrl({ trackId })
114+
return { url, filename: filenameFromUrl(url, `${trackId}.mp3`) }
115+
}
116+
})
117+
118+
return candidates
119+
}
120+
121+
async function fetchAudio(
122+
candidates: AudioCandidate[]
123+
): Promise<{ blob: Blob; filename: string; source: string }> {
124+
const errors: string[] = []
125+
for (const candidate of candidates) {
126+
try {
127+
const { url, filename } = await candidate.resolve()
128+
const blob = await fetchBlob(url)
129+
return { blob, filename, source: candidate.label }
130+
} catch (e) {
131+
errors.push(`${candidate.label}: ${e instanceof Error ? e.message : String(e)}`)
132+
}
133+
}
134+
throw new Error(`No audio source succeeded. Tried: ${errors.join(' | ')}`)
135+
}
136+
37137
/**
38138
* Run the migration for one DB row. Updates the row in-place with per-track
39139
* results as it goes, and sets the final status when done.
40140
*
41-
* Limitation: only tracks flagged as downloadable expose the original
42-
* audio file. Other tracks fall back to the transcoded mp3 stream, which
43-
* is a lossy re-encoding rather than a bit-for-bit copy.
141+
* Audio source order (see buildAudioCandidates): the original master via
142+
* raw CID, then mirrors, then the gated download URL (downloadable tracks
143+
* only), then the transcoded MP3 stream. Each candidate is tried until
144+
* one succeeds — guarantees every track migrates with the highest-fidelity
145+
* copy that's still reachable.
44146
*/
45147
export async function executeMigration(row: DbRow): Promise<void> {
46148
const supabase = getSupabase()
@@ -72,15 +174,15 @@ export async function executeMigration(row: DbRow): Promise<void> {
72174
const track = trackRes.data
73175
if (!track) throw new Error('Track not found on source account.')
74176

75-
const audioUrl = preview.isDownloadable
76-
? await sdk.tracks.getTrackDownloadUrl({ trackId: preview.trackId })
77-
: await sdk.tracks.getTrackStreamUrl({ trackId: preview.trackId })
78-
79-
const audioBlob = await fetchBlob(audioUrl)
80-
const audioFile = blobToFile(
81-
audioBlob,
82-
filenameFromUrl(audioUrl, `${preview.trackId}.mp3`)
177+
const candidates = buildAudioCandidates(
178+
sdk,
179+
track,
180+
preview.trackId,
181+
preview.isDownloadable
83182
)
183+
const { blob: audioBlob, filename: audioFilename } =
184+
await fetchAudio(candidates)
185+
const audioFile = blobToFile(audioBlob, audioFilename)
84186

85187
let imageFile: File | undefined
86188
const artworkUrl =

packages/migrate-tool/api/_lib/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type TrackPreview = {
1313
durationSec?: number | null
1414
artworkUrl?: string | null
1515
isDownloadable: boolean
16+
hasOriginal: boolean
1617
}
1718

1819
export type TrackResult = {

packages/migrate-tool/src/pages/Admin.tsx

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,7 @@ export function Admin() {
177177
<div className="track-sub">
178178
{t.genre ?? 'Unknown'}
179179
{' · '}
180-
{t.isDownloadable ? 'original audio' : 'transcoded mp3'}
180+
{t.hasOriginal ? 'original audio' : 'mp3 only'}
181181
</div>
182182
</div>
183183
</li>

packages/migrate-tool/src/pages/Home.tsx

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -102,7 +102,8 @@ export function Home({ navigate }: Props) {
102102
genre: t.genre ?? null,
103103
durationSec: t.duration ?? null,
104104
artworkUrl: t.artwork?._150x150 ?? t.artwork?._480x480 ?? null,
105-
isDownloadable: Boolean(t.isDownloadable)
105+
isDownloadable: Boolean(t.isDownloadable),
106+
hasOriginal: Boolean(t.origFileCid) && t.isOriginalAvailable !== false
106107
}))
107108
if (previews.length === 0) {
108109
setError(`No tracks found for @${handle}.`)
@@ -211,10 +212,11 @@ export function Home({ navigate }: Props) {
211212
<div className="card">
212213
<h2>Step 3 — Review &amp; submit</h2>
213214
<div className="note">
214-
<strong>Heads up:</strong> tracks marked downloadable will migrate
215-
with the original audio file. Tracks not flagged as downloadable
216-
will migrate with the transcoded MP3 stream only — the original
217-
master isn't accessible to the developer app.
215+
<strong>Heads up:</strong> the worker migrates the original
216+
uploaded master whenever it's still on the network, regardless
217+
of whether the track is marked downloadable. The few tracks
218+
tagged below as <em>mp3 only</em> have no original on file and
219+
will migrate with the transcoded MP3 stream.
218220
</div>
219221
<p className="muted" style={{ marginTop: 0 }}>
220222
{tracks.length} track{tracks.length === 1 ? '' : 's'} will be
@@ -234,7 +236,7 @@ export function Home({ navigate }: Props) {
234236
<div className="track-sub">
235237
{t.genre ?? 'Unknown genre'}
236238
{' · '}
237-
{t.isDownloadable ? 'original audio' : 'transcoded mp3'}
239+
{t.hasOriginal ? 'original audio' : 'mp3 only'}
238240
</div>
239241
</div>
240242
</li>

packages/migrate-tool/src/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,6 +13,7 @@ export type TrackPreview = {
1313
durationSec?: number | null
1414
artworkUrl?: string | null
1515
isDownloadable: boolean
16+
hasOriginal: boolean
1617
}
1718

1819
export type TrackResult = {

0 commit comments

Comments
 (0)