use chrono::{DateTime, Utc};
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoListResponse {
pub kind: String,
pub etag: String,
#[serde(default)]
pub next_page_token: Option<String>,
#[serde(default)]
pub prev_page_token: Option<String>,
pub page_info: PageInfo,
#[serde(default)]
pub items: Vec<Video>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct PageInfo {
pub total_results: i32,
pub results_per_page: i32,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Video {
pub kind: String,
pub etag: String,
pub id: String,
#[serde(default)]
pub snippet: Option<VideoSnippet>,
#[serde(default)]
pub statistics: Option<VideoStatistics>,
#[serde(default)]
pub content_details: Option<VideoContentDetails>,
#[serde(default)]
pub status: Option<VideoStatus>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoSnippet {
pub published_at: DateTime<Utc>,
pub channel_id: String,
pub title: String,
pub description: String,
pub thumbnails: Thumbnails,
pub channel_title: String,
#[serde(default)]
pub tags: Vec<String>,
pub category_id: String,
pub live_broadcast_content: String,
#[serde(default)]
pub default_language: Option<String>,
#[serde(default)]
pub localized: Option<Localized>,
#[serde(default)]
pub default_audio_language: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoStatistics {
#[serde(default)]
pub view_count: Option<String>,
#[serde(default)]
pub like_count: Option<String>,
#[serde(default)]
pub favorite_count: Option<String>,
#[serde(default)]
pub comment_count: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoContentDetails {
pub duration: String,
pub dimension: String,
pub definition: String,
#[serde(default)]
pub caption: Option<String>,
pub licensed_content: bool,
#[serde(default)]
pub region_restriction: Option<RegionRestriction>,
pub projection: String,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RegionRestriction {
#[serde(default)]
pub allowed: Vec<String>,
#[serde(default)]
pub blocked: Vec<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct VideoStatus {
pub upload_status: String,
#[serde(default)]
pub failure_reason: Option<String>,
#[serde(default)]
pub rejection_reason: Option<String>,
pub privacy_status: String,
#[serde(default)]
pub publish_at: Option<DateTime<Utc>>,
pub license: String,
pub embeddable: bool,
pub public_stats_viewable: bool,
#[serde(default)]
pub made_for_kids: Option<bool>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelListResponse {
pub kind: String,
pub etag: String,
#[serde(default)]
pub next_page_token: Option<String>,
#[serde(default)]
pub prev_page_token: Option<String>,
pub page_info: PageInfo,
#[serde(default)]
pub items: Vec<Channel>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct Channel {
pub kind: String,
pub etag: String,
pub id: String,
#[serde(default)]
pub snippet: Option<ChannelSnippet>,
#[serde(default)]
pub statistics: Option<ChannelStatistics>,
#[serde(default)]
pub content_details: Option<ChannelContentDetails>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelSnippet {
pub title: String,
pub description: String,
#[serde(default)]
pub custom_url: Option<String>,
pub published_at: DateTime<Utc>,
pub thumbnails: Thumbnails,
#[serde(default)]
pub default_language: Option<String>,
#[serde(default)]
pub localized: Option<Localized>,
#[serde(default)]
pub country: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelStatistics {
#[serde(default)]
pub view_count: Option<String>,
#[serde(default)]
pub subscriber_count: Option<String>,
#[serde(default)]
pub hidden_subscriber_count: Option<bool>,
#[serde(default)]
pub video_count: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct ChannelContentDetails {
pub related_playlists: RelatedPlaylists,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
#[serde(rename_all = "camelCase")]
pub struct RelatedPlaylists {
#[serde(default)]
pub likes: Option<String>,
#[serde(default)]
pub uploads: Option<String>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Thumbnails {
#[serde(default)]
pub default: Option<Thumbnail>,
#[serde(default)]
pub medium: Option<Thumbnail>,
#[serde(default)]
pub high: Option<Thumbnail>,
#[serde(default)]
pub standard: Option<Thumbnail>,
#[serde(default)]
pub maxres: Option<Thumbnail>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Thumbnail {
pub url: String,
#[serde(default)]
pub width: Option<u32>,
#[serde(default)]
pub height: Option<u32>,
}
#[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Localized {
pub title: String,
pub description: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct YouTubeApiError {
pub error: YouTubeApiErrorBody,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct YouTubeApiErrorBody {
pub code: u16,
pub message: String,
#[serde(default)]
pub errors: Vec<YouTubeApiErrorDetail>,
}
#[allow(dead_code)]
#[derive(Debug, Clone, Deserialize)]
pub struct YouTubeApiErrorDetail {
#[serde(default)]
pub domain: Option<String>,
#[serde(default)]
pub reason: Option<String>,
#[serde(default)]
pub message: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_video_list_response_deserialization() {
let json = r#"{
"kind": "youtube#videoListResponse",
"etag": "abc123",
"pageInfo": {
"totalResults": 1,
"resultsPerPage": 1
},
"items": [{
"kind": "youtube#video",
"etag": "def456",
"id": "OVNXs2U_ckc",
"snippet": {
"publishedAt": "2019-06-12T18:00:00Z",
"channelId": "UCvjgXvBlsoQg2a_4dAqjJ5g",
"title": "Automate the Boring Stuff with Python",
"description": "Learn Python programming",
"thumbnails": {
"default": {
"url": "https://i.ytimg.com/vi/OVNXs2U_ckc/default.jpg",
"width": 120,
"height": 90
}
},
"channelTitle": "Al Sweigart",
"tags": ["python", "programming"],
"categoryId": "27",
"liveBroadcastContent": "none"
},
"statistics": {
"viewCount": "1000000",
"likeCount": "50000",
"favoriteCount": "0",
"commentCount": "1000"
},
"contentDetails": {
"duration": "PT1H30M",
"dimension": "2d",
"definition": "hd",
"caption": "true",
"licensedContent": true,
"projection": "rectangular"
},
"status": {
"uploadStatus": "processed",
"privacyStatus": "public",
"license": "youtube",
"embeddable": true,
"publicStatsViewable": true
}
}]
}"#;
let response: VideoListResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.kind, "youtube#videoListResponse");
assert_eq!(response.items.len(), 1);
let video = &response.items[0];
assert_eq!(video.id, "OVNXs2U_ckc");
let snippet = video.snippet.as_ref().unwrap();
assert_eq!(snippet.channel_id, "UCvjgXvBlsoQg2a_4dAqjJ5g");
assert_eq!(snippet.title, "Automate the Boring Stuff with Python");
assert_eq!(snippet.tags, vec!["python", "programming"]);
let stats = video.statistics.as_ref().unwrap();
assert_eq!(stats.view_count, Some("1000000".to_string()));
assert_eq!(stats.like_count, Some("50000".to_string()));
let content = video.content_details.as_ref().unwrap();
assert_eq!(content.duration, "PT1H30M");
assert!(content.licensed_content);
let status = video.status.as_ref().unwrap();
assert_eq!(status.privacy_status, "public");
assert!(status.embeddable);
}
#[test]
fn test_video_with_minimal_fields() {
let json = r#"{
"kind": "youtube#video",
"etag": "abc123",
"id": "4U3_FakJ_Pg"
}"#;
let video: Video = serde_json::from_str(json).unwrap();
assert_eq!(video.id, "4U3_FakJ_Pg");
assert!(video.snippet.is_none());
assert!(video.statistics.is_none());
assert!(video.content_details.is_none());
assert!(video.status.is_none());
}
#[test]
fn test_channel_list_response_deserialization() {
let json = r#"{
"kind": "youtube#channelListResponse",
"etag": "abc123",
"pageInfo": {
"totalResults": 1,
"resultsPerPage": 1
},
"items": [{
"kind": "youtube#channel",
"etag": "def456",
"id": "UCvjgXvBlsoQg2a_4dAqjJ5g",
"snippet": {
"title": "Al Sweigart",
"description": "Python tutorials",
"publishedAt": "2015-01-01T00:00:00Z",
"thumbnails": {
"default": {
"url": "https://example.com/thumb.jpg"
}
},
"customUrl": "@AlSweigart",
"country": "US"
},
"statistics": {
"viewCount": "5000000",
"subscriberCount": "100000",
"hiddenSubscriberCount": false,
"videoCount": "50"
},
"contentDetails": {
"relatedPlaylists": {
"likes": "LLabc123",
"uploads": "UUabc123"
}
}
}]
}"#;
let response: ChannelListResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.kind, "youtube#channelListResponse");
assert_eq!(response.items.len(), 1);
let channel = &response.items[0];
assert_eq!(channel.id, "UCvjgXvBlsoQg2a_4dAqjJ5g");
let snippet = channel.snippet.as_ref().unwrap();
assert_eq!(snippet.title, "Al Sweigart");
assert_eq!(snippet.custom_url, Some("@AlSweigart".to_string()));
assert_eq!(snippet.country, Some("US".to_string()));
let stats = channel.statistics.as_ref().unwrap();
assert_eq!(stats.subscriber_count, Some("100000".to_string()));
assert_eq!(stats.video_count, Some("50".to_string()));
let content = channel.content_details.as_ref().unwrap();
assert_eq!(
content.related_playlists.uploads,
Some("UUabc123".to_string())
);
}
#[test]
fn test_empty_items_response() {
let json = r#"{
"kind": "youtube#videoListResponse",
"etag": "abc123",
"pageInfo": {
"totalResults": 0,
"resultsPerPage": 0
},
"items": []
}"#;
let response: VideoListResponse = serde_json::from_str(json).unwrap();
assert!(response.items.is_empty());
assert_eq!(response.page_info.total_results, 0);
}
#[test]
fn test_video_with_region_restriction() {
let json = r#"{
"kind": "youtube#video",
"etag": "abc123",
"id": "7Eq0h_dvUGE",
"contentDetails": {
"duration": "PT10M",
"dimension": "2d",
"definition": "hd",
"licensedContent": false,
"projection": "rectangular",
"regionRestriction": {
"blocked": ["US", "CA"]
}
}
}"#;
let video: Video = serde_json::from_str(json).unwrap();
let content = video.content_details.as_ref().unwrap();
let restriction = content.region_restriction.as_ref().unwrap();
assert_eq!(restriction.blocked, vec!["US", "CA"]);
assert!(restriction.allowed.is_empty());
}
#[test]
fn test_youtube_api_error_deserialization() {
let json = r#"{
"error": {
"code": 403,
"message": "The request cannot be completed because you have exceeded your quota.",
"errors": [{
"domain": "youtube.quota",
"reason": "quotaExceeded",
"message": "Quota exceeded"
}]
}
}"#;
let error: YouTubeApiError = serde_json::from_str(json).unwrap();
assert_eq!(error.error.code, 403);
assert!(error.error.message.contains("quota"));
assert_eq!(error.error.errors.len(), 1);
assert_eq!(
error.error.errors[0].reason,
Some("quotaExceeded".to_string())
);
}
#[test]
fn test_thumbnails_with_all_sizes() {
let json = r#"{
"default": {"url": "https://example.com/default.jpg", "width": 120, "height": 90},
"medium": {"url": "https://example.com/medium.jpg", "width": 320, "height": 180},
"high": {"url": "https://example.com/high.jpg", "width": 480, "height": 360},
"standard": {"url": "https://example.com/standard.jpg", "width": 640, "height": 480},
"maxres": {"url": "https://example.com/maxres.jpg", "width": 1280, "height": 720}
}"#;
let thumbnails: Thumbnails = serde_json::from_str(json).unwrap();
assert!(thumbnails.default.is_some());
assert!(thumbnails.medium.is_some());
assert!(thumbnails.high.is_some());
assert!(thumbnails.standard.is_some());
assert!(thumbnails.maxres.is_some());
assert_eq!(thumbnails.maxres.as_ref().unwrap().width, Some(1280));
}
#[test]
fn test_video_statistics_optional_fields() {
let json = r#"{
"viewCount": "1000"
}"#;
let stats: VideoStatistics = serde_json::from_str(json).unwrap();
assert_eq!(stats.view_count, Some("1000".to_string()));
assert!(stats.like_count.is_none());
assert!(stats.favorite_count.is_none());
assert!(stats.comment_count.is_none());
}
#[test]
fn test_pagination_tokens() {
let json = r#"{
"kind": "youtube#videoListResponse",
"etag": "abc123",
"nextPageToken": "CAUQAA",
"prevPageToken": "CAEQAQ",
"pageInfo": {
"totalResults": 100,
"resultsPerPage": 5
},
"items": []
}"#;
let response: VideoListResponse = serde_json::from_str(json).unwrap();
assert_eq!(response.next_page_token, Some("CAUQAA".to_string()));
assert_eq!(response.prev_page_token, Some("CAEQAQ".to_string()));
}
}