use std::fs; use serde::Deserialize; use serde_json::{json, Value}; use reqwest::blocking::Client; use reqwest::{self, multipart}; use std::error::Error; use base64::{Engine as _, engine::general_purpose::STANDARD}; #[derive(Debug, Deserialize)] struct Config { pixelfed_url: String, access_token: String, visibility: String, // Should be "unlisted" default_text: String, batch_size: usize, openai_api_key: String, openai_api_url: String, openai_model: String } fn load_config() -> Result> { let config_str = fs::read_to_string("config.json")?; let config: Config = serde_json::from_str(&config_str)?; Ok(config) } fn get_jpeg_files(directory: &str) -> Vec { let mut images = Vec::new(); if let Ok(entries) = fs::read_dir(directory) { for entry in entries.flatten() { let path = entry.path(); if let Some(ext) = path.extension() { if ext.eq_ignore_ascii_case("jpg") || ext.eq_ignore_ascii_case("jpeg") { images.push(path.to_string_lossy().to_string()); } } } } images } async fn get_image_description(config: &Config, image_path: &String) -> Result> { // Read and encode image let image_data = tokio::fs::read(image_path) .await .map_err(|e| format!("Failed to read image file: {}", e))?; let base64_image = STANDARD.encode(&image_data); // Create ChatGPT API request let client = reqwest::Client::new(); let response = client .post(&config.openai_api_url) .header("Authorization", format!("Bearer {}", config.openai_api_key)) .header("Content-Type", "application/json") .json(&json!({ "model": config.openai_model, "max_tokens": 300, "messages": [ { "role": "user", "content": [ { "type": "text", "text": "Please describe this image concisely for use as an alt text description. Focus on key visual elements and context." }, { "type": "image_url", "image_url": { "url": format!("data:image/jpeg;base64,{}", base64_image) } } ] } ] })) .send() .await?; // Improved error handling for API response if !response.status().is_success() { let error_text = response.text().await?; return Err(format!("OpenAI API error: {}", error_text).into()); } let result: Value = response.json().await?; // More detailed error handling for JSON parsing let description = result["choices"] .get(0) .ok_or("No choices in response")? ["message"]["content"] .as_str() .ok_or("Invalid content format in response")? .to_string(); Ok(description) } fn format_post_text(template: &str, batch_num: usize, total_batches: usize, title: &str) -> String { template .replace("@batch@", &format!("Batch {} out of {}", batch_num, total_batches)) .replace("@title@", title) } async fn upload_images_batch(client: &Client, config: &Config, images: &[String], batch_num: usize, total_batches: usize, title: &str) -> Result<(), Box> { let url = format!("{}/api/v1/media", config.pixelfed_url); let mut media_ids = Vec::new(); let mut media_descriptions = Vec::new(); for image_path in images { println!("Fetching image description from OpenAI for {}", image_path.to_string()); let image_description: String ; if !config.openai_api_key.is_empty() { image_description = get_image_description(&config, &image_path).await?; println!("DESC:\n {} \n", &image_description); media_descriptions.push(image_description.clone()); } else { image_description = String::new(); } println!("Uploading image {}", image_path.to_string()); let form = reqwest::blocking::multipart::Form::new().text("description", image_description.clone()) .file("file", image_path)?; let res = client.post(&url) .bearer_auth(&config.access_token) .multipart(form) .send()?; let json: serde_json::Value = res.json()?; if let Some(id) = json["id"].as_str() { media_ids.push(id.to_string()); } } if !media_ids.is_empty() { let post_url = format!("{}/api/v1/statuses", config.pixelfed_url); let post_text = format_post_text(&config.default_text, batch_num, total_batches, title); let body = serde_json::json!({ "status": post_text, "media_ids": media_ids, "alt_texts": media_descriptions, "visibility": config.visibility, }); println!("Posting batch {} out of {} with media {}", batch_num, total_batches, media_ids.len()); client.post(&post_url) .bearer_auth(&config.access_token) .json(&body) .send()?; } Ok(()) } #[tokio::main] async fn main() -> Result<(), Box> { let args: Vec = std::env::args().collect(); if args.len() < 2 { eprintln!("Usage: {} [--title ]", args[0]); std::process::exit(1); } let mut title = "".to_string(); if let Some(index) = args.iter().position(|x| x == "--title") { if index + 1 < args.len() { title = args[index + 1].clone(); } } let config = load_config()?; let images = get_jpeg_files(&args[1]); let client = Client::new(); let total_batches = (images.len() + config.batch_size - 1) / config.batch_size; println!("Found a total of {} images to upload. Will take {} batches", images.len(), total_batches); for (i, chunk) in images.chunks(config.batch_size).enumerate() { upload_images_batch(&client, &config, chunk, i + 1, total_batches, &title).await?; } println!("All images uploaded successfully."); Ok(()) }