Skip to main content

matrixcode_tui/
image_utils.rs

1//! Image Search - Real API implementation
2//!
3//! Calls Unsplash, Pexels, and Pixabay APIs to get actual image URLs
4
5use anyhow::Result;
6use serde::{Deserialize, Serialize};
7use std::time::Duration;
8
9/// Unsplash API response
10#[derive(Debug, Deserialize)]
11struct UnsplashResponse {
12    results: Vec<UnsplashPhoto>,
13}
14
15#[derive(Debug, Deserialize)]
16struct UnsplashPhoto {
17    id: String,
18    urls: UnsplashUrls,
19    description: Option<String>,
20    alt_description: Option<String>,
21    user: UnsplashUser,
22    width: u32,
23    height: u32,
24}
25
26#[derive(Debug, Deserialize)]
27struct UnsplashUrls {
28    regular: String,
29    full: String,
30    thumb: String,
31}
32
33#[derive(Debug, Deserialize)]
34struct UnsplashUser {
35    name: String,
36    links: UnsplashLinks,
37}
38
39#[derive(Debug, Deserialize)]
40struct UnsplashLinks {
41    html: String,
42}
43
44/// Pexels API response
45#[derive(Debug, Deserialize)]
46struct PexelsResponse {
47    photos: Vec<PexelsPhoto>,
48}
49
50#[derive(Debug, Deserialize)]
51struct PexelsPhoto {
52    id: u32,
53    src: PexelsSrc,
54    alt: Option<String>,
55    photographer: String,
56    photographer_url: String,
57    width: u32,
58    height: u32,
59}
60
61#[derive(Debug, Deserialize)]
62struct PexelsSrc {
63    large: String,
64    original: String,
65    medium: String,
66}
67
68/// Pixabay API response
69#[derive(Debug, Deserialize)]
70struct PixabayResponse {
71    hits: Vec<PixabayHit>,
72}
73
74#[derive(Debug, Deserialize)]
75struct PixabayHit {
76    id: u32,
77    #[serde(rename = "webformatURL")]
78    webformat_url: String,
79    #[serde(rename = "largeImageURL")]
80    large_image_url: String,
81    #[serde(rename = "previewURL")]
82    preview_url: String,
83    tags: Option<String>,
84    user: String,
85    user_id: u32,
86    #[serde(rename = "webformatWidth")]
87    webformat_width: u32,
88    #[serde(rename = "webformatHeight")]
89    webformat_height: u32,
90}
91
92/// Normalized image result
93#[derive(Debug, Serialize, Clone)]
94pub struct ImageResult {
95    pub id: String,
96    pub url: String,
97    pub full_url: String,
98    pub thumb_url: String,
99    pub description: String,
100    pub photographer: String,
101    pub photographer_url: String,
102    pub width: u32,
103    pub height: u32,
104    pub platform: String,
105    /// URL availability validated
106    pub validated: bool,
107}
108
109/// Get API keys from environment variables (secure approach)
110/// Keys should be set in .env or config file, never hardcoded
111fn get_unsplash_key() -> Option<String> {
112    std::env::var("UNSPLASH_ACCESS_KEY").ok()
113}
114
115fn get_pexels_key() -> Option<String> {
116    std::env::var("PEXELS_API_KEY").ok()
117}
118
119fn get_pixabay_key() -> Option<String> {
120    std::env::var("PIXABAY_API_KEY").ok()
121}
122
123/// Validate image URL accessibility (HEAD request)
124async fn validate_image_url(client: &reqwest::Client, url: &str) -> bool {
125    if url.is_empty() {
126        return false;
127    }
128    
129    match client.head(url).send().await {
130        Ok(resp) => {
131            let status = resp.status();
132            if status.is_success() || status.as_u16() == 302 {
133                // Check content-type if available
134                if let Some(content_type) = resp.headers().get("content-type")
135                    && let Ok(ct) = content_type.to_str() {
136                        return ct.starts_with("image/");
137                    }
138                // Some servers don't return content-type for HEAD, assume valid
139                return status.is_success();
140            }
141            false
142        }
143        Err(e) => {
144            log::debug!("URL validation failed for {}: {}", url, e);
145            false
146        }
147    }
148}
149
150/// Validate image URL with GET request (fallback when HEAD fails)
151async fn validate_image_url_get(client: &reqwest::Client, url: &str) -> bool {
152    if url.is_empty() {
153        return false;
154    }
155    
156    // Only fetch first 1KB to verify it's accessible
157    match client
158        .get(url)
159        .header("Range", "bytes=0-1023")
160        .timeout(Duration::from_secs(5))
161        .send()
162        .await
163    {
164        Ok(resp) => {
165            let status = resp.status();
166            status.is_success() || status.as_u16() == 206 // Partial Content
167        }
168        Err(_) => false,
169    }
170}
171
172/// Search Unsplash API
173pub async fn search_unsplash(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
174    let key = get_unsplash_key().ok_or_else(|| {
175        anyhow::anyhow!("UNSPLASH_ACCESS_KEY not set in environment")
176    })?;
177
178    let client = reqwest::Client::builder()
179        .timeout(Duration::from_secs(10))
180        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
181        .build()?;
182
183    let url = format!(
184        "https://api.unsplash.com/search/photos?query={}&per_page={}&page=1",
185        query, per_page
186    );
187    let response = client
188        .get(&url)
189        .header("Authorization", format!("Client-ID {}", key))
190        .send()
191        .await?;
192    
193    if !response.status().is_success() {
194        log::warn!("Unsplash API error: {}", response.status());
195        return Ok(vec![]);
196    }
197    
198    let data: UnsplashResponse = response.json().await?;
199    
200    Ok(data.results.into_iter().map(|photo| ImageResult {
201        id: photo.id,
202        url: photo.urls.regular,
203        full_url: photo.urls.full,
204        thumb_url: photo.urls.thumb,
205        description: photo.description.or(photo.alt_description).unwrap_or_else(|| "无描述".to_string()),
206        photographer: photo.user.name,
207        photographer_url: photo.user.links.html,
208        width: photo.width,
209        height: photo.height,
210        platform: "Unsplash".to_string(),
211        validated: false, // Will be validated later
212    }).collect())
213}
214
215/// Search Pexels API
216pub async fn search_pexels(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
217    let key = get_pexels_key().ok_or_else(|| {
218        anyhow::anyhow!("PEXELS_API_KEY not set in environment")
219    })?;
220
221    let client = reqwest::Client::builder()
222        .timeout(Duration::from_secs(10))
223        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
224        .build()?;
225
226    let url = format!(
227        "https://api.pexels.com/v1/search?query={}&per_page={}&page=1&locale=zh-CN",
228        query, per_page
229    );
230    let response = client
231        .get(&url)
232        .header("Authorization", key)
233        .send()
234        .await?;
235    
236    if !response.status().is_success() {
237        log::warn!("Pexels API error: {}", response.status());
238        return Ok(vec![]);
239    }
240    
241    let data: PexelsResponse = response.json().await?;
242    
243    Ok(data.photos.into_iter().map(|photo| ImageResult {
244        id: photo.id.to_string(),
245        url: photo.src.large,
246        full_url: photo.src.original,
247        thumb_url: photo.src.medium,
248        description: photo.alt.unwrap_or_else(|| "无描述".to_string()),
249        photographer: photo.photographer,
250        photographer_url: photo.photographer_url,
251        width: photo.width,
252        height: photo.height,
253        platform: "Pexels".to_string(),
254        validated: false,
255    }).collect())
256}
257
258/// Search Pixabay API
259pub async fn search_pixabay(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
260    let key = get_pixabay_key().ok_or_else(|| {
261        anyhow::anyhow!("PIXABAY_API_KEY not set in environment")
262    })?;
263
264    let client = reqwest::Client::builder()
265        .timeout(Duration::from_secs(10))
266        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
267        .build()?;
268
269    let url = format!(
270        "https://pixabay.com/api/?key={}&q={}&per_page={}&page=1&image_type=photo&safesearch=true",
271        key, query, per_page
272    );
273    let response = client
274        .get(&url)
275        .send()
276        .await?;
277    
278    if !response.status().is_success() {
279        log::warn!("Pixabay API error: {}", response.status());
280        return Ok(vec![]);
281    }
282    
283    let data: PixabayResponse = response.json().await?;
284    
285    Ok(data.hits.into_iter().map(|hit| {
286        let user = hit.user.clone();
287        ImageResult {
288            id: hit.id.to_string(),
289            url: hit.webformat_url,
290            full_url: hit.large_image_url,
291            thumb_url: hit.preview_url,
292            description: hit.tags.unwrap_or_else(|| "无描述".to_string()),
293            photographer: user.clone(),
294            photographer_url: format!("https://pixabay.com/users/{}/{}", user, hit.user_id),
295            width: hit.webformat_width,
296            height: hit.webformat_height,
297            platform: "Pixabay".to_string(),
298            validated: false,
299        }
300    }).collect())
301}
302
303/// Validate all image URLs concurrently and filter out invalid ones
304/// Returns only images with validated URLs
305async fn validate_images(images: Vec<ImageResult>) -> Vec<ImageResult> {
306    if images.is_empty() {
307        return images;
308    }
309    
310    let client = reqwest::Client::builder()
311        .timeout(Duration::from_secs(5))
312        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
313        .danger_accept_invalid_certs(true) // Some CDNs may have cert issues
314        .build()
315        .unwrap_or_else(|_| reqwest::Client::new());
316    
317    log::info!("Validating {} image URLs...", images.len());
318    
319    // Validate all images concurrently
320    let mut tasks = Vec::new();
321    for img in images {
322        let client = client.clone();
323        let url = img.url.clone();
324        
325        let task = tokio::spawn(async move {
326            // Try HEAD first (faster)
327            let valid = validate_image_url(&client, &url).await;
328            
329            // If HEAD fails (some servers don't support HEAD), try GET
330            let valid = if !valid {
331                log::debug!("HEAD failed for {}, trying GET...", url);
332                validate_image_url_get(&client, &url).await
333            } else {
334                true
335            };
336            
337            if valid {
338                log::debug!("URL validated: {}", url);
339            } else {
340                log::debug!("URL invalid: {}", url);
341            }
342            
343            (img, valid)
344        });
345        
346        tasks.push(task);
347    }
348    
349    // Execute all validations concurrently
350    let results = futures_util::future::join_all(tasks).await;
351    
352    // Filter and return only validated images
353    let mut validated = Vec::new();
354    let total_count = results.len();
355    
356    for result in results {
357        if let Ok((mut img, valid)) = result
358            && valid {
359                img.validated = true;
360                validated.push(img);
361            }
362    }
363    
364    log::info!("Validated {}/{} images", validated.len(), total_count);
365    validated
366}
367
368/// Search all platforms and return combined results
369/// Only searches platforms that have API keys configured
370/// Validates all image URLs before returning
371pub async fn search_all(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
372    let mut all_results = Vec::new();
373    let mut errors = Vec::new();
374
375    // Search each platform (only if key is available)
376    if get_unsplash_key().is_some() {
377        match search_unsplash(query, per_page).await {
378            Ok(results) => all_results.extend(results),
379            Err(e) => errors.push(format!("Unsplash: {}", e)),
380        }
381    }
382
383    if get_pexels_key().is_some() {
384        match search_pexels(query, per_page).await {
385            Ok(results) => all_results.extend(results),
386            Err(e) => errors.push(format!("Pexels: {}", e)),
387        }
388    }
389
390    if get_pixabay_key().is_some() {
391        match search_pixabay(query, per_page).await {
392            Ok(results) => all_results.extend(results),
393            Err(e) => errors.push(format!("Pixabay: {}", e)),
394        }
395    }
396
397    // If no keys configured, return error
398    if all_results.is_empty() && errors.is_empty() {
399        return Err(anyhow::anyhow!(
400            "No image search API keys configured. Set UNSPLASH_ACCESS_KEY, PEXELS_API_KEY, or PIXABAY_API_KEY in environment."
401        ));
402    }
403
404    // Log errors but return results if any succeeded
405    if !errors.is_empty() && all_results.is_empty() {
406        return Err(anyhow::anyhow!("All searches failed: {}", errors.join("; ")));
407    }
408
409    for e in &errors {
410        log::warn!("Image search partial error: {}", e);
411    }
412
413    // Validate all image URLs
414    let total_count = all_results.len();
415    let validated_results = validate_images(all_results).await;
416    
417    // Return error if all images failed validation
418    if validated_results.is_empty() && !errors.is_empty() {
419        return Err(anyhow::anyhow!(
420            "All {} images failed URL validation. Errors: {}",
421            total_count,
422            errors.join("; ")
423        ));
424    }
425
426    Ok(validated_results)
427}