You've already forked flix
Update dependencies and lints
This commit is contained in:
@@ -1,14 +1,16 @@
|
||||
[package]
|
||||
name = "flix-mux"
|
||||
version = "0.0.20"
|
||||
license-file.workspace = true
|
||||
|
||||
version = "0.1.0"
|
||||
description = "CLI for bulk media muxing"
|
||||
repository = "https://github.com/QuantumShade/flix"
|
||||
categories = ["command-line-utilities"]
|
||||
repository = "https://git.skrundz.dev/quantumshade/flix"
|
||||
keywords = ["flix"]
|
||||
categories = ["command-line-utilities", "multimedia"]
|
||||
include = ["/src"]
|
||||
publish = true
|
||||
|
||||
edition.workspace = true
|
||||
rust-version.workspace = true
|
||||
license-file.workspace = true
|
||||
|
||||
[package.metadata.docs.rs]
|
||||
all-features = true
|
||||
@@ -31,27 +33,10 @@ clap = { workspace = true, features = [
|
||||
"usage",
|
||||
] }
|
||||
console = { workspace = true }
|
||||
dialoguer = { workspace = true }
|
||||
indicatif = { workspace = true }
|
||||
serde = { workspace = true, features = ["derive", "std"] }
|
||||
serde_json = { workspace = true, features = ["alloc"] }
|
||||
walkdir = { workspace = true }
|
||||
|
||||
[lints.clippy]
|
||||
arithmetic_side_effects = "deny"
|
||||
as_conversions = "deny"
|
||||
checked_conversions = "deny"
|
||||
default_union_representation = "deny"
|
||||
expect_used = "deny"
|
||||
indexing_slicing = "deny"
|
||||
integer_division = "deny"
|
||||
integer_division_remainder_used = "deny"
|
||||
transmute_undefined_repr = "deny"
|
||||
unchecked_time_subtraction = "deny"
|
||||
unwrap_used = "deny"
|
||||
|
||||
[lints.rust]
|
||||
arithmetic_overflow = "forbid"
|
||||
missing_docs = "forbid"
|
||||
unsafe_code = "forbid"
|
||||
unused_doc_comments = "forbid"
|
||||
[lints]
|
||||
workspace = true
|
||||
|
||||
+30
-20
@@ -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)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
@@ -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:?}")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user