fix(probe): do not hide prober failures or cover art
ci / web (pull_request) Successful in 6s
ci / rust (pull_request) Successful in 1m30s
e2e / e2e (pull_request) Successful in 1m35s

Review on #59. select_feature skipped every probe error, so a missing
ffprobe or a timeout reported "no video file" for a torrent that has
one; only per-file content failures are skipped now. And an audio file
with embedded artwork reports the cover as a video stream, which made a
soundtrack look importable.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
Miguel Palhas
2026-08-22 20:39:25 +01:00
co-authored by Claude Opus 5
parent 3927927466
commit 4e880413ae
7 changed files with 78 additions and 5 deletions
+21
View File
@@ -95,3 +95,24 @@ pub enum Error {
#[error("no video file among the candidates")]
NoCandidates,
}
impl Error {
/// Whether this failure is a fact about one file rather than about the
/// prober.
///
/// Feature selection walks a torrent full of `.nfo`, `.jpg` and `.txt`
/// files, so a file that is not importable media is expected and gets
/// skipped. A missing binary, a timeout or unparseable output is not about
/// the file at all, and swallowing it would report "no video file" for a
/// torrent that has one.
#[must_use]
pub const fn is_about_the_file(&self) -> bool {
matches!(
self,
Self::Rejected { .. }
| Self::NotVideo { .. }
| Self::NoVideoStream { .. }
| Self::NoDuration { .. }
)
}
}
+16
View File
@@ -35,6 +35,8 @@ pub(crate) struct Stream {
pub color_transfer: Option<String>,
pub duration: Option<String>,
#[serde(default)]
pub disposition: Disposition,
#[serde(default)]
pub tags: Tags,
#[serde(default)]
pub side_data_list: Vec<SideData>,
@@ -44,6 +46,20 @@ impl Stream {
pub fn is_kind(&self, kind: &str) -> bool {
self.codec_type.as_deref() == Some(kind)
}
/// A real video stream, not the cover art bolted onto an audio file.
pub fn is_video(&self) -> bool {
self.is_kind("video") && self.disposition.attached_pic == 0
}
}
/// Stream flags. Only `attached_pic` is read: an MP3 or FLAC with embedded
/// artwork reports it as a video stream, and taking that for video would make
/// an audio file look importable.
#[derive(Debug, Default, Deserialize)]
pub(crate) struct Disposition {
#[serde(default)]
pub attached_pic: u8,
}
/// Stream metadata. Matroska writes `HANDLER_NAME`, MP4 writes `handler_name`,
+7 -4
View File
@@ -123,13 +123,15 @@ impl Prober {
/// marked [`RuntimeMatch::Mismatched`]; deciding what to do about that is
/// the import's call, not this crate's.
///
/// Paths `ffprobe` cannot read are skipped rather than failing the
/// selection — a torrent is full of `.nfo` and `.jpg` files.
/// Paths that are not importable media are skipped rather than failing the
/// selection — a torrent is full of `.nfo` and `.jpg` files. A failure of
/// the prober itself is not skipped: see [`Error::is_about_the_file`].
///
/// # Errors
///
/// [`Error::NoCandidates`] when no path in the set is a readable video
/// file.
/// file, and anything [`Prober::probe`] raises that is about the prober
/// rather than about one file.
pub async fn select_feature(
&self,
paths: impl IntoIterator<Item = impl Into<PathBuf>>,
@@ -140,9 +142,10 @@ impl Prober {
let path = path.into();
match self.probe(path.clone()).await {
Ok(file) => candidates.push(file),
Err(error) => {
Err(error) if error.is_about_the_file() => {
tracing::debug!(path = %path.display(), %error, "not a video file, skipping");
}
Err(error) => return Err(error),
}
}
+1 -1
View File
@@ -50,7 +50,7 @@ impl ProbedFile {
let video = output
.streams
.iter()
.find(|stream| stream.is_kind("video"))
.find(|stream| stream.is_video())
.ok_or_else(|| Error::NoVideoStream { path: path.clone() })?;
let duration = duration(output).ok_or_else(|| Error::NoDuration { path: path.clone() })?;
Binary file not shown.
+24
View File
@@ -128,6 +128,17 @@ async fn a_text_file_is_not_media() {
assert!(matches!(error, Error::NotVideo { .. }), "{error:?}");
}
/// Cover art is a video stream as far as ffprobe is concerned.
#[tokio::test]
async fn an_audio_file_with_artwork_is_not_video() {
let error = Prober::new()
.probe(fixture("audio-with-cover.m4a"))
.await
.expect_err("an attached picture is not a video stream");
assert!(matches!(error, Error::NoVideoStream { .. }), "{error:?}");
}
#[tokio::test]
async fn a_missing_file_is_an_error() {
let error = Prober::new()
@@ -208,6 +219,19 @@ async fn nothing_within_tolerance_still_returns_the_largest_file() {
);
}
/// A prober that cannot run is not the same fact as a torrent without video,
/// and must not be reported as one.
#[tokio::test]
async fn a_broken_prober_fails_the_selection() {
let error = Prober::new()
.with_binary("ffprobe-that-does-not-exist")
.select_feature([fixture("movie-1080p.mkv")], None)
.await
.expect_err("nothing could be probed");
assert!(matches!(error, Error::Spawn { .. }), "{error:?}");
}
#[tokio::test]
async fn a_torrent_with_no_video_file_selects_nothing() {
let error = Prober::new()
+9
View File
@@ -144,6 +144,15 @@ PY
python3 "$work/dovi.py" "$work/base.mp4" "$out/dv-profile5.mp4" 5 0
python3 "$work/dovi.py" "$work/base.mp4" "$out/dv-profile8-1.mp4" 8 1
# An audio file with embedded artwork. ffprobe reports the cover as a video
# stream, so this is what stops a soundtrack passing for a feature.
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "color=c=red:s=64x64:d=1" -frames:v 1 "$work/cover.jpg"
ffmpeg -hide_banner -loglevel error -y \
-f lavfi -i "sine=frequency=440:duration=2" -i "$work/cover.jpg" \
-map 0:a -map 1:v -c:a aac -b:a 8k -c:v mjpeg -disposition:v attached_pic \
"$out/audio-with-cover.m4a"
# Not media at all: the thing a multi-file torrent is full of.
printf 'Release notes. Not a video file.\n' > "$out/notes.nfo"