diff --git a/crates/xmedia-bot/src/handlers.rs b/crates/xmedia-bot/src/handlers.rs index 19f8114..971586b 100644 --- a/crates/xmedia-bot/src/handlers.rs +++ b/crates/xmedia-bot/src/handlers.rs @@ -569,7 +569,9 @@ fn build_send_task( chat_id, reply_to_message_id: message.id.0 as i64, caption, - media_batches: send::chunk_media_items(items), + // Photos first so a mixed photo+video group starts with a photo + // (Telegram's sendMediaGroup rule); order within each kind is kept. + media_batches: send::chunk_media_items(send::photos_first(items)), batch_index: 0, sent_message_ids: vec![], source_url, diff --git a/crates/xmedia-bot/src/send.rs b/crates/xmedia-bot/src/send.rs index 9706f5a..74049bc 100644 --- a/crates/xmedia-bot/src/send.rs +++ b/crates/xmedia-bot/src/send.rs @@ -296,6 +296,19 @@ pub fn chunk_media_items(items: Vec) -> Vec> { .collect() } +/// Orders media for a Telegram media group: when photos and videos are +/// mixed, the first item must be a photo (Telegram's sendMediaGroup rule). +/// Stable sort keeps the source order within each kind; a lone animation is +/// untouched (it takes the SendAnimation path before this runs). +pub fn photos_first(items: Vec) -> Vec { + let mut items = items; + items.sort_by_key(|item| match item { + MediaItemPayload::Photo { .. } => 0, + MediaItemPayload::Video { .. } | MediaItemPayload::Animation { .. } => 1, + }); + items +} + /// Exponential backoff with jitter, capped at 30s. pub fn retry_delay_seconds(attempts: u32) -> f64 { let jitter: f64 = rand::thread_rng().gen_range(0.2..0.8); @@ -1355,6 +1368,44 @@ mod tests { ); } + #[test] + fn photos_first_orders_photos_before_videos() { + use MediaItemPayload::{Animation, Photo, Video}; + let photo = |u: &str| Photo { + media: u.into(), + has_spoiler: false, + fallback_url: None, + file_id: false, + }; + let video = |u: &str| Video { + media: u.into(), + has_spoiler: false, + thumbnail: None, + fallback_url: None, + file_id: false, + }; + let items = vec![ + video("https://v/1.mp4"), + photo("https://p/1.jpg"), + video("https://v/2.mp4"), + photo("https://p/2.jpg"), + ]; + let ordered = photos_first(items); + // All photos first (stable: p1 before p2), then all videos in order. + let kinds: Vec<&str> = ordered.iter().map(|i| match i { + Photo { media, .. } => media.as_str(), + Video { media, .. } => media.as_str(), + Animation { .. } => unreachable!(), + }).collect(); + assert_eq!( + kinds, + ["https://p/1.jpg", "https://p/2.jpg", "https://v/1.mp4", "https://v/2.mp4"] + ); + // Already-photos-first input is unchanged. + let items = vec![photo("https://p/1.jpg"), video("https://v/1.mp4")]; + assert!(matches!(photos_first(items)[0], Photo { .. })); + } + #[test] fn retry_delay_seconds_bounds() { for attempts in 0..10 {