Skip to main content

rs_plugin_common_interfaces/request/
mod.rs

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