use chrono::{SecondsFormat, TimeZone, Utc}; use serde::{Deserialize, Serialize}; use crate::videos::VideoItem; #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct UploadersRequest { #[serde(default, alias = "uploader_id")] pub uploaderId: Option, #[serde(default, alias = "uploader_name")] pub uploaderName: Option, #[serde(default, alias = "profile_content")] pub profileContent: bool, #[serde(default)] pub query: Option, } impl UploadersRequest { pub fn normalized(self) -> Self { Self { uploaderId: normalize_optional_string(self.uploaderId), uploaderName: normalize_optional_string(self.uploaderName), profileContent: self.profileContent, query: normalize_optional_string(self.query), } } } #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct UploaderProfile { pub id: String, pub name: String, pub url: Option, pub channel: Option, pub verified: bool, pub videoCount: u64, pub totalViews: u64, #[serde(default)] pub channels: Option>, #[serde(default, alias = "profile_picture_url")] pub avatar: Option, pub description: Option, pub bio: Option, #[serde(default)] pub videos: Option>, #[serde(default)] pub tapes: Option>, #[serde(default)] pub playlists: Option>, #[serde(default)] pub layout: Option>, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct UploaderChannelStat { pub channel: String, pub videoCount: u64, #[serde(default, alias = "first_seen_at")] pub firstSeenAt: Option, #[serde(default, alias = "last_seen_at")] pub lastSeenAt: Option, } #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct UploaderVideoRef { pub id: String, pub url: String, pub title: String, pub duration: u32, pub channel: String, #[serde(default, alias = "uploaded_at")] pub uploadedAt: Option, pub uploader: String, #[serde(alias = "uploader_id")] pub uploaderId: String, pub thumb: String, pub preview: Option, pub views: u32, pub rating: u32, #[serde(default, alias = "aspect_ratio")] pub aspectRatio: Option, } impl UploaderVideoRef { pub fn from_video_item(item: &VideoItem, uploader_name: &str, uploader_id: &str) -> Self { Self { id: item.id.clone(), url: item.url.clone(), title: item.title.clone(), duration: item.duration, channel: item.channel.clone(), uploadedAt: iso_timestamp_from_unix(item.uploadedAt), uploader: item .uploader .clone() .unwrap_or_else(|| uploader_name.to_string()), uploaderId: item .uploaderId .clone() .unwrap_or_else(|| uploader_id.to_string()), thumb: item.thumb.clone(), preview: item.preview.clone(), views: item.views.unwrap_or_default(), rating: item.rating.map(normalize_rating).unwrap_or_default(), aspectRatio: item.aspectRatio, } } } #[derive(Serialize, Deserialize, Debug, Clone, Default)] pub struct UploaderLayoutRow { #[serde(rename = "type")] pub rowType: UploaderLayoutRowType, pub title: Option, #[serde(default, alias = "video_ids")] pub videoIds: Option>, } impl UploaderLayoutRow { pub fn horizontal(title: Option, video_ids: Vec) -> Self { Self { rowType: UploaderLayoutRowType::Horizontal, title, videoIds: Some(video_ids), } } pub fn videos(title: Option) -> Self { Self { rowType: UploaderLayoutRowType::Videos, title, videoIds: None, } } } #[derive(Serialize, Deserialize, Debug, Clone, Default, PartialEq, Eq)] pub enum UploaderLayoutRowType { #[default] #[serde(rename = "videos")] Videos, #[serde(rename = "horizontal", alias = "horizontal_videos")] Horizontal, } pub fn normalize_optional_string(value: Option) -> Option { value.and_then(|value| { let trimmed = value.trim(); (!trimmed.is_empty()).then(|| trimmed.to_string()) }) } pub fn iso_timestamp_from_unix(value: Option) -> Option { let timestamp = value?; let dt = Utc.timestamp_opt(timestamp as i64, 0).single()?; Some(dt.to_rfc3339_opts(SecondsFormat::Millis, true)) } fn normalize_rating(value: f32) -> u32 { value.clamp(0.0, 100.0).round() as u32 } #[cfg(test)] mod tests { use super::*; #[test] fn request_accepts_snake_case_aliases() { let request: UploadersRequest = serde_json::from_str( r#"{ "uploader_id": "hsex:xihongshiddd", "uploader_name": "xihongshiddd", "profile_content": true, "query": "teacher" }"#, ) .expect("request should decode"); assert_eq!(request.uploaderId.as_deref(), Some("hsex:xihongshiddd")); assert_eq!(request.uploaderName.as_deref(), Some("xihongshiddd")); assert!(request.profileContent); assert_eq!(request.query.as_deref(), Some("teacher")); } #[test] fn layout_aliases_decode() { let row: UploaderLayoutRow = serde_json::from_str( r#"{ "type": "horizontal_videos", "title": "For You", "video_ids": ["one", "two"] }"#, ) .expect("row should decode"); assert_eq!(row.rowType, UploaderLayoutRowType::Horizontal); assert_eq!(row.videoIds.as_ref().map(Vec::len), Some(2)); } #[test] fn avatar_alias_decodes() { let profile: UploaderProfile = serde_json::from_str( r#"{ "id": "abc", "name": "Example", "verified": false, "videoCount": 1, "totalViews": 2, "profile_picture_url": "https://example.com/a.jpg" }"#, ) .expect("profile should decode"); assert_eq!(profile.avatar.as_deref(), Some("https://example.com/a.jpg")); } }