1use anyhow::{Result, anyhow};
7use rand::Rng;
8use regex::Regex;
9use serde::{Deserialize, Serialize};
10use std::path::Path;
11use validator::{Validate, ValidationError};
12
13#[derive(Debug, Clone, PartialEq)]
15pub enum ConfigFormat {
16 Individual,
17 Batch,
18}
19
20#[derive(Debug, Clone, Serialize, Deserialize)]
22pub struct RetryConfig {
23 pub max_retries: u32,
25 pub base_sleep: f64,
27 pub max_sleep: f64,
29 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 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#[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 pub fn formatted_recording_date(&self) -> String {
80 if self.recording_date.contains('T') {
81 self.recording_date.clone()
83 } else {
84 format!("{}T00:00:00.000Z", self.recording_date)
86 }
87 }
88}
89
90#[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#[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 pub fn as_u32(&self) -> u32 {
163 *self as u32
164 }
165
166 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
205pub 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
215fn 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
225fn 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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
240pub struct CommonConfig {
241 #[validate(length(min = 1))]
243 pub prefix: String,
244
245 #[validate(length(min = 1))]
247 pub keywords: String,
248
249 #[serde(default)]
251 pub category: VideoCategory,
252
253 #[serde(default, rename = "privacyStatus")]
255 pub privacy_status: PrivacyStatus,
256
257 #[validate(custom(function = "validate_playlist_id"))]
259 #[serde(rename = "playlistId")]
260 pub playlist_id: String,
261
262 #[serde(rename = "defaultAudioLanguage")]
264 pub default_audio_language: String,
265
266 #[serde(rename = "defaultLanguage")]
268 pub default_language: String,
269
270 #[serde(rename = "recordingDate")]
272 pub recording_date: String,
273}
274
275impl CommonConfig {
276 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#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
287pub struct VideoConfig {
288 #[validate(length(min = 1, max = 100))]
290 pub title: String,
291
292 #[serde(default)]
294 pub description: String,
295
296 #[validate(length(min = 1))]
298 pub keywords: String,
299
300 #[validate(length(min = 1), custom(function = "validate_file_exists"))]
302 pub file: String,
303
304 pub category: VideoCategory,
306
307 #[serde(rename = "privacyStatus")]
309 pub privacy_status: PrivacyStatus,
310
311 #[validate(custom(function = "validate_playlist_id"))]
313 #[serde(rename = "playlistId")]
314 pub playlist_id: String,
315
316 #[serde(rename = "defaultAudioLanguage")]
318 pub default_audio_language: String,
319
320 #[serde(rename = "defaultLanguage")]
322 pub default_language: String,
323
324 #[serde(rename = "recordingDate")]
326 pub recording_date: String,
327}
328
329#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
331pub struct IndividualConfigRoot {
332 #[serde(default = "bool::default")]
334 pub test: bool,
335
336 #[validate(length(min = 1), nested)]
338 pub videos: Vec<VideoConfig>,
339}
340
341#[derive(Debug, Clone, Serialize, Deserialize, Validate)]
343pub struct BatchConfigRoot {
344 #[serde(default = "bool::default")]
346 pub test: bool,
347
348 #[validate(nested)]
350 pub common: CommonConfig,
351
352 #[validate(length(min = 1))]
354 pub titles: Vec<String>,
355
356 #[validate(length(min = 1))]
358 pub files: Vec<String>,
359}
360
361impl BatchConfigRoot {
362 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 pub async fn validate_files_and_lengths(&self) -> Result<()> {
378 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 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 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 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()); }
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 assert_eq!(
482 options.formatted_recording_date(),
483 "2026-01-24T00:00:00.000Z"
484 );
485
486 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}