diff --git a/src/lib/twitter-client-utils.ts b/src/lib/twitter-client-utils.ts index a36d49e..60eb7e2 100644 --- a/src/lib/twitter-client-utils.ts +++ b/src/lib/twitter-client-utils.ts @@ -189,18 +189,21 @@ export function extractMedia(result: GraphqlTweetResult | undefined): TweetMedia // Extract video URL for video/animated_gif if ((item.type === 'video' || item.type === 'animated_gif') && item.video_info?.variants) { - // Find highest bitrate mp4 variant - const mp4Variants = item.video_info.variants - .filter((v): v is { bitrate: number; content_type: string; url: string } => - v.content_type === 'video/mp4' && typeof v.bitrate === 'number' && typeof v.url === 'string' - ) + // Prefer highest bitrate MP4, fall back to first MP4 when bitrate is missing. + const mp4Variants = item.video_info.variants.filter( + (v): v is { bitrate?: number; content_type: string; url: string } => + v.content_type === 'video/mp4' && typeof v.url === 'string', + ); + const mp4WithBitrate = mp4Variants + .filter((v): v is { bitrate: number; content_type: string; url: string } => typeof v.bitrate === 'number') .sort((a, b) => b.bitrate - a.bitrate); + const selectedVariant = mp4WithBitrate[0] ?? mp4Variants[0]; - if (mp4Variants.length > 0) { - mediaItem.videoUrl = mp4Variants[0].url; + if (selectedVariant) { + mediaItem.videoUrl = selectedVariant.url; } - if (item.video_info.duration_millis) { + if (typeof item.video_info.duration_millis === 'number') { mediaItem.durationMs = item.video_info.duration_millis; } } diff --git a/tests/extract-media.test.ts b/tests/extract-media.test.ts index 108a988..97998e8 100644 --- a/tests/extract-media.test.ts +++ b/tests/extract-media.test.ts @@ -110,6 +110,31 @@ describe('extractMedia', () => { }); }); + it('falls back to first mp4 when bitrate is missing', () => { + const result: GraphqlTweetResult = { + legacy: { + extended_entities: { + media: [ + { + type: 'video', + media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/123/img/thumb.jpg', + video_info: { + variants: [ + { content_type: 'video/mp4', url: 'https://video.twimg.com/no-bitrate.mp4' }, + { content_type: 'application/x-mpegURL', url: 'https://video.twimg.com/pl.m3u8' }, + ], + }, + }, + ], + }, + }, + }; + + const media = extractMedia(result); + expect(media).toHaveLength(1); + expect(media![0].videoUrl).toBe('https://video.twimg.com/no-bitrate.mp4'); + }); + it('extracts animated_gif with video url', () => { const result: GraphqlTweetResult = { legacy: { @@ -140,6 +165,29 @@ describe('extractMedia', () => { expect(media![0].durationMs).toBeUndefined(); }); + it('keeps zero duration for videos', () => { + const result: GraphqlTweetResult = { + legacy: { + extended_entities: { + media: [ + { + type: 'video', + media_url_https: 'https://pbs.twimg.com/ext_tw_video_thumb/zero.jpg', + video_info: { + duration_millis: 0, + variants: [{ content_type: 'video/mp4', url: 'https://video.twimg.com/zero.mp4' }], + }, + }, + ], + }, + }, + }; + + const media = extractMedia(result); + expect(media).toHaveLength(1); + expect(media![0].durationMs).toBe(0); + }); + it('handles multiple media items', () => { const result: GraphqlTweetResult = { legacy: {