Skip to main content

rs_plugin_common_interfaces/request/
mod.rs

1use std::{collections::HashMap, str::FromStr};
2
3use crate::domain::media::{FileEpisode, Media, MediaForUpdate};
4use crate::lookup::RsLookupMatchType;
5use crate::{CustomParamTypes, PluginCredential, RsFileType, RsVideoFormat};
6use crate::{RsAudio, RsResolution, RsVideoCodec};
7use regex::Regex;
8use serde::{Deserialize, Serialize};
9use serde_json::Value;
10use strum_macros::EnumString;
11use urlencoding::decode;
12
13pub mod error;
14
15#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
16#[serde(rename_all = "camelCase")]
17pub struct RsCookie {
18    pub domain: String,
19    pub http_only: bool,
20    pub path: String,
21    pub secure: bool,
22    pub expiration: Option<f64>,
23    pub name: String,
24    pub value: String,
25}
26
27impl FromStr for RsCookie {
28    type Err = error::RequestError;
29    fn from_str(line: &str) -> Result<Self, Self::Err> {
30        //let [domain, httpOnly, path, secure, expiration, name, value ] = line.split(';');
31        let mut splitted = line.split(';');
32        Ok(RsCookie {
33            domain: splitted
34                .next()
35                .ok_or(error::RequestError::UnableToParseCookieString(
36                    "domain".to_owned(),
37                    line.to_owned(),
38                ))?
39                .to_owned(),
40            http_only: "true"
41                == splitted
42                    .next()
43                    .ok_or(error::RequestError::UnableToParseCookieString(
44                        "http_only".to_owned(),
45                        line.to_owned(),
46                    ))?,
47            path: splitted
48                .next()
49                .ok_or(error::RequestError::UnableToParseCookieString(
50                    "path".to_owned(),
51                    line.to_owned(),
52                ))?
53                .to_owned(),
54            secure: "true"
55                == splitted
56                    .next()
57                    .ok_or(error::RequestError::UnableToParseCookieString(
58                        "secure".to_owned(),
59                        line.to_owned(),
60                    ))?,
61            expiration: {
62                let t = splitted
63                    .next()
64                    .ok_or(error::RequestError::UnableToParseCookieString(
65                        "expiration".to_owned(),
66                        line.to_owned(),
67                    ))?
68                    .to_owned();
69                if t.is_empty() {
70                    None
71                } else {
72                    Some(t.parse().map_err(|_| {
73                        error::RequestError::UnableToParseCookieString(
74                            "expiration parsing".to_owned(),
75                            line.to_owned(),
76                        )
77                    })?)
78                }
79            },
80            name: splitted
81                .next()
82                .ok_or(error::RequestError::UnableToParseCookieString(
83                    "name".to_owned(),
84                    line.to_owned(),
85                ))?
86                .to_owned(),
87            value: splitted
88                .next()
89                .ok_or(error::RequestError::UnableToParseCookieString(
90                    "value".to_owned(),
91                    line.to_owned(),
92                ))?
93                .to_owned(),
94        })
95    }
96}
97
98impl RsCookie {
99    pub fn netscape(&self) -> String {
100        let second = if self.domain.starts_with('.') {
101            "TRUE"
102        } else {
103            "FALSE"
104        };
105        let secure = if self.secure { "TRUE" } else { "FALSE" };
106        let expiration = if let Some(expiration) = self.expiration {
107            (expiration as u32).to_string()
108        } else {
109            "".to_owned()
110        };
111        //return [domain, domain.startsWith('.') ? 'TRUE' : 'FALSE', path, secure ? 'TRUE' : 'FALSE', expiration.split('.')[0], name, value].join('\t')
112        format!(
113            "{}\t{}\t{}\t{}\t{}\t{}\t{}",
114            self.domain, second, self.path, secure, expiration, self.name, self.value
115        )
116    }
117
118    pub fn header(&self) -> String {
119        format!("{}={}", self.name, self.value)
120    }
121}
122
123pub trait RsCookies {
124    fn header_value(&self) -> String;
125    fn headers(&self) -> (String, String);
126}
127
128impl RsCookies for Vec<RsCookie> {
129    fn header_value(&self) -> String {
130        self.iter()
131            .map(|t| t.header())
132            .collect::<Vec<String>>()
133            .join("; ")
134    }
135
136    fn headers(&self) -> (String, String) {
137        (
138            "cookie".to_owned(),
139            self.iter()
140                .map(|t| t.header())
141                .collect::<Vec<String>>()
142                .join("; "),
143        )
144    }
145}
146
147#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
148#[serde(rename_all = "camelCase")]
149pub struct RsRequest {
150    pub upload_id: Option<String>,
151    pub url: String,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub mime: Option<String>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub size: Option<u64>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub filename: Option<String>,
158    #[serde(default)]
159    pub status: RsRequestStatus,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub plugin_id: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub plugin_name: Option<String>,
164
165    /// If true this request can be saved for later use and will remain valid
166    /// If Permanent is true but status is intermediate the process will go through request plugins to try to get a permanant link
167    #[serde(default)]
168    pub permanent: bool,
169    /// If true can be played/downloaded instantly (streamable link, no need to add to service first)
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub instant: Option<bool>,
172
173    pub json_body: Option<Value>,
174    #[serde(default)]
175    pub method: RsRequestMethod,
176
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub referer: Option<String>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub headers: Option<Vec<(String, String)>>,
181    /// some downloader like YTDL require detailed cookies. You can create Header equivalent  with `headers` fn on the vector
182    #[serde(skip_serializing_if = "Option::is_none")]
183    pub cookies: Option<Vec<RsCookie>>,
184    /// If must choose between multiple files. Recall plugin with a `selected_file` containing one of the name in this list to get link
185    #[serde(skip_serializing_if = "Option::is_none")]
186    pub files: Option<Vec<RsRequestFiles>>,
187    /// one of the `files` selected for download
188    #[serde(skip_serializing_if = "Option::is_none")]
189    pub selected_file: Option<String>,
190
191    #[serde(skip_serializing_if = "Option::is_none")]
192    pub description: Option<String>,
193    #[serde(skip_serializing_if = "Option::is_none")]
194    pub tags: Option<Vec<String>>,
195    #[serde(skip_serializing_if = "Option::is_none")]
196    pub people: Option<Vec<String>>,
197    #[serde(skip_serializing_if = "Option::is_none")]
198    pub albums: Option<Vec<String>>,
199    #[serde(skip_serializing_if = "Option::is_none")]
200    pub season: Option<u32>,
201    #[serde(skip_serializing_if = "Option::is_none")]
202    pub episode: Option<u32>,
203    #[serde(skip_serializing_if = "Option::is_none")]
204    pub movie: Option<String>,
205    #[serde(skip_serializing_if = "Option::is_none")]
206    pub book: Option<FileEpisode>,
207    #[serde(skip_serializing_if = "Option::is_none")]
208    pub language: Option<String>,
209    #[serde(skip_serializing_if = "Option::is_none")]
210    pub resolution: Option<RsResolution>,
211    #[serde(skip_serializing_if = "Option::is_none")]
212    pub video_format: Option<RsVideoFormat>,
213    #[serde(skip_serializing_if = "Option::is_none")]
214    pub videocodec: Option<RsVideoCodec>,
215    #[serde(skip_serializing_if = "Option::is_none")]
216    pub audio: Option<Vec<RsAudio>>,
217    #[serde(skip_serializing_if = "Option::is_none")]
218    pub quality: Option<u64>,
219
220    #[serde(default)]
221    pub ignore_origin_duplicate: bool,
222
223    // Download-specific fields
224    #[serde(skip_serializing_if = "Option::is_none")]
225    pub thumbnail_url: Option<String>,
226    #[serde(skip_serializing_if = "Option::is_none")]
227    pub origin_url: Option<String>,
228    #[serde(skip_serializing_if = "Option::is_none")]
229    pub title: Option<String>,
230    #[serde(skip_serializing_if = "Option::is_none")]
231    pub kind: Option<RsFileType>,
232
233    // Lookup fields (text to search in database, NOT IDs)
234    #[serde(skip_serializing_if = "Option::is_none")]
235    pub tags_lookup: Option<Vec<String>>,
236    #[serde(skip_serializing_if = "Option::is_none")]
237    pub people_lookup: Option<Vec<String>>,
238    #[serde(skip_serializing_if = "Option::is_none")]
239    pub albums_lookup: Option<Vec<String>>,
240}
241
242impl RsRequest {
243    pub fn set_cookies(&mut self, cookies: Vec<RsCookie>) {
244        let mut existing = if let Some(headers) = &self.headers {
245            headers.to_owned()
246        } else {
247            vec![]
248        };
249        existing.push(cookies.headers());
250        self.headers = Some(existing);
251    }
252
253    pub fn filename_or_extract_from_url(&self) -> Option<String> {
254        if self.filename.is_some() {
255            self.filename.clone()
256        } else {
257            self.url
258                .split('/')
259                .last()
260                .and_then(|segment| {
261                    segment
262                        .split('?')
263                        .next()
264                        .filter(|s| !s.is_empty())
265                        .map(|s| s.to_string())
266                })
267                .and_then(|potential| {
268                    let extension = potential
269                        .split('.')
270                        .map(|t| t.to_string())
271                        .collect::<Vec<String>>();
272                    if extension.len() > 1
273                        && extension.last().unwrap_or(&"".to_string()).len() > 2
274                        && extension.last().unwrap_or(&"".to_string()).len() < 5
275                    {
276                        let decoded = decode(&potential)
277                            .map(|x| x.into_owned())
278                            .unwrap_or(potential); // Decodes the URL
279                        Some(decoded)
280                    } else {
281                        None
282                    }
283                })
284        }
285    }
286
287    pub fn parse_filename(&mut self) {
288        if let Some(filename) = &self.filename {
289            let resolution = RsResolution::from_filename(filename);
290            if resolution != RsResolution::Unknown {
291                self.resolution = Some(resolution);
292            }
293            let video_format = RsVideoFormat::from_filename(filename);
294            if video_format != RsVideoFormat::Other {
295                self.video_format = Some(video_format);
296            }
297            let videocodec = RsVideoCodec::from_filename(filename);
298            if videocodec != RsVideoCodec::Unknown {
299                self.videocodec = Some(videocodec);
300            }
301            let audio = RsAudio::list_from_filename(filename);
302            if !audio.is_empty() {
303                self.audio = Some(audio);
304            }
305
306            let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
307            if let Some(caps) = re.captures(filename) {
308                self.season = caps[1].parse::<u32>().ok();
309                self.episode = caps[2].parse::<u32>().ok();
310            }
311        }
312    }
313
314    pub fn parse_subfilenames(&mut self) {
315        if let Some(ref mut files) = self.files {
316            for file in files {
317                file.parse_filename();
318            }
319        }
320    }
321
322    /// When a file has been selected from the `files` list via `selected_file`,
323    /// copy the selected file's properties (mime, size, filename, season, episode, etc.)
324    /// to the top-level request fields so downstream code uses the correct values.
325    pub fn apply_selected_file_info(&mut self) {
326        if let (Some(selected), Some(files)) = (&self.selected_file, &self.files) {
327            if let Some(file) = files.iter().find(|f| f.name == *selected) {
328                if file.mime.is_some() {
329                    self.mime = file.mime.clone();
330                }
331                if file.size > 0 {
332                    self.size = Some(file.size);
333                }
334                if self.filename.is_none() {
335                    self.filename = Some(file.name.clone());
336                }
337                if self.season.is_none() {
338                    self.season = file.season;
339                }
340                if self.episode.is_none() {
341                    self.episode = file.episode;
342                }
343            }
344        }
345    }
346}
347
348#[derive(
349    Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default,
350)]
351#[serde(rename_all = "camelCase")]
352#[strum(serialize_all = "camelCase")]
353pub enum RsRequestStatus {
354    /// No plugin yet processed this request
355    #[default]
356    Unprocessed,
357    /// All plugin processed but with no result
358    Processed,
359    ///if remain in this state after all plugin it will go through YtDl to try to extract medias
360    NeedParsing,
361    /// Link can be processed but first need to be added to the service and downloaded
362    ///   -First call this plugin again with `add` method
363    ///   -Check status and once ready call `process` again
364    RequireAdd,
365    /// Modified but need a second pass of plugins
366    Intermediate,
367    /// Multiple files found, current plugin need to be recalled with a `selected_file``
368    NeedFileSelection,
369    /// `url` is ready but should be proxied by the server as it contains sensitive informations (like token)
370    FinalPrivate,
371    /// `url` is ready and can be directly sent to _any_ user directly (using redirect)
372    FinalPublic,
373}
374
375#[derive(
376    Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default,
377)]
378#[serde(rename_all = "camelCase")]
379#[strum(serialize_all = "camelCase")]
380pub enum RsRequestMethod {
381    #[default]
382    Get,
383    Post,
384    Patch,
385    Delete,
386    Head,
387}
388
389#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
390#[serde(rename_all = "camelCase")]
391pub struct RsRequestFiles {
392    pub name: String,
393    pub size: u64,
394
395    pub mime: Option<String>,
396    #[serde(skip_serializing_if = "Option::is_none")]
397    pub description: Option<String>,
398    #[serde(skip_serializing_if = "Option::is_none")]
399    pub tags: Option<Vec<String>>,
400    #[serde(skip_serializing_if = "Option::is_none")]
401    pub people: Option<Vec<String>>,
402    #[serde(skip_serializing_if = "Option::is_none")]
403    pub albums: Option<Vec<String>>,
404    #[serde(skip_serializing_if = "Option::is_none")]
405    pub season: Option<u32>,
406    #[serde(skip_serializing_if = "Option::is_none")]
407    pub episode: Option<u32>,
408    #[serde(skip_serializing_if = "Option::is_none")]
409    pub language: Option<String>,
410    #[serde(skip_serializing_if = "Option::is_none")]
411    pub resolution: Option<RsResolution>,
412    #[serde(skip_serializing_if = "Option::is_none")]
413    pub video_format: Option<RsVideoFormat>,
414    #[serde(skip_serializing_if = "Option::is_none")]
415    pub videocodec: Option<RsVideoCodec>,
416    #[serde(skip_serializing_if = "Option::is_none")]
417    pub audio: Option<Vec<RsAudio>>,
418    #[serde(skip_serializing_if = "Option::is_none")]
419    pub quality: Option<u64>,
420
421    // Lookup fields (text to search in database, NOT IDs)
422    #[serde(skip_serializing_if = "Option::is_none")]
423    pub tags_lookup: Option<Vec<String>>,
424    #[serde(skip_serializing_if = "Option::is_none")]
425    pub people_lookup: Option<Vec<String>>,
426    #[serde(skip_serializing_if = "Option::is_none")]
427    pub albums_lookup: Option<Vec<String>>,
428}
429
430impl RsRequestFiles {
431    pub fn parse_filename(&mut self) {
432        let resolution = RsResolution::from_filename(&self.name);
433        if resolution != RsResolution::Unknown {
434            self.resolution = Some(resolution);
435        }
436        let video_format = RsVideoFormat::from_filename(&self.name);
437        if video_format != RsVideoFormat::Other {
438            self.video_format = Some(video_format);
439        }
440        let videocodec = RsVideoCodec::from_filename(&self.name);
441        if videocodec != RsVideoCodec::Unknown {
442            self.videocodec = Some(videocodec);
443        }
444        let audio = RsAudio::list_from_filename(&self.name);
445        if !audio.is_empty() {
446            self.audio = Some(audio);
447        }
448
449        let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
450        if let Some(caps) = re.captures(&self.name) {
451            self.season = caps[1].parse::<u32>().ok();
452            self.episode = caps[2].parse::<u32>().ok();
453        }
454    }
455}
456
457#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
458#[serde(rename_all = "camelCase")]
459pub struct RsRequestPluginRequest {
460    pub request: RsRequest,
461    pub credential: Option<PluginCredential>,
462    pub params: Option<HashMap<String, CustomParamTypes>>,
463}
464
465/// Groups multiple download requests together, optionally combining them into a single media item
466#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
467#[serde(rename_all = "camelCase")]
468pub struct RsGroupDownload {
469    /// If true, all requests will be grouped into a single media item (album)
470    #[serde(default)]
471    pub group: bool,
472    #[serde(skip_serializing_if = "Option::is_none")]
473    pub group_thumbnail_url: Option<String>,
474    #[serde(skip_serializing_if = "Option::is_none")]
475    pub group_filename: Option<String>,
476    #[serde(skip_serializing_if = "Option::is_none")]
477    pub group_mime: Option<String>,
478    pub requests: Vec<RsRequest>,
479
480    #[serde(skip_serializing_if = "Option::is_none")]
481    pub infos: Option<MediaForUpdate>,
482
483    #[serde(skip_serializing_if = "Option::is_none")]
484    pub match_type: Option<RsLookupMatchType>,
485}
486
487/// Status of a processing task added via request_add
488#[derive(
489    Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default,
490)]
491#[serde(rename_all = "camelCase")]
492#[strum(serialize_all = "camelCase")]
493pub enum RsProcessingStatus {
494    #[default]
495    Pending,
496    Processing,
497    Finished,
498    Error,
499    Paused,
500}
501
502/// Response from request_add plugin method
503#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
504#[serde(rename_all = "camelCase")]
505pub struct RsRequestAddResponse {
506    /// Processing ID returned by the plugin service
507    pub processing_id: String,
508    /// Initial status
509    #[serde(default)]
510    pub status: RsProcessingStatus,
511    /// Relative ETA in milliseconds until completion (host converts to absolute timestamp)
512    #[serde(skip_serializing_if = "Option::is_none")]
513    pub eta: Option<i64>,
514}
515
516/// Response from get_progress plugin method
517#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
518#[serde(rename_all = "camelCase")]
519pub struct RsProcessingProgress {
520    /// Processing ID
521    pub processing_id: String,
522    /// Progress percentage (0-100)
523    pub progress: u32,
524    /// Current status
525    pub status: RsProcessingStatus,
526    /// Error message if status is Error
527    #[serde(skip_serializing_if = "Option::is_none")]
528    pub error: Option<String>,
529    /// Relative ETA in milliseconds until completion (host converts to absolute timestamp)
530    #[serde(skip_serializing_if = "Option::is_none")]
531    pub eta: Option<i64>,
532    /// Updated request with final URL when finished
533    #[serde(skip_serializing_if = "Option::is_none")]
534    pub request: Option<Box<RsRequest>>,
535}
536
537/// Request for pause/remove/get_progress plugin methods
538#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
539#[serde(rename_all = "camelCase")]
540pub struct RsProcessingActionRequest {
541    /// Processing ID to act on
542    pub processing_id: String,
543    /// Credential for the plugin
544    pub credential: Option<PluginCredential>,
545    /// Optional params
546    pub params: Option<HashMap<String, CustomParamTypes>>,
547}
548
549#[cfg(test)]
550mod tests {
551
552    use self::error::RequestError;
553
554    use super::*;
555
556    #[test]
557    fn test_cookie_parsing() -> Result<(), RequestError> {
558        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
559        assert!(parsed.domain == ".twitter.com".to_owned());
560        assert!(parsed.http_only == false);
561        assert!(parsed.path == "/".to_owned());
562        assert!(parsed.secure == true);
563        assert!(parsed.expiration == Some(1722364794.437907));
564        assert!(parsed.name == "kdt".to_owned());
565        assert!(parsed.value == "w1j".to_owned());
566        Ok(())
567    }
568
569    #[test]
570    fn test_cookie_parsing_no_expi() -> Result<(), RequestError> {
571        let parsed = RsCookie::from_str(".twitter.com;false;/;true;;kdt;w1j")?;
572        assert!(parsed.domain == ".twitter.com".to_owned());
573        assert!(parsed.http_only == false);
574        assert!(parsed.path == "/".to_owned());
575        assert!(parsed.secure == true);
576        assert!(parsed.expiration == None);
577        assert!(parsed.name == "kdt".to_owned());
578        assert!(parsed.value == "w1j".to_owned());
579        Ok(())
580    }
581
582    #[test]
583    fn test_netscape() -> Result<(), RequestError> {
584        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
585        assert!(parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1722364794\tkdt\tw1j");
586        Ok(())
587    }
588    #[test]
589    fn test_netscape_doublequote() -> Result<(), RequestError> {
590        let parsed = RsCookie::from_str(
591            ".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"",
592        )?;
593        assert!(
594            parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1726506480\tads_prefs\t\"HBESAAA=\""
595        );
596        Ok(())
597    }
598
599    #[test]
600    fn test_parse_filename() -> Result<(), RequestError> {
601        let req = RsRequest {
602            url: "http://www.test.com/filename.mp4?toto=3".to_string(),
603            filename: Some("test.mkv".to_owned()),
604            ..Default::default()
605        };
606        assert_eq!(
607            req.filename_or_extract_from_url(),
608            Some("test.mkv".to_string())
609        );
610        let req = RsRequest {
611            url: "http://www.test.com/filename.mp4?toto=3".to_string(),
612            ..Default::default()
613        };
614        assert_eq!(
615            req.filename_or_extract_from_url(),
616            Some("filename.mp4".to_string()),
617            "We are expecting a filename from the url"
618        );
619        let req = RsRequest {
620            url: "http://www.test.com/notfilename?toto=3".to_string(),
621            ..Default::default()
622        };
623        assert_eq!(
624            req.filename_or_extract_from_url(),
625            None,
626            "Should return none as there is no filename with extensiopn in url"
627        );
628        let req = RsRequest {
629            url: "http://www.test.com/notfilename.toolong?toto=3".to_string(),
630            ..Default::default()
631        };
632        assert_eq!(
633            req.filename_or_extract_from_url(),
634            None,
635            "Should return none as too long after dot is not an extension"
636        );
637        let req = RsRequest {
638            url: "http://www.test.com/filename%20test.mp4?toto=3".to_string(),
639            ..Default::default()
640        };
641        assert_eq!(
642            req.filename_or_extract_from_url(),
643            Some("filename test.mp4".to_string()),
644            "Should decode URL-encoded filename"
645        );
646        Ok(())
647    }
648
649    #[test]
650    fn test_header() -> Result<(), RequestError> {
651        let parsed = vec![
652            RsCookie::from_str(
653                ".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"",
654            )?,
655            RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?,
656        ];
657        println!("header: {}", parsed.header_value());
658        assert!(parsed.header_value() == "ads_prefs=\"HBESAAA=\"; kdt=w1j");
659        Ok(())
660    }
661
662    #[test]
663    fn test_parse() -> Result<(), RequestError> {
664        let mut req = RsRequest {
665            filename: Some(
666                "Shogun.2024.S01E01.Anjin.1080p.VOSTFR.DSNP.WEB-DL.DDP5.1.H.264-NTb.mkv".to_owned(),
667            ),
668            ..Default::default()
669        };
670        req.parse_filename();
671        assert_eq!(req.season.unwrap(), 1);
672        assert_eq!(req.episode.unwrap(), 1);
673        assert_eq!(req.resolution.unwrap(), RsResolution::FullHD);
674        assert_eq!(req.videocodec.unwrap(), RsVideoCodec::H264);
675        assert_eq!(req.video_format.unwrap(), RsVideoFormat::Mkv);
676        assert_eq!(req.audio.unwrap().len(), 1);
677        Ok(())
678    }
679
680    #[test]
681    fn test_parse2() -> Result<(), RequestError> {
682        let mut req = RsRequest {
683            filename: Some("Shogun.2024.S01E05.MULTi.HDR.DV.2160p.WEB.H265-FW".to_owned()),
684            ..Default::default()
685        };
686        req.parse_filename();
687        assert_eq!(req.season.expect("a season"), 1);
688        assert_eq!(req.episode.expect("an episode"), 5);
689        assert_eq!(req.resolution.expect("a resolution"), RsResolution::UHD);
690        assert_eq!(req.videocodec.expect("a videocodec"), RsVideoCodec::H265);
691
692        Ok(())
693    }
694}