|
1 | | -import type { Genre, Mood } from '@audius/sdk' |
| 1 | +import type { AudiusSdkWithServices, Genre, Mood, Track } from '@audius/sdk' |
2 | 2 |
|
3 | 3 | import { getServerSDK } from './audius' |
4 | 4 | import { getSupabase, TABLE } from './supabase' |
@@ -34,13 +34,115 @@ function blobToFile(blob: Blob, name: string): File { |
34 | 34 | return new File([blob], name, { type: blob.type || 'application/octet-stream' }) |
35 | 35 | } |
36 | 36 |
|
| 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 | + |
37 | 137 | /** |
38 | 138 | * Run the migration for one DB row. Updates the row in-place with per-track |
39 | 139 | * results as it goes, and sets the final status when done. |
40 | 140 | * |
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. |
44 | 146 | */ |
45 | 147 | export async function executeMigration(row: DbRow): Promise<void> { |
46 | 148 | const supabase = getSupabase() |
@@ -72,15 +174,15 @@ export async function executeMigration(row: DbRow): Promise<void> { |
72 | 174 | const track = trackRes.data |
73 | 175 | if (!track) throw new Error('Track not found on source account.') |
74 | 176 |
|
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 |
83 | 182 | ) |
| 183 | + const { blob: audioBlob, filename: audioFilename } = |
| 184 | + await fetchAudio(candidates) |
| 185 | + const audioFile = blobToFile(audioBlob, audioFilename) |
84 | 186 |
|
85 | 187 | let imageFile: File | undefined |
86 | 188 | const artworkUrl = |
|
0 commit comments