zoog 0.8.1

Tools for modifying Ogg Opus output gain and R128 tags and Ogg Opus/Vorbis comment tags
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
#![warn(clippy::pedantic)]
#![allow(clippy::uninlined_format_args)]

#[path = "../ctrlc_handling.rs"]
mod ctrlc_handling;

#[path = "../output_file.rs"]
mod output_file;

use std::borrow::Cow;
use std::collections::{HashMap, HashSet};
use std::convert::Into;
use std::fs::File;
use std::io::{self, BufRead, BufReader, BufWriter, Read, Seek as _, Write as _};
use std::ops::BitOrAssign;
use std::path::{Path, PathBuf};

use clap::Parser;
use ctrlc_handling::CtrlCChecker;
use output_file::OutputFile;
use thiserror::Error;
use zoog::comment_rewrite::{CommentHeaderRewrite, CommentHeaderSummary, CommentRewriterAction, CommentRewriterConfig};
use zoog::header::{parse_comment, validate_comment_field_name, CommentList, DiscreteCommentList};
use zoog::header_rewriter::{rewrite_stream_with_interrupt, SubmitResult};
use zoog::{escaping, Error};

const OGG_OPUS_EXTENSIONS: [&str; 7] = ["ogg", "ogv", "oga", "ogx", "ogm", "spx", "opus"];
const STANDARD_STREAM_NAME: &str = "-";

#[derive(Debug, Error)]
enum AppError {
    #[error("{0}")]
    LibraryError(#[from] Error),

    #[error("Silent exit because error was already printed")]
    SilentExit,

    #[error("Unable to register Ctrl-C handler: `{0}`")]
    CtrlCRegistration(#[from] ctrlc_handling::CtrlCRegistrationError),

    #[error("Failed to read from standard input: `{0}`")]
    StandardInputReadError(io::Error),
}

fn main() {
    if let Err(e) = main_impl() {
        match e {
            AppError::LibraryError(e) => eprintln!("Aborted due to error: {}", e),
            AppError::SilentExit => {}
            e => eprintln!("{}", e),
        }
        std::process::exit(1);
    }
}

#[derive(Debug, Parser)]
#[allow(clippy::struct_excessive_bools)]
#[clap(author, version, about = "List or edit comments in Ogg Opus and Ogg Vorbis files.")]
struct Cli {
    #[clap(short, long, action, conflicts_with = "replace", conflicts_with = "modify")]
    /// List comments in the Ogg Opus file
    list: bool,

    #[clap(short, long, action, conflicts_with = "replace")]
    /// Delete specific comments and append new ones to the Ogg Opus file
    modify: bool,

    #[clap(short, long, action)]
    /// Replace comments in the Ogg Opus file
    replace: bool,

    #[clap(short = 't', long = "tag", value_name = "NAME=VALUE", conflicts_with = "list")]
    /// Specify a tag
    tags: Vec<String>,

    #[clap(short, long, value_name = "NAME[=VALUE]", conflicts_with = "replace", conflicts_with = "list")]
    /// Specify a tag name or name-value mapping to be deleted
    delete: Vec<String>,

    #[clap(short, long, action)]
    /// Use escapes \n, \r, \0 and \\ for tag-value input and output
    escapes: bool,

    #[clap(short = 'n', long = "dry-run", action)]
    /// Display output without performing any file modification.
    dry_run: bool,

    #[clap(short = 'I', long = "tags-in", conflicts_with = "list")]
    /// File for reading tags from
    tags_in: Option<PathBuf>,

    #[clap(short = 'O', long = "tags-out", conflicts_with = "modify", conflicts_with = "replace")]
    /// File for writing tags to
    tags_out: Option<PathBuf>,

    /// Input file
    input_file: PathBuf,

    /// Output file (cannot be specified in list mode)
    #[clap(conflicts_with = "list")]
    output_file: Option<PathBuf>,
}

#[derive(Clone, Copy, Debug)]
enum OperationMode {
    List,
    Modify,
    Replace,
}

/// Match type for Opus comment values
#[derive(Clone, Debug)]
enum ValueMatch {
    All,
    ContainedIn(HashSet<String>),
}

impl ValueMatch {
    pub fn singleton(value: String) -> ValueMatch { ValueMatch::ContainedIn(HashSet::from([value])) }

    pub fn matches(&self, value: &str) -> bool {
        match self {
            ValueMatch::All => true,
            ValueMatch::ContainedIn(values) => values.contains(value),
        }
    }
}

impl Default for ValueMatch {
    fn default() -> ValueMatch { ValueMatch::ContainedIn(HashSet::new()) }
}

impl BitOrAssign for ValueMatch {
    fn bitor_assign(&mut self, rhs: ValueMatch) {
        let mut old_lhs = ValueMatch::All;
        std::mem::swap(self, &mut old_lhs);
        let new_value = match (old_lhs, rhs) {
            (ValueMatch::ContainedIn(mut lhs), ValueMatch::ContainedIn(mut rhs)) => {
                // Preserve the larger set when merging
                if rhs.len() > lhs.len() {
                    std::mem::swap(&mut rhs, &mut lhs);
                }
                lhs.extend(rhs.into_iter());
                ValueMatch::ContainedIn(lhs)
            }
            _ => ValueMatch::All,
        };
        *self = new_value;
    }
}

#[derive(Clone, Debug, Default)]
struct KeyValueMatch {
    keys: HashMap<String, ValueMatch>,
}

impl KeyValueMatch {
    pub fn add(&mut self, mut key: String, value: ValueMatch) {
        key.make_ascii_uppercase();
        *self.keys.entry(key).or_default() |= value;
    }

    pub fn matches(&self, key: &str, value: &str) -> bool {
        let key = key.to_ascii_uppercase();
        match self.keys.get(&key) {
            None => false,
            Some(value_match) => value_match.matches(value),
        }
    }
}

fn parse_new_comment_args<S, I>(comments: I, escaped: bool) -> Result<DiscreteCommentList, Error>
where
    S: AsRef<str>,
    I: IntoIterator<Item = S>,
{
    let comments = comments.into_iter();
    let mut result = DiscreteCommentList::with_capacity(comments.size_hint().0);
    for comment in comments {
        let comment = comment.as_ref();
        let (key, value) = parse_comment(comment)?;
        let value = if escaped { escaping::unescape_str(value)? } else { Cow::from(value) };
        result.push(key, &value)?;
    }
    Ok(result)
}

/// Try to protect user against passing a media file as a tags file
fn validate_comment_filename(path: &Path) -> Result<(), AppError> {
    if let Some(ext) = path.extension() {
        let mut ext = ext.to_string_lossy().to_string();
        ext.make_ascii_lowercase();
        if OGG_OPUS_EXTENSIONS.iter().any(|e| ext == *e) {
            eprintln!(
                "Based on the file extension {:?} looks like it might be a media file. Refusing to use it for tags.",
                path
            );
            return Err(AppError::SilentExit);
        }
    }
    Ok(())
}

fn parse_delete_comment_args<S, I>(patterns: I, escaped: bool) -> Result<KeyValueMatch, Error>
where
    S: AsRef<str>,
    I: IntoIterator<Item = S>,
{
    let patterns = patterns.into_iter();
    let mut result = KeyValueMatch::default();
    for pattern_string in patterns {
        let pattern_string = pattern_string.as_ref();
        let (key, value) = match parse_comment(pattern_string) {
            Ok((key, value)) => {
                let value = if escaped { escaping::unescape_str(value)? } else { Cow::from(value) };
                (key, Some(value))
            }
            Err(_) => match validate_comment_field_name(pattern_string) {
                Ok(()) => (pattern_string, None),
                Err(e) => return Err(e),
            },
        };
        let rhs = match value {
            None => ValueMatch::All,
            Some(value) => ValueMatch::singleton(value.to_string()),
        };
        result.add(key.to_string(), rhs);
    }
    Ok(result)
}

fn read_comments_from_read<R, M, E>(read: R, escaped: bool, error_map: M) -> Result<DiscreteCommentList, E>
where
    R: Read,
    M: Fn(io::Error) -> E,
    E: From<Error>,
{
    let read = BufReader::new(read);
    let mut result = DiscreteCommentList::default();
    for line in read.lines() {
        let line = line.map_err(&error_map)?;
        if line.trim().is_empty() {
            continue;
        }
        let (key, value) = parse_comment(&line)?;
        let value = if escaped { escaping::unescape_str(value).map_err(Into::into)? } else { Cow::from(value) };
        result.push(key, &value)?;
    }
    Ok(result)
}

fn read_comments_from_file<P: AsRef<Path>>(path: P, escaped: bool) -> Result<DiscreteCommentList, Error> {
    let path = path.as_ref();
    let file = File::open(path).map_err(|e| Error::FileOpenError(path.to_path_buf(), e))?;
    let error_map = |e| Error::FileReadError(path.to_path_buf(), e);
    read_comments_from_read(file, escaped, error_map)
}

fn read_comments_from_stdin(escaped: bool) -> Result<DiscreteCommentList, AppError> {
    let stdin = io::stdin();
    let error_map = AppError::StandardInputReadError;
    read_comments_from_read(stdin, escaped, error_map)
}

fn main_impl() -> Result<(), AppError> {
    let interrupt_checker = CtrlCChecker::new()?;
    let cli = Cli::parse_from(wild::args_os());
    let operation_mode = match (cli.list, cli.modify, cli.replace) {
        (_, false, false) => OperationMode::List,
        (false, true, false) => OperationMode::Modify,
        (false, false, true) => OperationMode::Replace,
        _ => {
            eprintln!("Invalid combination of modes passed");
            return Err(AppError::SilentExit);
        }
    };

    for comment_file in [&cli.tags_in, &cli.tags_out].iter().copied().flatten() {
        validate_comment_filename(comment_file)?;
    }

    let dry_run = cli.dry_run;
    let escape = cli.escapes;
    let delete_tags = parse_delete_comment_args(cli.delete, escape)?;
    let append = {
        let mut append = parse_new_comment_args(cli.tags, escape)?;
        if let Some(ref file) = cli.tags_in {
            let mut tags = if file == std::ffi::OsStr::new(STANDARD_STREAM_NAME) {
                read_comments_from_stdin(escape)?
            } else {
                read_comments_from_file(file, escape)?
            };
            append.append(&mut tags);
        }
        append
    };

    let action = match operation_mode {
        OperationMode::List => CommentRewriterAction::NoChange,
        OperationMode::Modify => {
            let retain: Box<dyn Fn(&str, &str) -> bool> = Box::new(|k, v| !delete_tags.matches(k, v));
            CommentRewriterAction::Modify { retain, append }
        }
        OperationMode::Replace => CommentRewriterAction::Replace(append),
    };

    let rewriter_config = CommentRewriterConfig { action };
    let input_path = cli.input_file;
    let output_path = cli.output_file.unwrap_or_else(|| input_path.clone());
    let input_file = File::open(&input_path).map_err(|e| Error::FileOpenError(input_path.clone(), e))?;
    let mut input_file = BufReader::new(input_file);

    let mut output_file = match operation_mode {
        OperationMode::List => OutputFile::new_sink(),
        OperationMode::Modify | OperationMode::Replace => OutputFile::new_target_or_discard(&output_path, dry_run)?,
    };

    let rewrite_result = {
        let mut output_file = BufWriter::new(&mut output_file);
        let rewrite = CommentHeaderRewrite::new(rewriter_config);
        let summarize = CommentHeaderSummary::default();
        let abort_on_unchanged = true;
        rewrite_stream_with_interrupt(
            rewrite,
            summarize,
            &mut input_file,
            &mut output_file,
            abort_on_unchanged,
            &interrupt_checker,
        )
    };
    let mut commit = false;
    match rewrite_result {
        Err(e) => {
            eprintln!("Failure during processing of {}.", input_path.display());
            return Err(e.into());
        }
        Ok(SubmitResult::Good) => {
            // We finished processing the file but never got the headers
            eprintln!("File {} appeared to be oddly truncated. Doing nothing.", input_path.display());
        }
        Ok(SubmitResult::HeadersUnchanged(comments)) => match operation_mode {
            OperationMode::List => {
                if let Some(ref path) = cli.tags_out.filter(|p| p != std::ffi::OsStr::new(STANDARD_STREAM_NAME)) {
                    let mut comment_file = OutputFile::new_target_or_discard(path, dry_run)?;
                    {
                        let mut comment_file = BufWriter::new(&mut comment_file);
                        comments
                            .write_as_text(&mut comment_file, escape)
                            .map_err(|e| Error::FileWriteError(path.into(), e))?;
                        comment_file.flush().map_err(|e| Error::FileWriteError(path.into(), e))?;
                    }
                    comment_file.commit()?;
                } else {
                    comments.write_as_text(io::stdout(), escape).map_err(Error::ConsoleIoError)?;
                }
            }
            OperationMode::Modify | OperationMode::Replace => {
                // If these match we are definitely in-place. If they don't we're probably not,
                // but can't be 100% certain. Hence we still do the copy via a
                // temporary file rather than just invoking a filesystem copy.
                if input_path != output_path {
                    // Drop the existing output file and create a new one
                    let mut old_output_file = OutputFile::new_target_or_discard(&output_path, dry_run)?;
                    std::mem::swap(&mut output_file, &mut old_output_file);
                    old_output_file.abort()?;
                    // Copy the input file to the output file
                    input_file.rewind().map_err(Error::ReadError)?;
                    std::io::copy(&mut input_file, &mut output_file)
                        .map_err(|e| Error::FileCopy(input_path, output_path, e))?;
                    commit = true;
                }
            }
        },
        Ok(SubmitResult::HeadersChanged { .. }) => {
            commit = true;
        }
    };
    drop(input_file); // Important for Windows so we can overwrite
    if commit {
        output_file.commit()?;
    } else {
        output_file.abort()?;
    }
    Ok(())
}

#[cfg(test)]
mod tests {
    use clap::error::ErrorKind;

    use super::*;

    #[test]
    fn cli_modes_conflict() {
        let result = Cli::try_parse_from(["zoogcomment", "--replace", "--list", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from(["zoogcomment", "--replace", "--modify", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from(["zoogcomment", "--modify", "--list", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
    }

    #[test]
    fn cli_list_mode() {
        let result = Cli::try_parse_from(["zoogcomment", "--list", "input.ogg"]);
        assert!(result.is_ok());

        let result = Cli::try_parse_from(["zoogcomment", "--list", "input.ogg", "output.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from(["zoogcomment", "--list", "-O", "output.tags", "input.ogg"]);
        assert!(result.is_ok());

        let result = Cli::try_parse_from(["zoogcomment", "--list", "-I", "input.tags", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from(["zoogcomment", "--list", "-d", "TAG=VALUE", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from(["zoogcomment", "--list", "-t", "TAG=VALUE", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
    }

    #[test]
    fn cli_modify_mode() {
        let result = Cli::try_parse_from(["zoogcomment", "--modify", "input.ogg"]);
        assert!(result.is_ok());

        let result = Cli::try_parse_from(["zoogcomment", "--modify", "-I", "input.tags", "input.ogg"]);
        assert!(result.is_ok());

        let result = Cli::try_parse_from(["zoogcomment", "--modify", "-I", "input.tags", "input.ogg", "output.ogg"]);
        assert!(result.is_ok());

        let result = Cli::try_parse_from(["zoogcomment", "--modify", "-O", "output.tags", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from([
            "zoogcomment",
            "--modify",
            "-I",
            "input.tags",
            "-d",
            "TAG=VALUE",
            "-t",
            "TAG2=VALUE2",
            "input.ogg",
        ]);
        assert!(result.is_ok());
    }

    #[test]
    fn cli_replace_mode() {
        let result = Cli::try_parse_from(["zoogcomment", "--replace", "input.ogg", "output.ogg"]);
        assert!(result.is_ok());

        let result =
            Cli::try_parse_from(["zoogcomment", "--replace", "-I", "input.tags", "-t", "TAG=VALUE", "input.ogg"]);
        assert!(result.is_ok());

        let result = Cli::try_parse_from(["zoogcomment", "--replace", "-O", "output.tags", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);

        let result = Cli::try_parse_from(["zoogcomment", "--replace", "-d", "TAG=VALUE", "input.ogg"]);
        assert_eq!(result.unwrap_err().kind(), ErrorKind::ArgumentConflict);
    }
}