Skip to content

Commit a0b6ad7

Browse files
authored
feat: add v2 upload media method (#562)
* feat: add v2 upload media method * tests: add v2.uploadMedia tests * chore(waitForMediaProcessing): recursively poll for media processing status
1 parent 28c87b0 commit a0b6ad7

File tree

4 files changed

+141
-0
lines changed

4 files changed

+141
-0
lines changed

src/types/v2/media.v2.types.ts

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,37 @@
1+
export interface MediaV2UploadInitParams {
2+
command: 'INIT';
3+
media_type: string;
4+
total_bytes: number;
5+
media_category?: string;
6+
}
7+
8+
export interface MediaV2UploadAppendParams {
9+
command: 'APPEND';
10+
media_id: string;
11+
segment_index: number;
12+
media: Buffer | string;
13+
}
14+
15+
export interface MediaV2UploadFinalizeParams {
16+
command: 'FINALIZE';
17+
media_id: string;
18+
}
19+
20+
export interface MediaV2ProcessingInfo {
21+
state: 'pending' | 'in_progress' | 'failed' | 'succeeded';
22+
check_after_secs?: number;
23+
error?: {
24+
code: number;
25+
message: string;
26+
};
27+
}
28+
29+
export interface MediaV2UploadResponse {
30+
data: {
31+
id: string;
32+
media_key: string;
33+
size?: number;
34+
expires_after_secs: number;
35+
processing_info?: MediaV2ProcessingInfo;
36+
};
37+
}

src/v2/client.v2.write.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import type {
2323
} from '../types';
2424
import TwitterApiv2LabsReadWrite from '../v2-labs/client.v2.labs.write';
2525
import { CreateDMConversationParams, PostDMInConversationParams, PostDMInConversationResult } from '../types/v2/dm.v2.types';
26+
import { MediaV2UploadAppendParams, MediaV2UploadFinalizeParams, MediaV2UploadInitParams, MediaV2UploadResponse } from '../types/v2/media.v2.types';
2627

2728
/**
2829
* Base Twitter v2 client with read/write rights.
@@ -121,6 +122,85 @@ export default class TwitterApiv2ReadWrite extends TwitterApiv2ReadOnly {
121122
return this.post<TweetV2PostTweetResult>('tweets', payload);
122123
}
123124

125+
/**
126+
* Uploads media to Twitter using chunked upload.
127+
* https://docs.x.com/x-api/media/media-upload
128+
*
129+
* @param media The media buffer to upload
130+
* @param options Upload options including media type and category
131+
* @param chunkSize Size of each chunk in bytes (default: 1MB)
132+
* @returns The media ID of the uploaded media
133+
*/
134+
public async uploadMedia(
135+
media: Buffer,
136+
options: { media_type: string; media_category?: string },
137+
chunkSize: number = 1024 * 1024
138+
): Promise<string> {
139+
const initArguments: MediaV2UploadInitParams = {
140+
command: 'INIT',
141+
media_type: options.media_type,
142+
total_bytes: media.length,
143+
media_category: options.media_category,
144+
};
145+
146+
const initResponse = await this.post<MediaV2UploadResponse>('media/upload', initArguments);
147+
const mediaId = initResponse.data.id;
148+
149+
const chunks = Math.ceil(media.length / chunkSize);
150+
for (let i = 0; i < chunks; i++) {
151+
const start = i * chunkSize;
152+
const end = Math.min(start + chunkSize, media.length);
153+
const mediaChunk = Uint8Array.prototype.slice.call(media, start, end);
154+
const chunkedBuffer = Buffer.from(mediaChunk);
155+
156+
const appendArguments: MediaV2UploadAppendParams = {
157+
command: 'APPEND',
158+
media_id: mediaId,
159+
segment_index: i,
160+
media: chunkedBuffer,
161+
};
162+
163+
await this.post('media/upload', appendArguments);
164+
}
165+
166+
const finalizeArguments: MediaV2UploadFinalizeParams = {
167+
command: 'FINALIZE',
168+
media_id: mediaId,
169+
};
170+
171+
const finalizeResponse = await this.post<MediaV2UploadResponse>('media/upload', finalizeArguments);
172+
if (finalizeResponse.data.processing_info) {
173+
await this.waitForMediaProcessing(mediaId);
174+
}
175+
176+
return mediaId;
177+
}
178+
179+
private async waitForMediaProcessing(mediaId: string): Promise<void> {
180+
const response = await this.get<MediaV2UploadResponse>('media/upload', {
181+
command: 'STATUS',
182+
media_id: mediaId,
183+
});
184+
185+
const info = response.data.processing_info;
186+
if (!info) return;
187+
188+
switch (info.state) {
189+
case 'succeeded':
190+
return;
191+
case 'failed':
192+
throw new Error(`Media processing failed: ${info.error?.message}`);
193+
case 'pending':
194+
case 'in_progress': {
195+
const waitTime = info?.check_after_secs;
196+
if(waitTime && waitTime > 0) {
197+
await new Promise(resolve => setTimeout(resolve, waitTime * 1000));
198+
await this.waitForMediaProcessing(mediaId);
199+
}
200+
}
201+
}
202+
}
203+
124204
/**
125205
* Reply to a Tweet on behalf of an authenticated user.
126206
* https://developer.x.com/en/docs/twitter-api/tweets/manage-tweets/api-reference/post-tweets

test/media-upload.v2.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
import 'mocha';
2+
import { expect } from 'chai';
3+
import { EUploadMimeType, TwitterApi } from '../src';
4+
import { getUserClient } from '../src/test/utils';
5+
import * as fs from 'fs';
6+
import * as path from 'path';
7+
8+
let client: TwitterApi;
9+
10+
const gifImg = path.resolve(__dirname, 'assets', 'pec.gif');
11+
const maxTimeout = 1000 * 60;
12+
13+
describe('Media upload for v2 API', () => {
14+
before(() => {
15+
client = getUserClient();
16+
});
17+
18+
it('Upload a GIF image from buffer', async () => {
19+
// Upload media (from buffer)
20+
const mediaId = await client.v2.uploadMedia(await fs.promises.readFile(gifImg), { media_type: EUploadMimeType.Gif });
21+
expect(mediaId).to.be.an('string');
22+
expect(mediaId).to.have.length.greaterThan(0);
23+
}).timeout(maxTimeout);
24+
});

0 commit comments

Comments
 (0)