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;
#[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 })
}
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();
if let Ok(api_error) = serde_json::from_str::<YouTubeApiError>(&error_body) {
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?)
}
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?;
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);
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)
}
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?;
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);
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)
}
}