diff --git a/README.md b/README.md index c94d8e6..01e2509 100644 --- a/README.md +++ b/README.md @@ -48,12 +48,13 @@ Documentation Usage: opustags --help opustags [OPTIONS] FILE + opustags OPTIONS -i FILE... opustags OPTIONS FILE -o FILE Options: -h, --help print this help -o, --output FILE specify the output file - -i, --in-place overwrite the input file + -i, --in-place overwrite the input files -y, --overwrite overwrite the output file if it already exists -a, --add FIELD=VALUE add a comment -d, --delete FIELD[=VALUE] delete previously existing comments diff --git a/opustags.1 b/opustags.1 index 172fa45..366a563 100644 --- a/opustags.1 +++ b/opustags.1 @@ -10,6 +10,11 @@ opustags \- Ogg Opus tag editor .br .B opustags .I OPTIONS +.B -i +.R \fIFILE\fP... +.br +.B opustags +.I OPTIONS .B -o .I OUTPUT INPUT .SH DESCRIPTION @@ -24,7 +29,7 @@ You can use the options below to edit the tags before printing them. This could be useful to preview some changes before writing them. .PP In editing mode, you need to specify an output file with \fB--output\fP, or use \fB--in-place\fP to -overwrite the input file. If the output is a regular file, the result is first written to a +overwrite the input files. If the output is a regular file, the result is first written to a temporary file and then moved to its final location on success. On error, the temporary output file is deleted. .PP diff --git a/src/cli.cc b/src/cli.cc index bfb591f..4d35ceb 100644 --- a/src/cli.cc +++ b/src/cli.cc @@ -23,12 +23,13 @@ R"raw( Usage: opustags --help opustags [OPTIONS] FILE + opustags OPTIONS -i FILE... opustags OPTIONS FILE -o FILE Options: -h, --help print this help -o, --output FILE specify the output file - -i, --in-place overwrite the input file + -i, --in-place overwrite the input files -y, --overwrite overwrite the output file if it already exists -a, --add FIELD=VALUE add a comment -d, --delete FIELD[=VALUE] delete previously existing comments @@ -62,7 +63,6 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm opt = {}; if (argc == 1) return {st::bad_arguments, "No arguments specified. Use -h for help."}; - bool in_place = false; int c; optind = 0; while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DS", getopt_options, NULL)) != -1) { @@ -76,7 +76,7 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm opt.path_out = optarg; break; case 'i': - in_place = true; + opt.in_place = true; break; case 'y': opt.overwrite = true; @@ -115,22 +115,23 @@ ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comm } if (opt.print_help) return st::ok; - if (optind != argc - 1) - return {st::bad_arguments, "Exactly one input file must be specified."}; - opt.path_in = argv[optind]; - if (opt.path_in.empty()) - return {st::bad_arguments, "Input file path cannot be empty."}; - if (in_place) { + if (opt.in_place) { if (opt.path_out) return {st::bad_arguments, "Cannot combine --in-place and --output."}; - if (opt.path_in == "-") - return {st::bad_arguments, "Cannot modify standard input in place."}; - opt.path_out = opt.path_in; opt.overwrite = true; + for (int i = optind; i < argc; i++) { + if (strcmp(argv[i], "-") == 0) + return {st::bad_arguments, "Cannot modify standard input in place."}; + opt.paths_in.emplace_back(argv[i]); + } + } else { + if (optind != argc - 1) + return {st::bad_arguments, "Exactly one input file must be specified."}; + if (set_all && strcmp(argv[optind], "-") == 0) + return {st::bad_arguments, + "Cannot use standard input as input file when --set-all is specified."}; + opt.paths_in.emplace_back(argv[optind]); } - if (opt.path_in == "-" && set_all) - return {st::bad_arguments, - "Cannot use standard input as input file when --set-all is specified."}; if (set_all) { // Read comments from stdin and prepend them to opt.to_add. std::vector comments; @@ -319,23 +320,18 @@ static ot::status process(ot::ogg_reader& reader, ot::ogg_writer* writer, const return ot::st::ok; } -ot::status ot::run(const ot::options& opt) +static ot::status run_single(const ot::options& opt, const std::string& path_in, const std::optional& path_out) { - if (opt.print_help) { - fputs(help_message, stdout); - return st::ok; - } - ot::file input; - if (opt.path_in == "-") + if (path_in == "-") input = stdin; - else if ((input = fopen(opt.path_in.c_str(), "r")) == nullptr) + else if ((input = fopen(path_in.c_str(), "r")) == nullptr) return {ot::st::standard_error, - "Could not open '" + opt.path_in + "' for reading: " + strerror(errno)}; + "Could not open '" + path_in + "' for reading: " + strerror(errno)}; ot::ogg_reader reader(input.get()); /* Read-only mode. */ - if (!opt.path_out) + if (!path_out) return process(reader, nullptr, opt); /* Read-write mode. @@ -363,30 +359,30 @@ ot::status ot::run(const ot::options& opt) ot::status rc = ot::st::ok; struct stat output_info; - if (opt.path_out == "-") { + if (path_out == "-") { output = stdout; - } else if (stat(opt.path_out->c_str(), &output_info) == 0) { + } else if (stat(path_out->c_str(), &output_info) == 0) { /* The output file exists. */ if (!S_ISREG(output_info.st_mode)) { /* Special files are opened for writing directly. */ - if ((final_output = fopen(opt.path_out->c_str(), "w")) == nullptr) + if ((final_output = fopen(path_out->c_str(), "w")) == nullptr) rc = {ot::st::standard_error, - "Could not open '" + opt.path_out.value() + "' for writing: " + + "Could not open '" + path_out.value() + "' for writing: " + strerror(errno)}; output = final_output.get(); } else if (opt.overwrite) { - rc = temporary_output.open(opt.path_out->c_str()); + rc = temporary_output.open(path_out->c_str()); output = temporary_output.get(); } else { rc = {ot::st::error, - "'" + opt.path_out.value() + "' already exists. Use -y to overwrite."}; + "'" + path_out.value() + "' already exists. Use -y to overwrite."}; } } else if (errno == ENOENT) { - rc = temporary_output.open(opt.path_out->c_str()); + rc = temporary_output.open(path_out->c_str()); output = temporary_output.get(); } else { rc = {ot::st::error, - "Could not identify '" + opt.path_in + "': " + strerror(errno)}; + "Could not identify '" + path_in + "': " + strerror(errno)}; } if (rc != ot::st::ok) return rc; @@ -398,3 +394,22 @@ ot::status ot::run(const ot::options& opt) return rc; } + +ot::status ot::run(const ot::options& opt) +{ + if (opt.print_help) { + fputs(help_message, stdout); + return st::ok; + } + + ot::status global_rc = st::ok; + for (const auto& path_in : opt.paths_in) { + ot::status rc = run_single(opt, path_in, opt.in_place ? path_in : opt.path_out); + if (rc != st::ok) { + global_rc = st::error; + if (!rc.message.empty()) + fprintf(stderr, "%s: error: %s\n", path_in.c_str(), rc.message.c_str()); + } + } + return global_rc; +} diff --git a/src/opustags.cc b/src/opustags.cc index 7837b29..024692a 100644 --- a/src/opustags.cc +++ b/src/opustags.cc @@ -20,12 +20,8 @@ int main(int argc, char** argv) { ot::status rc = ot::parse_options(argc, argv, opt, stdin); if (rc == ot::st::ok) rc = ot::run(opt); + else if (!rc.message.empty()) + fprintf(stderr, "error: %s\n", rc.message.c_str()); - if (rc != ot::st::ok) { - if (!rc.message.empty()) - fprintf(stderr, "error: %s\n", rc.message.c_str()); - return EXIT_FAILURE; - } else { - return EXIT_SUCCESS; - } + return rc == ot::st::ok ? EXIT_SUCCESS : EXIT_FAILURE; } diff --git a/src/opustags.h b/src/opustags.h index 2f865f6..438e604 100644 --- a/src/opustags.h +++ b/src/opustags.h @@ -374,14 +374,16 @@ struct options { */ bool print_help = false; /** - * Path to the input file. It cannot be empty. The special "-" string means stdin. + * Paths to the input files. The special string "-" means stdin. * - * This is the mandatory non-flagged parameter. + * At least one input file must be given. If `--in-place` is used, + * more than one may be given. */ - std::string path_in; + std::vector paths_in; /** - * Optional path to output file. The special "-" string means stdout. When absent, opustags - * runs in read-only mode. For in-place editing, path_out is defined equal to path_in. + * Optional path to output file. The special string "-" means stdout. For in-place + * editing, the input file name is used. If no output file name is supplied, and + * --in-place is not used, opustags runs in read-only mode. * * Options: --output, --in-place */ @@ -393,6 +395,12 @@ struct options { * Options: --overwrite, --in-place */ bool overwrite = false; + /** + * Process files in-place. + * + * Options: --in-place + */ + bool in_place = false; /** * List of comments to delete. Each string is a selector according to the definition of * #delete_comments. diff --git a/t/cli.cc b/t/cli.cc index 4746961..073709c 100644 --- a/t/cli.cc +++ b/t/cli.cc @@ -69,16 +69,22 @@ void check_good_arguments() throw failure("did not catch --help"); opt = parse({"opustags", "x", "--output", "y", "-D", "-s", "X=Y Z", "-d", "a=b"}); - if (opt.path_in != "x" || opt.path_out != "y" || !opt.delete_all || opt.overwrite || - opt.to_delete.size() != 2 || opt.to_delete[0] != "X" || opt.to_delete[1] != "a=b" || + if (opt.paths_in.size() != 1 || opt.paths_in.front() != "x" || !opt.path_out || + opt.path_out != "y" || !opt.delete_all || opt.overwrite || opt.to_delete.size() != 2 || + opt.to_delete[0] != "X" || opt.to_delete[1] != "a=b" || opt.to_add.size() != 1 || opt.to_add[0] != "X=Y Z") throw failure("unexpected option parsing result for case #1"); opt = parse({"opustags", "-S", "x", "-S", "-a", "x=y z", "-i"}); - if (opt.path_in != "x" || opt.path_out != "x" || !opt.overwrite || - opt.to_delete.size() != 0 || opt.to_add.size() != 2 || opt.to_add[0] != "N=1" || - opt.to_add[1] != "x=y z") + if (opt.paths_in.size() != 1 || opt.paths_in.front() != "x" || opt.path_out || + !opt.overwrite || opt.to_delete.size() != 0 || + opt.to_add.size() != 2 || opt.to_add[0] != "N=1" || opt.to_add[1] != "x=y z") throw failure("unexpected option parsing result for case #2"); + + opt = parse({"opustags", "-i", "x", "y", "z"}); + if (opt.paths_in.size() != 3 || opt.paths_in[0] != "x" || opt.paths_in[1] != "y" || + opt.paths_in[2] != "z" || !opt.overwrite || !opt.in_place) + throw failure("unexpected option parsing result for case #3"); } void check_bad_arguments() @@ -106,7 +112,6 @@ void check_bad_arguments() error_case({"opustags", "-x=y"}, "Unrecognized option '-x'.", "unrecognized short option with value"); error_case({"opustags", "--derp=y"}, "Unrecognized option '--derp=y'.", "unrecognized long option with value"); error_case({"opustags", "-aX=Y"}, "Exactly one input file must be specified.", "no input file"); - error_case({"opustags", ""}, "Input file path cannot be empty.", "empty input file path"); error_case({"opustags", "-i", "-o", "/dev/null", "-"}, "Cannot combine --in-place and --output.", "in-place + output"); error_case({"opustags", "-S", "-"}, "Cannot use standard input as input file when --set-all is specified.", "set all and read opus from stdin"); diff --git a/t/opustags.t b/t/opustags.t index 9170a96..c21a094 100755 --- a/t/opustags.t +++ b/t/opustags.t @@ -4,10 +4,11 @@ use strict; use warnings; use utf8; -use Test::More tests => 37; +use Test::More tests => 41; use Digest::MD5; use File::Basename; +use File::Copy; use IPC::Open3; use List::MoreUtils qw(any); use Symbol 'gensym'; @@ -57,12 +58,13 @@ $version Usage: opustags --help opustags [OPTIONS] FILE + opustags OPTIONS -i FILE... opustags OPTIONS FILE -o FILE Options: -h, --help print this help -o, --output FILE specify the output file - -i, --in-place overwrite the input file + -i, --in-place overwrite the input files -y, --overwrite overwrite the output file if it already exists -a, --add FIELD=VALUE add a comment -d, --delete FIELD[=VALUE] delete previously existing comments @@ -81,7 +83,7 @@ error: Unrecognized option '--derp'. EOF is_deeply(opustags('../opustags'), ['', <<"EOF", 256], 'not an Ogg stream'); -error: Input is not a valid Ogg file. +../opustags: error: Input is not a valid Ogg file. EOF #################################################################################################### @@ -110,7 +112,7 @@ umask($previous_umask); # empty out.opus { my $fh; open($fh, '>', 'out.opus') and close($fh) or die } is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', <<'EOF', 256], 'refuse to override'); -error: 'out.opus' already exists. Use -y to overwrite. +gobble.opus: error: 'out.opus' already exists. Use -y to overwrite. EOF is(md5('out.opus'), 'd41d8cd98f00b204e9800998ecf8427e', 'the output wasn\'t written'); @@ -208,6 +210,17 @@ is_deeply(opustags('-', '-o', '-', {in => $data, mode => ':raw'}), [$data, '', 0 unlink('out.opus'); +# Test --in-place +unlink('out2.opus'); +copy('gobble.opus', 'out.opus'); +is_deeply(opustags(qw(out.opus --add BAR=baz -o out2.opus)), ['', '', 0], 'process multiple files with --in-place'); +is_deeply(opustags(qw(--in-place --add FOO=bar out.opus out2.opus)), ['', '', 0], 'process multiple files with --in-place'); +is(md5('out.opus'), '30ba30c4f236c09429473f36f8f861d2', 'the tags were added correctly (out.opus)'); +is(md5('out2.opus'), '0a4d20c287b2e46b26cb0eee353c2069', 'the tags were added correctly (out2.opus)'); + +unlink('out.opus'); +unlink('out2.opus'); + #################################################################################################### # Test muxed streams @@ -215,7 +228,7 @@ system('ffmpeg -loglevel error -y -i gobble.opus -c copy -map 0:0 -map 0:0 -shor or BAIL_OUT('could not create a muxed stream'); is_deeply(opustags('muxed.ogg'), ['', <<'END_ERR', 256], 'muxed streams detection'); -error: Muxed streams are not supported yet. +muxed.ogg: error: Muxed streams are not supported yet. END_ERR unlink('muxed.ogg');