1use regex::Regex;
2use std::collections::HashMap;
3use std::path::Path;
4use once_cell::sync::Lazy;
5
6use crate::error::{Error, Result};
7
8static TIME_REGEX: Lazy<Regex> = Lazy::new(|| {
10 Regex::new(r"^(\d{2}):(\d{2}):(\d{2})(?:\.(\d+))?$").unwrap()
11});
12
13static BITRATE_REGEX: Lazy<Regex> = Lazy::new(|| {
14 Regex::new(r"^(\d+(?:\.\d+)?)\s*([kmgKMG])?(?:bit|bps|b)?(?:/s)?$").unwrap()
15});
16
17static RESOLUTION_REGEX: Lazy<Regex> = Lazy::new(|| {
18 Regex::new(r"^(\d+)[xX](\d+)$").unwrap()
19});
20
21pub fn parse_bitrate(s: &str) -> Result<u64> {
24 let s = s.trim();
25
26 if let Some(captures) = BITRATE_REGEX.captures(s) {
27 let number: f64 = captures[1].parse()
28 .map_err(|_| Error::ParseError(format!("Invalid bitrate number: {}", &captures[1])))?;
29
30 let suffix = captures.get(2).map(|m| m.as_str().to_lowercase());
32
33 let multiplier = match suffix.as_deref() {
35 Some("k") => 1_000.0,
36 Some("m") => 1_000_000.0,
37 Some("g") => 1_000_000_000.0,
38 None => 1.0,
39 _ => return Err(Error::ParseError(format!("Invalid bitrate suffix in: {}", s))),
40 };
41
42 Ok((number * multiplier) as u64)
43 } else {
44 s.parse::<u64>()
46 .map_err(|_| Error::ParseError(format!("Invalid bitrate: {}", s)))
47 }
48}
49
50pub fn parse_resolution(s: &str) -> Result<(u32, u32)> {
52 if let Some(captures) = RESOLUTION_REGEX.captures(s.trim()) {
53 let width: u32 = captures[1].parse()
54 .map_err(|_| Error::ParseError(format!("Invalid width: {}", &captures[1])))?;
55 let height: u32 = captures[2].parse()
56 .map_err(|_| Error::ParseError(format!("Invalid height: {}", &captures[2])))?;
57 Ok((width, height))
58 } else {
59 Err(Error::ParseError(format!("Invalid resolution format: {}", s)))
60 }
61}
62
63pub fn parse_key_value_pairs(text: &str) -> HashMap<String, String> {
65 let mut map = HashMap::new();
66
67 for line in text.lines() {
68 let line = line.trim();
69 if line.is_empty() || line.starts_with('#') {
70 continue;
71 }
72
73 if let Some((key, value)) = line.split_once('=') {
74 map.insert(key.trim().to_string(), value.trim().to_string());
75 }
76 }
77
78 map
79}
80
81pub fn escape_filter_string(s: &str) -> String {
83 s.chars()
84 .flat_map(|c| match c {
85 '\\' => vec!['\\', '\\'],
86 ':' => vec!['\\', ':'],
87 '\'' => vec!['\\', '\''],
88 '[' => vec!['\\', '['],
89 ']' => vec!['\\', ']'],
90 ',' => vec!['\\', ','],
91 ';' => vec!['\\', ';'],
92 '=' => vec!['\\', '='],
93 c => vec![c],
94 })
95 .collect()
96}
97
98pub fn quote_path(path: &Path) -> String {
100 let s = path.to_string_lossy();
101
102 if s.contains(' ') || s.contains('\'') || s.contains('"') || s.contains('\\') {
104 format!("'{}'", s.replace('\'', "'\\''"))
106 } else {
107 s.into_owned()
108 }
109}
110
111pub fn format_duration_human(duration: &std::time::Duration) -> String {
113 let total_secs = duration.as_secs();
114 let hours = total_secs / 3600;
115 let minutes = (total_secs % 3600) / 60;
116 let seconds = total_secs % 60;
117 let millis = duration.subsec_millis();
118
119 if hours > 0 {
120 format!("{}h {}m {}s", hours, minutes, seconds)
121 } else if minutes > 0 {
122 format!("{}m {}.{:03}s", minutes, seconds, millis)
123 } else {
124 format!("{}.{:03}s", seconds, millis)
125 }
126}
127
128pub fn parse_framerate(s: &str) -> Result<f64> {
130 let s = s.trim();
131
132 if let Some((num, den)) = s.split_once('/') {
134 let numerator: f64 = num.parse()
135 .map_err(|_| Error::ParseError(format!("Invalid framerate numerator: {}", num)))?;
136 let denominator: f64 = den.parse()
137 .map_err(|_| Error::ParseError(format!("Invalid framerate denominator: {}", den)))?;
138
139 if denominator == 0.0 {
140 return Err(Error::ParseError("Framerate denominator cannot be zero".to_string()));
141 }
142
143 Ok(numerator / denominator)
144 } else {
145 s.parse::<f64>()
147 .map_err(|_| Error::ParseError(format!("Invalid framerate: {}", s)))
148 }
149}
150
151pub fn get_extension(path: &Path) -> Option<String> {
153 path.extension()
154 .and_then(|ext| ext.to_str())
155 .map(|s| s.to_lowercase())
156}
157
158pub fn guess_format_from_extension(path: &Path) -> Option<&'static str> {
160 match get_extension(path)?.as_str() {
161 "mp4" => Some("mp4"),
163 "m4v" => Some("mp4"),
164 "mkv" => Some("matroska"),
165 "webm" => Some("webm"),
166 "avi" => Some("avi"),
167 "mov" => Some("mov"),
168 "qt" => Some("mov"),
169 "flv" => Some("flv"),
170 "wmv" => Some("asf"),
171 "mpg" | "mpeg" => Some("mpeg"),
172 "ts" | "m2ts" => Some("mpegts"),
173 "vob" => Some("mpeg"),
174 "3gp" => Some("3gp"),
175 "ogv" => Some("ogg"),
176
177 "mp3" => Some("mp3"),
179 "m4a" => Some("mp4"),
180 "aac" => Some("aac"),
181 "ogg" | "oga" => Some("ogg"),
182 "flac" => Some("flac"),
183 "wav" => Some("wav"),
184 "opus" => Some("opus"),
185 "wma" => Some("asf"),
186 "ac3" => Some("ac3"),
187 "dts" => Some("dts"),
188
189 "jpg" | "jpeg" => Some("image2"),
191 "png" => Some("image2"),
192 "bmp" => Some("image2"),
193 "gif" => Some("gif"),
194 "webp" => Some("webp"),
195
196 "srt" => Some("srt"),
198 "ass" | "ssa" => Some("ass"),
199 "vtt" => Some("webvtt"),
200 "sub" => Some("subviewer"),
201
202 _ => None,
203 }
204}
205
206pub fn sanitize_filename(name: &str) -> String {
208 name.chars()
209 .map(|c| match c {
210 '/' | '\\' | ':' | '*' | '?' | '"' | '<' | '>' | '|' => '_',
211 c if c.is_control() => '_',
212 c => c,
213 })
214 .collect()
215}
216
217pub fn is_url(s: &str) -> bool {
219 s.starts_with("http://") ||
220 s.starts_with("https://") ||
221 s.starts_with("rtmp://") ||
222 s.starts_with("rtmps://") ||
223 s.starts_with("rtsp://") ||
224 s.starts_with("rtsps://") ||
225 s.starts_with("file://") ||
226 s.starts_with("udp://") ||
227 s.starts_with("tcp://") ||
228 s.starts_with("pipe:") ||
229 s.contains("://")
230}
231
232pub fn merge_args(base: Vec<String>, overrides: Vec<String>) -> Vec<String> {
234 let mut result = base;
235 let mut seen_flags = std::collections::HashSet::new();
236
237 let value_flags: std::collections::HashSet<&str> = [
239 "-i", "-f", "-c", "-codec", "-vf", "-af", "-s", "-r", "-b", "-aspect",
240 "-t", "-ss", "-to", "-fs", "-preset", "-crf", "-qp", "-profile", "-level",
241 "-pix_fmt", "-ar", "-ac", "-ab", "-map", "-metadata", "-filter_complex",
242 ].iter().cloned().collect();
243
244 let mut i = 0;
246 while i < overrides.len() {
247 let flag = &overrides[i];
248
249 if value_flags.contains(flag.as_str()) && i + 1 < overrides.len() {
250 if !seen_flags.contains(flag) {
252 result.push(flag.clone());
253 result.push(overrides[i + 1].clone());
254 seen_flags.insert(flag.clone());
255 }
256 i += 2;
257 } else {
258 if !seen_flags.contains(flag) {
260 result.push(flag.clone());
261 seen_flags.insert(flag.clone());
262 }
263 i += 1;
264 }
265 }
266
267 result
268}
269
270#[cfg(test)]
271mod tests {
272 use super::*;
273
274 #[test]
275 fn test_parse_bitrate() {
276 assert_eq!(parse_bitrate("128k").unwrap(), 128_000);
277 assert_eq!(parse_bitrate("5M").unwrap(), 5_000_000);
278 assert_eq!(parse_bitrate("1.5m").unwrap(), 1_500_000);
279 assert_eq!(parse_bitrate("1000").unwrap(), 1000);
280 assert_eq!(parse_bitrate("2.5G").unwrap(), 2_500_000_000);
281 }
282
283 #[test]
284 fn test_parse_resolution() {
285 assert_eq!(parse_resolution("1920x1080").unwrap(), (1920, 1080));
286 assert_eq!(parse_resolution("1280X720").unwrap(), (1280, 720));
287 assert_eq!(parse_resolution(" 640x480 ").unwrap(), (640, 480));
288 }
289
290 #[test]
291 fn test_parse_framerate() {
292 assert_eq!(parse_framerate("25").unwrap(), 25.0);
293 assert_eq!(parse_framerate("29.97").unwrap(), 29.97);
294 assert_eq!(parse_framerate("30000/1001").unwrap(), 29.97002997002997);
295 assert_eq!(parse_framerate("24").unwrap(), 24.0);
296 }
297
298 #[test]
299 fn test_escape_filter_string() {
300 assert_eq!(escape_filter_string("text"), "text");
301 assert_eq!(escape_filter_string("text:with:colons"), "text\\:with\\:colons");
302 assert_eq!(escape_filter_string("text[with]brackets"), "text\\[with\\]brackets");
303 assert_eq!(escape_filter_string("text='value'"), "text\\=\\'value\\'");
304 }
305
306 #[test]
307 fn test_sanitize_filename() {
308 assert_eq!(sanitize_filename("normal_file.mp4"), "normal_file.mp4");
309 assert_eq!(sanitize_filename("file:with*invalid?chars.mp4"), "file_with_invalid_chars.mp4");
310 assert_eq!(sanitize_filename("path/to/file.mp4"), "path_to_file.mp4");
311 }
312
313 #[test]
314 fn test_is_url() {
315 assert!(is_url("https://example.com/video.mp4"));
316 assert!(is_url("rtmp://server/live/stream"));
317 assert!(is_url("file:///path/to/file.mp4"));
318 assert!(!is_url("/path/to/file.mp4"));
319 assert!(!is_url("C:\\path\\to\\file.mp4"));
320 }
321
322 #[test]
323 fn test_guess_format() {
324 assert_eq!(guess_format_from_extension(Path::new("video.mp4")), Some("mp4"));
325 assert_eq!(guess_format_from_extension(Path::new("audio.mp3")), Some("mp3"));
326 assert_eq!(guess_format_from_extension(Path::new("video.mkv")), Some("matroska"));
327 assert_eq!(guess_format_from_extension(Path::new("image.jpg")), Some("image2"));
328 }
329}