rs_plugin_common_interfaces/request/
mod.rs

1use std::{collections::HashMap, 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    #[serde(skip_serializing_if = "Option::is_none")]
103    pub plugin_id: Option<String>,
104    #[serde(skip_serializing_if = "Option::is_none")]
105    pub plugin_name: Option<String>,
106
107    /// If true this request can be saved for later use and will remain valid
108    /// If Permanent is true but status is intermediate the process will go through request plugins to try to get a permanant link
109    #[serde(default)]
110    pub permanent: bool,
111    /// If true can be played/downloaded instantly (streamable link, no need to add to service first)
112    #[serde(skip_serializing_if = "Option::is_none")]
113    pub instant: Option<bool>,
114    
115    pub json_body: Option<Value>,
116    #[serde(default)]
117    pub method: RsRequestMethod,
118
119    #[serde(skip_serializing_if = "Option::is_none")]
120    pub referer: Option<String>,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub headers: Option<Vec<(String, String)>>,
123    /// some downloader like YTDL require detailed cookies. You can create Header equivalent  with `headers` fn on the vector
124    #[serde(skip_serializing_if = "Option::is_none")]
125    pub cookies: Option<Vec<RsCookie>>,
126    /// If must choose between multiple files. Recall plugin with a `selected_file` containing one of the name in this list to get link
127    #[serde(skip_serializing_if = "Option::is_none")]
128    pub files: Option<Vec<RsRequestFiles>>,
129    /// one of the `files` selected for download
130    #[serde(skip_serializing_if = "Option::is_none")]
131    pub selected_file: Option<String>,
132
133    
134    #[serde(skip_serializing_if = "Option::is_none")]
135    pub description: Option<String>,
136    #[serde(skip_serializing_if = "Option::is_none")]
137    pub tags: Option<Vec<String>>,
138    #[serde(skip_serializing_if = "Option::is_none")]
139    pub people: Option<Vec<String>>,
140    #[serde(skip_serializing_if = "Option::is_none")]
141    pub albums: Option<Vec<String>>,
142    #[serde(skip_serializing_if = "Option::is_none")]
143    pub season: Option<u32>,
144    #[serde(skip_serializing_if = "Option::is_none")]
145    pub episode: Option<u32>,
146    #[serde(skip_serializing_if = "Option::is_none")]
147    pub movie: Option<String>,
148    #[serde(skip_serializing_if = "Option::is_none")]
149    pub language: Option<String>,
150    #[serde(skip_serializing_if = "Option::is_none")]
151    pub resolution: Option<RsResolution>,
152    #[serde(skip_serializing_if = "Option::is_none")]
153    pub video_format: Option<RsVideoFormat>,
154    #[serde(skip_serializing_if = "Option::is_none")]
155    pub videocodec: Option<RsVideoCodec>,
156    #[serde(skip_serializing_if = "Option::is_none")]
157    pub audio: Option<Vec<RsAudio>>,
158    #[serde(skip_serializing_if = "Option::is_none")]
159    pub quality: Option<u64>,
160
161    #[serde(default)]
162    pub ignore_origin_duplicate: bool,
163
164    // Download-specific fields
165    #[serde(skip_serializing_if = "Option::is_none")]
166    pub thumbnail_url: Option<String>,
167    #[serde(skip_serializing_if = "Option::is_none")]
168    pub origin_url: Option<String>,
169    #[serde(skip_serializing_if = "Option::is_none")]
170    pub title: Option<String>,
171    #[serde(skip_serializing_if = "Option::is_none")]
172    pub kind: Option<RsFileType>,
173
174    // Lookup fields (text to search in database, NOT IDs)
175    #[serde(skip_serializing_if = "Option::is_none")]
176    pub tags_lookup: Option<Vec<String>>,
177    #[serde(skip_serializing_if = "Option::is_none")]
178    pub people_lookup: Option<Vec<String>>,
179    #[serde(skip_serializing_if = "Option::is_none")]
180    pub albums_lookup: Option<Vec<String>>,
181}
182
183impl RsRequest {
184    pub fn set_cookies(&mut self, cookies: Vec<RsCookie>) {
185        let mut existing = if let Some(headers) = &self.headers {
186            headers.to_owned()
187        } else{
188            vec![]
189        };
190        existing.push(cookies.headers());
191        self.headers = Some(existing);
192    }
193
194    pub fn filename_or_extract_from_url(&self) -> Option<String> {
195        if self.filename.is_some() {
196            self.filename.clone()
197        } else {
198            self.url.split('/')
199            .last()
200            .and_then(|segment| {
201                segment.split('?')
202                    .next()
203                    .filter(|s| !s.is_empty())
204                    .map(|s| s.to_string())
205            })
206            .and_then(|potential| {
207                let extension = potential.split('.').map(|t| t.to_string()).collect::<Vec<String>>();
208                if extension.len() > 1 && extension.last().unwrap_or(&"".to_string()).len() > 2 &&  extension.last().unwrap_or(&"".to_string()).len() < 5{
209                    let decoded = decode(&potential).map(|x| x.into_owned()).unwrap_or(potential); // Decodes the URL
210                    Some(decoded)
211                } else {
212                    None
213                }
214            })
215        }
216        
217    }
218
219    pub fn parse_filename(&mut self) {
220        if let Some(filename) = &self.filename {
221            let resolution = RsResolution::from_filename(filename);
222            if resolution != RsResolution::Unknown {
223                self.resolution = Some(resolution);
224            }
225            let video_format = RsVideoFormat::from_filename(filename);
226            if video_format != RsVideoFormat::Other {
227                self.video_format = Some(video_format);
228            }
229            let videocodec = RsVideoCodec::from_filename(filename);
230            if videocodec != RsVideoCodec::Unknown {
231                self.videocodec = Some(videocodec);
232            }
233            let audio = RsAudio::list_from_filename(filename);
234            if !audio.is_empty() {
235                self.audio = Some(audio);
236            }
237
238            let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
239            if let Some(caps) = re.captures(filename) {
240                self.season = caps[1].parse::<u32>().ok();
241                self.episode = caps[2].parse::<u32>().ok();
242            }
243        }
244 
245    }
246
247    pub fn parse_subfilenames(&mut self) {
248        if let Some(ref mut files) = self.files {
249            for file in files {
250                file.parse_filename();
251            }
252        }
253    }
254}
255
256#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display,EnumString, Default)]
257#[serde(rename_all = "camelCase")] 
258#[strum(serialize_all = "camelCase")]
259pub enum RsRequestStatus {
260    /// No plugin yet processed this request
261    #[default]
262	Unprocessed,
263    /// All plugin processed but with no result
264    Processed,
265    ///if remain in this state after all plugin it will go through YtDl to try to extract medias
266    NeedParsing,
267    /// Link can be processed but first need to be added to the service and downloaded
268    ///   -First call this plugin again with `add` method
269    ///   -Check status and once ready call `process` again
270    RequireAdd,
271    /// Modified but need a second pass of plugins
272    Intermediate,
273    /// Multiple files found, current plugin need to be recalled with a `selected_file``
274    NeedFileSelection,
275    /// `url` is ready but should be proxied by the server as it contains sensitive informations (like token)
276    FinalPrivate,
277    /// `url` is ready and can be directly sent to _any_ user directly (using redirect)
278    FinalPublic
279}
280
281
282#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display,EnumString, Default)]
283#[serde(rename_all = "camelCase")] 
284#[strum(serialize_all = "camelCase")]
285pub enum RsRequestMethod {
286    
287    #[default]
288	Get,
289    Post,
290    Patch,
291    Delete,
292    Head,
293  
294}
295
296
297
298#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
299#[serde(rename_all = "camelCase")] 
300pub struct RsRequestFiles {
301    pub name: String,
302    pub size: u64,
303    
304    pub mime: Option<String>,
305    #[serde(skip_serializing_if = "Option::is_none")]
306    pub description: Option<String>,
307    #[serde(skip_serializing_if = "Option::is_none")]
308    pub tags: Option<Vec<String>>,
309    #[serde(skip_serializing_if = "Option::is_none")]
310    pub people: Option<Vec<String>>,
311    #[serde(skip_serializing_if = "Option::is_none")]
312    pub albums: Option<Vec<String>>,
313    #[serde(skip_serializing_if = "Option::is_none")]
314    pub season: Option<u32>,
315    #[serde(skip_serializing_if = "Option::is_none")]
316    pub episode: Option<u32>,
317    #[serde(skip_serializing_if = "Option::is_none")]
318    pub language: Option<String>,
319    #[serde(skip_serializing_if = "Option::is_none")]
320    pub resolution: Option<RsResolution>,
321    #[serde(skip_serializing_if = "Option::is_none")]
322    pub video_format: Option<RsVideoFormat>,
323    #[serde(skip_serializing_if = "Option::is_none")]
324    pub videocodec: Option<RsVideoCodec>,
325    #[serde(skip_serializing_if = "Option::is_none")]
326    pub audio: Option<Vec<RsAudio>>,
327    #[serde(skip_serializing_if = "Option::is_none")]
328    pub quality: Option<u64>,
329
330    // Lookup fields (text to search in database, NOT IDs)
331    #[serde(skip_serializing_if = "Option::is_none")]
332    pub tags_lookup: Option<Vec<String>>,
333    #[serde(skip_serializing_if = "Option::is_none")]
334    pub people_lookup: Option<Vec<String>>,
335    #[serde(skip_serializing_if = "Option::is_none")]
336    pub albums_lookup: Option<Vec<String>>,
337}
338
339impl RsRequestFiles {
340    pub fn parse_filename(&mut self) {
341        let resolution = RsResolution::from_filename(&self.name);
342        if resolution != RsResolution::Unknown {
343            self.resolution = Some(resolution);
344        }
345        let video_format = RsVideoFormat::from_filename(&self.name);
346        if video_format != RsVideoFormat::Other {
347            self.video_format = Some(video_format);
348        }
349        let videocodec = RsVideoCodec::from_filename(&self.name);
350        if videocodec != RsVideoCodec::Unknown {
351            self.videocodec = Some(videocodec);
352        }
353        let audio = RsAudio::list_from_filename(&self.name);
354        if !audio.is_empty() {
355            self.audio = Some(audio);
356        }
357
358        let re = Regex::new(r"(?i)s(\d+)e(\d+)").unwrap();
359        if let Some(caps) = re.captures(&self.name) {
360            self.season = caps[1].parse::<u32>().ok();
361            self.episode = caps[2].parse::<u32>().ok();
362        }
363    }
364}
365
366#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
367#[serde(rename_all = "camelCase")]
368pub struct RsRequestPluginRequest {
369    pub request: RsRequest,
370    pub credential: Option<PluginCredential>,
371    pub params: Option<HashMap<String, String>>,
372}
373
374/// Groups multiple download requests together, optionally combining them into a single media item
375#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
376#[serde(rename_all = "camelCase")]
377pub struct RsGroupDownload {
378    /// If true, all requests will be grouped into a single media item (album)
379    #[serde(default)]
380    pub group: bool,
381    #[serde(skip_serializing_if = "Option::is_none")]
382    pub group_thumbnail_url: Option<String>,
383    #[serde(skip_serializing_if = "Option::is_none")]
384    pub group_filename: Option<String>,
385    #[serde(skip_serializing_if = "Option::is_none")]
386    pub group_mime: Option<String>,
387    pub requests: Vec<RsRequest>,
388}
389
390/// Status of a processing task added via request_add
391#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, strum_macros::Display, EnumString, Default)]
392#[serde(rename_all = "camelCase")]
393#[strum(serialize_all = "camelCase")]
394pub enum RsProcessingStatus {
395    #[default]
396    Pending,
397    Processing,
398    Finished,
399    Error,
400    Paused,
401}
402
403/// Response from request_add plugin method
404#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
405#[serde(rename_all = "camelCase")]
406pub struct RsRequestAddResponse {
407    /// Processing ID returned by the plugin service
408    pub processing_id: String,
409    /// Initial status
410    #[serde(default)]
411    pub status: RsProcessingStatus,
412    /// UTC timestamp (milliseconds) for estimated completion
413    #[serde(skip_serializing_if = "Option::is_none")]
414    pub eta: Option<i64>,
415}
416
417/// Response from get_progress plugin method
418#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
419#[serde(rename_all = "camelCase")]
420pub struct RsProcessingProgress {
421    /// Processing ID
422    pub processing_id: String,
423    /// Progress percentage (0-100)
424    pub progress: u32,
425    /// Current status
426    pub status: RsProcessingStatus,
427    /// Error message if status is Error
428    #[serde(skip_serializing_if = "Option::is_none")]
429    pub error: Option<String>,
430    /// UTC timestamp (milliseconds) for estimated completion - can be updated
431    #[serde(skip_serializing_if = "Option::is_none")]
432    pub eta: Option<i64>,
433    /// Updated request with final URL when finished
434    #[serde(skip_serializing_if = "Option::is_none")]
435    pub request: Option<Box<RsRequest>>,
436}
437
438/// Request for pause/remove/get_progress plugin methods
439#[derive(Debug, Serialize, Deserialize, Clone, PartialEq, Default)]
440#[serde(rename_all = "camelCase")]
441pub struct RsProcessingActionRequest {
442    /// Processing ID to act on
443    pub processing_id: String,
444    /// Credential for the plugin
445    pub credential: Option<PluginCredential>,
446    /// Optional params
447    pub params: Option<HashMap<String, String>>,
448}
449
450#[cfg(test)]
451mod tests {
452
453    use self::error::RequestError;
454
455    use super::*;
456
457    #[test]
458    fn test_cookie_parsing() -> Result<(), RequestError> {
459        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
460        assert!(parsed.domain == ".twitter.com".to_owned());
461        assert!(parsed.http_only == false);
462        assert!(parsed.path == "/".to_owned());
463        assert!(parsed.secure == true);
464        assert!(parsed.expiration == Some(1722364794.437907));
465        assert!(parsed.name == "kdt".to_owned());
466        assert!(parsed.value == "w1j".to_owned());
467        Ok(())
468    }
469    
470    #[test]
471    fn test_cookie_parsing_no_expi() -> Result<(), RequestError> {
472        let parsed = RsCookie::from_str(".twitter.com;false;/;true;;kdt;w1j")?;
473        assert!(parsed.domain == ".twitter.com".to_owned());
474        assert!(parsed.http_only == false);
475        assert!(parsed.path == "/".to_owned());
476        assert!(parsed.secure == true);
477        assert!(parsed.expiration == None);
478        assert!(parsed.name == "kdt".to_owned());
479        assert!(parsed.value == "w1j".to_owned());
480        Ok(())
481    }
482
483    #[test]
484    fn test_netscape() -> Result<(), RequestError> {
485        let parsed = RsCookie::from_str(".twitter.com;false;/;true;1722364794.437907;kdt;w1j")?;
486        assert!(parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1722364794\tkdt\tw1j");
487        Ok(())
488    }
489    #[test]
490    fn test_netscape_doublequote() -> Result<(), RequestError> {
491        let parsed = RsCookie::from_str(".twitter.com;true;/;true;1726506480.700665;ads_prefs;\"HBESAAA=\"")?;
492        assert!(parsed.netscape() == ".twitter.com\tTRUE\t/\tTRUE\t1726506480\tads_prefs\t\"HBESAAA=\"");
493        Ok(())
494    }
495
496    #[test]
497    fn test_parse_filename() -> Result<(), RequestError> {
498        let req = RsRequest {url: "http://www.test.com/filename.mp4?toto=3".to_string(), filename: Some("test.mkv".to_owned()), ..Default::default()};
499        assert_eq!(req.filename_or_extract_from_url(), Some("test.mkv".to_string()));
500        let req = RsRequest {url: "http://www.test.com/filename.mp4?toto=3".to_string(), ..Default::default()};
501        assert_eq!(req.filename_or_extract_from_url(), Some("filename.mp4".to_string()), "We are expecting a filename from the url");
502        let req = RsRequest {url: "http://www.test.com/notfilename?toto=3".to_string(), ..Default::default()};
503        assert_eq!(req.filename_or_extract_from_url(), None, "Should return none as there is no filename with extensiopn in url");
504        let req = RsRequest {url: "http://www.test.com/notfilename.toolong?toto=3".to_string(), ..Default::default()};
505        assert_eq!(req.filename_or_extract_from_url(), None, "Should return none as too long after dot is not an extension");
506        let req = RsRequest {url: "http://www.test.com/filename%20test.mp4?toto=3".to_string(), ..Default::default()};
507        assert_eq!(req.filename_or_extract_from_url(), Some("filename test.mp4".to_string()), "Should decode URL-encoded filename");
508        Ok(())
509    }
510
511
512    #[test]
513    fn test_header() -> Result<(), RequestError> {
514        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")?];
515        println!("header: {}", parsed.header_value());
516        assert!(parsed.header_value() == "ads_prefs=\"HBESAAA=\"; kdt=w1j");
517        Ok(())
518    }
519
520    #[test]
521    fn test_parse() -> Result<(), RequestError> {
522        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()};
523        req.parse_filename();
524        assert_eq!(req.season.unwrap(), 1);
525        assert_eq!(req.episode.unwrap(), 1);
526        assert_eq!(req.resolution.unwrap(), RsResolution::FullHD);
527        assert_eq!(req.videocodec.unwrap(), RsVideoCodec::H264);
528        assert_eq!(req.video_format.unwrap(), RsVideoFormat::Mkv);
529        assert_eq!(req.audio.unwrap().len(), 1);
530        Ok(())
531    }
532
533    #[test]
534    fn test_parse2() -> Result<(), RequestError> {
535        let mut req = RsRequest { filename: Some("Shogun.2024.S01E05.MULTi.HDR.DV.2160p.WEB.H265-FW".to_owned()), ..Default::default()};
536        req.parse_filename();
537        assert_eq!(req.season.expect("a season"), 1);
538        assert_eq!(req.episode.expect("an episode"), 5);
539        assert_eq!(req.resolution.expect("a resolution"), RsResolution::UHD);
540        assert_eq!(req.videocodec.expect("a videocodec"), RsVideoCodec::H265);
541
542        Ok(())
543    }
544}
545