feat(graphql): support video uploads
This commit is contained in:
+1
-1
@@ -6,7 +6,7 @@
|
|||||||
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
|
- Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`).
|
||||||
- `help` command: `bird help <command>`.
|
- `help` command: `bird help <command>`.
|
||||||
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
|
- Runtime GraphQL query ID refresh: `bird query-ids --fresh` (cached on disk; auto-retry on 404; override cache via `BIRD_QUERY_IDS_CACHE`).
|
||||||
- GraphQL media uploads for images/GIFs via `--media` (video still requires Sweetistics).
|
- GraphQL media uploads via `--media` (up to 4 images/GIFs, or 1 video).
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
|
- CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available.
|
||||||
|
|||||||
@@ -130,7 +130,7 @@ Exit codes:
|
|||||||
|
|
||||||
- Attach media with `--media` (repeatable) and optional `--alt` per item.
|
- Attach media with `--media` (repeatable) and optional `--alt` per item.
|
||||||
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
|
- Up to 4 images/GIFs, or 1 video (no mixing). Supported: jpg, jpeg, png, webp, gif, mp4, mov.
|
||||||
- `--engine graphql`: images/GIFs supported (uploads via Twitter legacy upload endpoint + cookies).
|
- `--engine graphql`: images/GIFs + 1 video supported (uploads via Twitter legacy upload endpoint + cookies; video may take longer to process).
|
||||||
- `--engine sweetistics`: images + video supported (requires `SWEETISTICS_API_KEY`).
|
- `--engine sweetistics`: images + video supported (requires `SWEETISTICS_API_KEY`).
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
|
|||||||
@@ -407,12 +407,6 @@ program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasVideo = media.some((m) => m.mime.startsWith('video/'));
|
|
||||||
if (hasVideo) {
|
|
||||||
console.error(`${p('err')}Video uploads are only supported via Sweetistics (for now).`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentials({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
@@ -547,12 +541,6 @@ program
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const hasVideo = media.some((m) => m.mime.startsWith('video/'));
|
|
||||||
if (hasVideo) {
|
|
||||||
console.error(`${p('err')}Video uploads are only supported via Sweetistics (for now).`);
|
|
||||||
process.exit(1);
|
|
||||||
}
|
|
||||||
|
|
||||||
const { cookies, warnings } = await resolveCredentials({
|
const { cookies, warnings } = await resolveCredentials({
|
||||||
authToken: opts.authToken,
|
authToken: opts.authToken,
|
||||||
ct0: opts.ct0,
|
ct0: opts.ct0,
|
||||||
|
|||||||
@@ -387,11 +387,6 @@ export class TwitterClient {
|
|||||||
const category = this.mediaCategoryForMime(input.mimeType);
|
const category = this.mediaCategoryForMime(input.mimeType);
|
||||||
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` };
|
if (!category) return { success: false, error: `Unsupported media type: ${input.mimeType}` };
|
||||||
|
|
||||||
// Keep scope small: images/gif first. Video requires longer processing + larger chunks.
|
|
||||||
if (input.mimeType.startsWith('video/')) {
|
|
||||||
return { success: false, error: 'Video uploads are not supported via GraphQL yet (use Sweetistics)' };
|
|
||||||
}
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const initParams = new URLSearchParams({
|
const initParams = new URLSearchParams({
|
||||||
command: 'INIT',
|
command: 'INIT',
|
||||||
@@ -501,7 +496,7 @@ export class TwitterClient {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (input.alt) {
|
if (input.alt && input.mimeType.startsWith('image/')) {
|
||||||
const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, {
|
const metaResp = await this.fetchWithTimeout(TWITTER_MEDIA_METADATA_URL, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: this.getJsonHeaders(),
|
headers: this.getJsonHeaders(),
|
||||||
|
|||||||
@@ -277,12 +277,37 @@ describe('TwitterClient', () => {
|
|||||||
expect(JSON.parse(metaOptions.body)).toEqual({ media_id: '999', alt_text: { text: 'alt text' } });
|
expect(JSON.parse(metaOptions.body)).toEqual({ media_id: '999', alt_text: { text: 'alt text' } });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects video uploads', async () => {
|
it('uploads a video and polls processing status', async () => {
|
||||||
|
mockFetch
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ media_id_string: '777' }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ processing_info: { state: 'pending', check_after_secs: 0 } }),
|
||||||
|
})
|
||||||
|
.mockResolvedValueOnce({
|
||||||
|
ok: true,
|
||||||
|
json: async () => ({ processing_info: { state: 'succeeded' } }),
|
||||||
|
});
|
||||||
|
|
||||||
const client = new TwitterClient({ cookies: validCookies });
|
const client = new TwitterClient({ cookies: validCookies });
|
||||||
const data = new Uint8Array([1, 2, 3]);
|
const data = new Uint8Array([1, 2, 3, 4, 5]);
|
||||||
const result = await client.uploadMedia({ data, mimeType: 'video/mp4' });
|
const result = await client.uploadMedia({ data, mimeType: 'video/mp4', alt: 'ignored' });
|
||||||
expect(result.success).toBe(false);
|
|
||||||
expect(result.error).toContain('Video uploads are not supported');
|
expect(result.success).toBe(true);
|
||||||
|
expect(result.mediaId).toBe('777');
|
||||||
|
expect(mockFetch).toHaveBeenCalledTimes(4);
|
||||||
|
|
||||||
|
const [, finalizeOptions] = mockFetch.mock.calls[2];
|
||||||
|
expect((finalizeOptions.body as URLSearchParams).get('command')).toBe('FINALIZE');
|
||||||
|
|
||||||
|
const [statusUrl] = mockFetch.mock.calls[3];
|
||||||
|
expect(String(statusUrl)).toContain('command=STATUS');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user