1use anyhow::{anyhow, Context};
6use ini::Ini;
7use lazy_static::lazy_static;
8use log::*;
9use regex::Regex;
10use reqwest::{
11 blocking::{multipart, Client},
12 StatusCode,
13};
14use serde::Deserialize;
15use std::{
16 cmp::{self, Eq, PartialEq},
17 convert::TryFrom,
18 env,
19 fmt::Debug,
20 fs,
21 path::Path,
22 str::{self, FromStr},
23};
24
25use super::interval::{self, Boundary, BoundaryType, Frame, Interval, Time};
26
27const CUTLIST_RETRIEVE_HEADERS_URI: &str = "http://cutlist.at/getxml.php?name=";
29const CUTLIST_RETRIEVE_LIST_DETAILS_URI: &str = "http://cutlist.at/getfile.php?id=";
30const CUTLIST_SUBMIT_LIST_URI: &str = "http://cutlist.at";
31
32const CUTLIST_AT_ERROR_ID_NOT_FOUND: &str = "Not found.";
34
35const CUTLIST_GENERAL_SECTION: &str = "General";
37const CUTLIST_INFO_SECTION: &str = "Info";
38const CUTLIST_META_SECTION: &str = "Meta";
39const CUTLIST_APPLICATION: &str = "Application";
40const CUTLIST_VERSION: &str = "Version";
41const CUTLIST_INTENDED_CUT_APP: &str = "IntendedCutApplicationName";
42const CUTLIST_APPLY_TO_FILE: &str = "ApplyToFile";
43const CUTLIST_ORIG_FILE_SIZE: &str = "OriginalFileSizeBytes";
44const CUTLIST_NUM_OF_CUTS: &str = "NoOfCuts";
45const CUTLIST_ID: &str = "CutlistId";
46const CUTLIST_CUT_SECTION: &str = "Cut";
47const CUTLIST_RATING_BY_AUTHOR: &str = "RatingByAuthor";
48const CUTLIST_ITEM_TIME_START: &str = "Start";
49const CUTLIST_ITEM_TIME_DURATION: &str = "Duration";
50const CUTLIST_ITEM_FRAMES_START: &str = "StartFrame";
51const CUTLIST_ITEM_FRAMES_DURATION: &str = "DurationFrames";
52
53lazy_static! {
55 static ref RE_CUTLIST_ID: Regex =
57 Regex::new(r"^ID=(\d+).*").unwrap();
58 static ref RE_INTERVALS: Regex = Regex::new(r#"^(?<type>frames|times):(?<intervals>\[.+\])$"#).unwrap();
60}
61
62macro_rules! display_option {
65 ($id:expr) => {
66 if let Some(_id) = $id {
67 format!("{}", _id)
68 } else {
69 "unknown".to_string()
70 }
71 };
72}
73
74pub type Rating = u8;
76
77pub type ID = u64;
79
80#[derive(Default)]
83pub struct Ctrl<'a> {
84 pub submit: bool,
85 pub rating: Rating,
86 pub min_rating: Option<Rating>,
87 pub access_token: Option<&'a str>,
88 pub access_type: AccessType<'a>,
89}
90
91#[derive(Default)]
93pub enum AccessType<'a> {
94 #[default]
95 Auto, Direct(&'a str), File(&'a Path), ID(ID), }
100
101#[derive(Default)]
103pub struct ProviderHeader {
104 id: ID,
105 rating: f64,
106}
107impl Eq for ProviderHeader {}
108impl Ord for ProviderHeader {
109 fn cmp(&self, other: &Self) -> cmp::Ordering {
110 if self.rating < other.rating {
111 return cmp::Ordering::Less;
112 };
113 if self.rating > other.rating {
114 return cmp::Ordering::Greater;
115 };
116 cmp::Ordering::Equal
117 }
118}
119impl PartialEq for ProviderHeader {
120 fn eq(&self, other: &Self) -> bool {
121 self.id == other.id
122 }
123}
124impl PartialOrd for ProviderHeader {
125 fn partial_cmp(&self, other: &Self) -> Option<cmp::Ordering> {
126 Some(self.cmp(other))
127 }
128}
129impl ProviderHeader {
130 pub fn id(&self) -> ID {
131 self.id
132 }
133}
134
135pub fn headers_from_provider(
140 file_name: &str,
141 min_rating: Option<Rating>,
142) -> anyhow::Result<Vec<ProviderHeader>> {
143 #[derive(Debug, Deserialize)]
144 struct RawHeaders {
145 #[serde(rename = "cutlist")]
146 headers: Vec<RawHeader>,
147 }
148 #[derive(Debug, Deserialize)]
149 struct RawHeader {
150 id: ID,
151 rating: String,
152 #[serde(rename = "ratingbyauthor")]
153 rating_by_author: String,
154 errors: String,
155 }
156
157 trace!("\"{}\": Request cut lists from provider", file_name);
158
159 let response = reqwest::blocking::get(CUTLIST_RETRIEVE_HEADERS_URI.to_string() + file_name)
160 .with_context(|| "Did not get a response for cut list header request")?
161 .text()
162 .with_context(|| "Could not parse cut list header response")?;
163
164 if response.is_empty() {
165 trace!("\"{}\": No cut lists retrieved from provider", file_name);
166 return Err(anyhow!("No cut list could be retrieved"));
167 }
168
169 let mut headers: Vec<ProviderHeader> = vec![];
170
171 let raw_headers: RawHeaders =
172 quick_xml::de::from_str(&response).with_context(|| "Could not parse cut list headers")?;
173
174 trace!(
175 "\"{}\": {} cut lists retrieved from provider",
176 file_name,
177 raw_headers.headers.len()
178 );
179
180 for raw_header in &raw_headers.headers {
181 let num_errs = raw_header.errors.parse::<i32>();
183 if num_errs.is_err() || num_errs.unwrap() > 0 {
184 warn!(
185 "\"{}\": Cut list {} has errors: Ignored",
186 file_name, raw_header.id
187 );
188 continue;
189 }
190
191 let mut header = ProviderHeader {
193 id: raw_header.id,
194 ..Default::default()
195 };
196
197 if let Ok(_rating) = raw_header.rating.parse::<f64>() {
200 header.rating = _rating;
201 } else if let Ok(_rating) = raw_header.rating_by_author.parse::<f64>() {
202 header.rating = _rating;
203 }
204
205 if let Some(_rating) = min_rating {
207 if header.rating < _rating as f64 {
208 info!(
209 "Rating of cut list {} for {} is too low",
210 header.id, file_name
211 );
212 } else {
213 headers.push(header);
214 }
215 } else {
216 headers.push(header);
217 }
218 }
219
220 headers.sort();
221 Ok(headers)
222}
223
224fn interval_from_ini<B>(cutlist_ini: &Ini, cut_no: usize) -> anyhow::Result<Interval<B>>
227where
228 B: Boundary,
229{
230 let err_msg = format!(
231 "Could not create interval from INI structure for cut internal no {}",
232 cut_no
233 );
234
235 let cut_ini = cutlist_ini
236 .section(Some(format!("{}{}", CUTLIST_CUT_SECTION, cut_no)))
237 .context(format!("Could not find section for cut no {}", cut_no))
238 .context(err_msg.clone())?;
239
240 let btype = BoundaryType::from_str(std::any::type_name::<B>()).context(err_msg.clone())?;
241
242 let (start, duration) = (
243 cut_ini
244 .get(item_attr_start(&btype))
245 .context({
246 format!(
247 "Could not find attribute \"{}\" for cut no {}",
248 item_attr_start(&btype),
249 cut_no
250 )
251 })
252 .ok(),
253 cut_ini
254 .get(item_attr_duration(&btype))
255 .context({
256 format!(
257 "Could not find attribute \"{}\" for cut no {}",
258 item_attr_duration(&btype),
259 cut_no
260 )
261 })
262 .ok(),
263 );
264
265 if start.is_none() {
266 return Err(
267 anyhow!("Could not retrieve start attribute from INI structure").context(err_msg),
268 );
269 }
270 if duration.is_none() {
271 return Err(
272 anyhow!("Could not retrieve duration attribute from INI structure").context(err_msg),
273 );
274 }
275
276 Ok(Interval::<B>::from_start_duration(
280 B::from(start.unwrap().parse::<f64>().context(err_msg.clone())?),
281 B::from(duration.unwrap().parse::<f64>().context(err_msg.clone())?),
282 ))
283}
284
285fn item_attr_start(btype: &BoundaryType) -> String {
288 if btype == &BoundaryType::Frame {
289 CUTLIST_ITEM_FRAMES_START.to_string()
290 } else {
291 CUTLIST_ITEM_TIME_START.to_string()
292 }
293}
294
295fn item_attr_duration(btype: &BoundaryType) -> String {
298 if btype == &BoundaryType::Frame {
299 CUTLIST_ITEM_FRAMES_DURATION.to_string()
300 } else {
301 CUTLIST_ITEM_TIME_DURATION.to_string()
302 }
303}
304
305#[derive(Default)]
308pub struct Cutlist {
309 id: Option<ID>,
310 frame_intervals: Option<Vec<Interval<Frame>>>,
311 time_intervals: Option<Vec<Interval<Time>>>,
312}
313
314impl TryFrom<&Ini> for Cutlist {
316 type Error = anyhow::Error;
317
318 fn try_from(cutlist_ini: &Ini) -> Result<Self, Self::Error> {
320 let mut cutlist = Cutlist {
321 id: match cutlist_ini
325 .section(Some(CUTLIST_META_SECTION))
326 .with_context(|| {
327 format!(
328 "Could not find section \"{}\" in cut list",
329 CUTLIST_META_SECTION
330 )
331 })?
332 .get(CUTLIST_ID)
333 .with_context(|| format!("Could not find attribute \"{}\" in cut list", CUTLIST_ID))
334 {
335 Ok(_id) => {
336 Some(str::parse(_id).context(
337 "Cut list ID does not have the correct format (must be a number)",
338 )?)
339 }
340 _ => None,
341 },
342 ..Default::default()
343 };
344
345 let num_cuts = cutlist_ini
347 .section(Some(CUTLIST_GENERAL_SECTION))
348 .with_context(|| {
349 format!(
350 "Could not find section \"{}\" in cutlist ID={}",
351 CUTLIST_GENERAL_SECTION,
352 display_option!(cutlist.id)
353 )
354 })?
355 .get(CUTLIST_NUM_OF_CUTS)
356 .with_context(|| {
357 format!(
358 "Could not find attribute \"{}\" in cutlist ID={}",
359 CUTLIST_NUM_OF_CUTS,
360 display_option!(cutlist.id)
361 )
362 })?
363 .parse::<usize>()
364 .with_context(|| {
365 format!(
366 "Could not parse attribute \"{}\" in cutlist ID={}",
367 CUTLIST_NUM_OF_CUTS,
368 display_option!(cutlist.id)
369 )
370 })?;
371
372 for i in 0..num_cuts {
374 cutlist
375 .extend_from_ini_cut(cutlist_ini, i)
376 .with_context(|| {
377 format!(
378 "Could not read cuts of cut list ID={}",
379 display_option!(cutlist.id)
380 )
381 })?;
382 }
383
384 cutlist
385 .validate()
386 .context("INI data does not represent a valid cut list")?;
387
388 Ok(cutlist)
389 }
390}
391
392impl TryFrom<&Path> for Cutlist {
394 type Error = anyhow::Error;
395
396 fn try_from(cutlist_file: &Path) -> Result<Self, Self::Error> {
398 Cutlist::try_from(
399 &Ini::load_from_str(&fs::read_to_string(cutlist_file).with_context(|| {
400 format!(
401 "Could not read from cut list file \"{}\"",
402 cutlist_file.display()
403 )
404 })?)
405 .with_context(|| {
406 format!(
407 "Could not parse response for cut list \"{}\" as INI",
408 cutlist_file.display()
409 )
410 })?,
411 )
412 .context(format!(
413 "\"{}\" does not contain a valid cut list",
414 cutlist_file.display()
415 ))
416 }
417}
418
419impl TryFrom<ID> for Cutlist {
421 type Error = anyhow::Error;
422
423 fn try_from(id: ID) -> Result<Self, Self::Error> {
424 let response =
426 reqwest::blocking::get(CUTLIST_RETRIEVE_LIST_DETAILS_URI.to_string() + &id.to_string())
427 .with_context(|| format!("Did not get a response for requesting cut list {}", id))?
428 .text()
429 .with_context(|| format!("Could not parse response for cut list {} as text", id))?;
430 if response == CUTLIST_AT_ERROR_ID_NOT_FOUND {
431 return Err(anyhow!(
432 "Cut list with ID={} does not exist at provider",
433 id
434 ));
435 }
436
437 let cutlist_ini = Ini::load_from_str(&response)
439 .with_context(|| format!("Could not parse response for cut list {} as INI", id))?;
440
441 Cutlist::try_from(&cutlist_ini)
442 }
443}
444
445impl Cutlist {
446 pub fn try_from_intervals(intervals: &str) -> anyhow::Result<Cutlist> {
449 let err_msg = format!(
450 "Could not create cut list from intervals string \"{}\"",
451 intervals
452 );
453
454 if !RE_INTERVALS.is_match(intervals) {
455 return Err(
456 anyhow!("\"{}\" is not a valid intervals string", intervals).context(err_msg)
457 );
458 }
459
460 let mut cutlist = Cutlist::default();
461
462 let btype = BoundaryType::from_str(
464 RE_INTERVALS
465 .captures(intervals)
466 .unwrap()
467 .name("type")
468 .unwrap()
469 .as_str(),
470 )
471 .context(err_msg.clone())?;
472 let intervals = RE_INTERVALS
473 .captures(intervals)
474 .unwrap()
475 .name("intervals")
476 .unwrap()
477 .as_str();
478
479 if btype == BoundaryType::Frame {
481 cutlist.frame_intervals =
482 Some(interval::intervals_from_str::<Frame>(intervals).context(err_msg.clone())?)
483 } else {
484 cutlist.time_intervals =
485 Some(interval::intervals_from_str::<Time>(intervals).context(err_msg.clone())?)
486 }
487
488 cutlist
489 .validate()
490 .context(format!("{} does not represent a valid cut list", intervals))
491 .context(err_msg)?;
492
493 Ok(cutlist)
494 }
495
496 pub fn has_frame_intervals(&self) -> bool {
498 self.frame_intervals.is_some()
499 }
500
501 pub fn has_time_intervals(&self) -> bool {
503 self.time_intervals.is_some()
504 }
505
506 pub fn frame_intervals(&self) -> anyhow::Result<std::slice::Iter<'_, Interval<Frame>>> {
508 match &self.frame_intervals {
509 Some(frame_intervals) => Ok(frame_intervals.iter()),
510 None => Err(anyhow!("Cut list does not have frame intervals")),
511 }
512 }
513
514 pub fn time_intervals(&self) -> anyhow::Result<std::slice::Iter<'_, Interval<Time>>> {
516 match &self.time_intervals {
517 Some(time_intervals) => Ok(time_intervals.iter()),
518 None => Err(anyhow!("Cut list does not have time intervals")),
519 }
520 }
521
522 pub fn submit<P, Q>(
525 &mut self,
526 video: P,
527 tmp_dir: Q,
528 access_token: &str,
529 rating: Rating,
530 ) -> anyhow::Result<()>
531 where
532 P: AsRef<Path>,
533 Q: AsRef<Path>,
534 {
535 let file_name = video.as_ref().file_name().unwrap().to_str().unwrap();
536 let cutlist_file = tmp_dir.as_ref().join(format!("{}.cutlist", file_name));
537
538 self.to_ini(video.as_ref(), rating)?
540 .write_to_file(cutlist_file.as_path())
541 .context(format!(
542 "Could not write cut list to file \"{}\"",
543 cutlist_file.display()
544 ))?;
545
546 let response = Client::new()
548 .post(format!("{}/{}/", CUTLIST_SUBMIT_LIST_URI, access_token))
549 .multipart(
550 multipart::Form::new()
551 .file("userfile[]", cutlist_file)
552 .context("Could not create cut list submission request")?,
553 )
554 .send()
555 .with_context(|| "Did not get a response for cut list submission request")?;
556
557 match response.status() {
559 StatusCode::OK => {
560 self.id =
561 Some(
562 str::parse(
563 RE_CUTLIST_ID
564 .captures(&response.text().with_context(|| {
565 "Could not parse cut list submission response"
566 })?)
567 .unwrap()
568 .get(1)
569 .unwrap()
570 .as_str(),
571 )
572 .unwrap(),
573 );
574
575 info!(
576 "Submitted cut list ID {} for \"{}\"",
577 self.id.unwrap(),
578 file_name,
579 );
580
581 Ok(())
582 }
583 _ => {
584 let resp_txt = response
585 .text()
586 .with_context(|| "Could not parse cut list submission response")?;
587
588 Err(anyhow!(if resp_txt.is_empty() {
589 "Received no response text for submission request".to_string()
590 } else {
591 resp_txt
592 }))
593 }
594 }
595 }
596
597 fn extend_from_ini_cut(&mut self, cutlist_ini: &Ini, cut_no: usize) -> anyhow::Result<()> {
600 let err_msg = format!(
601 "Could not extend cut list by cut interval number {}",
602 cut_no
603 );
604
605 if let Ok(interval) =
607 interval_from_ini::<Frame>(cutlist_ini, cut_no).context(err_msg.clone())
608 {
609 if !interval.is_empty() {
610 if cut_no == 0 {
611 self.frame_intervals = Some(vec![interval]);
612 } else if self.has_frame_intervals() {
613 self.frame_intervals.as_mut().unwrap().push(interval)
614 } else {
615 return Err(anyhow!(
616 "Cannot add frame interval to cut list since it had no frame intervals so far"
617 )
618 .context(err_msg));
619 }
620 }
621 }
622 if let Ok(interval) =
624 interval_from_ini::<Time>(cutlist_ini, cut_no).context(err_msg.clone())
625 {
626 if !interval.is_empty() {
627 if cut_no == 0 {
628 self.time_intervals = Some(vec![interval]);
629 } else if self.has_time_intervals() {
630 self.time_intervals.as_mut().unwrap().push(interval)
631 } else {
632 return Err(anyhow!(
633 "Cannot add time interval to cut list since it had no time intervals so far"
634 )
635 .context(err_msg));
636 }
637 }
638 }
639
640 Ok(())
641 }
642
643 fn len(&self) -> usize {
647 if self.has_frame_intervals() {
648 return self.frame_intervals.as_ref().unwrap().len();
649 }
650 if self.has_time_intervals() {
651 return self.time_intervals.as_ref().unwrap().len();
652 }
653 0
654 }
655
656 fn to_ini<P>(&self, video_path: P, rating: Rating) -> anyhow::Result<Ini>
659 where
660 P: AsRef<Path>,
661 {
662 let mut cutlist_ini = Ini::new();
663 let file_name = video_path.as_ref().file_name().unwrap().to_str().unwrap();
664
665 cutlist_ini
667 .with_section(Some(CUTLIST_GENERAL_SECTION))
668 .set(CUTLIST_APPLICATION, "otr")
669 .set(CUTLIST_VERSION, env!("CARGO_PKG_VERSION"))
670 .set(CUTLIST_INTENDED_CUT_APP, "ffmpeg")
671 .set(CUTLIST_NUM_OF_CUTS, format!("{}", self.len()))
672 .set(CUTLIST_APPLY_TO_FILE, file_name)
673 .set(
674 CUTLIST_ORIG_FILE_SIZE,
675 format!(
676 "{}",
677 fs::metadata(video_path.as_ref())
678 .context("Cannot create INI structure for cut list")?
679 .len()
680 ),
681 );
682
683 fn add_cut_interval<B>(
685 ini: &mut Ini,
686 cut_no: usize,
687 interval: &Interval<B>,
688 ) -> anyhow::Result<()>
689 where
690 B: Boundary,
691 {
692 let btype = BoundaryType::from_str(std::any::type_name::<B>()).context(format!(
693 "Could not add cut interval for {} to INI structure",
694 interval
695 ))?;
696
697 ini.with_section(Some(format!("{}{}", CUTLIST_CUT_SECTION, cut_no)))
698 .set(item_attr_start(&btype), format!("{}", interval.from()))
699 .set(
700 item_attr_duration(&btype),
701 format!("{}", interval.to() - interval.from()),
702 );
703
704 Ok(())
705 }
706
707 for i in 0..self.len() {
709 if self.has_frame_intervals() {
711 add_cut_interval::<Frame>(
712 &mut cutlist_ini,
713 i,
714 &self.frame_intervals.as_ref().unwrap()[i],
715 )?;
716 }
717 if self.has_time_intervals() {
719 add_cut_interval::<Time>(
720 &mut cutlist_ini,
721 i,
722 &self.time_intervals.as_ref().unwrap()[i],
723 )?;
724 }
725 }
726
727 cutlist_ini
729 .with_section(Some(CUTLIST_INFO_SECTION))
730 .set(CUTLIST_RATING_BY_AUTHOR, format!("{}", rating));
731
732 Ok(cutlist_ini)
733 }
734
735 fn validate(&self) -> anyhow::Result<()> {
739 if !self.has_frame_intervals() && !self.has_time_intervals() {
740 return Err(anyhow!("Cut list does not contain intervals"));
741 }
742
743 fn validate_intervals<B>(intervals: &Vec<Interval<B>>) -> anyhow::Result<()>
744 where
745 B: Boundary,
746 {
747 let last_interval: Option<&Interval<B>> = None;
748
749 for interval in intervals {
750 if interval.from() > interval.to() {
751 return Err(anyhow!(
752 "Cut list intervals are invalid: From ({}) is after to ({})",
753 interval.from(),
754 interval.to()
755 ));
756 }
757 if let Some(last_interval) = last_interval {
758 if last_interval.to() > interval.from() {
759 return Err(anyhow!(
760 "Cut list intervals overlap: {} > {}",
761 last_interval.to(),
762 interval.from()
763 ));
764 }
765 }
766 }
767 Ok(())
768 }
769
770 if self.has_frame_intervals()
773 && self.has_time_intervals()
774 && self.frame_intervals.as_ref().unwrap().len()
775 != self.time_intervals.as_ref().unwrap().len()
776 {
777 return Err(anyhow!(
778 "Cut list has time and frame intervals, but the numbers of intervals differ"
779 ));
780 }
781
782 if self.has_frame_intervals() {
783 validate_intervals(self.frame_intervals.as_ref().unwrap())
784 .context("Frame intervals of cut list are invalid")?;
785 }
786 if self.has_time_intervals() {
787 validate_intervals(self.time_intervals.as_ref().unwrap())
788 .context("Time intervals of cut list are invalid")?;
789 }
790
791 Ok(())
792 }
793}