diff --git a/CHANGELOG.md b/CHANGELOG.md index de10f9c..4dbc9eb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,7 @@ - Output controls: `--plain`, `--no-emoji`, `--no-color` (respects `NO_COLOR`). - `help` command: `bird help `. - 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 - CLI `--version`: read version from `package.json`/`VERSION` (no hardcoded string) + append git sha when available. diff --git a/README.md b/README.md index aca35d2..3067afd 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ Exit codes: - 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. -- `--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`). Example: diff --git a/src/index.ts b/src/index.ts index dcd4e28..84a67bd 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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({ authToken: opts.authToken, 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({ authToken: opts.authToken, ct0: opts.ct0, diff --git a/src/lib/twitter-client.ts b/src/lib/twitter-client.ts index 98751d3..ff5e5ab 100644 --- a/src/lib/twitter-client.ts +++ b/src/lib/twitter-client.ts @@ -387,11 +387,6 @@ export class TwitterClient { const category = this.mediaCategoryForMime(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 { const initParams = new URLSearchParams({ 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, { method: 'POST', headers: this.getJsonHeaders(), diff --git a/tests/twitter-client.test.ts b/tests/twitter-client.test.ts index 48ee5fc..013f37c 100644 --- a/tests/twitter-client.test.ts +++ b/tests/twitter-client.test.ts @@ -277,12 +277,37 @@ describe('TwitterClient', () => { 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 data = new Uint8Array([1, 2, 3]); - const result = await client.uploadMedia({ data, mimeType: 'video/mp4' }); - expect(result.success).toBe(false); - expect(result.error).toContain('Video uploads are not supported'); + const data = new Uint8Array([1, 2, 3, 4, 5]); + const result = await client.uploadMedia({ data, mimeType: 'video/mp4', alt: 'ignored' }); + + 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'); }); });