Skip to main content

rs_plugin_common_interfaces/request/
mod.rs

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