You've already forked flix
Model updates, bugfixes, and a new muxing cli tool
This commit is contained in:
@@ -0,0 +1,49 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use clap::Parser;
|
||||
|
||||
use crate::parser::Selector;
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(version, about, long_about = None)]
|
||||
pub struct Cli {
|
||||
/// Dry run and print commands
|
||||
#[arg(short, long)]
|
||||
dry_run: bool,
|
||||
|
||||
/// Stream selectors
|
||||
#[arg(
|
||||
short,
|
||||
long,
|
||||
required = true,
|
||||
value_name = "SEL",
|
||||
value_delimiter = ';'
|
||||
)]
|
||||
selectors: Vec<Selector>,
|
||||
|
||||
/// The path to the directory to scan
|
||||
#[arg(value_name = "DIR")]
|
||||
scan_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl Cli {
|
||||
pub fn is_dry(&self) -> bool {
|
||||
self.dry_run
|
||||
}
|
||||
|
||||
pub 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(),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
//! flix-mux
|
||||
|
||||
use core::time::Duration;
|
||||
|
||||
use clap::Parser;
|
||||
use console::style;
|
||||
use indicatif::{HumanBytes, ProgressBar, ProgressStyle};
|
||||
|
||||
use crate::mux::mux_files;
|
||||
use crate::scan::scan_directory;
|
||||
|
||||
mod cli;
|
||||
mod model;
|
||||
mod mux;
|
||||
mod parser;
|
||||
mod probe;
|
||||
mod scan;
|
||||
|
||||
fn main() {
|
||||
let cli = cli::Cli::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.enable_steady_tick(Duration::from_millis(50));
|
||||
progress
|
||||
},
|
||||
|progress| progress.finish(),
|
||||
|len| {
|
||||
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
|
||||
progress.set_style(
|
||||
#[expect(clippy::expect_used)]
|
||||
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
|
||||
.expect("static template"),
|
||||
);
|
||||
progress
|
||||
},
|
||||
|progress| progress.inc(1),
|
||||
|progress| progress.finish_and_clear(),
|
||||
|progress, msg| {
|
||||
progress.suspend(|| eprintln!("{} {}", style("[WARN]").bold().yellow(), msg))
|
||||
},
|
||||
);
|
||||
println!("Found {} files ({})", files.len(), HumanBytes(size));
|
||||
|
||||
mux_files(
|
||||
cli.is_dry(),
|
||||
&files,
|
||||
cli.selectors(),
|
||||
|len| {
|
||||
let progress = ProgressBar::new(u64::try_from(len).unwrap_or(0));
|
||||
progress.set_style(
|
||||
#[expect(clippy::expect_used)]
|
||||
ProgressStyle::with_template("[{elapsed_precise}] {wide_bar} {pos}/{len} ({msg})")
|
||||
.expect("static template"),
|
||||
);
|
||||
progress.enable_steady_tick(Duration::from_secs(1));
|
||||
progress
|
||||
},
|
||||
|progress| progress.inc(1),
|
||||
|progress| progress.finish_with_message("done"),
|
||||
|progress, msg| progress.suspend(|| eprintln!("{} {}", style("[ERR]").bold().red(), msg)),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::path::PathBuf;
|
||||
|
||||
use serde::Deserialize;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct MediaFile {
|
||||
pub path: PathBuf,
|
||||
pub byte_size: u64,
|
||||
pub streams: Streams,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Streams {
|
||||
pub video: Vec<VideoStream>,
|
||||
pub audio: Vec<AudioStream>,
|
||||
pub subtitle: Vec<SubtitleStream>,
|
||||
}
|
||||
|
||||
pub trait FFStream: serde::de::DeserializeOwned {
|
||||
const FF_TYPE_NAME: &str;
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct VideoStream {
|
||||
codec_name: String,
|
||||
tags: Option<VideoTags>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct VideoTags {
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl VideoStream {
|
||||
pub fn codec(&self) -> &str {
|
||||
&self.codec_name
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.language.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl FFStream for VideoStream {
|
||||
const FF_TYPE_NAME: &str = "v";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AudioStream {
|
||||
codec_name: String,
|
||||
tags: Option<AudioTags>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct AudioTags {
|
||||
language: Option<String>,
|
||||
}
|
||||
|
||||
impl AudioStream {
|
||||
pub fn codec(&self) -> &str {
|
||||
&self.codec_name
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.language.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl FFStream for AudioStream {
|
||||
const FF_TYPE_NAME: &str = "a";
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubtitleStream {
|
||||
codec_name: String,
|
||||
tags: Option<SubtitleTags>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct SubtitleTags {
|
||||
language: Option<String>,
|
||||
title: Option<String>,
|
||||
}
|
||||
|
||||
impl SubtitleStream {
|
||||
pub fn codec(&self) -> &str {
|
||||
&self.codec_name
|
||||
}
|
||||
|
||||
pub fn language(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.language.as_deref()
|
||||
}
|
||||
|
||||
pub fn title(&self) -> Option<&str> {
|
||||
self.tags.as_ref()?.title.as_deref()
|
||||
}
|
||||
}
|
||||
|
||||
impl FFStream for SubtitleStream {
|
||||
const FF_TYPE_NAME: &str = "s";
|
||||
}
|
||||
@@ -0,0 +1,227 @@
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
|
||||
use crate::model::MediaFile;
|
||||
use crate::parser::{Matcher, Selector, StreamFlag, StreamType};
|
||||
|
||||
pub fn mux_files<T>(
|
||||
dry_run: bool,
|
||||
files: &[MediaFile],
|
||||
selectors: &[Selector],
|
||||
fixed_length_start: impl FnOnce(usize) -> T,
|
||||
fixed_length_update: impl Fn(&mut T),
|
||||
fixed_length_end: impl FnOnce(T),
|
||||
print_fn: impl Fn(&T, &str),
|
||||
) {
|
||||
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));
|
||||
}
|
||||
fixed_length_update(&mut progress);
|
||||
}
|
||||
fixed_length_end(progress);
|
||||
}
|
||||
|
||||
#[expect(clippy::expect_used)]
|
||||
fn mux(dry_run: bool, file: &MediaFile, selectors: &[Selector]) -> Result<()> {
|
||||
let mut command = Command::new("ffmpeg");
|
||||
let mut command = command.args(["-v", "error"]);
|
||||
|
||||
command = command.arg("-i");
|
||||
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(["-c:v", "copy", "-c:a", "copy", "-c:s", "mov_text"]);
|
||||
command = command.args(["-movflags", "faststart+disable_chpl+write_colr"]);
|
||||
command = command.args(["-map_chapters", "-1"]);
|
||||
command = command.args(["-map_metadata", "-1"]);
|
||||
command = command.args(["-metadata:g", "encoding_tool=Skrundzflix"]);
|
||||
|
||||
for selector in selectors {
|
||||
command = command.args(
|
||||
make_metadata_args(file, selector)
|
||||
.with_context(|| format!("Failed to mux {:?}", file.path))?,
|
||||
);
|
||||
}
|
||||
|
||||
let temp_path = file.path.with_extension("mp4");
|
||||
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))?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"ffmpeg failed for {:?}:\n\n{}",
|
||||
file.path,
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn make_map_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
|
||||
let source_index = 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");
|
||||
}
|
||||
};
|
||||
|
||||
Ok(vec![
|
||||
String::from("-map"),
|
||||
format!("{}:{}:{}", source_index, stream_type, stream_index),
|
||||
])
|
||||
}
|
||||
|
||||
fn make_metadata_args(file: &MediaFile, selector: &Selector) -> Result<Vec<String>> {
|
||||
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");
|
||||
}
|
||||
};
|
||||
|
||||
let stream_language = selector.language();
|
||||
|
||||
let mut args = vec![
|
||||
format!("-metadata:s:{}:{}", stream_type, stream_index),
|
||||
format!("language={}", stream_language),
|
||||
];
|
||||
|
||||
if selector.stream_type == StreamType::Subtitle {
|
||||
let sub_title = match selector.flag {
|
||||
Some(StreamFlag::Forced) => "Forced",
|
||||
Some(StreamFlag::Sdh) => "SDH",
|
||||
None => match stream_language {
|
||||
"eng" => "English",
|
||||
"jpn" => "Japanese",
|
||||
_ => anyhow::bail!("Unhandled subtitle language: {}", stream_language),
|
||||
},
|
||||
};
|
||||
|
||||
args.push(format!("-metadata:s:s:{}", stream_index));
|
||||
args.push(format!("title={}", sub_title));
|
||||
}
|
||||
|
||||
Ok(args)
|
||||
}
|
||||
|
||||
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
|
||||
.streams
|
||||
.video
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.language() == Some(language.as_str()))
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
Matcher::Codec(ref codec) => file
|
||||
.streams
|
||||
.video
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.codec() == codec)
|
||||
.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
|
||||
.streams
|
||||
.audio
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.language() == Some(language.as_str()))
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
Matcher::Codec(ref codec) => file
|
||||
.streams
|
||||
.audio
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.codec() == codec)
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
},
|
||||
StreamType::Subtitle => match selector.matcher {
|
||||
Matcher::Index(index) => (file.streams.subtitle.len() > index).then_some(index),
|
||||
Matcher::Language(ref language) => {
|
||||
file.streams
|
||||
.subtitle
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.language() == Some(language.as_str()))
|
||||
.filter(|(_, c)| {
|
||||
c.title()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains("forced") == needs_forced
|
||||
})
|
||||
.filter(|(_, c)| {
|
||||
c.title()
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase()
|
||||
.contains("sdh") == needs_sdh
|
||||
})
|
||||
.map(|(i, _)| i)
|
||||
.next()
|
||||
}
|
||||
Matcher::Codec(ref codec) => file
|
||||
.streams
|
||||
.subtitle
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, c)| c.codec() == codec)
|
||||
.map(|(i, _)| i)
|
||||
.next(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
fn print_command(cmd: &Command) {
|
||||
let program = cmd.get_program().to_string_lossy();
|
||||
|
||||
let args = cmd
|
||||
.get_args()
|
||||
.map(|a| shell_escape(a.to_string_lossy().as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
|
||||
println!("{} {}", program, args);
|
||||
}
|
||||
|
||||
fn shell_escape(s: &str) -> String {
|
||||
if s.chars()
|
||||
.all(|c| c.is_ascii_alphanumeric() || "-_./".contains(c))
|
||||
{
|
||||
s.to_string()
|
||||
} else {
|
||||
format!("{:?}", s)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
use core::error::Error;
|
||||
use core::fmt;
|
||||
use core::str::FromStr;
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamType {
|
||||
Video,
|
||||
Audio,
|
||||
Subtitle,
|
||||
}
|
||||
|
||||
impl AsRef<str> for StreamType {
|
||||
fn as_ref(&self) -> &str {
|
||||
match self {
|
||||
StreamType::Video => "v",
|
||||
StreamType::Audio => "a",
|
||||
StreamType::Subtitle => "s",
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for StreamType {
|
||||
type Err = ParseSelectorError;
|
||||
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum Matcher {
|
||||
Index(usize),
|
||||
Language(String),
|
||||
Codec(String),
|
||||
}
|
||||
|
||||
impl FromStr for Matcher {
|
||||
type Err = ParseSelectorError;
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
if let Ok(idx) = s.parse::<usize>() {
|
||||
return Ok(Matcher::Index(idx));
|
||||
}
|
||||
|
||||
if s.len() == 3 {
|
||||
return Ok(Matcher::Language(s.to_ascii_lowercase()));
|
||||
}
|
||||
Ok(Matcher::Codec(s.to_ascii_lowercase()))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StreamFlag {
|
||||
Forced,
|
||||
Sdh,
|
||||
}
|
||||
|
||||
impl FromStr for StreamFlag {
|
||||
type Err = ParseSelectorError;
|
||||
|
||||
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())),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Selector {
|
||||
pub stream_type: StreamType,
|
||||
pub matcher: Matcher,
|
||||
pub flag: Option<StreamFlag>,
|
||||
pub optional: bool,
|
||||
pub out_lang: String,
|
||||
}
|
||||
|
||||
impl Selector {
|
||||
pub fn language(&self) -> &str {
|
||||
&self.out_lang
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Selector {
|
||||
type Err = ParseSelectorError;
|
||||
|
||||
fn from_str(input: &str) -> Result<Self, Self::Err> {
|
||||
if input.is_empty() {
|
||||
return Err(ParseSelectorError::Empty);
|
||||
}
|
||||
|
||||
let (input, optional) = input
|
||||
.strip_suffix('?')
|
||||
.map_or((input, false), |s| (s, true));
|
||||
|
||||
let (left, out_lang) = input.split_once('>').unwrap_or((input, ""));
|
||||
|
||||
let mut parts = left.splitn(3, ':');
|
||||
|
||||
let stream_type: StreamType = parts
|
||||
.next()
|
||||
.ok_or(ParseSelectorError::InvalidFormat)?
|
||||
.parse()?;
|
||||
|
||||
let matcher: Matcher = parts
|
||||
.next()
|
||||
.ok_or(ParseSelectorError::InvalidFormat)?
|
||||
.parse()?;
|
||||
|
||||
let flag = parts
|
||||
.next()
|
||||
.filter(|s| !s.is_empty())
|
||||
.map(str::parse)
|
||||
.transpose()?;
|
||||
|
||||
let out_lang = match (out_lang.is_empty(), &matcher) {
|
||||
(false, _) => out_lang.to_owned(),
|
||||
(true, Matcher::Language(language)) => language.clone(),
|
||||
(true, _) => return Err(ParseSelectorError::UnspecifiedLanguage),
|
||||
};
|
||||
|
||||
Ok(Selector {
|
||||
stream_type,
|
||||
matcher,
|
||||
flag,
|
||||
optional,
|
||||
out_lang,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ParseSelectorError {
|
||||
Empty,
|
||||
InvalidFormat,
|
||||
InvalidStreamType(String),
|
||||
InvalidFlag(String),
|
||||
UnspecifiedLanguage,
|
||||
}
|
||||
|
||||
impl Error for ParseSelectorError {}
|
||||
|
||||
impl fmt::Display for ParseSelectorError {
|
||||
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
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::UnspecifiedLanguage => write!(f, "unspecified output language"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
use std::process::Command;
|
||||
|
||||
use anyhow::{Context as _, Result};
|
||||
use serde::Deserialize;
|
||||
use walkdir::DirEntry;
|
||||
|
||||
use crate::model::{FFStream, MediaFile, Streams};
|
||||
|
||||
#[derive(Debug, Deserialize)]
|
||||
struct FFProbeOutput<S> {
|
||||
streams: Vec<S>,
|
||||
}
|
||||
|
||||
fn probe_file_streams<S: FFStream>(entry: &DirEntry) -> Result<Vec<S>> {
|
||||
let output = Command::new("ffprobe")
|
||||
.args([
|
||||
"-v",
|
||||
"error",
|
||||
"-output_format",
|
||||
"json",
|
||||
"-show_streams",
|
||||
"-select_streams",
|
||||
S::FF_TYPE_NAME,
|
||||
#[expect(clippy::expect_used)]
|
||||
entry.path().to_str().expect("path should be utf8"),
|
||||
])
|
||||
.output()
|
||||
.with_context(|| format!("Failed to run ffprobe on {:?}", entry.path()))?;
|
||||
|
||||
if !output.status.success() {
|
||||
anyhow::bail!(
|
||||
"ffprobe failed for {:?}:\n\n{}",
|
||||
entry.path(),
|
||||
String::from_utf8_lossy(&output.stderr)
|
||||
);
|
||||
}
|
||||
|
||||
let parsed: FFProbeOutput<S> = serde_json::from_slice(&output.stdout)?;
|
||||
Ok(parsed.streams)
|
||||
}
|
||||
|
||||
pub fn probe_file(entry: &DirEntry) -> Result<MediaFile> {
|
||||
Ok(MediaFile {
|
||||
path: entry.path().to_path_buf(),
|
||||
byte_size: entry.metadata()?.size(),
|
||||
streams: Streams {
|
||||
video: probe_file_streams(entry)?,
|
||||
audio: probe_file_streams(entry)?,
|
||||
subtitle: probe_file_streams(entry)?,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,58 @@
|
||||
use std::path::Path;
|
||||
|
||||
use walkdir::WalkDir;
|
||||
|
||||
use crate::model::MediaFile;
|
||||
use crate::probe::probe_file;
|
||||
|
||||
pub fn scan_directory<T>(
|
||||
path: &Path,
|
||||
unknown_length_start: impl FnOnce() -> T,
|
||||
unknown_length_end: impl FnOnce(T),
|
||||
fixed_length_start: impl FnOnce(usize) -> T,
|
||||
fixed_length_update: impl Fn(&mut T),
|
||||
fixed_length_end: impl FnOnce(T),
|
||||
print_fn: impl Fn(&T, &str),
|
||||
) -> (Vec<MediaFile>, u64) {
|
||||
let spinner = unknown_length_start();
|
||||
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
|
||||
})
|
||||
.collect();
|
||||
unknown_length_end(spinner);
|
||||
|
||||
let mut progress = fixed_length_start(files.len());
|
||||
let mut total_byte_size = 0u64;
|
||||
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));
|
||||
None
|
||||
}
|
||||
};
|
||||
let size = file.as_ref().map(|r| r.byte_size).unwrap_or_default();
|
||||
total_byte_size = total_byte_size.saturating_add(size);
|
||||
fixed_length_update(&mut progress);
|
||||
file
|
||||
})
|
||||
.collect();
|
||||
fixed_length_end(progress);
|
||||
(files, total_byte_size)
|
||||
}
|
||||
Reference in New Issue
Block a user