use axum::{ extract::{Multipart, Path, Query, State}, http::StatusCode, response::Json, routing::{get, post}, Router, }; use chrono::{DateTime, Utc}; use serde::{Deserialize, Serialize}; use std::collections::HashMap; use std::sync::Arc; use tokio::sync::RwLock; use uuid::Uuid; use crate::shared::state::AppState; #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum Platform { Twitter, Facebook, Instagram, LinkedIn, TikTok, YouTube, Pinterest, Snapchat, Discord, Bluesky, Threads, WeChat, Reddit, } impl Platform { pub fn as_str(&self) -> &'static str { match self { Self::Twitter => "twitter", Self::Facebook => "facebook", Self::Instagram => "instagram", Self::LinkedIn => "linkedin", Self::TikTok => "tiktok", Self::YouTube => "youtube", Self::Pinterest => "pinterest", Self::Snapchat => "snapchat", Self::Discord => "discord", Self::Bluesky => "bluesky", Self::Threads => "threads", Self::WeChat => "wechat", Self::Reddit => "reddit", } } pub fn from_str(s: &str) -> Option { match s.to_lowercase().as_str() { "twitter" | "x" => Some(Self::Twitter), "facebook" | "fb" => Some(Self::Facebook), "instagram" | "ig" => Some(Self::Instagram), "linkedin" => Some(Self::LinkedIn), "tiktok" => Some(Self::TikTok), "youtube" | "yt" => Some(Self::YouTube), "pinterest" => Some(Self::Pinterest), "snapchat" | "snap" => Some(Self::Snapchat), "discord" => Some(Self::Discord), "bluesky" | "bsky" => Some(Self::Bluesky), "threads" => Some(Self::Threads), "wechat" => Some(Self::WeChat), "reddit" => Some(Self::Reddit), _ => None, } } } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum MediaType { Image, Video, Gif, Audio, Document, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] pub enum UploadStatus { Pending, Uploading, Processing, Ready, Failed, Expired, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct PlatformLimits { pub max_image_size_bytes: u64, pub max_video_size_bytes: u64, pub max_video_duration_seconds: u32, pub supported_image_formats: Vec, pub supported_video_formats: Vec, pub max_images_per_post: u32, pub max_videos_per_post: u32, pub image_dimensions: Option, pub video_dimensions: Option, pub requires_aspect_ratio: Option, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ImageDimensions { pub min_width: u32, pub min_height: u32, pub max_width: u32, pub max_height: u32, pub recommended_width: u32, pub recommended_height: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct VideoDimensions { pub min_width: u32, pub min_height: u32, pub max_width: u32, pub max_height: u32, pub min_fps: u32, pub max_fps: u32, pub min_bitrate_kbps: u32, pub max_bitrate_kbps: u32, } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct MediaUpload { pub id: Uuid, pub organization_id: Uuid, pub platform: Platform, pub media_type: MediaType, pub status: UploadStatus, pub original_filename: String, pub content_type: String, pub size_bytes: u64, pub width: Option, pub height: Option, pub duration_seconds: Option, pub local_path: Option, pub platform_media_id: Option, pub platform_url: Option, pub thumbnail_url: Option, pub error_message: Option, pub upload_progress: f32, pub created_at: DateTime, pub updated_at: DateTime, pub expires_at: Option>, pub metadata: HashMap, } #[derive(Debug, Deserialize)] pub struct UploadRequest { pub platform: String, pub media_type: Option, pub organization_id: Uuid, pub alt_text: Option, pub title: Option, pub description: Option, } #[derive(Debug, Deserialize)] pub struct ChunkedUploadInit { pub platform: String, pub organization_id: Uuid, pub filename: String, pub content_type: String, pub total_size: u64, pub media_type: String, } #[derive(Debug, Deserialize)] pub struct ChunkedUploadAppend { pub upload_id: Uuid, pub chunk_index: u32, pub total_chunks: u32, } #[derive(Debug, Deserialize)] pub struct ChunkedUploadFinalize { pub upload_id: Uuid, pub alt_text: Option, pub title: Option, } #[derive(Debug, Serialize)] pub struct UploadResponse { pub id: Uuid, pub status: String, pub platform_media_id: Option, pub platform_url: Option, pub thumbnail_url: Option, pub message: String, } #[derive(Debug, Serialize)] pub struct ChunkedUploadInitResponse { pub upload_id: Uuid, pub chunk_size: u64, pub total_chunks: u32, pub expires_at: DateTime, } #[derive(Debug, Serialize)] pub struct ChunkedUploadAppendResponse { pub upload_id: Uuid, pub chunks_received: u32, pub total_chunks: u32, pub progress: f32, } #[derive(Debug, Serialize)] pub struct MediaListResponse { pub media: Vec, pub total: u64, pub page: u32, pub per_page: u32, } #[derive(Debug, Deserialize)] pub struct MediaListQuery { pub platform: Option, pub status: Option, pub media_type: Option, pub page: Option, pub per_page: Option, } pub struct ChunkedUploadState { pub upload: MediaUpload, pub chunks_received: Vec, pub chunk_data: Vec>, pub chunk_size: u64, } pub struct MediaUploadService { uploads: Arc>>, chunked_uploads: Arc>>, platform_limits: HashMap, } impl Default for MediaUploadService { fn default() -> Self { Self::new() } } impl MediaUploadService { pub fn new() -> Self { let mut platform_limits = HashMap::new(); platform_limits.insert( Platform::Twitter, PlatformLimits { max_image_size_bytes: 5 * 1024 * 1024, max_video_size_bytes: 512 * 1024 * 1024, max_video_duration_seconds: 140, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into(), "webp".into()], supported_video_formats: vec!["mp4".into(), "mov".into()], max_images_per_post: 4, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 4, min_height: 4, max_width: 8192, max_height: 8192, recommended_width: 1200, recommended_height: 675, }), video_dimensions: Some(VideoDimensions { min_width: 32, min_height: 32, max_width: 1920, max_height: 1200, min_fps: 15, max_fps: 60, min_bitrate_kbps: 100, max_bitrate_kbps: 25000, }), requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Instagram, PlatformLimits { max_image_size_bytes: 8 * 1024 * 1024, max_video_size_bytes: 4 * 1024 * 1024 * 1024, max_video_duration_seconds: 3600, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into()], supported_video_formats: vec!["mp4".into(), "mov".into()], max_images_per_post: 10, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 320, min_height: 320, max_width: 1080, max_height: 1350, recommended_width: 1080, recommended_height: 1080, }), video_dimensions: Some(VideoDimensions { min_width: 500, min_height: 500, max_width: 1920, max_height: 1080, min_fps: 23, max_fps: 60, min_bitrate_kbps: 1000, max_bitrate_kbps: 25000, }), requires_aspect_ratio: Some("4:5 to 1.91:1".into()), }, ); platform_limits.insert( Platform::TikTok, PlatformLimits { max_image_size_bytes: 20 * 1024 * 1024, max_video_size_bytes: 4 * 1024 * 1024 * 1024, max_video_duration_seconds: 600, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "webp".into()], supported_video_formats: vec!["mp4".into(), "webm".into(), "mov".into()], max_images_per_post: 35, max_videos_per_post: 1, image_dimensions: None, video_dimensions: Some(VideoDimensions { min_width: 720, min_height: 1280, max_width: 1080, max_height: 1920, min_fps: 24, max_fps: 60, min_bitrate_kbps: 1000, max_bitrate_kbps: 50000, }), requires_aspect_ratio: Some("9:16".into()), }, ); platform_limits.insert( Platform::YouTube, PlatformLimits { max_image_size_bytes: 2 * 1024 * 1024, max_video_size_bytes: 256 * 1024 * 1024 * 1024, max_video_duration_seconds: 43200, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into()], supported_video_formats: vec![ "mp4".into(), "mov".into(), "avi".into(), "wmv".into(), "flv".into(), "webm".into(), "mkv".into(), "3gp".into(), ], max_images_per_post: 1, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 640, min_height: 360, max_width: 2560, max_height: 1440, recommended_width: 1280, recommended_height: 720, }), video_dimensions: Some(VideoDimensions { min_width: 426, min_height: 240, max_width: 7680, max_height: 4320, min_fps: 24, max_fps: 60, min_bitrate_kbps: 500, max_bitrate_kbps: 128000, }), requires_aspect_ratio: Some("16:9".into()), }, ); platform_limits.insert( Platform::LinkedIn, PlatformLimits { max_image_size_bytes: 8 * 1024 * 1024, max_video_size_bytes: 5 * 1024 * 1024 * 1024, max_video_duration_seconds: 600, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into()], supported_video_formats: vec!["mp4".into(), "mov".into(), "avi".into()], max_images_per_post: 9, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 552, min_height: 276, max_width: 7680, max_height: 4320, recommended_width: 1200, recommended_height: 627, }), video_dimensions: Some(VideoDimensions { min_width: 256, min_height: 144, max_width: 4096, max_height: 2304, min_fps: 15, max_fps: 60, min_bitrate_kbps: 500, max_bitrate_kbps: 30000, }), requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Facebook, PlatformLimits { max_image_size_bytes: 10 * 1024 * 1024, max_video_size_bytes: 10 * 1024 * 1024 * 1024, max_video_duration_seconds: 14400, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into(), "bmp".into(), "tiff".into()], supported_video_formats: vec!["mp4".into(), "mov".into(), "avi".into(), "wmv".into(), "mkv".into()], max_images_per_post: 10, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 320, min_height: 320, max_width: 4096, max_height: 4096, recommended_width: 1200, recommended_height: 630, }), video_dimensions: Some(VideoDimensions { min_width: 120, min_height: 120, max_width: 4096, max_height: 4096, min_fps: 15, max_fps: 60, min_bitrate_kbps: 500, max_bitrate_kbps: 50000, }), requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Pinterest, PlatformLimits { max_image_size_bytes: 32 * 1024 * 1024, max_video_size_bytes: 2 * 1024 * 1024 * 1024, max_video_duration_seconds: 3600, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into(), "webp".into()], supported_video_formats: vec!["mp4".into(), "mov".into()], max_images_per_post: 5, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 100, min_height: 100, max_width: 10000, max_height: 10000, recommended_width: 1000, recommended_height: 1500, }), video_dimensions: Some(VideoDimensions { min_width: 240, min_height: 240, max_width: 1920, max_height: 1080, min_fps: 15, max_fps: 60, min_bitrate_kbps: 1000, max_bitrate_kbps: 25000, }), requires_aspect_ratio: Some("2:3 recommended".into()), }, ); platform_limits.insert( Platform::Discord, PlatformLimits { max_image_size_bytes: 25 * 1024 * 1024, max_video_size_bytes: 500 * 1024 * 1024, max_video_duration_seconds: 0, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into(), "webp".into()], supported_video_formats: vec!["mp4".into(), "webm".into(), "mov".into()], max_images_per_post: 10, max_videos_per_post: 10, image_dimensions: None, video_dimensions: None, requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Bluesky, PlatformLimits { max_image_size_bytes: 1024 * 1024, max_video_size_bytes: 50 * 1024 * 1024, max_video_duration_seconds: 60, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into()], supported_video_formats: vec!["mp4".into()], max_images_per_post: 4, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 100, min_height: 100, max_width: 2000, max_height: 2000, recommended_width: 1000, recommended_height: 1000, }), video_dimensions: None, requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Threads, PlatformLimits { max_image_size_bytes: 8 * 1024 * 1024, max_video_size_bytes: 1024 * 1024 * 1024, max_video_duration_seconds: 300, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into()], supported_video_formats: vec!["mp4".into(), "mov".into()], max_images_per_post: 10, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 320, min_height: 320, max_width: 1440, max_height: 1800, recommended_width: 1080, recommended_height: 1350, }), video_dimensions: Some(VideoDimensions { min_width: 500, min_height: 500, max_width: 1920, max_height: 1080, min_fps: 23, max_fps: 60, min_bitrate_kbps: 1000, max_bitrate_kbps: 25000, }), requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Snapchat, PlatformLimits { max_image_size_bytes: 5 * 1024 * 1024, max_video_size_bytes: 1024 * 1024 * 1024, max_video_duration_seconds: 180, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into()], supported_video_formats: vec!["mp4".into(), "mov".into()], max_images_per_post: 1, max_videos_per_post: 1, image_dimensions: Some(ImageDimensions { min_width: 1080, min_height: 1920, max_width: 1080, max_height: 1920, recommended_width: 1080, recommended_height: 1920, }), video_dimensions: Some(VideoDimensions { min_width: 1080, min_height: 1920, max_width: 1080, max_height: 1920, min_fps: 24, max_fps: 30, min_bitrate_kbps: 1000, max_bitrate_kbps: 8000, }), requires_aspect_ratio: Some("9:16".into()), }, ); platform_limits.insert( Platform::WeChat, PlatformLimits { max_image_size_bytes: 10 * 1024 * 1024, max_video_size_bytes: 200 * 1024 * 1024, max_video_duration_seconds: 900, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into()], supported_video_formats: vec!["mp4".into()], max_images_per_post: 9, max_videos_per_post: 1, image_dimensions: None, video_dimensions: None, requires_aspect_ratio: None, }, ); platform_limits.insert( Platform::Reddit, PlatformLimits { max_image_size_bytes: 20 * 1024 * 1024, max_video_size_bytes: 1024 * 1024 * 1024, max_video_duration_seconds: 900, supported_image_formats: vec!["jpg".into(), "jpeg".into(), "png".into(), "gif".into()], supported_video_formats: vec!["mp4".into(), "mov".into()], max_images_per_post: 20, max_videos_per_post: 1, image_dimensions: None, video_dimensions: Some(VideoDimensions { min_width: 480, min_height: 270, max_width: 1920, max_height: 1080, min_fps: 15, max_fps: 60, min_bitrate_kbps: 500, max_bitrate_kbps: 20000, }), requires_aspect_ratio: None, }, ); Self { uploads: Arc::new(RwLock::new(HashMap::new())), chunked_uploads: Arc::new(RwLock::new(HashMap::new())), platform_limits, } } pub fn get_platform_limits(&self, platform: &Platform) -> Option<&PlatformLimits> { self.platform_limits.get(platform) } pub async fn upload_media( &self, platform: Platform, organization_id: Uuid, data: Vec, filename: String, content_type: String, alt_text: Option, ) -> Result { let limits = self .get_platform_limits(&platform) .ok_or_else(|| MediaUploadError::UnsupportedPlatform(platform.as_str().to_string()))?; let media_type = self.detect_media_type(&content_type); let extension = self.get_extension(&filename).unwrap_or_default(); self.validate_format(&media_type, &extension, limits)?; self.validate_size(&media_type, data.len() as u64, limits)?; let now = Utc::now(); let upload_id = Uuid::new_v4(); let mut upload = MediaUpload { id: upload_id, organization_id, platform: platform.clone(), media_type, status: UploadStatus::Uploading, original_filename: filename.clone(), content_type: content_type.clone(), size_bytes: data.len() as u64, width: None, height: None, duration_seconds: None, local_path: None, platform_media_id: None, platform_url: None, thumbnail_url: None, error_message: None, upload_progress: 0.0, created_at: now, updated_at: now, expires_at: Some(now + chrono::Duration::hours(24)), metadata: HashMap::new(), }; if let Some(alt) = alt_text { upload.metadata.insert("alt_text".into(), serde_json::json!(alt)); } let platform_result = self.upload_to_platform(&platform, &data, &upload).await?; upload.platform_media_id = Some(platform_result.media_id); upload.platform_url = platform_result.url; upload.thumbnail_url = platform_result.thumbnail_url; upload.status = UploadStatus::Ready; upload.upload_progress = 100.0; upload.updated_at = Utc::now(); { let mut uploads = self.uploads.write().await; uploads.insert(upload_id, upload.clone()); } Ok(upload) } pub async fn init_chunked_upload( &self, platform: Platform, organization_id: Uuid, filename: String, content_type: String, total_size: u64, media_type: MediaType, ) -> Result { let limits = self .get_platform_limits(&platform) .ok_or_else(|| MediaUploadError::UnsupportedPlatform(platform.as_str().to_string()))?; self.validate_size(&media_type, total_size, limits)?; let chunk_size: u64 = 5 * 1024 * 1024; let total_chunks = ((total_size as f64) / (chunk_size as f64)).ceil() as u32; let now = Utc::now(); let upload_id = Uuid::new_v4(); let upload = MediaUpload { id: upload_id, organization_id, platform: platform.clone(), media_type, status: UploadStatus::Pending, original_filename: filename, content_type, size_bytes: total_size, width: None, height: None, duration_seconds: None, local_path: None, platform_media_id: None, platform_url: None, thumbnail_url: None, error_message: None, upload_progress: 0.0, created_at: now, updated_at: now, expires_at: Some(now + chrono::Duration::hours(24)), metadata: HashMap::new(), }; let state = ChunkedUploadState { upload, chunks_received: vec![false; total_chunks as usize], chunk_data: vec![Vec::new(); total_chunks as usize], chunk_size, }; { let mut chunked = self.chunked_uploads.write().await; chunked.insert(upload_id, state); } Ok(ChunkedUploadInitResponse { upload_id, chunk_size, total_chunks, expires_at: now + chrono::Duration::hours(24), }) } pub async fn append_chunk( &self, upload_id: Uuid, chunk_index: u32, data: Vec, ) -> Result { let mut chunked = self.chunked_uploads.write().await; let state = chunked .get_mut(&upload_id) .ok_or(MediaUploadError::UploadNotFound)?; let total_chunks = state.chunks_received.len() as u32; if chunk_index >= total_chunks { return Err(MediaUploadError::InvalidChunkIndex); } if state.chunks_received[chunk_index as usize] { return Err(MediaUploadError::ChunkAlreadyReceived); } state.chunk_data[chunk_index as usize] = data; state.chunks_received[chunk_index as usize] = true; let chunks_done = state.chunks_received.iter().filter(|&&x| x).count() as u32; let progress = (chunks_done as f32 / total_chunks as f32) * 100.0; state.upload.upload_progress = progress; state.upload.status = UploadStatus::Uploading; state.upload.updated_at = Utc::now(); Ok(ChunkedUploadAppendResponse { upload_id, chunks_received: chunks_done, total_chunks, progress, }) } } #[derive(Debug, Clone)] pub enum MediaUploadError { UploadNotFound, InvalidChunkIndex, ChunkAlreadyReceived, UploadExpired, FileTooLarge, UnsupportedFormat, ProcessingError(String), StorageError(String), } impl std::fmt::Display for MediaUploadError { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { Self::UploadNotFound => write!(f, "Upload not found"), Self::InvalidChunkIndex => write!(f, "Invalid chunk index"), Self::ChunkAlreadyReceived => write!(f, "Chunk already received"), Self::UploadExpired => write!(f, "Upload expired"), Self::FileTooLarge => write!(f, "File too large"), Self::UnsupportedFormat => write!(f, "Unsupported format"), Self::ProcessingError(e) => write!(f, "Processing error: {e}"), Self::StorageError(e) => write!(f, "Storage error: {e}"), } } } impl std::error::Error for MediaUploadError {}