rs_plugin_common_interfaces/request/
mod.rs

1use std::str::FromStr;
2
3use regex::Regex;
4use serde_json::Value;
5use urlencoding::decode;
6use crate::{PluginCredential, RsFileType, RsVideoFormat};
7use crate::{RsAudio, RsResolution, RsVideoCodec};
8use serde::{Deserialize, Serialize};
9use strum_macros::EnumString;
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.next().ok_or(error::RequestError::UnableToParseCookieString("domain".to_owned(), line.to_owned()))?.to_owned(), 
32            http_only: "true" == splitted.next().ok_or(error::RequestError::UnableToParseCookieString("http_only".to_owned(), line.to_owned()))?, 
33            path: splitted.next().ok_or(error::RequestError::UnableToParseCookieString("path".to_owned(), line.to_owned()))?.to_owned(), 
34            secure: "true" == splitted.next().ok_or(error::RequestError::UnableToParseCookieString("secure".to_owned(), line.to_owned()))?, 
35            expiration: {
36                let t = splitted.next().ok_or(error::RequestError::UnableToParseCookieString("expiration".to_owned(), line.to_owned()))?.to_owned();
37                if t.is_empty() {
38                    None  
39                } else {
40                    Some(t.parse().map_err(|_| error::RequestError::UnableToParseCookieString("expiration parsing".to_owned(), line.to_owned()))?)
41                }
42            }, 
43            name: splitted.next().ok_or(error::RequestError::UnableToParseCookieString("name".to_owned(), line.to_owned()))?.to_owned(), 
44            value: splitted.next().ok_or(error::RequestError::UnableToParseCookieString("value".to_owned(), line.to_owned()))?.to_owned() })
45    }
46}
47
48impl  RsCookie {
49    pub fn netscape(&self) -> String {
50        let second = if self.domain.starts_with('.') {
51            "TRUE"
52        } else {
53            "FALSE"
54        };
55        let secure = if self.secure {
56            "TRUE"
57        } else {
58            "FALSE"
59        };
60        let expiration = if let Some(expiration) = self.expiration {
61           (expiration as u32).to_string()
62        } else {
63            "".to_owned()
64        };
65        //return [domain, domain.startsWith('.') ? 'TRUE' : 'FALSE', path, secure ? 'TRUE' : 'FALSE', expiration.split('.')[0], name, value].join('\t')
66        format!("{}\t{}\t{}\t{}\t{}\t{}\t{}", self.domain, second, self.path, secure, expiration, self.name, self.value)
67    }
68
69    pub fn header(&self) -> String {
70        format!("{}={}", self.name, self.value)
71    }
72}
73
74pub trait RsCookies {
75    fn header_value(&self) -> String;
76    fn headers(&self) -> (String, String);
77}
78
79impl RsCookies for Vec<RsCookie> {
80    fn header_value(&self) -> String {
81        self.iter().map(|t| t.header()).collect::<Vec<String>>().join("; ")
82    }
83
84    fn headers(&self) -> (String, String) {
85        ("cookie".to_owned(), self.iter().map(|t| t.header()).collect::<Vec<String>>().join("; "))
86    }
87}
88
89#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
90#[serde(rename_all = "camelCase")] 
91pub struct RsRequest {
92    pub upload_id: Option<String>,
93    pub url: String,
94    #[serde(skip_serializing_if = "Option::is_none")]
95    pub mime: Option<String>,
96    #[serde(skip_serializing_if = "Option::is_none")]
97    pub size: Option<u64>,
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub filename: Option<String>,
100    #[serde(default)]
101    pub status: RsRequestStatus,
102
103    /// If true this request can be saved for later use and will remain valid
104    /// If Permanent is true but status is intermediate the process will go through request plugins to try to get a permanant link
105    #[serde(default)]
106    pub permanent: bool,
107    
108    pub json_body: Option<Value>,
109    #[serde(default)]
110    pub method: RsRequestMethod,
111
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub referer: Option<String>,
114    #[serde(skip_serializing_if = "Option::is_none")]
115    pub headers: Option<Vec<(String, String)>>,
116    /// some downloader like YTDL require detailed cookies. You can create Header equivalent  with `headers` fn on the vector
117    #[serde(skip_serializing_if = "Option::is_none")]
118    pub cookies: Option<Vec<RsCookie>>,
119    /// If must choose between multiple files. Recall plugin with a `selected_file` containing one of the name in this list to get link
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub files: Option<Vec<RsRequestFiles>>,
122    /// one of the `files` selected for download
123    #[serde(skip_serializing_if = "Option::is_none")]
124    pub selected_file: Option<String>,
125
126    
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub description: Option<String>,
129    #[serde(skip_serializing_if = "Option::is_none")]
130    pub tags: Option<Vec<String>>,
131    #[serde(skip_serializing_if = "Option::is_none")]
132    pub people: Option<Vec<String>>,
133    #[serde(skip_serializing_if = "Option::is_none")]
134    pub albums: Option<Vec<String>>,
135    #[serde(skip_serializing_if = "Option::is_none")]
136    pub season: Option<u32>,
137    #[serde(skip_serializing_if = "Option::is_none")]
138    pub episode: Option<u32>,
139    #[serde(skip_serializing_if = "Option::is_none")]
140    pub movie: Option<String>,
141    #[serde(skip_serializing_if = "Option::is_none")]
142    pub language: Option<String>,
143    #[serde(skip_serializing_if = "Option::is_none")]
144    pub resolution: Option<RsResolution>,
145    #[serde(skip_serializing_if = "Option::is_none")]
146    pub video_format: Option<RsVideoFormat>,
147    #[serde(skip_serializing_if = "Option::is_none")]
148    pub videocodec: Option<RsVideoCodec>,
149    #[serde(skip_serializing_if = "Option::is_none")]
150    pub audio: Option<Vec<RsAudio>>,
151    #[serde(skip_serializing_if = "Option::is_none")]
152    pub quality: Option<u64>,
153
154    #[serde(default)]
155    pub ignore_origin_duplicate: bool,
156
157    // Download-specific fields
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub thumbnail_url: Option<String>,
160    #[serde(skip_serializing_if = "Option::is_none")]
161    pub origin_url: Option<String>,
162    #[serde(skip_serializing_if = "Option::is_none")]
163    pub title: Option<String>,
164    #[serde(skip_serializing_if = "Option::is_none")]
165    pub kind: Option<RsFileType>,
166
167    // Lookup fields (text to search in database, NOT IDs)
168    #[serde(skip_serializing_if = "Option::is_none")]
169    pub tags_lookup: Option<Vec<String>>,
170    #[serde(skip_serializing_if = "Option::is_none")]
171    pub people_lookup: Option<Vec<String>>,
172    #[serde(skip_serializing_if = "Option::is_none")]
173    pub albums_lookup: Option<Vec<String>>,
174}
175
176impl RsRequest {
177    pub fn set_cookies(&mut self, cookies: Vec<RsCookie>) {
178        let mut existing = if let Some(headers) = &self.headers {
179            headers.to_owned()
180        } else{
181            vec![]
182        };
183        existing.push(cookies.headers());
184        self.headers = Some(existing);
185    }
186
187    pub fn filename_or_extract_from_url(&self) -> Option<String> {
188        if self.filename.is_some() {
189            self.filename.clone()
190        } else {
191            self.url.split('/')
192            .last()
193            .and_then(|segment| {
194                segment.split('?')
195                    .next()
196                    .filter(|s| !s.is_empty())
197                    .map(|s| s.to_string())
198            })
199            .and_then(|potential| {
200                let extension = potential.split('.').map(|t| t.to_string()).collect::<Vec<String>>();
201                if extension.len() > 1 && extension.last().unwrap_or(&"".to_string()).len() > 2 &&  extension.last().unwrap_or(&"".to_string()).len() < 5{
202                    let decoded = decode(&potential).map(|x| x.into_owned()).unwrap_or(potential); // Decodes the URL
203                    Some(decoded)
204                } else {
205                    None
206                }
207            })
208        }
209        
210    }
211
212    pub fn parse_filename(&mut self) {
213        if let Some(filename) = &self.filename {
214            let resolution = RsResolution::from_filename(filename);
215            if resolution != RsResolution::Unknown {
216                self.resolution = Some(resolution);
217            }
218            let video_format = RsVideoFormat::from_filename(filename);
219            if video_format != RsVideoFormat::Other {
220                self.video_format = Some(video_format);
221            }
222            let videocodec = RsVideoCodec::from_filename(filename);
223            if videocodec != RsVideoCodec::Unknown {
224                self.videocodec = Some(videocodec);
225            }
226            let audio = RsAudio::list_from_filename(filename);
227            if !audio.is_empty() {
228                self.audio = Some(audio);
229            }
230
231            let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
232            if let Some(caps) = re.captures(filename) {
233                self.season = caps[1].parse::<u32>().ok();
234                self.episode = caps[2].parse::<u32>().ok();
235            }
236        }
237 
238    }
239
240    pub fn parse_subfilenames(&mut self) {
241        if let Some(ref mut files) = self.files {
242            for file in files {
243                file.parse_filename();
244            }
245        }
246    }
247}
248
249#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display,EnumString, Default)]
250#[serde(rename_all = "camelCase")] 
251#[strum(serialize_all = "camelCase")]
252pub enum RsRequestStatus {
253    /// No plugin yet processed this request
254    #[default]
255	Unprocessed,
256    /// All plugin processed but with no result
257    Processed,
258    ///if remain in this state after all plugin it will go through YtDl to try to extract medias
259    NeedParsing,
260    /// Link can be processed but first need to be added to the service and downloaded
261    ///   -First call this plugin again with `add` method
262    ///   -Check status and once ready call `process` again
263    RequireAdd,
264    /// Modified but need a second pass of plugins
265    Intermediate,
266    /// Multiple files found, current plugin need to be recalled with a `selected_file``
267    NeedFileSelection,
268    /// `url` is ready but should be proxied by the server as it contains sensitive informations (like token)
269    FinalPrivate,
270    /// `url` is ready and can be directly sent to _any_ user directly (using redirect)
271    FinalPublic
272}
273
274
275#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display,EnumString, Default)]
276#[serde(rename_all = "camelCase")] 
277#[strum(serialize_all = "camelCase")]
278pub enum RsRequestMethod {
279    
280    #[default]
281	Get,
282    Post,
283    Patch,
284    Delete,
285    Head,
286  
287}
288
289
290
291#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
292#[serde(rename_all = "camelCase")] 
293pub struct RsRequestFiles {
294    pub name: String,
295    pub size: u64,
296    
297    pub mime: Option<String>,
298    #[serde(skip_serializing_if = "Option::is_none")]
299    pub description: Option<String>,
300    #[serde(skip_serializing_if = "Option::is_none")]
301    pub tags: Option<Vec<String>>,
302    #[serde(skip_serializing_if = "Option::is_none")]
303    pub people: Option<Vec<String>>,
304    #[serde(skip_serializing_if = "Option::is_none")]
305    pub albums: Option<Vec<String>>,
306    #[serde(skip_serializing_if = "Option::is_none")]
307    pub season: Option<u32>,
308    #[serde(skip_serializing_if = "Option::is_none")]
309    pub episode: Option<u32>,
310    #[serde(skip_serializing_if = "Option::is_none")]
311    pub language: Option<String>,
312    #[serde(skip_serializing_if = "Option::is_none")]
313    pub resolution: Option<RsResolution>,
314    #[serde(skip_serializing_if = "Option::is_none")]
315    pub video_format: Option<RsVideoFormat>,
316    #[serde(skip_serializing_if = "Option::is_none")]
317    pub videocodec: Option<RsVideoCodec>,
318    #[serde(skip_serializing_if = "Option::is_none")]
319    pub audio: Option<Vec<RsAudio>>,
320    #[serde(skip_serializing_if = "Option::is_none")]
321    pub quality: Option<u64>,
322
323    // Lookup fields (text to search in database, NOT IDs)
324    #[serde(skip_serializing_if = "Option::is_none")]
325    pub tags_lookup: Option<Vec<String>>,
326    #[serde(skip_serializing_if = "Option::is_none")]
327    pub people_lookup: Option<Vec<String>>,
328    #[serde(skip_serializing_if = "Option::is_none")]
329    pub albums_lookup: Option<Vec<String>>,
330}
331
332impl RsRequestFiles {
333    pub fn parse_filename(&mut self) {
334        let resolution = RsResolution::from_filename(&self.name);
335        if resolution != RsResolution::Unknown {
336            self.resolution = Some(resolution);
337        }
338        let video_format = RsVideoFormat::from_filename(&self.name);
339        if video_format != RsVideoFormat::Other {
340            self.video_format = Some(video_format);
341        }
342        let videocodec = RsVideoCodec::from_filename(&self.name);
343        if videocodec != RsVideoCodec::Unknown {
344            self.videocodec = Some(videocodec);
345        }
346        let audio = RsAudio::list_from_filename(&self.name);
347        if !audio.is_empty() {
348            self.audio = Some(audio);
349        }
350
351        let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
352        if let Some(caps) = re.captures(&self.name) {
353            self.season = caps[1].parse::<u32>().ok();
354            self.episode = caps[2].parse::<u32>().ok();
355        }
356    }
357}
358
359#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
360#[serde(rename_all = "camelCase")]
361pub struct RsRequestPluginRequest {
362    pub request: RsRequest,
363    pub credential: Option<PluginCredential>,
364}
365
366/// Groups multiple download requests together, optionally combining them into a single media item
367#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
368#[serde(rename_all = "camelCase")]
369pub struct RsGroupDownload {
370    /// If true, all requests will be grouped into a single media item (album)
371    #[serde(default)]
372    pub group: bool,
373    #[serde(skip_serializing_if = "Option::is_none")]
374    pub group_thumbnail_url: Option<String>,
375    #[serde(skip_serializing_if = "Option::is_none")]
376    pub group_filename: Option<String>,
377    #[serde(skip_serializing_if = "Option::is_none")]
378    pub group_mime: Option<String>,
379    pub requests: Vec<RsRequest>,
380}
381
382
383
384#[cfg(test)]
385mod tests {
386
387    use self::error::RequestError;
388
389    use super::*;
390
391    #[test]
392    fn test_cookie_parsing() -> Result<(), RequestError> {
393        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
394        assert!(parsed.domain == ".twitter.com".to_owned());
395        assert!(parsed.http_only == false);
396        assert!(parsed.path == "/".to_owned());
397        assert!(parsed.secure == true);
398        assert!(parsed.expiration == Some(1722364794.437907));
399        assert!(parsed.name == "kdt".to_owned());
400        assert!(parsed.value == "w1j".to_owned());
401        Ok(())
402    }
403    
404    #[test]
405    fn test_cookie_parsing_no_expi() -> Result<(), RequestError> {
406        let parsed = RsCookie::from_str(".twitter.com;false;/;true;;kdt;w1j")?;
407        assert!(parsed.domain == ".twitter.com".to_owned());
408        assert!(parsed.http_only == false);
409        assert!(parsed.path == "/".to_owned());
410        assert!(parsed.secure == true);
411        assert!(parsed.expiration == None);
412        assert!(parsed.name == "kdt".to_owned());
413        assert!(parsed.value == "w1j".to_owned());
414        Ok(())
415    }
416
417    #[test]
418    fn test_netscape() -> Result<(), RequestError> {
419        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
420        assert!(parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1722364794\tkdt\tw1j");
421        Ok(())
422    }
423    #[test]
424    fn test_netscape_doublequote() -> Result<(), RequestError> {
425        let parsed = RsCookie::from_str(".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"")?;
426        assert!(parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1726506480\tads_prefs\t\"HBESAAA=\"");
427        Ok(())
428    }
429
430    #[test]
431    fn test_parse_filename() -> Result<(), RequestError> {
432        let req = RsRequest {url: "http://www.test.com/filename.mp4?toto=3".to_string(), filename: Some("test.mkv".to_owned()), ..Default::default()};
433        assert_eq!(req.filename_or_extract_from_url(), Some("test.mkv".to_string()));
434        let req = RsRequest {url: "http://www.test.com/filename.mp4?toto=3".to_string(), ..Default::default()};
435        assert_eq!(req.filename_or_extract_from_url(), Some("filename.mp4".to_string()), "We are expecting a filename from the url");
436        let req = RsRequest {url: "http://www.test.com/notfilename?toto=3".to_string(), ..Default::default()};
437        assert_eq!(req.filename_or_extract_from_url(), None, "Should return none as there is no filename with extensiopn in url");
438        let req = RsRequest {url: "http://www.test.com/notfilename.toolong?toto=3".to_string(), ..Default::default()};
439        assert_eq!(req.filename_or_extract_from_url(), None, "Should return none as too long after dot is not an extension");
440        let req = RsRequest {url: "http://www.test.com/filename%20test.mp4?toto=3".to_string(), ..Default::default()};
441        assert_eq!(req.filename_or_extract_from_url(), Some("filename test.mp4".to_string()), "Should decode URL-encoded filename");
442        Ok(())
443    }
444
445
446    #[test]
447    fn test_header() -> Result<(), RequestError> {
448        let parsed = vec![RsCookie::from_str(".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"")?, RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?];
449        println!("header: {}", parsed.header_value());
450        assert!(parsed.header_value() == "ads_prefs=\"HBESAAA=\"; kdt=w1j");
451        Ok(())
452    }
453
454    #[test]
455    fn test_parse() -> Result<(), RequestError> {
456        let mut req = RsRequest { filename: Some("Shogun.2024.S01E01.Anjin.1080p.VOSTFR.DSNP.WEB-DL.DDP5.1.H.264-NTb.mkv".to_owned()), ..Default::default()};
457        req.parse_filename();
458        assert_eq!(req.season.unwrap(), 1);
459        assert_eq!(req.episode.unwrap(), 1);
460        assert_eq!(req.resolution.unwrap(), RsResolution::FullHD);
461        assert_eq!(req.videocodec.unwrap(), RsVideoCodec::H264);
462        assert_eq!(req.video_format.unwrap(), RsVideoFormat::Mkv);
463        assert_eq!(req.audio.unwrap().len(), 1);
464        Ok(())
465    }
466
467    #[test]
468    fn test_parse2() -> Result<(), RequestError> {
469        let mut req = RsRequest { filename: Some("Shogun.2024.S01E05.MULTi.HDR.DV.2160p.WEB.H265-FW".to_owned()), ..Default::default()};
470        req.parse_filename();
471        assert_eq!(req.season.expect("a season"), 1);
472        assert_eq!(req.episode.expect("an episode"), 5);
473        assert_eq!(req.resolution.expect("a resolution"), RsResolution::UHD);
474        assert_eq!(req.videocodec.expect("a videocodec"), RsVideoCodec::H265);
475
476        Ok(())
477    }
478}
479