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
323#[derive(
324    Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default,
325)]
326#[serde(rename_all = "camelCase")]
327#[strum(serialize_all = "camelCase")]
328pub enum RsRequestStatus {
329    /// No plugin yet processed this request
330    #[default]
331    Unprocessed,
332    /// All plugin processed but with no result
333    Processed,
334    ///if remain in this state after all plugin it will go through YtDl to try to extract medias
335    NeedParsing,
336    /// Link can be processed but first need to be added to the service and downloaded
337    ///   -First call this plugin again with `add` method
338    ///   -Check status and once ready call `process` again
339    RequireAdd,
340    /// Modified but need a second pass of plugins
341    Intermediate,
342    /// Multiple files found, current plugin need to be recalled with a `selected_file``
343    NeedFileSelection,
344    /// `url` is ready but should be proxied by the server as it contains sensitive informations (like token)
345    FinalPrivate,
346    /// `url` is ready and can be directly sent to _any_ user directly (using redirect)
347    FinalPublic,
348}
349
350#[derive(
351    Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default,
352)]
353#[serde(rename_all = "camelCase")]
354#[strum(serialize_all = "camelCase")]
355pub enum RsRequestMethod {
356    #[default]
357    Get,
358    Post,
359    Patch,
360    Delete,
361    Head,
362}
363
364#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
365#[serde(rename_all = "camelCase")]
366pub struct RsRequestFiles {
367    pub name: String,
368    pub size: u64,
369
370    pub mime: Option<String>,
371    #[serde(skip_serializing_if = "Option::is_none")]
372    pub description: Option<String>,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub tags: Option<Vec<String>>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub people: Option<Vec<String>>,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub albums: Option<Vec<String>>,
379    #[serde(skip_serializing_if = "Option::is_none")]
380    pub season: Option<u32>,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub episode: Option<u32>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub language: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub resolution: Option<RsResolution>,
387    #[serde(skip_serializing_if = "Option::is_none")]
388    pub video_format: Option<RsVideoFormat>,
389    #[serde(skip_serializing_if = "Option::is_none")]
390    pub videocodec: Option<RsVideoCodec>,
391    #[serde(skip_serializing_if = "Option::is_none")]
392    pub audio: Option<Vec<RsAudio>>,
393    #[serde(skip_serializing_if = "Option::is_none")]
394    pub quality: Option<u64>,
395
396    // Lookup fields (text to search in database, NOT IDs)
397    #[serde(skip_serializing_if = "Option::is_none")]
398    pub tags_lookup: Option<Vec<String>>,
399    #[serde(skip_serializing_if = "Option::is_none")]
400    pub people_lookup: Option<Vec<String>>,
401    #[serde(skip_serializing_if = "Option::is_none")]
402    pub albums_lookup: Option<Vec<String>>,
403}
404
405impl RsRequestFiles {
406    pub fn parse_filename(&mut self) {
407        let resolution = RsResolution::from_filename(&self.name);
408        if resolution != RsResolution::Unknown {
409            self.resolution = Some(resolution);
410        }
411        let video_format = RsVideoFormat::from_filename(&self.name);
412        if video_format != RsVideoFormat::Other {
413            self.video_format = Some(video_format);
414        }
415        let videocodec = RsVideoCodec::from_filename(&self.name);
416        if videocodec != RsVideoCodec::Unknown {
417            self.videocodec = Some(videocodec);
418        }
419        let audio = RsAudio::list_from_filename(&self.name);
420        if !audio.is_empty() {
421            self.audio = Some(audio);
422        }
423
424        let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
425        if let Some(caps) = re.captures(&self.name) {
426            self.season = caps[1].parse::<u32>().ok();
427            self.episode = caps[2].parse::<u32>().ok();
428        }
429    }
430}
431
432#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
433#[serde(rename_all = "camelCase")]
434pub struct RsRequestPluginRequest {
435    pub request: RsRequest,
436    pub credential: Option<PluginCredential>,
437    pub params: Option<HashMap<String, CustomParamTypes>>,
438}
439
440/// Groups multiple download requests together, optionally combining them into a single media item
441#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
442#[serde(rename_all = "camelCase")]
443pub struct RsGroupDownload {
444    /// If true, all requests will be grouped into a single media item (album)
445    #[serde(default)]
446    pub group: bool,
447    #[serde(skip_serializing_if = "Option::is_none")]
448    pub group_thumbnail_url: Option<String>,
449    #[serde(skip_serializing_if = "Option::is_none")]
450    pub group_filename: Option<String>,
451    #[serde(skip_serializing_if = "Option::is_none")]
452    pub group_mime: Option<String>,
453    pub requests: Vec<RsRequest>,
454
455    #[serde(skip_serializing_if = "Option::is_none")]
456    pub infos: Option<MediaForUpdate>,
457
458    #[serde(skip_serializing_if = "Option::is_none")]
459    pub match_type: Option<RsLookupMatchType>,
460}
461
462/// Status of a processing task added via request_add
463#[derive(
464    Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default,
465)]
466#[serde(rename_all = "camelCase")]
467#[strum(serialize_all = "camelCase")]
468pub enum RsProcessingStatus {
469    #[default]
470    Pending,
471    Processing,
472    Finished,
473    Error,
474    Paused,
475}
476
477/// Response from request_add plugin method
478#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
479#[serde(rename_all = "camelCase")]
480pub struct RsRequestAddResponse {
481    /// Processing ID returned by the plugin service
482    pub processing_id: String,
483    /// Initial status
484    #[serde(default)]
485    pub status: RsProcessingStatus,
486    /// Relative ETA in milliseconds until completion (host converts to absolute timestamp)
487    #[serde(skip_serializing_if = "Option::is_none")]
488    pub eta: Option<i64>,
489}
490
491/// Response from get_progress plugin method
492#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
493#[serde(rename_all = "camelCase")]
494pub struct RsProcessingProgress {
495    /// Processing ID
496    pub processing_id: String,
497    /// Progress percentage (0-100)
498    pub progress: u32,
499    /// Current status
500    pub status: RsProcessingStatus,
501    /// Error message if status is Error
502    #[serde(skip_serializing_if = "Option::is_none")]
503    pub error: Option<String>,
504    /// Relative ETA in milliseconds until completion (host converts to absolute timestamp)
505    #[serde(skip_serializing_if = "Option::is_none")]
506    pub eta: Option<i64>,
507    /// Updated request with final URL when finished
508    #[serde(skip_serializing_if = "Option::is_none")]
509    pub request: Option<Box<RsRequest>>,
510}
511
512/// Request for pause/remove/get_progress plugin methods
513#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
514#[serde(rename_all = "camelCase")]
515pub struct RsProcessingActionRequest {
516    /// Processing ID to act on
517    pub processing_id: String,
518    /// Credential for the plugin
519    pub credential: Option<PluginCredential>,
520    /// Optional params
521    pub params: Option<HashMap<String, CustomParamTypes>>,
522}
523
524#[cfg(test)]
525mod tests {
526
527    use self::error::RequestError;
528
529    use super::*;
530
531    #[test]
532    fn test_cookie_parsing() -> Result<(), RequestError> {
533        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
534        assert!(parsed.domain == ".twitter.com".to_owned());
535        assert!(parsed.http_only == false);
536        assert!(parsed.path == "/".to_owned());
537        assert!(parsed.secure == true);
538        assert!(parsed.expiration == Some(1722364794.437907));
539        assert!(parsed.name == "kdt".to_owned());
540        assert!(parsed.value == "w1j".to_owned());
541        Ok(())
542    }
543
544    #[test]
545    fn test_cookie_parsing_no_expi() -> Result<(), RequestError> {
546        let parsed = RsCookie::from_str(".twitter.com;false;/;true;;kdt;w1j")?;
547        assert!(parsed.domain == ".twitter.com".to_owned());
548        assert!(parsed.http_only == false);
549        assert!(parsed.path == "/".to_owned());
550        assert!(parsed.secure == true);
551        assert!(parsed.expiration == None);
552        assert!(parsed.name == "kdt".to_owned());
553        assert!(parsed.value == "w1j".to_owned());
554        Ok(())
555    }
556
557    #[test]
558    fn test_netscape() -> Result<(), RequestError> {
559        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
560        assert!(parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1722364794\tkdt\tw1j");
561        Ok(())
562    }
563    #[test]
564    fn test_netscape_doublequote() -> Result<(), RequestError> {
565        let parsed = RsCookie::from_str(
566            ".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"",
567        )?;
568        assert!(
569            parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1726506480\tads_prefs\t\"HBESAAA=\""
570        );
571        Ok(())
572    }
573
574    #[test]
575    fn test_parse_filename() -> Result<(), RequestError> {
576        let req = RsRequest {
577            url: "http://www.test.com/filename.mp4?toto=3".to_string(),
578            filename: Some("test.mkv".to_owned()),
579            ..Default::default()
580        };
581        assert_eq!(
582            req.filename_or_extract_from_url(),
583            Some("test.mkv".to_string())
584        );
585        let req = RsRequest {
586            url: "http://www.test.com/filename.mp4?toto=3".to_string(),
587            ..Default::default()
588        };
589        assert_eq!(
590            req.filename_or_extract_from_url(),
591            Some("filename.mp4".to_string()),
592            "We are expecting a filename from the url"
593        );
594        let req = RsRequest {
595            url: "http://www.test.com/notfilename?toto=3".to_string(),
596            ..Default::default()
597        };
598        assert_eq!(
599            req.filename_or_extract_from_url(),
600            None,
601            "Should return none as there is no filename with extensiopn in url"
602        );
603        let req = RsRequest {
604            url: "http://www.test.com/notfilename.toolong?toto=3".to_string(),
605            ..Default::default()
606        };
607        assert_eq!(
608            req.filename_or_extract_from_url(),
609            None,
610            "Should return none as too long after dot is not an extension"
611        );
612        let req = RsRequest {
613            url: "http://www.test.com/filename%20test.mp4?toto=3".to_string(),
614            ..Default::default()
615        };
616        assert_eq!(
617            req.filename_or_extract_from_url(),
618            Some("filename test.mp4".to_string()),
619            "Should decode URL-encoded filename"
620        );
621        Ok(())
622    }
623
624    #[test]
625    fn test_header() -> Result<(), RequestError> {
626        let parsed = vec![
627            RsCookie::from_str(
628                ".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"",
629            )?,
630            RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?,
631        ];
632        println!("header: {}", parsed.header_value());
633        assert!(parsed.header_value() == "ads_prefs=\"HBESAAA=\"; kdt=w1j");
634        Ok(())
635    }
636
637    #[test]
638    fn test_parse() -> Result<(), RequestError> {
639        let mut req = RsRequest {
640            filename: Some(
641                "Shogun.2024.S01E01.Anjin.1080p.VOSTFR.DSNP.WEB-DL.DDP5.1.H.264-NTb.mkv".to_owned(),
642            ),
643            ..Default::default()
644        };
645        req.parse_filename();
646        assert_eq!(req.season.unwrap(), 1);
647        assert_eq!(req.episode.unwrap(), 1);
648        assert_eq!(req.resolution.unwrap(), RsResolution::FullHD);
649        assert_eq!(req.videocodec.unwrap(), RsVideoCodec::H264);
650        assert_eq!(req.video_format.unwrap(), RsVideoFormat::Mkv);
651        assert_eq!(req.audio.unwrap().len(), 1);
652        Ok(())
653    }
654
655    #[test]
656    fn test_parse2() -> Result<(), RequestError> {
657        let mut req = RsRequest {
658            filename: Some("Shogun.2024.S01E05.MULTi.HDR.DV.2160p.WEB.H265-FW".to_owned()),
659            ..Default::default()
660        };
661        req.parse_filename();
662        assert_eq!(req.season.expect("a season"), 1);
663        assert_eq!(req.episode.expect("an episode"), 5);
664        assert_eq!(req.resolution.expect("a resolution"), RsResolution::UHD);
665        assert_eq!(req.videocodec.expect("a videocodec"), RsVideoCodec::H265);
666
667        Ok(())
668    }
669}