Skip to main content

rust_yt_uploader/
models.rs

1//! Configuration models for YouTube uploader with validation.
2//!
3//! This module provides Serde-based models that mirror the Python Pydantic models,
4//! supporting both individual and batch YAML configuration formats.
5
6use anyhow::{Result, anyhow};
7use rand::Rng;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use validator::{Validate, ValidationError};
12
13/// Configuration format detection result
14#[derive(Debug, Clone, PartialEq)]
15pub enum ConfigFormat {
16    Individual,
17    Batch,
18}
19
20/// Configuration for retry behavior during uploads.
21#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct RetryConfig {
23    /// Maximum number of retry attempts
24    pub max_retries: u32,
25    /// Base sleep time in seconds
26    pub base_sleep: f64,
27    /// Maximum sleep time in seconds
28    pub max_sleep: f64,
29    /// Exponential backoff base
30    pub exponential_base: u32,
31}
32
33impl Default for RetryConfig {
34    fn default() -> Self {
35        Self {
36            max_retries: 10,
37            base_sleep: 1.0,
38            max_sleep: 60.0,
39            exponential_base: 2,
40        }
41    }
42}
43
44impl RetryConfig {
45    /// Calculate sleep time using exponential backoff with jitter.
46    ///
47    /// # Arguments
48    /// * `retry_attempt` - The current retry attempt number (1-based)
49    ///
50    /// # Returns
51    /// * Sleep time in seconds, capped at max_sleep
52    pub fn calculate_sleep_time(&self, retry_attempt: u32) -> f64 {
53        let exponential_sleep = (self.exponential_base.pow(retry_attempt)) as f64;
54        let sleep_time = rand::rng().random::<f64>() * exponential_sleep;
55        sleep_time.min(self.max_sleep)
56    }
57}
58
59/// Options for uploading a single video.
60#[derive(Debug, Clone, Serialize, Deserialize)]
61pub struct VideoUploadOptions {
62    pub file: String,
63    pub title: String,
64    pub description: String,
65    pub keywords: String,
66    pub category: u32,
67    pub privacy_status: String,
68    pub playlist_id: String,
69    #[serde(rename = "defaultAudioLanguage")]
70    pub default_audio_language: String,
71    #[serde(rename = "defaultLanguage")]
72    pub default_language: String,
73    #[serde(rename = "recordingDate")]
74    pub recording_date: String,
75}
76
77impl VideoUploadOptions {
78    /// Convert recording_date from "YYYY-MM-DD" to YouTube API timestamp format "YYYY-MM-DDTHH:MM:SS.000Z"
79    pub fn formatted_recording_date(&self) -> String {
80        if self.recording_date.contains('T') {
81            // Already in timestamp format, return as-is
82            self.recording_date.clone()
83        } else {
84            // Convert "YYYY-MM-DD" to "YYYY-MM-DDT00:00:00.000Z"
85            format!("{}T00:00:00.000Z", self.recording_date)
86        }
87    }
88}
89
90/// Valid YouTube video privacy status values.
91#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
92#[serde(rename_all = "lowercase")]
93pub enum PrivacyStatus {
94    Public,
95    #[default]
96    Private,
97    Unlisted,
98}
99
100impl From<&str> for PrivacyStatus {
101    fn from(s: &str) -> Self {
102        match s.to_lowercase().as_str() {
103            "public" => PrivacyStatus::Public,
104            "unlisted" => PrivacyStatus::Unlisted,
105            _ => PrivacyStatus::Private,
106        }
107    }
108}
109
110impl AsRef<str> for PrivacyStatus {
111    fn as_ref(&self) -> &str {
112        match self {
113            PrivacyStatus::Public => "public",
114            PrivacyStatus::Unlisted => "unlisted",
115            PrivacyStatus::Private => "private",
116        }
117    }
118}
119
120/// YouTube video category IDs.
121///
122/// See: <https://developers.google.com/youtube/v3/docs/videoCategories/list>
123#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Default)]
124#[repr(u32)]
125pub enum VideoCategory {
126    FilmAnimation = 1,
127    AutosVehicles = 2,
128    Music = 10,
129    PetsAnimals = 15,
130    Sports = 17,
131    ShortMovies = 18,
132    TravelEvents = 19,
133    Gaming = 20,
134    Videoblogging = 21,
135    #[default]
136    PeopleBlogs = 22,
137    Comedy = 23,
138    Entertainment = 24,
139    NewsPolitics = 25,
140    HowtoStyle = 26,
141    Education = 27,
142    ScienceTechnology = 28,
143    NonprofitsActivism = 29,
144    Movies = 30,
145    AnimationAnime = 31,
146    ActionAdventure = 32,
147    Classics = 33,
148    ComedyFilm = 34,
149    Documentary = 35,
150    Drama = 36,
151    Family = 37,
152    Foreign = 38,
153    Horror = 39,
154    SciFiFantasy = 40,
155    Thriller = 41,
156    Shorts = 42,
157    Shows = 43,
158}
159
160impl VideoCategory {
161    /// Convert to u32 value for YouTube API
162    pub fn as_u32(&self) -> u32 {
163        *self as u32
164    }
165
166    /// Create from u32 value
167    pub fn from_u32(value: u32) -> Result<Self> {
168        match value {
169            1 => Ok(Self::FilmAnimation),
170            2 => Ok(Self::AutosVehicles),
171            10 => Ok(Self::Music),
172            15 => Ok(Self::PetsAnimals),
173            17 => Ok(Self::Sports),
174            18 => Ok(Self::ShortMovies),
175            19 => Ok(Self::TravelEvents),
176            20 => Ok(Self::Gaming),
177            21 => Ok(Self::Videoblogging),
178            22 => Ok(Self::PeopleBlogs),
179            23 => Ok(Self::Comedy),
180            24 => Ok(Self::Entertainment),
181            25 => Ok(Self::NewsPolitics),
182            26 => Ok(Self::HowtoStyle),
183            27 => Ok(Self::Education),
184            28 => Ok(Self::ScienceTechnology),
185            29 => Ok(Self::NonprofitsActivism),
186            30 => Ok(Self::Movies),
187            31 => Ok(Self::AnimationAnime),
188            32 => Ok(Self::ActionAdventure),
189            33 => Ok(Self::Classics),
190            34 => Ok(Self::ComedyFilm),
191            35 => Ok(Self::Documentary),
192            36 => Ok(Self::Drama),
193            37 => Ok(Self::Family),
194            38 => Ok(Self::Foreign),
195            39 => Ok(Self::Horror),
196            40 => Ok(Self::SciFiFantasy),
197            41 => Ok(Self::Thriller),
198            42 => Ok(Self::Shorts),
199            43 => Ok(Self::Shows),
200            _ => Err(anyhow!("Invalid video category ID: {}", value)),
201        }
202    }
203}
204
205/// Custom validation function for playlist ID
206pub fn validate_playlist_id(playlist_id: &str) -> Result<(), ValidationError> {
207    let re = Regex::new(r"^PL[a-zA-Z0-9_-]{16,33}$").unwrap();
208    if re.is_match(playlist_id) {
209        Ok(())
210    } else {
211        Err(ValidationError::new("Invalid playlist ID format"))
212    }
213}
214
215/// Custom validation function for file existence
216fn validate_file_exists(file_path: &str) -> Result<(), ValidationError> {
217    let expanded_path = shellexpand::tilde(file_path);
218    if Path::new(expanded_path.as_ref()).exists() {
219        Ok(())
220    } else {
221        Err(ValidationError::new("File does not exist"))
222    }
223}
224
225/// Custom validation function for file existence with multiple files
226fn validate_files_exist(file_paths: &[String]) -> Result<(), ValidationError> {
227    for file_path in file_paths {
228        let expanded_path = shellexpand::tilde(file_path);
229        if !Path::new(expanded_path.as_ref()).exists() {
230            let mut err = ValidationError::new("file_does_not_exist");
231            err.add_param(std::borrow::Cow::from("file_path"), &file_path);
232            return Err(err);
233        }
234    }
235    Ok(())
236}
237
238/// Common configuration shared across multiple videos.
239#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
240pub struct CommonConfig {
241    /// Title prefix for all videos
242    #[validate(length(min = 1))]
243    pub prefix: String,
244
245    /// Comma-separated keywords/tags
246    #[validate(length(min = 1))]
247    pub keywords: String,
248
249    /// Video category
250    #[serde(default)]
251    pub category: VideoCategory,
252
253    /// Privacy status
254    #[serde(default, rename = "privacyStatus")]
255    pub privacy_status: PrivacyStatus,
256
257    /// Playlist ID
258    #[validate(custom(function = "validate_playlist_id"))]
259    #[serde(rename = "playlistId")]
260    pub playlist_id: String,
261
262    /// Default audio language for the video
263    #[serde(rename = "defaultAudioLanguage")]
264    pub default_audio_language: String,
265
266    /// Default language for the video
267    #[serde(rename = "defaultLanguage")]
268    pub default_language: String,
269
270    /// Recording date for the video
271    #[serde(rename = "recordingDate")]
272    pub recording_date: String,
273}
274
275impl CommonConfig {
276    /// Validate keywords are not empty or whitespace only
277    pub fn validate_keywords(&self) -> Result<()> {
278        if self.keywords.trim().is_empty() {
279            return Err(anyhow!("keywords cannot be empty or whitespace only"));
280        }
281        Ok(())
282    }
283}
284
285/// Configuration for a single video (individual format).
286#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
287pub struct VideoConfig {
288    /// Video title
289    #[validate(length(min = 1, max = 100))]
290    pub title: String,
291
292    /// Video description
293    #[serde(default)]
294    pub description: String,
295
296    /// Comma-separated keywords/tags
297    #[validate(length(min = 1))]
298    pub keywords: String,
299
300    /// Path to video file
301    #[validate(length(min = 1), custom(function = "validate_file_exists"))]
302    pub file: String,
303
304    /// Video category
305    pub category: VideoCategory,
306
307    /// Privacy status
308    #[serde(rename = "privacyStatus")]
309    pub privacy_status: PrivacyStatus,
310
311    /// Playlist ID
312    #[validate(custom(function = "validate_playlist_id"))]
313    #[serde(rename = "playlistId")]
314    pub playlist_id: String,
315
316    /// Default audio language for the video
317    #[serde(rename = "defaultAudioLanguage")]
318    pub default_audio_language: String,
319
320    /// Default language for the video
321    #[serde(rename = "defaultLanguage")]
322    pub default_language: String,
323
324    /// Recording date for the video
325    #[serde(rename = "recordingDate")]
326    pub recording_date: String,
327}
328
329/// Root model for individual YAML format with videos array.
330#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
331pub struct IndividualConfigRoot {
332    /// Test mode flag - if true, delete videos after upload
333    #[serde(default = "bool::default")]
334    pub test: bool,
335
336    /// List of video configurations
337    #[validate(length(min = 1), nested)]
338    pub videos: Vec<VideoConfig>,
339}
340
341/// Root model for batch YAML format with common config.
342#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
343pub struct BatchConfigRoot {
344    /// Test mode flag - if true, delete videos after upload
345    #[serde(default = "bool::default")]
346    pub test: bool,
347
348    /// Common configuration for all videos
349    #[validate(nested)]
350    pub common: CommonConfig,
351
352    /// List of video titles
353    #[validate(length(min = 1))]
354    pub titles: Vec<String>,
355
356    /// List of video file paths
357    #[validate(length(min = 1))]
358    pub files: Vec<String>,
359}
360
361impl BatchConfigRoot {
362    /// Parse files entries into Vec<Vec<String>> (each entry can have multiple files separated by comma, semicolon, or space)
363    pub fn parse_files(&self) -> Vec<Vec<String>> {
364        self.files
365            .iter()
366            .map(|file_entry| {
367                file_entry
368                    .split([',', ';', ' ', '\t'])
369                    .filter(|s| !s.is_empty())
370                    .map(|s| s.to_string())
371                    .collect()
372            })
373            .collect()
374    }
375
376    /// Validate that files exist and titles/files have matching lengths
377    pub async fn validate_files_and_lengths(&self) -> Result<()> {
378        // Check that titles and files entries have same length
379        if self.titles.len() != self.files.len() {
380            return Err(anyhow!(
381                "Mismatch between titles and files: {} titles != {} files entries",
382                self.titles.len(),
383                self.files.len()
384            ));
385        }
386
387        let parsed_files = self.parse_files();
388
389        // Check that each file entry has at least one file
390        for (idx, file_entry) in parsed_files.iter().enumerate() {
391            if file_entry.is_empty() {
392                return Err(anyhow!(
393                    "Files entry {} is empty or contains only whitespace",
394                    idx + 1
395                ));
396            }
397        }
398
399        // Check that all files exist
400        for (idx, file_entry) in parsed_files.iter().enumerate() {
401            if let Err(e) = validate_files_exist(file_entry) {
402                return Err(anyhow!("Files entry {} validation failed: {}", idx + 1, e));
403            }
404        }
405
406        // Check that all files are unique (only in non-test mode)
407        if !self.test {
408            let mut seen_files = std::collections::HashSet::new();
409            for file_entry in parsed_files.iter() {
410                for file_path in file_entry {
411                    let expanded_path = shellexpand::tilde(file_path);
412                    if !seen_files.insert(expanded_path.as_ref().to_string()) {
413                        return Err(anyhow!(
414                            "Duplicate file found: '{}' appears multiple times",
415                            file_path
416                        ));
417                    }
418                }
419            }
420        }
421
422        Ok(())
423    }
424}
425
426#[cfg(test)]
427mod tests {
428    use super::*;
429
430    #[test]
431    fn test_retry_config_default() {
432        let config = RetryConfig::default();
433        assert_eq!(config.max_retries, 10);
434        assert_eq!(config.base_sleep, 1.0);
435        assert_eq!(config.max_sleep, 60.0);
436        assert_eq!(config.exponential_base, 2);
437    }
438
439    #[test]
440    fn test_retry_config_calculate_sleep_time() {
441        let config = RetryConfig::default();
442        let sleep_time = config.calculate_sleep_time(1);
443        assert!(sleep_time >= 0.0);
444        assert!(sleep_time <= config.max_sleep);
445    }
446
447    #[test]
448    fn test_video_category_conversion() {
449        assert_eq!(VideoCategory::PeopleBlogs.as_u32(), 22);
450        assert_eq!(
451            VideoCategory::from_u32(22).unwrap(),
452            VideoCategory::PeopleBlogs
453        );
454        assert!(VideoCategory::from_u32(999).is_err());
455    }
456
457    #[test]
458    fn test_playlist_id_validation() {
459        assert!(validate_playlist_id("PL1234567890123456").is_ok());
460        assert!(validate_playlist_id("PLAbCdEfGhIjKlMnOpQrStUvWxYz").is_ok());
461        assert!(validate_playlist_id("invalid").is_err());
462        assert!(validate_playlist_id("PL123").is_err()); // too short
463    }
464
465    #[test]
466    fn test_formatted_recording_date() {
467        let options = VideoUploadOptions {
468            file: "test.mp4".to_string(),
469            title: "Test".to_string(),
470            description: "Test".to_string(),
471            keywords: "test".to_string(),
472            category: 22,
473            privacy_status: "private".to_string(),
474            playlist_id: "PL1234567890123456".to_string(),
475            default_audio_language: "en".to_string(),
476            default_language: "en".to_string(),
477            recording_date: "2026-01-24".to_string(),
478        };
479
480        // Should convert YYYY-MM-DD to YYYY-MM-DDT00:00:00.000Z
481        assert_eq!(
482            options.formatted_recording_date(),
483            "2026-01-24T00:00:00.000Z"
484        );
485
486        // Already in timestamp format should remain unchanged
487        let options_with_timestamp = VideoUploadOptions {
488            recording_date: "2026-01-24T12:30:45.000Z".to_string(),
489            ..options
490        };
491        assert_eq!(
492            options_with_timestamp.formatted_recording_date(),
493            "2026-01-24T12:30:45.000Z"
494        );
495    }
496
497    #[test]
498    fn test_parse_files_with_separators() {
499        let config = BatchConfigRoot {
500            test: false,
501            common: CommonConfig {
502                prefix: "Test".to_string(),
503                keywords: "test".to_string(),
504                category: VideoCategory::PeopleBlogs,
505                privacy_status: PrivacyStatus::Private,
506                playlist_id: "PL1234567890123456".to_string(),
507                default_audio_language: "en".to_string(),
508                default_language: "en".to_string(),
509                recording_date: "2026-01-24".to_string(),
510            },
511            titles: vec!["Video 1".to_string(), "Video 2".to_string()],
512            files: vec![
513                "/path/to/video1.mp4".to_string(),
514                "/path/to/part1.mp4;/path/to/part2.mp4".to_string(),
515                "/path/to/video3.mp4, /path/to/video3_extra.mp4".to_string(),
516                "/path/to/video4a.mp4 /path/to/video4b.mp4".to_string(),
517            ],
518        };
519
520        let parsed = config.parse_files();
521        assert_eq!(parsed.len(), 4);
522        assert_eq!(parsed[0], vec!["/path/to/video1.mp4"]);
523        assert_eq!(parsed[1], vec!["/path/to/part1.mp4", "/path/to/part2.mp4"]);
524        assert_eq!(
525            parsed[2],
526            vec!["/path/to/video3.mp4", "/path/to/video3_extra.mp4"]
527        );
528        assert_eq!(
529            parsed[3],
530            vec!["/path/to/video4a.mp4", "/path/to/video4b.mp4"]
531        );
532    }
533}