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                {
137                    return ct.starts_with("image/");
138                }
139                // Some servers don't return content-type for HEAD, assume valid
140                return status.is_success();
141            }
142            false
143        }
144        Err(e) => {
145            log::debug!("URL validation failed for {}: {}", url, e);
146            false
147        }
148    }
149}
150
151/// Validate image URL with GET request (fallback when HEAD fails)
152async fn validate_image_url_get(client: &reqwest::Client, url: &str) -> bool {
153    if url.is_empty() {
154        return false;
155    }
156
157    // Only fetch first 1KB to verify it's accessible
158    match client
159        .get(url)
160        .header("Range", "bytes=0-1023")
161        .timeout(Duration::from_secs(5))
162        .send()
163        .await
164    {
165        Ok(resp) => {
166            let status = resp.status();
167            status.is_success() || status.as_u16() == 206 // Partial Content
168        }
169        Err(_) => false,
170    }
171}
172
173/// Search Unsplash API
174pub async fn search_unsplash(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
175    let key = get_unsplash_key()
176        .ok_or_else(|| anyhow::anyhow!("UNSPLASH_ACCESS_KEY not set in environment"))?;
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
201        .results
202        .into_iter()
203        .map(|photo| ImageResult {
204            id: photo.id,
205            url: photo.urls.regular,
206            full_url: photo.urls.full,
207            thumb_url: photo.urls.thumb,
208            description: photo
209                .description
210                .or(photo.alt_description)
211                .unwrap_or_else(|| "无描述".to_string()),
212            photographer: photo.user.name,
213            photographer_url: photo.user.links.html,
214            width: photo.width,
215            height: photo.height,
216            platform: "Unsplash".to_string(),
217            validated: false, // Will be validated later
218        })
219        .collect())
220}
221
222/// Search Pexels API
223pub async fn search_pexels(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
224    let key =
225        get_pexels_key().ok_or_else(|| anyhow::anyhow!("PEXELS_API_KEY not set in environment"))?;
226
227    let client = reqwest::Client::builder()
228        .timeout(Duration::from_secs(10))
229        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
230        .build()?;
231
232    let url = format!(
233        "https://api.pexels.com/v1/search?query={}&per_page={}&page=1&locale=zh-CN",
234        query, per_page
235    );
236    let response = client.get(&url).header("Authorization", key).send().await?;
237
238    if !response.status().is_success() {
239        log::warn!("Pexels API error: {}", response.status());
240        return Ok(vec![]);
241    }
242
243    let data: PexelsResponse = response.json().await?;
244
245    Ok(data
246        .photos
247        .into_iter()
248        .map(|photo| ImageResult {
249            id: photo.id.to_string(),
250            url: photo.src.large,
251            full_url: photo.src.original,
252            thumb_url: photo.src.medium,
253            description: photo.alt.unwrap_or_else(|| "无描述".to_string()),
254            photographer: photo.photographer,
255            photographer_url: photo.photographer_url,
256            width: photo.width,
257            height: photo.height,
258            platform: "Pexels".to_string(),
259            validated: false,
260        })
261        .collect())
262}
263
264/// Search Pixabay API
265pub async fn search_pixabay(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
266    let key = get_pixabay_key()
267        .ok_or_else(|| anyhow::anyhow!("PIXABAY_API_KEY not set in environment"))?;
268
269    let client = reqwest::Client::builder()
270        .timeout(Duration::from_secs(10))
271        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
272        .build()?;
273
274    let url = format!(
275        "https://pixabay.com/api/?key={}&q={}&per_page={}&page=1&image_type=photo&safesearch=true",
276        key, query, per_page
277    );
278    let response = client.get(&url).send().await?;
279
280    if !response.status().is_success() {
281        log::warn!("Pixabay API error: {}", response.status());
282        return Ok(vec![]);
283    }
284
285    let data: PixabayResponse = response.json().await?;
286
287    Ok(data
288        .hits
289        .into_iter()
290        .map(|hit| {
291            let user = hit.user.clone();
292            ImageResult {
293                id: hit.id.to_string(),
294                url: hit.webformat_url,
295                full_url: hit.large_image_url,
296                thumb_url: hit.preview_url,
297                description: hit.tags.unwrap_or_else(|| "无描述".to_string()),
298                photographer: user.clone(),
299                photographer_url: format!("https://pixabay.com/users/{}/{}", user, hit.user_id),
300                width: hit.webformat_width,
301                height: hit.webformat_height,
302                platform: "Pixabay".to_string(),
303                validated: false,
304            }
305        })
306        .collect())
307}
308
309/// Validate all image URLs concurrently and filter out invalid ones
310/// Returns only images with validated URLs
311async fn validate_images(images: Vec<ImageResult>) -> Vec<ImageResult> {
312    if images.is_empty() {
313        return images;
314    }
315
316    let client = reqwest::Client::builder()
317        .timeout(Duration::from_secs(5))
318        .user_agent("Mozilla/5.0 (compatible; MatrixCode/1.0)")
319        .danger_accept_invalid_certs(true) // Some CDNs may have cert issues
320        .build()
321        .unwrap_or_else(|_| reqwest::Client::new());
322
323    log::info!("Validating {} image URLs...", images.len());
324
325    // Validate all images concurrently
326    let mut tasks = Vec::new();
327    for img in images {
328        let client = client.clone();
329        let url = img.url.clone();
330
331        let task = tokio::spawn(async move {
332            // Try HEAD first (faster)
333            let valid = validate_image_url(&client, &url).await;
334
335            // If HEAD fails (some servers don't support HEAD), try GET
336            let valid = if !valid {
337                log::debug!("HEAD failed for {}, trying GET...", url);
338                validate_image_url_get(&client, &url).await
339            } else {
340                true
341            };
342
343            if valid {
344                log::debug!("URL validated: {}", url);
345            } else {
346                log::debug!("URL invalid: {}", url);
347            }
348
349            (img, valid)
350        });
351
352        tasks.push(task);
353    }
354
355    // Execute all validations concurrently
356    let results = futures_util::future::join_all(tasks).await;
357
358    // Filter and return only validated images
359    let mut validated = Vec::new();
360    let total_count = results.len();
361
362    for result in results {
363        if let Ok((mut img, valid)) = result
364            && valid
365        {
366            img.validated = true;
367            validated.push(img);
368        }
369    }
370
371    log::info!("Validated {}/{} images", validated.len(), total_count);
372    validated
373}
374
375/// Search all platforms and return combined results
376/// Only searches platforms that have API keys configured
377/// Validates all image URLs before returning
378pub async fn search_all(query: &str, per_page: u32) -> Result<Vec<ImageResult>> {
379    let mut all_results = Vec::new();
380    let mut errors = Vec::new();
381
382    // Search each platform (only if key is available)
383    if get_unsplash_key().is_some() {
384        match search_unsplash(query, per_page).await {
385            Ok(results) => all_results.extend(results),
386            Err(e) => errors.push(format!("Unsplash: {}", e)),
387        }
388    }
389
390    if get_pexels_key().is_some() {
391        match search_pexels(query, per_page).await {
392            Ok(results) => all_results.extend(results),
393            Err(e) => errors.push(format!("Pexels: {}", e)),
394        }
395    }
396
397    if get_pixabay_key().is_some() {
398        match search_pixabay(query, per_page).await {
399            Ok(results) => all_results.extend(results),
400            Err(e) => errors.push(format!("Pixabay: {}", e)),
401        }
402    }
403
404    // If no keys configured, return error
405    if all_results.is_empty() && errors.is_empty() {
406        return Err(anyhow::anyhow!(
407            "No image search API keys configured. Set UNSPLASH_ACCESS_KEY, PEXELS_API_KEY, or PIXABAY_API_KEY in environment."
408        ));
409    }
410
411    // Log errors but return results if any succeeded
412    if !errors.is_empty() && all_results.is_empty() {
413        return Err(anyhow::anyhow!(
414            "All searches failed: {}",
415            errors.join("; ")
416        ));
417    }
418
419    for e in &errors {
420        log::warn!("Image search partial error: {}", e);
421    }
422
423    // Validate all image URLs
424    let total_count = all_results.len();
425    let validated_results = validate_images(all_results).await;
426
427    // Return error if all images failed validation
428    if validated_results.is_empty() && !errors.is_empty() {
429        return Err(anyhow::anyhow!(
430            "All {} images failed URL validation. Errors: {}",
431            total_count,
432            errors.join("; ")
433        ));
434    }
435
436    Ok(validated_results)
437}