Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 9 additions & 0 deletions .changeset/sdk-upload-user-attribution.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
---
'@audius/sdk': major
---

Audio uploads and preview generation now carry the id of the user they are made for. Validator nodes attest the resulting cids to that user on chain, which is what makes them claimable on a track once content authorization is enforced; `generate_preview` additionally refuses users that do not already claim the source audio.

BREAKING: `userId` (encoded id) is now required on `tracks.uploadTrackFiles` and `uploads.createAudioUpload`, and `Storage.generatePreview` requires a decoded `userId`. The high-level methods (`createTrack`, `updateTrack`, `uploadTrack`, `publishTrack`) already required `userId` and now thread it through automatically — callers of those need no changes. The id is always explicit, never derived from auth state, because a manager or developer-app session can act for more than one user.

No signature or wallet is involved: the id is an assertion, and ownership is enforced where it always was — in the signed entity-manager write that names a cid on a track.
51 changes: 40 additions & 11 deletions packages/common/src/api/tan-query/upload/useUpload.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { useRef, useCallback } from 'react'

import { HashId, type UploadTrackFilesTask } from '@audius/sdk'
import { HashId, Id, type UploadTrackFilesTask } from '@audius/sdk'
import { useDispatch } from 'react-redux'

import { fileToSdk } from '~/adapters'
Expand All @@ -15,6 +15,7 @@ import {
UploadType
} from '~/store'

import { useCurrentAccount } from '../users/account/useCurrentAccount'
import { type QueryContextType, useQueryContext } from '../utils'

import { usePublishCollection } from './usePublishCollection'
Expand All @@ -31,7 +32,8 @@ const {

const getStemUploadTasks = async (
context: Pick<QueryContextType, 'audiusSdk' | 'dispatch'>,
tracks: TrackForUpload[]
tracks: TrackForUpload[],
userId: number
) => {
const sdk = await context.audiusSdk()
return tracks.flatMap(
Expand All @@ -40,6 +42,7 @@ const getStemUploadTasks = async (
const file = (stemFile as StemUploadWithFile).file
const task = sdk.tracks.uploadTrackFiles({
audioFile: fileToSdk(file, 'audio'),
userId: Id.parse(userId),
onProgress: (_, { key, loaded, total, transcode }) => {
context.dispatch(
updateProgress({
Expand Down Expand Up @@ -70,7 +73,8 @@ const getStemUploadTasks = async (

const getCoverArtUploadTasks = async (
context: Pick<QueryContextType, 'audiusSdk' | 'dispatch'>,
tracks: TrackForUpload[]
tracks: TrackForUpload[],
userId: number
) => {
const sdk = await context.audiusSdk()
return tracks
Expand All @@ -91,6 +95,7 @@ const getCoverArtUploadTasks = async (
const file = fileToSdk(t.metadata.artwork.file, 'cover_art')
const task = sdk.tracks.uploadTrackFiles({
imageFile: file,
userId: Id.parse(userId),
onProgress: (_, { key, loaded, total }) => {
context.dispatch(
uploadActions.updateProgress({
Expand Down Expand Up @@ -120,12 +125,14 @@ const getCoverArtUploadTasks = async (

const getTrackUploadTasks = async (
context: Pick<QueryContextType, 'audiusSdk' | 'dispatch'>,
tracks: TrackForUpload[]
tracks: TrackForUpload[],
userId: number
) => {
const sdk = await context.audiusSdk()
return tracks.map((t) => {
const task = sdk.tracks.uploadTrackFiles({
audioFile: fileToSdk(t.file, 'audio'),
userId: Id.parse(userId),
onProgress: (_, { key, loaded, total, transcode }) => {
context.dispatch(
uploadActions.updateProgress({
Expand Down Expand Up @@ -162,6 +169,18 @@ export const useUpload = (
audiusSdk,
analytics: { make, track }
} = useQueryContext()
// Uploads carry this user's id so the validator can attest on chain who the
// bytes were uploaded for, which is what makes the resulting cids claimable
// on a track. The SDK requires it, so a missing account fails loudly here
// rather than producing an unclaimable upload.
const { data: account } = useCurrentAccount()
const userId = account?.userId ?? undefined
const requireUserId = useCallback(() => {
if (userId === undefined) {
throw new Error('useUpload: no current user id — cannot upload')
}
return userId
}, [userId])

const { mutateAsync: publishTracksAsync } = usePublishTracks()
const { mutateAsync: publishCollectionAsync } = usePublishCollection()
Expand Down Expand Up @@ -208,7 +227,11 @@ export const useUpload = (
)
})

const tasks = await getTrackUploadTasks({ audiusSdk, dispatch }, tracks)
const tasks = await getTrackUploadTasks(
{ audiusSdk, dispatch },
tracks,
requireUserId()
)

// Store the upload tasks for potential aborting later
tasks.forEach((task, i) => {
Expand All @@ -217,7 +240,7 @@ export const useUpload = (

return await uploadFiles(tasks)
},
[audiusSdk, dispatch, uploadFiles, track, make]
[audiusSdk, dispatch, uploadFiles, track, make, requireUserId]
)

/**
Expand Down Expand Up @@ -265,11 +288,12 @@ export const useUpload = (
async (tracks: TrackForUpload[]) => {
const tasks = await getCoverArtUploadTasks(
{ audiusSdk, dispatch },
tracks
tracks,
requireUserId()
)
return await uploadFiles(tasks)
},
[audiusSdk, dispatch, uploadFiles]
[audiusSdk, dispatch, uploadFiles, requireUserId]
)

const uploadCollectionArtwork = useCallback(
Expand All @@ -295,6 +319,7 @@ export const useUpload = (

const uploadTask = sdk.tracks.uploadTrackFiles({
imageFile: fileToSdk(imageFile, 'artwork'),
userId: Id.parse(requireUserId()),
onProgress: (_, { key, loaded, total }) => {
dispatch(
updateProgress({
Expand All @@ -319,15 +344,19 @@ export const useUpload = (
{ ...uploadTask, clientId: 'collection-artwork', key: 'image' }
])
},
[audiusSdk, dispatch, uploadFiles]
[audiusSdk, dispatch, uploadFiles, requireUserId]
)

const uploadStemFiles = useCallback(
async (tracks: TrackForUpload[]) => {
const tasks = await getStemUploadTasks({ audiusSdk, dispatch }, tracks)
const tasks = await getStemUploadTasks(
{ audiusSdk, dispatch },
tracks,
requireUserId()
)
return await uploadFiles(tasks)
},
[audiusSdk, dispatch, uploadFiles]
[audiusSdk, dispatch, uploadFiles, requireUserId]
)

const startUpload = useCallback(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,7 @@ The [gated-upload example](https://github.com/AudiusProject/apps/tree/main/packa

**Client**

- OAuth login, upload via SDK (`uploadTrackFiles`, then `create-track` with the server), and streaming via `GET /stream/:trackId` (server redirects to signed URL).
- OAuth login, upload via SDK (`uploadTrackFiles` with the uploading user's `userId`, then `create-track` with the server), and streaming via `GET /stream/:trackId` (server redirects to signed URL).

Run the server from `packages/web/examples/gated-upload/server` with `AUDIUS_API_KEY`, `AUDIUS_BEARER_TOKEN`, and `SIGNER_PRIVATE_KEY` in `.env`. See the [README](https://github.com/AudiusProject/apps/blob/main/packages/web/examples/gated-upload/README.md) for full setup.

Expand Down
5 changes: 4 additions & 1 deletion packages/docs/docs/pages/sdk/tracks.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -199,14 +199,17 @@ Example:
```ts
import fs from 'fs'

// First, upload files using the Uploads API
// First, upload files using the Uploads API.
// Audio uploads name the user they are made for; the resulting CIDs are only
// claimable on that user's tracks.
const trackBuffer = fs.readFileSync('path/to/track.mp3')
const audioUpload = audiusSdk.uploads.createAudioUpload({
file: {
buffer: Buffer.from(trackBuffer),
name: 'track.mp3',
type: 'audio/mpeg',
},
userId: '7eP5n',
})
const audioResult = await audioUpload.start()

Expand Down
11 changes: 10 additions & 1 deletion packages/docs/docs/pages/sdk/uploads.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,12 @@ Uploads API to get CIDs, then pass those CIDs as metadata when creating or updat
Upload an audio file to a storage node. Returns the resulting CIDs and audio analysis metadata
(duration, BPM, musical key).

The `userId` names the user the upload is made for. Validator nodes record it on chain against the
resulting CIDs, which is what allows those CIDs to later be named on that user's track. Uploads made
without the correct user id cannot be published once content authorization is enforced. The id is
always passed explicitly — it is never inferred from your API key or session, because a manager or
developer app can act on behalf of more than one user.

Example:

```ts
Expand All @@ -35,6 +41,7 @@ const upload = audiusSdk.uploads.createAudioUpload({
name: 'track.mp3',
type: 'audio/mpeg',
},
userId: '7eP5n',
onProgress: ({ loaded, total }) => {
console.log(`Upload progress: ${Math.round((loaded / total) * 100)}%`)
},
Expand All @@ -52,6 +59,7 @@ Create an object with the following fields and pass it as the first argument.
| Name | Type | Description | Required? |
| :-------------------- | :---------------------------------------------------------------------- | :--------------------------------------------------- | :----------- |
| `file` | `File` | The audio file to upload | **Required** |
| `userId` | `string` | The ID of the user the audio is uploaded for | **Required** |
| `onProgress` | `(event: { loaded: number; total: number; transcode: number }) => void` | A callback for tracking upload progress | _Optional_ |
| `previewStartSeconds` | `number` | Start time in seconds for generating a preview clip | _Optional_ |
| `placementHosts` | `string[]` | A list of storage node hosts to prefer for placement | _Optional_ |
Expand Down Expand Up @@ -126,14 +134,15 @@ a track with the returned CIDs.
```ts
import fs from 'fs'

// Step 1: Upload the audio file
// Step 1: Upload the audio file for the user who will own the track
const trackBuffer = fs.readFileSync('path/to/track.mp3')
const audioUpload = audiusSdk.uploads.createAudioUpload({
file: {
buffer: Buffer.from(trackBuffer),
name: 'track.mp3',
type: 'audio/mpeg',
},
userId: '7eP5n',
previewStartSeconds: 30,
})
const audioResult = await audioUpload.start()
Expand Down
3 changes: 2 additions & 1 deletion packages/mobile/examples/upload/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -182,7 +182,8 @@ export default function App() {
uri: audioFile.uri,
name: audioFile.name ?? 'audio',
type: audioFile.mimeType ?? 'audio/mpeg'
}
},
userId: String(profile.id ?? '')
})

const imageUpload = coverUri
Expand Down
11 changes: 8 additions & 3 deletions packages/sdk/src/sdk/api/tracks/TracksApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,7 @@ export class TracksApi extends GeneratedTracksApi {
let totalProgressPercentage = 0
return {
start: async () => {
const { audioFile, imageFile, fileMetadata, onProgress } =
const { audioFile, imageFile, fileMetadata, userId, onProgress } =
await parseParams('uploadTrackFiles', UploadTrackFilesSchema)(params)

imageUpload = imageFile
Expand Down Expand Up @@ -214,6 +214,7 @@ export class TracksApi extends GeneratedTracksApi {
template: 'audio',
filename: audioFile.name ?? undefined,
filetype: audioFile.type ?? undefined,
userId,
placementHosts: fileMetadata?.placementHosts,
previewStartSeconds: fileMetadata?.previewStartSeconds
}
Expand Down Expand Up @@ -268,7 +269,8 @@ export class TracksApi extends GeneratedTracksApi {
async () =>
await this.storage.generatePreview({
cid: populatedMetadata.trackCid!,
secondOffset: populatedMetadata.previewStartSeconds!
secondOffset: populatedMetadata.previewStartSeconds!,
userId
}),
(e) => {
this.logger.info('Retrying generatePreview', e)
Expand Down Expand Up @@ -396,6 +398,7 @@ export class TracksApi extends GeneratedTracksApi {
await this.uploadTrackFiles({
audioFile: params.audioFile,
imageFile: params.imageFile,
userId: params.userId,
fileMetadata: {
placementHosts: params.metadata.placementHosts,
previewStartSeconds: params.metadata.previewStartSeconds
Expand Down Expand Up @@ -480,6 +483,7 @@ export class TracksApi extends GeneratedTracksApi {
await this.uploadTrackFiles({
audioFile: params.audioFile,
imageFile: params.imageFile,
userId: params.userId,
fileMetadata: {
placementHosts: params.metadata.placementHosts,
previewStartSeconds: params.metadata.previewStartSeconds
Expand Down Expand Up @@ -509,7 +513,8 @@ export class TracksApi extends GeneratedTracksApi {
async () =>
await this.storage.generatePreview({
cid: metadata.trackCid!,
secondOffset: metadata.previewStartSeconds!
secondOffset: metadata.previewStartSeconds!,
userId: decodeHashId(params.userId)!
}),
(e) => {
this.logger.info('Retrying generatePreview', e)
Expand Down
7 changes: 7 additions & 0 deletions packages/sdk/src/sdk/api/tracks/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -271,6 +271,13 @@ export type UploadTrackRequest = Omit<

export const UploadTrackFilesSchema = z
.object({
// The user the upload is made for. Required — and always explicit, never
// derived from auth state, because a manager or developer-app session can
// act for more than one user. Audio uploads carry it so the validator can
// attest on chain that the bytes were uploaded for this user, which is
// what makes the resulting cids claimable on a track. Without a claim,
// publishing fails once content authorization is enforced.
userId: HashId,
audioFile: z.optional(AudioFile),
imageFile: z.optional(ImageFile),
fileMetadata: z
Expand Down
15 changes: 15 additions & 0 deletions packages/sdk/src/sdk/api/uploads/UploadsApi.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { type ProgressHandler } from '../../services'
import type { CrossPlatformFile } from '../../types/File'
import { decodeHashId } from '../../utils/hashId'

import type { UploadsApiServicesConfig } from './types'

Expand All @@ -14,28 +15,42 @@ export class UploadsApi {
* Creates an audio file upload task that uploads to a validator and returns
* the resulting CIDs and analysis metadata.
*
* The user id names who the upload is made for; the validator attests the
* resulting cids to that user on chain, which is what lets them be named on
* a track. It is required — and always explicit, never derived from auth
* state, because a manager or developer-app session can act for more than
* one user.
*
* Optionally accepts a callback for tracking upload progress, a list of
* placement hosts to prefer for storage, and a start time in seconds
* for generating a preview clip.
*/
public createAudioUpload({
file,
userId,
onProgress,
previewStartSeconds,
placementHosts
}: {
file: CrossPlatformFile
/** The encoded id of the user the audio is uploaded for */
userId: string
onProgress?: ProgressHandler
placementHosts?: string[]
previewStartSeconds?: number
}) {
const decodedUserId = decodeHashId(userId)
if (decodedUserId === null) {
throw new Error(`createAudioUpload: could not decode userId "${userId}"`)
}
const upload = this.storage.uploadFile({
file,
onProgress,
metadata: {
template: 'audio',
filename: file.name ?? undefined,
filetype: file.type ?? undefined,
userId: decodedUserId,
previewStartSeconds,
placementHosts: placementHosts?.join(',')
}
Expand Down
Loading
Loading