otr_utils/cutting/
cutlist.rs

1// SPDX-FileCopyrightText: 2024 Michael Picht <mipi@fsfe.org>
2//
3// SPDX-License-Identifier: GPL-3.0-or-later
4
5use 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
27/// URI's for retrieving and submitting cut list data from/to cutlist.at
28const 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
32/// cutlist.at (error) messages
33const CUTLIST_AT_ERROR_ID_NOT_FOUND: &str = "Not found.";
34
35/// Names for sections and attributes for the INI file of cutlist.at
36const 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
53// Regular expressions
54lazy_static! {
55    // Parse cut list ID from cutlist.at's response to the submission request
56    static ref RE_CUTLIST_ID: Regex =
57        Regex::new(r"^ID=(\d+).*").unwrap();
58    /// Reg exp for the intervals string
59    static ref RE_INTERVALS: Regex = Regex::new(r#"^(?<type>frames|times):(?<intervals>\[.+\])$"#).unwrap();
60}
61
62/// Display an option: Print value as string in case of it is Some(value),
63/// "unknown" otherwise
64macro_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
74/// Alias for cut list rating
75pub type Rating = u8;
76
77/// Alias for cut list ID
78pub type ID = u64;
79
80/// Fields that control processing of cut lists. Structure is made for the API
81/// of this crate
82#[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/// Cut list access type
92#[derive(Default)]
93pub enum AccessType<'a> {
94    #[default]
95    Auto, // retrieve cut lists from provider and select one automatically
96    Direct(&'a str), // direct access to cut list (as string consisting of intervals)
97    File(&'a Path),  // retrieve cut list from file
98    ID(ID),          // retrieve cut list from provider by ID
99}
100
101/// Header data to retrieve cut lists from a provider
102#[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
135/// Retrieves the headers of cut lists for a video from a provider. If no cut
136/// list exists, an empty array but no error is returned.
137/// file_name is the name of the video file. min_rating specifies the minimum
138/// rating a cut list must have to be accepted
139pub 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        // Do not accept cut lists with errors
182        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        // Create default cut list header
192        let mut header = ProviderHeader {
193            id: raw_header.id,
194            ..Default::default()
195        };
196
197        // Parse rating. First try general rating. If that does not exist, try
198        // the rating by the author of the cut list
199        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        // Check if rating is good enough
206        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
224/// Create a cut interval from an INI structure. If an interval of length zero
225/// would be created, Ok(None) is returned. Ok(Some(Interval<B>)) otherwise
226fn 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    // Though start.unwrap() and duration.unwrap() are &str, a conversion to f64
277    // is done since the strings are no valid time strings (in case the interval
278    // is provided as time interval)
279    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
285/// Attribute name for start of a cut interval depending on the boundary type -
286/// i.e., frame or time
287fn 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
295/// Attribute name for the duration of a cut interval depending on the boundary
296/// type - i.e., frame or time
297fn 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/// Cut list, consisting of intervals of frame numbers and/or times. At least one
306/// of both must be there
307#[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
314/// Create a cut list from an ini structure
315impl TryFrom<&Ini> for Cutlist {
316    type Error = anyhow::Error;
317
318    /// Create a cut list from an INI structure
319    fn try_from(cutlist_ini: &Ini) -> Result<Self, Self::Error> {
320        let mut cutlist = Cutlist {
321            // Get cut list id. This is done in a separate step early in the
322            // parsing process of the entire INI structure to be able to use the
323            // id in error messages
324            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        // Get number of cuts
346        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        // Retrieve cuts from ini structure and create a cut list from them
373        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
392/// Retrieve a cut list from a file
393impl TryFrom<&Path> for Cutlist {
394    type Error = anyhow::Error;
395
396    /// Create a cut list from a file
397    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
419/// Retrieve a cut list from a cut list provider by cut list id
420impl TryFrom<ID> for Cutlist {
421    type Error = anyhow::Error;
422
423    fn try_from(id: ID) -> Result<Self, Self::Error> {
424        // Retrieve cut list by ID
425        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        // Parse cut list
438        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    /// Creates a cut list from an intervals string, i.e. "frames:[...]" or
447    /// "times:[...]"
448    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        // Extract boundary type and intervals string
463        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        // Create interval list from string
480        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    /// Whether or not cut list has frame intervals
497    pub fn has_frame_intervals(&self) -> bool {
498        self.frame_intervals.is_some()
499    }
500
501    /// Whether or not cut list has time intervals
502    pub fn has_time_intervals(&self) -> bool {
503        self.time_intervals.is_some()
504    }
505
506    /// Provide an iterator for frame intervals
507    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    /// Provide an iterator for frame intervals
515    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    /// Submit cut list to cutlist.at and set the cut list ID in self from the
523    /// response
524    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        // Generate INI structure and write it to a file
539        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        // Upload file to cutlist.at
547        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        // Process response
558        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    /// Retrieves cut interval number cut_no from ini structure, creates a cut
598    /// list item from it and appends it to the cut list
599    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        // Try to retrieve and add frame interval
606        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        // Try to retrieve and add time interval
623        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    /// Length of cut list (i.e., the number of cuts). If the cut list has both,
644    /// frame and time intervals, the number of cuts must be equal, since
645    /// otherwise the cut list is invalid
646    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    /// Create an INI structure from a cut list. video_path and rating are
657    /// used to create the corresponding mandatory fields in the INI structure
658    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        // Section "[General]"
666        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        // Add a cut interval to INI structure ini
684        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        // Sections "[CutN]" for N=0,...,<number-of-cuts>-1
708        for i in 0..self.len() {
709            // Write frame interval to INI structure
710            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            // Write time interval to INI structure
718            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        // Section "[Info]"
728        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    /// Checks if a cut list is valid - i.e., whether at least one intervals
736    /// array exists, whether the intervals of an array do not overlap and
737    /// whether the start is before the end of each interval
738    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 cut list contains time and frames intervals, both must have the
771        // same number of items
772        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}