youtubeinfo-sync 1.0.2

Download YouTube video and channel metadata
use reqwest::Client;
use serde::de::DeserializeOwned;
use std::collections::HashSet;
use std::time::Duration;
use tracing::{debug, info, warn};

use crate::error::{Result, YouTubeError};
use crate::models::{Channel, ChannelListResponse, Video, VideoListResponse, YouTubeApiError};

const YOUTUBE_API_BASE: &str = "https://www.googleapis.com/youtube/v3";
const MAX_IDS_PER_REQUEST: usize = 50;
const DEFAULT_TIMEOUT_SECS: u64 = 30;

/// Secure wrapper for API key that redacts in debug output
#[derive(Clone)]
struct SecretKey(String);

impl std::fmt::Debug for SecretKey {
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
        f.write_str("[REDACTED]")
    }
}

fn get_api_key_from_env() -> Result<SecretKey> {
    std::env::var("YOUTUBE_API_KEY")
        .map(SecretKey)
        .map_err(|_| YouTubeError::MissingApiKey)
}

#[derive(Debug, Clone)]
pub struct YouTubeClient {
    client: Client,
    api_key: SecretKey,
}

impl YouTubeClient {
    pub fn new() -> Result<Self> {
        let api_key = get_api_key_from_env()?;

        let client = Client::builder()
            .timeout(Duration::from_secs(DEFAULT_TIMEOUT_SECS))
            .build()?;

        Ok(Self { client, api_key })
    }

    /// Generic API request with error handling
    async fn make_request<T: DeserializeOwned>(&self, url: &str) -> Result<T> {
        debug!("Making request to: {}", url);

        let response = self.client.get(url).send().await?;
        let status = response.status();

        if !status.is_success() {
            let error_body = response.text().await.unwrap_or_default();

            // Try to parse as YouTube API error
            if let Ok(api_error) = serde_json::from_str::<YouTubeApiError>(&error_body) {
                // Check for quota exceeded
                if api_error
                    .error
                    .errors
                    .iter()
                    .any(|e| e.reason.as_deref() == Some("quotaExceeded"))
                {
                    return Err(YouTubeError::QuotaExceeded);
                }

                return Err(YouTubeError::Api {
                    status: status.as_u16(),
                    message: api_error.error.message,
                    endpoint: url.to_string(),
                });
            }

            return Err(YouTubeError::Api {
                status: status.as_u16(),
                message: error_body,
                endpoint: url.to_string(),
            });
        }

        Ok(response.json().await?)
    }

    /// Fetch videos by IDs, batching requests (max 50 IDs per request)
    pub async fn fetch_videos(&self, video_ids: &[String]) -> Result<Vec<Video>> {
        if video_ids.is_empty() {
            return Ok(Vec::new());
        }

        let mut all_videos = Vec::new();

        for chunk in video_ids.chunks(MAX_IDS_PER_REQUEST) {
            let ids = chunk.join(",");
            let url = format!(
                "{}/videos?part=snippet,statistics,contentDetails,status&id={}&key={}",
                YOUTUBE_API_BASE, ids, self.api_key.0
            );

            info!("Fetching {} videos", chunk.len());
            let response: VideoListResponse = self.make_request(&url).await?;

            // Log any missing videos
            let returned_ids: HashSet<_> = response.items.iter().map(|v| v.id.as_str()).collect();
            for id in chunk {
                if !returned_ids.contains(id.as_str()) {
                    warn!("Video not found or unavailable: {}", id);
                }
            }

            all_videos.extend(response.items);

            // Small delay between batches to be respectful
            if chunk.len() == MAX_IDS_PER_REQUEST {
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
        }

        info!("Fetched {} total videos", all_videos.len());
        Ok(all_videos)
    }

    /// Fetch channels by IDs, batching requests (max 50 IDs per request)
    pub async fn fetch_channels(&self, channel_ids: &[String]) -> Result<Vec<Channel>> {
        if channel_ids.is_empty() {
            return Ok(Vec::new());
        }

        let mut all_channels = Vec::new();

        for chunk in channel_ids.chunks(MAX_IDS_PER_REQUEST) {
            let ids = chunk.join(",");
            let url = format!(
                "{}/channels?part=snippet,statistics,contentDetails&id={}&key={}",
                YOUTUBE_API_BASE, ids, self.api_key.0
            );

            info!("Fetching {} channels", chunk.len());
            let response: ChannelListResponse = self.make_request(&url).await?;

            // Log any missing channels
            let returned_ids: HashSet<_> = response.items.iter().map(|c| c.id.as_str()).collect();
            for id in chunk {
                if !returned_ids.contains(id.as_str()) {
                    warn!("Channel not found: {}", id);
                }
            }

            all_channels.extend(response.items);

            // Small delay between batches
            if chunk.len() == MAX_IDS_PER_REQUEST {
                tokio::time::sleep(Duration::from_millis(100)).await;
            }
        }

        info!("Fetched {} total channels", all_channels.len());
        Ok(all_channels)
    }
}