Skip to main content

unbundle/
validation.rs

1//! Media file validation.
2//!
3//! Provides [`crate::MediaFile::validate`] which inspects a media file and
4//! returns a [`ValidationReport`] describing its structure and any potential
5//! issues.
6//!
7//! # Example
8//!
9//! ```no_run
10//! use unbundle::{MediaFile, UnbundleError};
11//!
12//! let unbundler = MediaFile::open("input.mp4")?;
13//! let report = unbundler.validate();
14//! if report.is_valid() {
15//!     println!("File is valid");
16//! } else {
17//!     for warning in &report.warnings {
18//!         println!("Warning: {warning}");
19//!     }
20//! }
21//! # Ok::<(), UnbundleError>(())
22//! ```
23
24use std::fmt::{Display, Formatter, Result as FmtResult};
25use std::time::Duration;
26
27use crate::metadata::MediaMetadata;
28
29/// Summary of media file validation.
30///
31/// Produced by [`MediaFile::validate`](crate::MediaFile::validate).
32/// Contains lists of informational notices, warnings, and errors found during
33/// validation.
34#[derive(Debug, Clone, Default)]
35pub struct ValidationReport {
36    /// Informational notices (not problems).
37    pub info: Vec<String>,
38    /// Non-fatal issues that may affect extraction quality.
39    pub warnings: Vec<String>,
40    /// Fatal issues that will prevent extraction.
41    pub errors: Vec<String>,
42}
43
44impl ValidationReport {
45    /// Returns `true` if no errors were found.
46    ///
47    /// Warnings do not affect this result — only errors make the report
48    /// invalid.
49    pub fn is_valid(&self) -> bool {
50        self.errors.is_empty()
51    }
52
53    /// Total number of issues (info + warnings + errors).
54    pub fn issue_count(&self) -> usize {
55        self.info.len() + self.warnings.len() + self.errors.len()
56    }
57}
58
59impl Display for ValidationReport {
60    fn fmt(&self, f: &mut Formatter<'_>) -> FmtResult {
61        for item in &self.info {
62            writeln!(f, "[INFO] {item}")?;
63        }
64        for item in &self.warnings {
65            writeln!(f, "[WARN] {item}")?;
66        }
67        for item in &self.errors {
68            writeln!(f, "[ERROR] {item}")?;
69        }
70        if self.issue_count() == 0 {
71            writeln!(f, "No issues found.")?;
72        }
73        Ok(())
74    }
75}
76
77/// Run validation checks on the cached metadata.
78///
79/// This function is called by [`MediaFile::validate`].
80pub(crate) fn validate_metadata(metadata: &MediaMetadata) -> ValidationReport {
81    let mut report = ValidationReport::default();
82
83    // ── Stream presence ────────────────────────────────────────────
84    if metadata.video.is_none() && metadata.audio.is_none() {
85        report
86            .errors
87            .push("File contains neither video nor audio streams".to_string());
88    }
89
90    if metadata.video.is_none() {
91        report.info.push("No video stream found".to_string());
92    }
93
94    if metadata.audio.is_none() {
95        report.info.push("No audio stream found".to_string());
96    }
97
98    // ── Duration ───────────────────────────────────────────────────
99    if metadata.duration == Duration::ZERO {
100        report
101            .warnings
102            .push("Media duration is zero — frame/time-based extraction may fail".to_string());
103    }
104
105    // ── Video checks ───────────────────────────────────────────────
106    if let Some(video) = &metadata.video {
107        if video.width == 0 || video.height == 0 {
108            report.errors.push(format!(
109                "Invalid video dimensions: {}×{}",
110                video.width, video.height,
111            ));
112        }
113
114        if video.frames_per_second <= 0.0 {
115            report.warnings.push(
116                "Video frame rate is zero or negative — frame counting will be unreliable"
117                    .to_string(),
118            );
119        } else if video.frames_per_second > 240.0 {
120            report.warnings.push(format!(
121                "Unusually high frame rate ({:.1} fps) — extraction may be slow",
122                video.frames_per_second,
123            ));
124        }
125
126        if video.frame_count == 0 && metadata.duration > Duration::ZERO {
127            report
128                .warnings
129                .push("Estimated frame count is zero despite non-zero duration".to_string());
130        }
131
132        report.info.push(format!(
133            "Video: {} {}×{} @ {:.2} fps, ~{} frames",
134            video.codec, video.width, video.height, video.frames_per_second, video.frame_count,
135        ));
136    }
137
138    // ── Audio checks ───────────────────────────────────────────────
139    if let Some(audio) = &metadata.audio {
140        if audio.sample_rate == 0 {
141            report.errors.push("Audio sample rate is zero".to_string());
142        }
143
144        if audio.channels == 0 {
145            report
146                .errors
147                .push("Audio channel count is zero".to_string());
148        }
149
150        report.info.push(format!(
151            "Audio: {} {}Hz {}ch",
152            audio.codec, audio.sample_rate, audio.channels,
153        ));
154    }
155
156    // ── Multi-track info ───────────────────────────────────────────
157    if let Some(tracks) = &metadata.audio_tracks {
158        if tracks.len() > 1 {
159            report
160                .info
161                .push(format!("{} audio tracks available", tracks.len(),));
162        }
163    }
164
165    // ── Subtitle info ──────────────────────────────────────────────
166    if let Some(subtitle) = &metadata.subtitle {
167        let language = subtitle.language.as_deref().unwrap_or("unknown language");
168        report
169            .info
170            .push(format!("Subtitle: {} ({})", subtitle.codec, language,));
171    }
172
173    if let Some(tracks) = &metadata.subtitle_tracks {
174        if tracks.len() > 1 {
175            report
176                .info
177                .push(format!("{} subtitle tracks available", tracks.len(),));
178        }
179    }
180
181    report
182}