Files
hottub/src/uploaders.rs
2026-03-31 13:39:11 +00:00

217 lines
6.4 KiB
Rust

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<String>,
#[serde(default, alias = "uploader_name")]
pub uploaderName: Option<String>,
#[serde(default, alias = "profile_content")]
pub profileContent: bool,
#[serde(default)]
pub query: Option<String>,
}
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<String>,
pub channel: Option<String>,
pub verified: bool,
pub videoCount: u64,
pub totalViews: u64,
#[serde(default)]
pub channels: Option<Vec<UploaderChannelStat>>,
#[serde(default, alias = "profile_picture_url")]
pub avatar: Option<String>,
pub description: Option<String>,
pub bio: Option<String>,
#[serde(default)]
pub videos: Option<Vec<UploaderVideoRef>>,
#[serde(default)]
pub tapes: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub playlists: Option<Vec<serde_json::Value>>,
#[serde(default)]
pub layout: Option<Vec<UploaderLayoutRow>>,
}
#[derive(Serialize, Deserialize, Debug, Clone, Default)]
pub struct UploaderChannelStat {
pub channel: String,
pub videoCount: u64,
#[serde(default, alias = "first_seen_at")]
pub firstSeenAt: Option<String>,
#[serde(default, alias = "last_seen_at")]
pub lastSeenAt: Option<String>,
}
#[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<String>,
pub uploader: String,
#[serde(alias = "uploader_id")]
pub uploaderId: String,
pub thumb: String,
pub preview: Option<String>,
pub views: u32,
pub rating: u32,
#[serde(default, alias = "aspect_ratio")]
pub aspectRatio: Option<f32>,
}
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<String>,
#[serde(default, alias = "video_ids")]
pub videoIds: Option<Vec<String>>,
}
impl UploaderLayoutRow {
pub fn horizontal(title: Option<String>, video_ids: Vec<String>) -> Self {
Self {
rowType: UploaderLayoutRowType::Horizontal,
title,
videoIds: Some(video_ids),
}
}
pub fn videos(title: Option<String>) -> 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<String>) -> Option<String> {
value.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then(|| trimmed.to_string())
})
}
pub fn iso_timestamp_from_unix(value: Option<u64>) -> Option<String> {
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"));
}
}