Update dependencies and lints

This commit is contained in:
2026-09-07 22:31:04 -07:00
parent b518d762f1
commit 9c288d051c
79 changed files with 3473 additions and 2923 deletions
+30 -20
View File
@@ -1,17 +1,18 @@
use std::path::PathBuf;
//! Command line argument parsing.
use clap::Parser;
use std::path::PathBuf;
use crate::parser::Selector;
#[derive(Parser)]
/// Command line argument parser struct.
#[derive(Debug, clap::Parser)]
#[command(version, about, long_about = None)]
pub struct Cli {
/// Dry run and print commands
pub(crate) struct Args {
/// Dry run and print commands.
#[arg(short, long)]
dry_run: bool,
/// Stream selectors
/// Stream selectors.
#[arg(
short,
long,
@@ -21,29 +22,38 @@ pub struct Cli {
)]
selectors: Vec<Selector>,
/// The path to the directory to scan
/// The path to the directory to scan.
#[arg(value_name = "DIR")]
scan_dir: PathBuf,
}
impl Cli {
pub fn is_dry(&self) -> bool {
impl Args {
/// True if the user requested a dry run.
pub(crate) const fn is_dry_run(&self) -> bool {
self.dry_run
}
pub fn selectors(&self) -> &[Selector] {
/// Returns a slice of provided [Selector].
pub(crate) fn selectors(&self) -> &[Selector] {
&self.selectors
}
pub fn scan_dir_path(&self) -> PathBuf {
fn expect_home_dir() -> PathBuf {
#[allow(clippy::expect_used)]
std::env::home_dir().expect("you do not have a home directory")
}
match self.scan_dir.strip_prefix("~/") {
Ok(path) => expect_home_dir().join(path),
Err(_) => self.scan_dir.to_owned(),
}
/// Get the path to scan.
///
/// # Panics
/// If the user doesn't have a home directory.
pub(crate) fn scan_dir_path(&self) -> PathBuf {
self.scan_dir.strip_prefix("~/").map_or_else(
|_| self.scan_dir.clone(),
|path| {
#[expect(
clippy::expect_used,
reason = "without a home directory this program is useless"
)]
std::env::home_dir()
.expect("you do not have a home directory")
.join(path)
},
)
}
}
+14 -9
View File
@@ -1,8 +1,8 @@
//! flix-mux
//! flix-mux.
use core::time::Duration;
use clap::Parser;
use clap::Parser as _;
use console::style;
use indicatif::{HumanBytes, ProgressBar, ProgressStyle};
@@ -16,14 +16,17 @@ mod parser;
mod probe;
mod scan;
#[cfg_attr(test, expect(clippy::missing_panics_doc, reason = "main function"))]
#[expect(clippy::print_stderr, reason = "we want to print in a binary")]
#[expect(clippy::print_stdout, reason = "we want to print in a binary")]
fn main() {
let cli = cli::Cli::parse();
let cli = cli::Args::parse();
let (files, size) = scan_directory(
&cli.scan_dir_path(),
|| {
let progress = ProgressBar::new_spinner();
progress.set_message(format!("Scanning {:?}", &cli.scan_dir_path()));
progress.set_message(format!("Scanning {}", cli.scan_dir_path().display()));
progress.enable_steady_tick(Duration::from_millis(50));
progress
},
@@ -31,7 +34,8 @@ fn main() {
|len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style(
#[expect(clippy::expect_used)]
#[expect(clippy::expect_used, reason = "panic if the template is invalid")]
#[expect(clippy::literal_string_with_formatting_args, reason = "template")]
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"),
);
@@ -40,19 +44,20 @@ fn main() {
|progress| progress.inc(1),
|progress| progress.finish_and_clear(),
|progress, msg| {
progress.suspend(|| eprintln!("{} {}", style("[WARN]").bold().yellow(), msg))
progress.suspend(|| eprintln!("{} {msg}", style("[WARN]").bold().yellow()));
},
);
println!("Found {} files ({})", files.len(), HumanBytes(size));
mux_files(
cli.is_dry(),
cli.is_dry_run(),
&files,
cli.selectors(),
|len| {
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
progress.set_style(
#[expect(clippy::expect_used)]
#[expect(clippy::expect_used, reason = "panic if the template is invalid")]
#[expect(clippy::literal_string_with_formatting_args, reason = "template")]
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
.expect("static template"),
);
@@ -61,6 +66,6 @@ fn main() {
},
|progress| progress.inc(1),
|progress| progress.finish_with_message("done"),
|progress, msg| progress.suspend(|| eprintln!("{} {}", style("[ERR]").bold().red(), msg)),
|progress, msg| progress.suspend(|| eprintln!("{} {msg}", style("[ERR]").bold().red())),
);
}
+51 -16
View File
@@ -1,42 +1,61 @@
//! Model for handling media streams.
use std::path::PathBuf;
use serde::Deserialize;
/// Represents a file that was scanned.
#[derive(Debug, Clone)]
pub struct MediaFile {
pub(crate) struct MediaFile {
/// The path to the file.
pub path: PathBuf,
/// The size of the file.
pub byte_size: u64,
/// The streams in the file.
pub streams: Streams,
}
/// Represents the collection of streams found in a media file.
#[derive(Debug, Clone)]
pub struct Streams {
pub(crate) struct Streams {
/// The video streams.
pub video: Vec<VideoStream>,
/// The audio streams.
pub audio: Vec<AudioStream>,
/// The subtitle streams.
pub subtitle: Vec<SubtitleStream>,
}
pub trait FFStream: serde::de::DeserializeOwned {
/// This trait helps with deserializing `ffmpeg` output.
pub(crate) trait FFStream: serde::de::DeserializeOwned {
/// The type name that `ffmpeg` uses.
const FF_TYPE_NAME: &str;
}
/// Represents an individual video stream in a media file.
#[derive(Debug, Clone, Deserialize)]
pub struct VideoStream {
pub(crate) struct VideoStream {
/// The name of the video codec.
codec_name: String,
/// Additional tags on the stream.
tags: Option<VideoTags>,
}
/// Represents tags on a video stream.
#[derive(Debug, Clone, Deserialize)]
pub struct VideoTags {
pub(crate) struct VideoTags {
/// The language of the stream.
language: Option<String>,
}
impl VideoStream {
pub fn codec(&self) -> &str {
/// Get the video codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
/// Get the stream language.
pub(crate) fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
}
@@ -45,23 +64,30 @@ impl FFStream for VideoStream {
const FF_TYPE_NAME: &str = "v";
}
/// Represents an individual audio stream in a media file.
#[derive(Debug, Clone, Deserialize)]
pub struct AudioStream {
pub(crate) struct AudioStream {
/// The name of the audio codec.
codec_name: String,
/// Additional tags on the stream.
tags: Option<AudioTags>,
}
/// Represents tags on an audio stream.
#[derive(Debug, Clone, Deserialize)]
pub struct AudioTags {
pub(crate) struct AudioTags {
/// The language of the stream.
language: Option<String>,
}
impl AudioStream {
pub fn codec(&self) -> &str {
/// Get the audio codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
/// Get the stream language.
pub(crate) fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
}
@@ -70,28 +96,37 @@ impl FFStream for AudioStream {
const FF_TYPE_NAME: &str = "a";
}
/// Represents an individual subtitle stream in a media file.
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleStream {
pub(crate) struct SubtitleStream {
/// The name of the subtitle codec.
codec_name: String,
/// Additional tags on the stream.
tags: Option<SubtitleTags>,
}
/// Represents tags on a subtitle stream.
#[derive(Debug, Clone, Deserialize)]
pub struct SubtitleTags {
pub(crate) struct SubtitleTags {
/// The language of the stream.
language: Option<String>,
/// The title of the stream.
title: Option<String>,
}
impl SubtitleStream {
pub fn codec(&self) -> &str {
/// Get the subtitle codec name.
pub(crate) fn codec(&self) -> &str {
&self.codec_name
}
pub fn language(&self) -> Option<&str> {
/// Get the stream language.
pub(crate) fn language(&self) -> Option<&str> {
self.tags.as_ref()?.language.as_deref()
}
pub fn title(&self) -> Option<&str> {
/// Get the stream title.
pub(crate) fn title(&self) -> Option<&str> {
self.tags.as_ref()?.title.as_deref()
}
}
+77 -39
View File
@@ -1,3 +1,5 @@
//! Wrapper around `ffmpeg` to mux media files.
use std::process::Command;
use anyhow::{Context as _, Result};
@@ -5,14 +7,19 @@ use anyhow::{Context as _, Result};
use crate::model::MediaFile;
use crate::parser::{Matcher, Selector, StreamFlag, StreamType};
/// Helper struct for tracking which output index is currently available.
#[derive(Default)]
struct OutputIndex {
/// The next free video index.
video: usize,
/// The next free audio index.
audio: usize,
/// The next free subtitle index.
subtitle: usize,
}
pub fn mux_files<T>(
/// For each input file, apply all selectors and mux the selection.
pub(crate) fn mux_files<T>(
dry_run: bool,
files: &[MediaFile],
selectors: &[Selector],
@@ -24,14 +31,20 @@ pub fn mux_files<T>(
let mut progress = fixed_length_start(files.len());
for file in files {
if let Err(err) = mux(dry_run, file, selectors) {
print_fn(&progress, &format!("{:?}", err));
print_fn(&progress, &format!("{err:?}"));
}
fixed_length_update(&mut progress);
}
fixed_length_end(progress);
}
#[expect(clippy::expect_used)]
/// Apply all selectors and mux the file.
///
/// # Errors
/// If the call to `ffmpeg` fails.
///
/// # Panics
/// If the system fails to allocate a temporary file.
fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
let mut command = Command::new("ffmpeg");
let mut command = command.args(["-v", "error"]);
@@ -40,10 +53,14 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
command = command.arg(file.path.as_os_str());
for selector in selectors {
command = command.args(
make_map_args(file, selector)
.with_context(|| format!("Failed to mux {:?}", file.path))?,
);
command = command.args(make_map_args(file, selector).with_context(|| {
let info = if dry_run {
format!("Streams:\n{file:#?}")
} else {
String::from("use -d for stream info")
};
format!("Failed to mux {}\n{info}", file.path.display())
})?);
}
command = command.args(["-c:v", "copy", "-c:a", "copy", "-c:s", "mov_text"]);
@@ -56,25 +73,34 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
let mut ouput_index = OutputIndex::default();
for selector in selectors {
command = command.args(
make_metadata_args(file, selector, &mut ouput_index)
.with_context(|| format!("Failed to mux {:?}", file.path))?,
make_metadata_args(file, selector, &mut ouput_index).with_context(|| {
let info = if dry_run {
format!("Streams:\n{file:#?}")
} else {
String::from("use -d for stream info")
};
format!("Failed to mux {}\n{info}", file.path.display())
})?,
);
}
let temp_path = file.path.with_extension("mp4");
command = command.arg(temp_path.file_name().expect("file name exists"));
#[expect(clippy::expect_used, reason = "files must have names")]
{
command = command.arg(temp_path.file_name().expect("file name exists"));
}
if dry_run {
print_command(command);
} else {
let output = command
.output()
.with_context(|| format!("Failed to mux {:?}", file.path))?;
.with_context(|| format!("Failed to mux {}", file.path.display()))?;
if !output.status.success() {
anyhow::bail!(
"ffmpeg failed for {:?}:\n\n{}",
file.path,
"ffmpeg failed for {}:\n\n{}",
file.path.display(),
String::from_utf8_lossy(&output.stderr)
);
}
@@ -83,23 +109,31 @@ fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
Ok(())
}
/// Generate the `-map` argument list for `ffmpeg`.
///
/// # Errors
/// If the stream selection is unsatisfyable for the file.
fn make_map_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
let source_index = 0;
let source_index: u32 = 0;
let stream_type = selector.stream_type.as_ref();
let Some(stream_index) = find_stream_index(file, selector) else {
if selector.optional {
return Ok(vec![]);
} else {
anyhow::bail!("unsatisfied stream selection");
}
anyhow::bail!("unsatisfied stream selection");
};
Ok(vec![
String::from("-map"),
format!("{}:{}:{}", source_index, stream_type, stream_index),
format!("{source_index}:{stream_type}:{stream_index}"),
])
}
/// Generate the `-metadata` argument list for `ffmpeg`.
///
/// # Errors
/// If the stream selection is unsatisfyable for the file.
fn make_metadata_args(
file: &MediaFile,
selector: &Selector,
@@ -112,9 +146,9 @@ fn make_metadata_args(
let Some(_) = find_stream_index(file, selector) else {
if selector.optional {
return Ok(vec![]);
} else {
anyhow::bail!("unsatisfied stream selection");
}
anyhow::bail!("unsatisfied stream selection");
};
let counter = match selector.stream_type {
@@ -126,8 +160,8 @@ fn make_metadata_args(
*counter = counter.saturating_add(1);
let mut args = vec![
format!("-metadata:s:{}:{}", stream_type, stream_index),
format!("language={}", stream_language),
format!("-metadata:s:{stream_type}:{stream_index}"),
format!("language={stream_language}"),
];
if selector.stream_type == StreamType::Subtitle {
@@ -137,25 +171,26 @@ fn make_metadata_args(
None => match stream_language {
"eng" => "English",
"jpn" => "Japanese",
_ => anyhow::bail!("Unhandled subtitle language: {}", stream_language),
_ => anyhow::bail!("Unhandled subtitle language: {stream_language}"),
},
};
args.push(format!("-metadata:s:s:{}", stream_index));
args.push(format!("title={}", sub_title));
args.push(format!("-metadata:s:s:{stream_index}"));
args.push(format!("title={sub_title}"));
}
Ok(args)
}
/// Walk the streams of a file to match the given [Selector].
fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
let needs_forced = selector.flag == Some(StreamFlag::Forced);
let needs_sdh = selector.flag == Some(StreamFlag::Sdh);
match selector.stream_type {
StreamType::Video => match selector.matcher {
Matcher::Index(index) => (file.streams.video.len() > index).then_some(index),
Matcher::Language(ref language) => file
StreamType::Video => match &selector.matcher {
Matcher::Index(index) => (file.streams.video.len() > *index).then_some(*index),
Matcher::Language(language) => file
.streams
.video
.iter()
@@ -163,7 +198,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i)
.next(),
Matcher::Codec(ref codec) => file
Matcher::Codec(codec) => file
.streams
.video
.iter()
@@ -172,9 +207,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i)
.next(),
},
StreamType::Audio => match selector.matcher {
Matcher::Index(index) => (file.streams.audio.len() > index).then_some(index),
Matcher::Language(ref language) => file
StreamType::Audio => match &selector.matcher {
Matcher::Index(index) => (file.streams.audio.len() > *index).then_some(*index),
Matcher::Language(language) => file
.streams
.audio
.iter()
@@ -182,7 +217,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.filter(|(_, c)| c.language() == Some(language.as_str()))
.map(|(i, _)| i)
.next(),
Matcher::Codec(ref codec) => file
Matcher::Codec(codec) => file
.streams
.audio
.iter()
@@ -191,9 +226,9 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i)
.next(),
},
StreamType::Subtitle => match selector.matcher {
Matcher::Index(index) => (file.streams.subtitle.len() > index).then_some(index),
Matcher::Language(ref language) => {
StreamType::Subtitle => match &selector.matcher {
Matcher::Index(index) => (file.streams.subtitle.len() > *index).then_some(*index),
Matcher::Language(language) => {
file.streams
.subtitle
.iter()
@@ -214,7 +249,7 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
.map(|(i, _)| i)
.next()
}
Matcher::Codec(ref codec) => file
Matcher::Codec(codec) => file
.streams
.subtitle
.iter()
@@ -226,6 +261,8 @@ fn find_stream_index(file: &MediaFile, selector: &Selector) -> Option<usize> {
}
}
/// Print out the command to stdout.
#[expect(clippy::print_stdout, reason = "the purpose is to print")]
fn print_command(cmd: &Command) {
let program = cmd.get_program().to_string_lossy();
@@ -235,15 +272,16 @@ fn print_command(cmd: &Command) {
.collect::<Vec<_>>()
.join(" ");
println!("{} {}", program, args);
println!("{program} {args}");
}
/// Escape a string to be used by a shell.
fn shell_escape(s: &str) -> String {
if s.chars()
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
{
s.to_string()
s.to_owned()
} else {
format!("{:?}", s)
format!("{s:?}")
}
}
+49 -22
View File
@@ -1,20 +1,26 @@
//! Parsing types for `ffmpeg` output.
use core::error::Error;
use core::fmt;
use core::str::FromStr;
/// The type of a stream.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamType {
pub(crate) enum StreamType {
/// A video stream.
Video,
/// An audio stream.
Audio,
/// A subtitle stream.
Subtitle,
}
impl AsRef<str> for StreamType {
fn as_ref(&self) -> &str {
match self {
StreamType::Video => "v",
StreamType::Audio => "a",
StreamType::Subtitle => "s",
Self::Video => "v",
Self::Audio => "a",
Self::Subtitle => "s",
}
}
}
@@ -24,18 +30,22 @@ impl FromStr for StreamType {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"v" => Ok(StreamType::Video),
"a" => Ok(StreamType::Audio),
"s" => Ok(StreamType::Subtitle),
other => Err(ParseSelectorError::InvalidStreamType(other.to_string())),
"v" => Ok(Self::Video),
"a" => Ok(Self::Audio),
"s" => Ok(Self::Subtitle),
other => Err(ParseSelectorError::InvalidStreamType(other.to_owned())),
}
}
}
/// A property of a stream to match on.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Matcher {
pub(crate) enum Matcher {
/// Match a specific stream index.
Index(usize),
/// Match the first stream of the given language.
Language(String),
/// Match the first stream of the given codec.
Codec(String),
}
@@ -44,19 +54,22 @@ impl FromStr for Matcher {
fn from_str(s: &str) -> Result<Self, Self::Err> {
if let Ok(idx) = s.parse::<usize>() {
return Ok(Matcher::Index(idx));
return Ok(Self::Index(idx));
}
if s.len() == 3 {
return Ok(Matcher::Language(s.to_ascii_lowercase()));
return Ok(Self::Language(s.to_ascii_lowercase()));
}
Ok(Matcher::Codec(s.to_ascii_lowercase()))
Ok(Self::Codec(s.to_ascii_lowercase()))
}
}
/// Flags applied to streams.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum StreamFlag {
pub(crate) enum StreamFlag {
/// Forced flag.
Forced,
/// SDH flag.
Sdh,
}
@@ -65,24 +78,31 @@ impl FromStr for StreamFlag {
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s.to_ascii_lowercase().as_str() {
"forced" => Ok(StreamFlag::Forced),
"sdh" => Ok(StreamFlag::Sdh),
other => Err(ParseSelectorError::InvalidFlag(other.to_string())),
"forced" => Ok(Self::Forced),
"sdh" => Ok(Self::Sdh),
other => Err(ParseSelectorError::InvalidFlag(other.to_owned())),
}
}
}
/// A complete selector which has everything it needs to find a specific stream.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Selector {
pub(crate) struct Selector {
/// The type of the stream to select.
pub stream_type: StreamType,
/// The matcher to use when selecting.
pub matcher: Matcher,
/// A desired flag.
pub flag: Option<StreamFlag>,
/// Whether or not to bail if the stream cannot be found.
pub optional: bool,
/// The output language of the stream.
pub out_lang: String,
}
impl Selector {
pub fn language(&self) -> &str {
/// Get the target language of the selector.
pub(crate) fn language(&self) -> &str {
&self.out_lang
}
}
@@ -125,7 +145,7 @@ impl FromStr for Selector {
(true, _) => return Err(ParseSelectorError::UnspecifiedLanguage),
};
Ok(Selector {
Ok(Self {
stream_type,
matcher,
flag,
@@ -135,15 +155,22 @@ impl FromStr for Selector {
}
}
/// Errors that can arise when parsing a selector string.
#[derive(Debug)]
pub enum ParseSelectorError {
pub(crate) enum ParseSelectorError {
/// The selector string is empty.
Empty,
/// The selector string has an invalid format.
InvalidFormat,
/// The requested stream type in invalid.
InvalidStreamType(String),
/// The requested flag is invalid.
InvalidFlag(String),
/// The target language is unspecified.
UnspecifiedLanguage,
}
#[expect(clippy::missing_trait_methods, reason = "accept default methods")]
impl Error for ParseSelectorError {}
impl fmt::Display for ParseSelectorError {
@@ -151,8 +178,8 @@ impl fmt::Display for ParseSelectorError {
match self {
Self::Empty => write!(f, "selector was empty"),
Self::InvalidFormat => write!(f, "invalid selector format"),
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{}'", s),
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{}'", s),
Self::InvalidStreamType(s) => write!(f, "invalid stream type '{s}'"),
Self::InvalidFlag(s) => write!(f, "invalid stream flag '{s}'"),
Self::UnspecifiedLanguage => write!(f, "unspecified output language"),
}
}
+21 -6
View File
@@ -1,4 +1,6 @@
use std::os::unix::fs::MetadataExt;
//! Helpers for using `ffprobe` to gather stream information from media files.
use std::os::unix::fs::MetadataExt as _;
use std::process::Command;
use anyhow::{Context as _, Result};
@@ -7,11 +9,20 @@ use walkdir::DirEntry;
use crate::model::{FFStream, MediaFile, Streams};
/// The top-level containing type for `ffprobe` output.
#[derive(Debug, Deserialize)]
struct FFProbeOutput<S> {
/// The list of returned streams.
streams: Vec<S>,
}
/// Calls `ffprobe` on a [`DirEntry`] to gather one type of stream.
///
/// # Errors
/// Forwards any IO or parsing errors.
///
/// # Panics
/// If the file path is not utf8.
fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
let output = Command::new("ffprobe")
.args([
@@ -22,16 +33,16 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
"-show_streams",
"-select_streams",
S::FF_TYPE_NAME,
#[expect(clippy::expect_used)]
#[expect(clippy::expect_used, reason = "panic if path is not utf8")]
entry.path().to_str().expect("path should be utf8"),
])
.output()
.with_context(|| format!("Failed to run ffprobe on {:?}", entry.path()))?;
.with_context(|| format!("Failed to run ffprobe on {}", entry.path().display()))?;
if !output.status.success() {
anyhow::bail!(
"ffprobe failed for {:?}:\n\n{}",
entry.path(),
"ffprobe failed for {}:\n\n{}",
entry.path().display(),
String::from_utf8_lossy(&output.stderr)
);
}
@@ -40,7 +51,11 @@ fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
Ok(parsed.streams)
}
pub fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
/// Calls `ffprobe` on a [`DirEntry`] to detect all streams.
///
/// # Errors
/// Forwards any IO or parsing errors.
pub(crate) fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
Ok(MediaFile {
path: entry.path().to_path_buf(),
byte_size: entry.metadata()?.size(),
+12 -15
View File
@@ -1,3 +1,5 @@
//! Helpers for filesystem scanning.
use std::path::Path;
use walkdir::WalkDir;
@@ -5,7 +7,8 @@ use walkdir::WalkDir;
use crate::model::MediaFile;
use crate::probe::probe_file;
pub fn scan_directory<T>(
/// Recursively scan a directory.
pub(crate) fn scan_directory<T>(
path: &Path,
unknown_length_start: impl FnOnce() -> T,
unknown_length_end: impl FnOnce(T),
@@ -18,32 +21,26 @@ pub fn scan_directory<T>(
let files: Vec<_> = WalkDir::new(path)
.into_iter()
.filter_map(Result::ok)
.filter(|e| e.file_type().is_file())
.filter(|e| {
let is_mkv = e
.path()
.extension()
.unwrap_or_default()
.eq_ignore_ascii_case("mkv");
let is_mp4 = e
.path()
.extension()
.unwrap_or_default()
.eq_ignore_ascii_case("mp4");
is_mkv || is_mp4
#[expect(clippy::filetype_is_file, reason = "we only want regular files")]
e.file_type().is_file()
})
.filter(|e| {
let ext = e.path().extension().unwrap_or_default();
ext.eq_ignore_ascii_case("mkv") || ext.eq_ignore_ascii_case("mp4")
})
.collect();
unknown_length_end(spinner);
let mut progress = fixed_length_start(files.len());
let mut total_byte_size = 0u64;
let mut total_byte_size = 0_u64;
let files: Vec<_> = files
.iter()
.filter_map(|entry| {
let file = match probe_file(entry) {
Ok(file) => Some(file),
Err(err) => {
print_fn(&progress, &format!("{:?}", err));
print_fn(&progress, &format!("{err:?}"));
None
}
};