1 /**
2 * \file src/cli.cc
3 * \ingroup cli
4 *
5 * Provide all the features of the opustags executable from a C++ API. The main point of separating
6 * this module from the main one is to allow easy testing.
7 */
8
9 #include <opustags.h>
10
11 #include <errno.h>
12 #include <getopt.h>
13 #include <limits.h>
14 #include <stdlib.h>
15 #include <string.h>
16 #include <sys/stat.h>
17 #include <unistd.h>
18
19 using namespace std::literals::string_literals;
20
21 static const char help_message[] =
22 PROJECT_NAME " version " PROJECT_VERSION
23 R"raw(
24
25 Usage: opustags --help
26 opustags [OPTIONS] FILE
27 opustags OPTIONS -i FILE...
28 opustags OPTIONS FILE -o FILE
29
30 Options:
31 -h, --help print this help
32 -o, --output FILE specify the output file
33 -i, --in-place overwrite the input files
34 -y, --overwrite overwrite the output file if it already exists
35 -a, --add FIELD=VALUE add a comment
36 -d, --delete FIELD[=VALUE] delete previously existing comments
37 -D, --delete-all delete all the previously existing comments
38 -s, --set FIELD=VALUE replace a comment
39 -S, --set-all import comments from standard input
40 -e, --edit edit tags interactively in VISUAL/EDITOR
41 --raw disable encoding conversion
42
43 See the man page for extensive documentation.
44 )raw";
45
46 static struct option getopt_options[] = {
47 {"help", no_argument, 0, 'h'},
48 {"output", required_argument, 0, 'o'},
49 {"in-place", optional_argument, 0, 'i'},
50 {"overwrite", no_argument, 0, 'y'},
51 {"delete", required_argument, 0, 'd'},
52 {"add", required_argument, 0, 'a'},
53 {"set", required_argument, 0, 's'},
54 {"delete-all", no_argument, 0, 'D'},
55 {"set-all", no_argument, 0, 'S'},
56 {"edit", no_argument, 0, 'e'},
57 {"raw", no_argument, 0, 'r'},
58 {NULL, 0, 0, 0}
59 };
60
parse_options(int argc,char ** argv,ot::options & opt,FILE * comments_input)61 ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comments_input)
62 {
63 static ot::encoding_converter to_utf8("", "UTF-8");
64 std::string utf8;
65 const char* equal;
66 ot::status rc;
67 bool set_all = false;
68 opt = {};
69 if (argc == 1)
70 return {st::bad_arguments, "No arguments specified. Use -h for help."};
71 int c;
72 optind = 0;
73 while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSe", getopt_options, NULL)) != -1) {
74 switch (c) {
75 case 'h':
76 opt.print_help = true;
77 break;
78 case 'o':
79 if (opt.path_out)
80 return {st::bad_arguments, "Cannot specify --output more than once."};
81 opt.path_out = optarg;
82 break;
83 case 'i':
84 opt.in_place = true;
85 opt.overwrite = true;
86 break;
87 case 'y':
88 opt.overwrite = true;
89 break;
90 case 'd':
91 opt.to_delete.emplace_back(optarg);
92 break;
93 case 'a':
94 case 's':
95 equal = strchr(optarg, '=');
96 if (equal == nullptr)
97 return {st::bad_arguments, "Comment does not contain an equal sign: "s + optarg + "."};
98 if (c == 's')
99 opt.to_delete.emplace_back(optarg, equal - optarg);
100 opt.to_add.emplace_back(optarg);
101 break;
102 case 'S':
103 opt.delete_all = true;
104 set_all = true;
105 break;
106 case 'D':
107 opt.delete_all = true;
108 break;
109 case 'e':
110 opt.edit_interactively = true;
111 break;
112 case 'r':
113 opt.raw = true;
114 break;
115 case ':':
116 return {st::bad_arguments,
117 "Missing value for option '"s + argv[optind - 1] + "'."};
118 default:
119 return {st::bad_arguments, "Unrecognized option '" +
120 (optopt ? "-"s + static_cast<char>(optopt) : argv[optind - 1]) + "'."};
121 }
122 }
123 if (opt.print_help)
124 return st::ok;
125
126 // All non-option arguments are input files.
127 bool stdin_as_input = false;
128 for (int i = optind; i < argc; i++) {
129 stdin_as_input = stdin_as_input || strcmp(argv[i], "-") == 0;
130 opt.paths_in.emplace_back(argv[i]);
131 }
132
133 // Convert arguments to UTF-8.
134 if (!opt.raw) {
135 for (std::list<std::string>* args : { &opt.to_add, &opt.to_delete }) {
136 for (std::string& arg : *args) {
137 rc = to_utf8(arg, utf8);
138 if (rc != ot::st::ok)
139 return {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
140 arg = std::move(utf8);
141 }
142 }
143 }
144
145 if (opt.in_place && opt.path_out)
146 return {st::bad_arguments, "Cannot combine --in-place and --output."};
147
148 if (opt.in_place && stdin_as_input)
149 return {st::bad_arguments, "Cannot modify standard input in place."};
150
151 if ((!opt.in_place || opt.edit_interactively) && opt.paths_in.size() != 1)
152 return {st::bad_arguments, "Exactly one input file must be specified."};
153
154 if (set_all && stdin_as_input)
155 return {st::bad_arguments, "Cannot use standard input as input file when --set-all is specified."};
156
157 if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-"))
158 return {st::bad_arguments, "Cannot edit interactively when standard input or standard output are already used."};
159
160 if (opt.edit_interactively && !opt.path_out.has_value() && !opt.in_place)
161 return {st::bad_arguments, "Cannot edit interactively when no output is specified."};
162
163 if (opt.edit_interactively && (opt.delete_all || !opt.to_add.empty() || !opt.to_delete.empty()))
164 return {st::bad_arguments, "Cannot mix --edit with -adDsS."};
165
166 if (set_all) {
167 // Read comments from stdin and prepend them to opt.to_add.
168 std::list<std::string> comments;
169 auto rc = read_comments(comments_input, comments, opt.raw);
170 if (rc != st::ok)
171 return rc;
172 opt.to_add.splice(opt.to_add.begin(), std::move(comments));
173 }
174 return st::ok;
175 }
176
177 /**
178 * \todo Find a way to support new lines such that they can be read back by #read_comment without
179 * ambiguity. We could add a raw mode and separate comments with a \0, or escape control
180 * characters with a backslash, but we should also preserve compatibiltity with potential
181 * callers that don’t escape backslashes. Maybe add options to select a mode between simple,
182 * raw, and escaped.
183 */
print_comments(const std::list<std::string> & comments,FILE * output,bool raw)184 ot::status ot::print_comments(const std::list<std::string>& comments, FILE* output, bool raw)
185 {
186 static ot::encoding_converter from_utf8("UTF-8", "");
187 std::string local;
188 bool has_newline = false;
189 bool has_control = false;
190 for (const std::string& utf8_comment : comments) {
191 const std::string* comment;
192 // Convert the comment from UTF-8 to the system encoding if relevant.
193 if (raw) {
194 comment = &utf8_comment;
195 } else {
196 ot::status rc = from_utf8(utf8_comment, local);
197 comment = &local;
198 if (rc != ot::st::ok) {
199 rc.message += " See --raw.";
200 return rc;
201 }
202 }
203
204 for (unsigned char c : *comment) {
205 if (c == '\n')
206 has_newline = true;
207 else if (c < 0x20)
208 has_control = true;
209 }
210 fwrite(comment->data(), 1, comment->size(), output);
211 putc('\n', output);
212 }
213 if (has_newline)
214 fputs("warning: Some tags contain unsupported newline characters.\n", stderr);
215 if (has_control)
216 fputs("warning: Some tags contain control characters.\n", stderr);
217 return st::ok;
218 }
219
read_comments(FILE * input,std::list<std::string> & comments,bool raw)220 ot::status ot::read_comments(FILE* input, std::list<std::string>& comments, bool raw)
221 {
222 static ot::encoding_converter to_utf8("", "UTF-8");
223 comments.clear();
224 char* line = nullptr;
225 size_t buflen = 0;
226 ssize_t nread;
227 while ((nread = getline(&line, &buflen, input)) != -1) {
228 if (nread > 0 && line[nread - 1] == '\n')
229 --nread;
230 if (nread == 0)
231 continue;
232 if (line[0] == '#') // comment
233 continue;
234 if (memchr(line, '=', nread) == nullptr) {
235 ot::status rc = {ot::st::error, "Malformed tag: " + std::string(line, nread)};
236 free(line);
237 return rc;
238 }
239 if (raw) {
240 comments.emplace_back(line, nread);
241 } else {
242 std::string utf8;
243 ot::status rc = to_utf8(std::string_view(line, nread), utf8);
244 if (rc == ot::st::ok) {
245 comments.emplace_back(std::move(utf8));
246 } else {
247 free(line);
248 return {ot::st::badly_encoded, "UTF-8 conversion error: " + rc.message};
249 }
250 }
251 }
252 free(line);
253 return ot::st::ok;
254 }
255
delete_comments(std::list<std::string> & comments,const std::string & selector)256 void ot::delete_comments(std::list<std::string>& comments, const std::string& selector)
257 {
258 auto name = selector.data();
259 auto equal = selector.find('=');
260 auto value = (equal == std::string::npos ? nullptr : name + equal + 1);
261 auto name_len = value ? equal : selector.size();
262 auto value_len = value ? selector.size() - equal - 1 : 0;
263 auto it = comments.begin(), end = comments.end();
264 while (it != end) {
265 auto current = it++;
266 bool name_match = current->size() > name_len + 1 &&
267 (*current)[name_len] == '=' &&
268 strncasecmp(current->data(), name, name_len) == 0;
269 if (!name_match)
270 continue;
271 bool value_match = value == nullptr ||
272 (current->size() == selector.size() &&
273 memcmp(current->data() + equal + 1, value, value_len) == 0);
274 if (value_match)
275 comments.erase(current);
276 }
277 }
278
279 /** Apply the modifications requested by the user to the opustags packet. */
edit_tags(ot::opus_tags & tags,const ot::options & opt)280 static ot::status edit_tags(ot::opus_tags& tags, const ot::options& opt)
281 {
282 if (opt.delete_all) {
283 tags.comments.clear();
284 } else for (const std::string& name : opt.to_delete) {
285 ot::delete_comments(tags.comments, name.c_str());
286 }
287
288 for (const std::string& comment : opt.to_add)
289 tags.comments.emplace_back(comment);
290
291 return ot::st::ok;
292 }
293
294 /** Spawn VISUAL or EDITOR to edit the given tags. */
edit_tags_interactively(ot::opus_tags & tags,const std::optional<std::string> & base_path,bool raw)295 static ot::status edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, bool raw)
296 {
297 const char* editor = nullptr;
298 if (getenv("TERM") != nullptr)
299 editor = getenv("VISUAL");
300 if (editor == nullptr) // without a terminal, or if VISUAL is unset
301 editor = getenv("EDITOR");
302 if (editor == nullptr)
303 return {ot::st::error,
304 "No editor specified in environment variable VISUAL or EDITOR."};
305
306 // Building the temporary tags file.
307 ot::status rc;
308 std::string tags_path = base_path.value_or("tags") + ".XXXXXX.opustags";
309 int fd = mkstemps(const_cast<char*>(tags_path.data()), 9);
310 ot::file tags_file;
311 if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
312 return {ot::st::standard_error,
313 "Could not open '" + tags_path + "': " + strerror(errno)};
314 if ((rc = ot::print_comments(tags.comments, tags_file.get(), raw)) != ot::st::ok)
315 return rc;
316 tags_file.reset();
317
318 // Spawn the editor, and watch the modification timestamps.
319 timespec before, after;
320 if ((rc = ot::get_file_timestamp(tags_path.c_str(), before)) != ot::st::ok)
321 return rc;
322 ot::status editor_rc = ot::run_editor(editor, tags_path);
323 if ((rc = ot::get_file_timestamp(tags_path.c_str(), after)) != ot::st::ok)
324 return rc; // probably because the file was deleted
325 bool modified = (before.tv_sec != after.tv_sec || before.tv_nsec != after.tv_nsec);
326 if (editor_rc != ot::st::ok) {
327 if (modified)
328 fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
329 else
330 remove(tags_path.c_str());
331 return editor_rc;
332 } else if (!modified) {
333 remove(tags_path.c_str());
334 fputs("Cancelling edition because the tags file was not modified.\n", stderr);
335 return ot::st::cancel;
336 }
337
338 // Applying the new tags.
339 tags_file = fopen(tags_path.c_str(), "re");
340 if (tags_file == nullptr)
341 return {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
342 if ((rc = ot::read_comments(tags_file.get(), tags.comments, raw)) != ot::st::ok) {
343 fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
344 return rc;
345 }
346 tags_file.reset();
347
348 // Remove the temporary tags file only on success, because unlike the
349 // partial Ogg file that is irrecoverable, the edited tags file
350 // contains user data, so let’s leave users a chance to recover it.
351 remove(tags_path.c_str());
352
353 return ot::st::ok;
354 }
355
356 /**
357 * Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
358 * Transform the OpusTags packet on the fly.
359 *
360 * The writer is optional. When writer is nullptr, opustags runs in read-only mode.
361 */
process(ot::ogg_reader & reader,ot::ogg_writer * writer,const ot::options & opt)362 static ot::status process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::options &opt)
363 {
364 bool focused = false; /*< the stream on which we operate is defined */
365 int focused_serialno; /*< when focused, the serialno of the focused stream */
366 int absolute_page_no = -1; /*< page number in the physical stream, not logical */
367 for (;;) {
368 ot::status rc = reader.next_page();
369 if (rc == ot::st::end_of_stream)
370 break;
371 else if (rc == ot::st::bad_stream && absolute_page_no == -1)
372 return {ot::st::bad_stream, "Input is not a valid Ogg file."};
373 else if (rc != ot::st::ok)
374 return rc;
375 ++absolute_page_no;
376 auto serialno = ogg_page_serialno(&reader.page);
377 auto pageno = ogg_page_pageno(&reader.page);
378 if (!focused) {
379 focused = true;
380 focused_serialno = serialno;
381 } else if (serialno != focused_serialno) {
382 /** \todo Support mixed streams. */
383 return {ot::st::error, "Muxed streams are not supported yet."};
384 }
385 if (absolute_page_no == 0) { // Identification header
386 if (!ot::is_opus_stream(reader.page))
387 return {ot::st::error, "Not an Opus stream."};
388 if (writer) {
389 rc = writer->write_page(reader.page);
390 if (rc != ot::st::ok)
391 return rc;
392 }
393 } else if (absolute_page_no == 1) { // Comment header
394 ot::opus_tags tags;
395 rc = reader.process_header_packet(
396 [&tags](ogg_packet& p) { return ot::parse_tags(p, tags); });
397 if (rc != ot::st::ok)
398 return rc;
399 if ((rc = edit_tags(tags, opt)) != ot::st::ok)
400 return rc;
401 if (writer) {
402 if (opt.edit_interactively) {
403 fflush(writer->file); // flush before calling the subprocess
404 if ((rc = edit_tags_interactively(tags, writer->path, opt.raw)) != ot::st::ok)
405 return rc;
406 }
407 auto packet = ot::render_tags(tags);
408 rc = writer->write_header_packet(serialno, pageno, packet);
409 if (rc != ot::st::ok)
410 return rc;
411 } else {
412 if ((rc = ot::print_comments(tags.comments, stdout, opt.raw)) != ot::st::ok)
413 return rc;
414 break;
415 }
416 } else {
417 if (writer && (rc = writer->write_page(reader.page)) != ot::st::ok)
418 return rc;
419 }
420 }
421 if (absolute_page_no < 1)
422 return {ot::st::error, "Expected at least 2 Ogg pages."};
423 return ot::st::ok;
424 }
425
run_single(const ot::options & opt,const std::string & path_in,const std::optional<std::string> & path_out)426 static ot::status run_single(const ot::options& opt, const std::string& path_in, const std::optional<std::string>& path_out)
427 {
428 ot::file input;
429 if (path_in == "-")
430 input = stdin;
431 else if ((input = fopen(path_in.c_str(), "re")) == nullptr)
432 return {ot::st::standard_error,
433 "Could not open '" + path_in + "' for reading: " + strerror(errno)};
434 ot::ogg_reader reader(input.get());
435
436 /* Read-only mode. */
437 if (!path_out)
438 return process(reader, nullptr, opt);
439
440 /* Read-write mode.
441 *
442 * The output pointer is set to one of:
443 * - stdout for "-",
444 * - final_output.get() for special files like /dev/null,
445 * - temporary_output.get() for regular files.
446 *
447 * We use a temporary output file for the following reasons:
448 * 1. A partial .opus output would be seen by softwares like media players, but a .part
449 * (for partial) won’t.
450 * 2. If the process crashes badly, or the power cuts off, we don't want to leave a partial
451 * file at the final location. The temporary file is going to remain though.
452 * 3. If we're overwriting a regular file, we'd rather avoid wiping its content before we
453 * even started reading the input file. That way, the original file is always preserved
454 * on error or crash.
455 * 4. It is necessary for in-place editing. We can't reliably open the same file as both
456 * input and output.
457 */
458
459 FILE* output = nullptr;
460 ot::partial_file temporary_output;
461 ot::file final_output;
462
463 ot::status rc = ot::st::ok;
464 struct stat output_info;
465 if (path_out == "-") {
466 output = stdout;
467 } else if (stat(path_out->c_str(), &output_info) == 0) {
468 /* The output file exists. */
469 if (!S_ISREG(output_info.st_mode)) {
470 /* Special files are opened for writing directly. */
471 if ((final_output = fopen(path_out->c_str(), "we")) == nullptr)
472 rc = {ot::st::standard_error,
473 "Could not open '" + path_out.value() + "' for writing: " +
474 strerror(errno)};
475 output = final_output.get();
476 } else if (opt.overwrite) {
477 rc = temporary_output.open(path_out->c_str());
478 output = temporary_output.get();
479 } else {
480 rc = {ot::st::error,
481 "'" + path_out.value() + "' already exists. Use -y to overwrite."};
482 }
483 } else if (errno == ENOENT) {
484 rc = temporary_output.open(path_out->c_str());
485 output = temporary_output.get();
486 } else {
487 rc = {ot::st::error,
488 "Could not identify '" + path_in + "': " + strerror(errno)};
489 }
490 if (rc != ot::st::ok)
491 return rc;
492
493 ot::ogg_writer writer(output);
494 writer.path = path_out;
495 rc = process(reader, &writer, opt);
496 if (rc == ot::st::ok)
497 rc = temporary_output.commit();
498
499 return rc;
500 }
501
run(const ot::options & opt)502 ot::status ot::run(const ot::options& opt)
503 {
504 if (opt.print_help) {
505 fputs(help_message, stdout);
506 return st::ok;
507 }
508
509 ot::status global_rc = st::ok;
510 for (const auto& path_in : opt.paths_in) {
511 ot::status rc = run_single(opt, path_in, opt.in_place ? path_in : opt.path_out);
512 if (rc != st::ok) {
513 global_rc = st::error;
514 if (!rc.message.empty())
515 fprintf(stderr, "%s: error: %s\n", path_in.c_str(), rc.message.c_str());
516 }
517 }
518 return global_rc;
519 }
520