209 Commits
1.1 ... 1.6.0

Author SHA1 Message Date
2d5db09bda Release 1.6.0 2021-01-01 11:41:03 +01:00
3e0b3fa56e Make encoding errors fatal
With --raw there is a workaround.

The tolerant approach was cool and nice until you want to edit something
non-interactively and get the warning telling you you might have lost
data after the file was written. Failing fast is most likely the better
option here.
2020-12-27 10:55:25 +01:00
3e7b42062a Discard incompatible comments entirely
//IGNORE is not portable either. Now that we have --raw it’s less an
issue though.
2020-12-27 10:55:20 +01:00
4cae6c44ee Introduce --raw for disabling transcoding 2020-12-26 16:51:36 +01:00
6db7f07bd5 Factor CLI argument transcoding 2020-12-26 13:00:20 +01:00
fd5fa3cd5f Make ot::encoding_converter use string views 2020-12-26 12:42:37 +01:00
c43704a0a7 Use //IGNORE instead of //TRANSLIT when transcoding
//TRANSLIT is not a well supported, and in most cases there’s not much
transliteration can help with when the encoding is limiting. Besides,
it sounds reasonable to assume most people use UTF-8 nowadays.
2020-12-26 12:30:44 +01:00
f98208c1a1 Support the various stat structures across systems 2020-11-25 20:07:23 +01:00
64fc6f8f6d Include config.h globally 2020-11-25 20:05:46 +01:00
1d03da324c Release 1.5.1 2020-11-21 11:05:56 +01:00
30b7f44ead Include endian.h or sys/endian.h depending on the platform 2020-11-14 20:27:08 +01:00
b8c8be453f Include headers for mkstemps
Linux requires <stdlib.h>, but FreeBSD requires <unistd.h>.
2020-11-14 18:18:42 +01:00
4a1b8705cc Release 1.5.0 2020-11-08 10:32:46 +01:00
7c8396ca45 run_editor: Pass the editor command through the shell
wordexp doesn’t work on OpenBSD, and escaping the path ourselves then
calling system() is actually easier than using wordexp.
2020-11-01 11:57:48 +01:00
639d46ed0f Introduce ot::shell_escape 2020-11-01 10:41:24 +01:00
d54bada7e6 Open handles with O_CLOEXEC
opustags’s only use of a sub-process is for spawning the EDITOR, and we
don’t want it to access our file handles.
2020-10-31 18:44:46 +01:00
57a4c0d5a0 Flush the writer before exec’ing
In the unlikely event the child process fails without exec’ing, we don’t
want both the child process and parent process to flush the OpusHead
header.

Thanks @omar-polo for reporting this!
2020-10-31 18:44:46 +01:00
d071b6cabd Fix error reporting when EDITOR fails 2020-10-31 18:10:33 +01:00
d8c36a3d3f Forbid mixing --edit with non-interactive edition options 2020-10-31 12:15:01 +01:00
ba2236facb Cancel --edit when the editor closes without saving 2020-10-31 12:11:26 +01:00
b3b092d241 Expand EDITOR/VISUAL with wordexp 2020-10-25 11:09:18 +01:00
8f0f29c056 Support VISUAL with --edit 2020-10-24 12:00:43 +02:00
e4ca6ca6ef Introduce the --edit option 2020-10-12 07:55:27 +02:00
df03cdf951 Introduce ot::execute_process 2020-10-11 18:06:40 +02:00
8252f94084 --set-all: Ignore comments starting with # 2020-10-11 18:06:39 +02:00
a1dcc8c47e Fix print_comments when output is not stdout 2020-10-11 17:43:04 +02:00
7206604f85 Make read_comments work on std::list
For consistency with ot::opus_tags.
2020-10-11 17:43:04 +02:00
6da5545b30 Flatten option compatibility checking
The more options we have the more nested it gets. It was getting
complicated.
2020-10-11 17:40:52 +02:00
537094fd53 use CMake’s FindIconv to detect iconv portably 2020-10-10 15:20:19 +02:00
be9740fe05 Explicitely include <optional>
It should have been included since we use std::optional, and not
including it breaks the build on OpenBSD.
2020-10-10 15:10:59 +02:00
a22c81e727 release 1.4.0 2020-10-04 09:34:13 +02:00
9715f0242f Define _GNU_SOURCE for BSD compability 2020-09-27 13:57:44 +02:00
b369aea8d4 Fix signedness warnings in t/cli.cc 2020-09-26 13:13:15 +02:00
84e238a4a9 Add support for multiple input files with --in-place
Co-authored-by: Frédéric Mangano-Tarumi <fmang@mg0.fr>
2020-09-26 13:12:15 +02:00
73a54d7ab7 Don't treat empty output filename specially (fix #27)
Instead, make opt.path_out a std::optional<std::string>.
2020-09-20 16:32:27 +02:00
ef15e7ad13 With --set-all, read comments from stdin before processing tags (#29)
With --set-all, read comments from stdin before processing tags
2020-09-19 11:02:43 +02:00
5ea2db2d6d upgrade to C++17 2020-08-31 21:25:03 +02:00
6f7ac1f13b review the code comments
In particular, delete the obsolete TODOs.
2020-08-24 21:51:23 +02:00
ea4d74d844 proper permissions setting on output files 2020-08-23 17:51:45 +02:00
4de428bf33 release 1.3.0 2019-02-02 16:58:09 -05:00
c774c86286 rename liblibopustags to libot
It follows the name of the C++ namespace, and avoids confusion with the
opustags executable.
2019-01-26 17:07:53 -05:00
51f635d6bf remove the docker thing
It's gonna stay in git but I don't plan to maintain it for now.
2019-01-26 17:05:51 -05:00
da8f8a343b Merge pull request #24 from akx/macos-compat
macOS compatibility (sort of)
2019-01-26 17:04:29 -05:00
8ba3db8bbd t: safer argument casting for getopt 2019-01-12 16:09:18 -05:00
87bdd6fe22 t: cannot rely on iconv's //TRANSLIT
It's really system-dependant. As long as it doesn't break the regular
conversion it's fine. Managing transliteration is a nice to have but we
cannot expect it would work everywhere.

On systems that don't support it, iconv will trigger an EILSEQ.
2019-01-12 15:46:04 -05:00
a9dd07ae1e Tweak CMakeLists.txt to build on macOS 2019-01-09 14:19:41 +02:00
40defdf2e1 Add headers required on macOS 2019-01-09 14:19:41 +02:00
48336b5367 Change libopustags to STATIC, not OBJECT:
> CMake Error at CMakeLists.txt:27 (target_link_libraries):
>   Object library target "libopustags" may not link to anything.
2019-01-09 14:19:41 +02:00
4d44550d3d Add Dockerfile for testing the build 2019-01-09 14:19:41 +02:00
8d287a8070 fix a memory leak in ot::read_comments 2019-01-08 20:57:55 -05:00
d09d7bd634 t: only run opustags.t in UTF-8 environments 2018-12-19 20:32:55 -05:00
191796a3d2 t: skip locale test when fr_FR.iso88591 is missing 2018-12-19 19:56:32 -05:00
cacbd43422 t: modernize system.t 2018-12-18 20:25:28 -05:00
2dbba5a23e t: extend the tap module 2018-12-18 20:25:26 -05:00
19c1a8361d update CONTRIBUTING 2018-12-17 21:13:16 -05:00
4036ce1f39 t: print errors on stderr
That way, they're shown by the prove command.
That's what the Perl test suite does too.
2018-12-17 20:58:39 -05:00
28ecbecdf0 prepare 1.3.0 2018-12-17 20:50:50 -05:00
06fff8cbeb support --delete NAME=VALUE 2018-12-17 20:00:27 -05:00
e2a1c06005 case-insensitive field name for comment deletion 2018-12-16 18:56:18 -05:00
a9adc11cad t: delete_comments 2018-12-16 18:51:28 -05:00
f872f71411 move delete_comments into cli 2018-12-16 18:44:08 -05:00
6797e59417 reduce match_field into delete_comments 2018-12-16 18:41:20 -05:00
7df8c5c426 --set: add only the field name to to_delete 2018-12-16 18:33:08 -05:00
e26f3f268c error when --set-all's parsing fails 2018-12-16 12:50:18 -05:00
46cd25f744 warn about newlines and control characters 2018-12-16 12:36:37 -05:00
70e9b576cf review the doc, for utf-8 in particular 2018-12-09 14:05:50 -05:00
102f683869 t: encoding conversion 2018-12-09 12:59:20 -05:00
e471c82605 convert command-line arguments to UTF-8 too 2018-12-09 12:33:48 -05:00
cc3bb6397d convert tags to and from the user locale 2018-12-09 12:18:17 -05:00
bb548f51d3 encoding_converter: overload for C strings 2018-12-09 12:17:10 -05:00
ebc8347c9e character encoding converter 2018-12-09 11:45:00 -05:00
ca06c6fb9d detect muxed streams 2018-12-08 12:55:58 -05:00
42845e4867 cli: don't increment the absolute page number on error 2018-12-08 12:27:40 -05:00
b2826bf0cc raise error on unsynced data 2018-12-08 12:20:00 -05:00
33ef7ee153 better error messages for multi-page headers 2018-12-08 11:42:10 -05:00
ccc8417413 rename the methods of ogg_reader
read_page → next_page, because it's more consistent with iterators.

read_header_packet → process_header_packet, because it doesn't actually
*read* anything.
2018-12-08 11:36:10 -05:00
d9dfc29b7d drop ot::validate_identification_header
No more need to extract the header packet.
2018-12-08 11:28:16 -05:00
23049a7ff6 introduce ot::is_opus_stream 2018-12-08 11:24:17 -05:00
f080f9da70 ogg_stream → ogg_logical_stream 2018-12-08 10:59:07 -05:00
4e3ee61ca3 reject continued header pages 2018-12-05 20:11:03 -05:00
c01045172c check for partial packets in header page 2018-12-05 19:21:48 -05:00
7e6d9eae39 reduce read_packet into read_header_packet 2018-12-05 18:42:58 -05:00
14ae681e61 get rid of ogg_writer::prepare_stream 2018-12-05 18:03:53 -05:00
7e575ffbc3 reduce write_packet and flush_page into write_header_packet 2018-12-05 17:37:59 -05:00
1ff5284b60 process the streams by page instead of packets 2018-12-03 20:07:00 -05:00
6da1a8703d create the oggdump tool 2018-12-03 18:43:02 -05:00
71c9dd7209 reduce process_tags into a simpler function
It had too many responsibilities.
2018-12-03 18:22:33 -05:00
fcfb4a2a1d fatal errors are not special 2018-12-03 18:13:51 -05:00
1d6ca8fc59 write the output to a temporary file 2018-12-02 16:20:40 -05:00
a74ea34352 introduce partial files 2018-12-02 12:12:58 -05:00
289391a9df more robust tests for input/output equality 2018-12-02 10:45:36 -05:00
5860902084 isolate the process function to the cli module
Its interface is not good enough to be exposed.
2018-12-02 10:10:40 -05:00
614bd6379b inplace -> in_place 2018-12-01 17:39:27 -05:00
1e69e89ff9 t: check a few cases of successful option parsing 2018-12-01 17:36:58 -05:00
7189d63c20 check for duplicate options 2018-12-01 17:23:38 -05:00
d67ce423d1 parse_options: return the error message in the status 2018-12-01 13:26:22 -05:00
6f290702a8 catch getopt's errors 2018-12-01 13:03:44 -05:00
067c9240c3 proces_options -> parse_options
The function is not supposed to have side effects anymore.
2018-12-01 12:02:19 -05:00
90bcf0bd71 process_options: don't deduce path_out from inplace 2018-12-01 11:51:00 -05:00
b60183c0ca calling opustags without arguments is now an error
Get rid of the exit_now status and simplify the help display code.
2018-12-01 11:36:03 -05:00
2b92ee0ce1 finalize 1.2.0 2018-11-25 12:13:30 -05:00
c4acca18d8 review the --help message 2018-11-24 20:02:24 -05:00
b7e133d6ba add exmaple to the man page 2018-11-24 12:05:55 -05:00
5b5b67a0df clean-up the includes 2018-11-24 11:56:43 -05:00
80a4b2ccf6 rewrite ot::read_comments with getline 2018-11-24 11:44:15 -05:00
d1299360de smart ot::file handle 2018-11-24 11:33:04 -05:00
bfa46273b9 fix ot::read_comments when handling empty lines 2018-11-24 11:33:04 -05:00
26411d3843 t: test ot::read_comments 2018-11-24 11:33:04 -05:00
af61b01448 substitute the @-markers in the man page 2018-11-21 21:41:55 -05:00
a043e74e14 review the user doc 2018-11-21 21:40:08 -05:00
20dc8d0fa2 add a changelog 2018-11-21 21:02:42 -05:00
407c12c7ac review the overall code documentation 2018-11-19 19:23:11 -05:00
8949094203 run: return better status 2018-11-18 11:43:45 -05:00
ddb838ac81 process: return better errors 2018-11-18 11:43:29 -05:00
62d56aafff accompany returned status codes with a message 2018-11-18 11:04:11 -05:00
b9a0ece567 include the error message in ot::status 2018-11-18 10:42:27 -05:00
5445c5bc7c t: test the ogg writer 2018-11-18 10:15:49 -05:00
cc83a438ae t: tests for ogg_reader 2018-11-18 09:45:11 -05:00
6ed0326a74 t: copy gobble.opus to the binary directory 2018-11-18 09:34:22 -05:00
0980b35ecd polish the interface of the opus module 2018-11-17 17:34:51 -05:00
2670b661a8 don't create null ogg writers 2018-11-17 17:17:59 -05:00
c604fdb667 encapsulate ogg_writer 2018-11-17 17:07:14 -05:00
8334a5617f polish ogg_reader 2018-11-17 16:10:20 -05:00
cdd591c0c1 really close the files before moving them 2018-11-17 15:40:57 -05:00
e22a1d381a hide the reader's stream in the ogg module 2018-11-16 19:10:14 -05:00
121220ea05 rewrite run with RAII in mind 2018-11-16 18:29:40 -05:00
b6c7a90d92 move run into the cli module
Now the code has been wholly reorganized!
2018-11-14 20:15:30 -05:00
2e88bdc207 t: cli.t -> opustags.t
It reflects the module it tests.
2018-11-14 19:56:51 -05:00
22bfd05b36 move the main loop to ot::process 2018-11-14 19:56:23 -05:00
8a5b80e075 process_tags function in the main module 2018-11-14 18:51:04 -05:00
e41cf918d1 RAII interface for dynamic ogg packets 2018-11-13 20:46:30 -05:00
82ff7f7751 validate_identification_header: take the ogg_packet 2018-11-13 18:51:28 -05:00
351d6149c9 identification header check in opus.cc 2018-11-13 18:45:44 -05:00
9ed2b82b4a error: static assertion of the list of messages 2018-11-13 18:35:34 -05:00
1866dbd1f0 call stderror for ot::status:standard_error 2018-11-13 18:15:43 -05:00
5ff99b620c ot::error_message 2018-11-13 18:04:26 -05:00
b0e8813be6 t: introduce tap.h 2018-11-13 18:04:08 -05:00
c17ad7853c move print_comments in cli, next to read_comments 2018-11-11 12:04:16 -05:00
632caae915 dedicated function for checking if two files are the same 2018-11-11 11:57:25 -05:00
b9dbaf1049 finish moving the argv checks to cli 2018-11-11 11:35:05 -05:00
326ae74afa t: factor opustags_binary and opustags together 2018-11-11 11:19:56 -05:00
497caaa8f3 t: simply the prototype of Perl's opustags 2018-11-11 11:12:26 -05:00
6565cb56b3 t: faithful copy without --overwrite 2018-11-11 11:01:13 -05:00
f664ed94d4 t: generate out.opus in the binary dir 2018-11-11 10:58:33 -05:00
b5dc595855 move the help and some arguments checking in cli.cc 2018-11-11 10:54:54 -05:00
bf386899ae fix a few signedness warnings 2018-11-11 10:30:48 -05:00
51a3eba093 dedicated function for set-all's parsing 2018-11-11 10:24:18 -05:00
fae547c4eb t: rename unit.t to opus.t 2018-11-10 14:07:14 -05:00
3aeb2097de cmake: factor the libogg dependency 2018-11-10 14:00:13 -05:00
132073b842 move argument parsing to cli.cc 2018-11-10 11:52:33 -05:00
2a31c5491b use std::string instead of ot::string_view
String views are cool, but let's play it safe and standard for now. The
impact on performance is insignificant, since most of the job is reading
the ogg file, not actually manipulating tags.
2018-11-10 11:30:30 -05:00
1b9bd83e8f store path_in and path_out as std::string 2018-11-10 11:24:07 -05:00
f02ff44e43 struct for the CLI arguments 2018-11-10 11:04:13 -05:00
b7f85b5fe2 main: store to_add and to_delete in std::vector 2018-11-10 10:54:21 -05:00
c338a04196 string_view: convert from std::string 2018-11-10 10:52:20 -05:00
0426c369be t: test --overwrite 2018-11-10 10:26:12 -05:00
74cc6038b2 t: pass opus data in stdin/stdout 2018-11-10 10:24:14 -05:00
702f86a355 ogg_reader::read_page() 2018-11-09 18:30:11 -05:00
0c4c11032f general status code enum 2018-11-09 18:28:21 -05:00
72a911c11b RAII for the stream and sync states 2018-11-09 18:00:52 -05:00
07af78519b group ogg encoding/decoding variables together 2018-11-09 17:28:03 -05:00
2905b193b1 t: trigger all the possible parse errors 2018-11-07 20:46:07 -05:00
9d3e9c20a3 make parse_tags return a precise error code 2018-11-07 20:31:08 -05:00
0b4e01c3b0 delete add_tags, it had become too simple 2018-11-06 21:14:40 -05:00
cc5896b1a0 move print_tags in the main module
That's where it belongs. The other modules are not supposed to write
anything to the console.
2018-11-06 21:07:43 -05:00
1744cab9ed string_view: expose data and size as functions
Be consistent with C++17.
2018-11-06 21:01:03 -05:00
d9b96d471d store the vendor as a string_view 2018-11-06 20:51:55 -05:00
590a6814dd delete free_tags, now useless thanks to RAII 2018-11-06 20:48:13 -05:00
7ae7a50151 store comments in a std::list 2018-11-06 20:46:34 -05:00
0df7514a83 preserve the extra data after the comments 2018-11-06 18:46:40 -05:00
bd50fb34d9 t: recode a packet with padding (fails) 2018-11-05 19:03:21 -05:00
3e77092f85 t: check that render_tags is faithful 2018-11-05 18:53:01 -05:00
a3a6cb4e36 t: check parse_tags on a simple sample 2018-11-05 18:41:14 -05:00
af988efd8a configure cmake for unit tests 2018-11-04 18:24:29 -05:00
f2a60e4220 overall documentation for opus.cc 2018-11-04 17:47:50 -05:00
002b253c06 t: safer calls to opustags 2018-11-04 14:15:49 -05:00
62ea90e5d5 t: merge tags.t and meta.t in cli.t 2018-11-04 13:15:27 -05:00
098eefe60f explicit use of the ot namespace 2018-11-03 17:25:14 -04:00
3ba7ba8166 create ogg.cc for libogg helpers 2018-11-03 17:22:31 -04:00
3c0aad169b move the opus-related functions in opus.cc 2018-11-03 17:18:15 -04:00
06520bf87e create opustags.h 2018-11-03 16:52:58 -04:00
7fb5b49b81 move the sources in src/ 2018-11-02 16:56:53 -04:00
a2eb11cbe3 make check depends on opustags 2018-10-31 18:24:58 -04:00
dd364c6262 t: check the exit code when called without options 2018-10-31 18:22:48 -04:00
a3e7624866 get the version number from the cmake project 2018-10-31 18:21:47 -04:00
65aad6f62a build the project with cmake 2018-10-30 19:14:34 -04:00
241c9b3071 t: allow running the suite from an arbitrary directory 2018-10-30 19:12:58 -04:00
dd0faa29bc don't print "no tags"
It's undesired, especially if the output is piped somewhere else.

It *could* be printed to stderr alternatively, but better not say
anything when there is nothing to say.
2018-10-30 18:29:08 -04:00
1837f0b0ec build as C++14 2018-10-30 18:28:12 -04:00
1e6698af3e t: check malformed tags 2018-10-29 18:46:37 -04:00
82d0400207 t: complex --set-all 2018-10-29 18:26:21 -04:00
24b6268d7a .gitignore 2018-10-28 20:02:38 -04:00
e91ad48c10 t: delete the temporary opus file at the end 2018-10-28 20:01:57 -04:00
9c50d7d047 t: set all, delete all, and final touches 2018-10-28 20:00:48 -04:00
3624761c7b t: complex tag editing 2018-10-28 19:56:00 -04:00
f56ade7941 delete_tags did not delete multiple tags correctly 2018-10-28 19:56:00 -04:00
15335da1f8 t: check -h too 2018-10-28 19:56:00 -04:00
2006431fa8 t: trivial manipulations 2018-10-28 18:41:26 -04:00
63fce2f555 t: read tags from a file 2018-10-28 18:17:52 -04:00
2181f9f0eb t: use git to detect the version number 2018-10-28 14:12:55 -04:00
69561ae05f update the copyright notice 2018-10-27 20:36:18 -04:00
5dcf9ec543 add make check 2018-10-27 20:33:43 -04:00
7cf478c9cc meta tests 2018-10-27 20:33:43 -04:00
2f98bba07c README: update the status of the project 2018-10-27 20:23:24 -04:00
e44ad86af3 add a contributing guide 2018-10-27 20:23:16 -04:00
7174a1f2f2 bump to 1.1.1 2018-10-24 18:27:57 -04:00
1a8aaff933 Don't croak on overlong opustags 2018-10-02 18:48:09 -04:00
4973a4deab Become macOS compatible 2018-10-02 18:48:09 -04:00
8e9d98ac62 README: show alternatives 2017-10-01 12:26:56 +02:00
25 changed files with 2877 additions and 601 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

74
CHANGELOG.md Normal file
View File

@ -0,0 +1,74 @@
opustags changelog
==================
1.6.0 - 2021-01-01
------------------
- UTF-8 conversion errors are now fatal.
- Introduce --raw for disabling encoding conversions.
- Improve platform compatibility.
This also happens to be opustagss 8-year anniversary!
1.5.1 - 2020-11-21
------------------
- Improve BSD support.
1.5.0 - 2020-11-08
------------------
- Introduce --edit for interactive edition.
1.4.0 - 2020-10-04
------------------
- Preserve permissions when overwriting files.
- Support multiple files with --in-place.
- Fix BSD support.
Thanks to Reuben Thomas for contributing the pièce de résistance of this
release!
1.3.0 - 2019-02-02
------------------
- Support for non-Unicode systems. Tags are automatically converted to and from the system locale.
- It is now possible to delete specific NAME=VALUE pairs.
- Option `--set-all` is now stricter and aborts with an error if the input is not valid.
- Printing tags will display a warning if the tags contain control characters.
opustags is now more aware of its limitations, and will print more helpful error messages when
trying to edit an unsupported file. It is also more cautious against corrupted streams.
1.2.0 - 2018-11-25
------------------
- Preserve extra data in OpusTags past the comments.
- Improve error reporting.
- Fix various bugs.
This is the biggest release for opustags. The whole code base was reviewed for robustness and
clarity. The program is now built as C++14, and the code refactored without sacrificing the
original simplicity. It is shipped with a new test suite.
1.1.1 - 2018-10-24
------------------
- Mac OS X support.
- Tolerate but truncate the data in the OpusTags packet past the comments.
1.1 - 2013-01-02
----------------
- Add the --in-place option.
- Fix a bug is --set-all where the last unterminated line was ignored.
- Remove broken output files on failure.
1.0 - 2013-01-01
----------------
This is the first release of opustags. It supports all the main feature for basic tag editing.
It was written in a day, and the code is quick and dirty, though the program is simple and
efficient.

54
CMakeLists.txt Normal file
View File

@ -0,0 +1,54 @@
cmake_minimum_required(VERSION 3.9)
project(
opustags
VERSION 1.6.0
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# opustags is mainly developed with glibc, which introduces a few
# incompatibilites with BSDs, like getline not being defined by default.
# _GNU_SOURCE should trigger BSDs libc GNU compatibility mode to fix that.
add_definitions(-D_GNU_SOURCE)
find_package(PkgConfig REQUIRED)
pkg_check_modules(OGG REQUIRED ogg)
add_compile_options(${OGG_CFLAGS})
link_directories(${OGG_LIBRARY_DIRS})
include(FindIconv)
# We need endian.h on Linux, and sys/endian.h on BSD.
include(CheckIncludeFileCXX)
check_include_file_cxx(endian.h HAVE_ENDIAN_H)
check_include_file_cxx(sys/endian.h HAVE_SYS_ENDIAN_H)
include(CheckStructHasMember)
check_struct_has_member("struct stat" st_mtim sys/stat.h HAVE_STAT_ST_MTIM LANGUAGE CXX)
check_struct_has_member("struct stat" st_mtimespec sys/stat.h HAVE_STAT_ST_MTIMESPEC LANGUAGE CXX)
configure_file(src/config.h.in config.h @ONLY)
include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS} ${Iconv_INCLUDE_DIRS})
add_library(
ot
STATIC
src/cli.cc
src/ogg.cc
src/opus.cc
src/system.cc
)
target_link_libraries(ot PUBLIC ${OGG_LIBRARIES} ${Iconv_LIBRARIES})
add_executable(opustags src/opustags.cc)
target_link_libraries(opustags ot)
include(GNUInstallDirs)
install(TARGETS opustags DESTINATION "${CMAKE_INSTALL_BINDIR}")
configure_file(opustags.1 . @ONLY)
install(FILES "${CMAKE_BINARY_DIR}/opustags.1" DESTINATION "${CMAKE_INSTALL_MANDIR}/man1")
add_subdirectory(t)

71
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,71 @@
# Contributing to opustags
opustags should now be mature enough, and contributions for new features are
welcome.
Before you open a pull request, you might want to talk about the change you'd
like to make to make sure it's relevant. In that case, feel free to open an
issue. You can expect a response within a week.
## Submitting pull requests
opustags has nothing really special, so basic git etiquette is just enough.
Please make focused pull requests, one feature at a time. Don't make huge
commits. Give clear names to your commits and pull requests. Extended
descriptions are welcome.
Stay objective in your changes. Adding a feature or fixing a bug is a clear
improvement, but stylistic changes like renaming a function or moving a few
braces around won't help the project move forward.
You should check that your changes don't break the test suite by running
`make check`
Following these practices is important to keep the history clean, and to allow
for better code reviews.
## History of opustags
opustags is originally a small project made to fill a need to edit tags in Opus
audio files when most taggers didn't support Opus at all. It was written in C
with libogg, and should be very light and fast compared to most alternatives.
However, because it was written on a whim, the code is hardly structured and
might even be fragile, who knows.
An ambitious desire to rewrite it in C++ with bells and whistles gave birth to
the `next` branch, but sadly it wasn't finalized and is currently not usable,
though it contains good pieces of code.
With the growing support of Opus in tag editors, the usefulness of opustags was
questioned, and it was thus abandoned for a few years. Judging by the
inquiries and contributions, albeit few, on GitHub, it looks like it remains
relevant, so let's dust it off a bit.
Today, opustags is written in C++ and features a unit test suite in C++, and
an integration test suite in Perl. The code was refactored, organized into
modules, and reviewed for safety.
1.3.0 was focused on correctness, and detects edge cases as early as possible,
instead of hoping something will eventually fail if something is weird.
## Candidate features
The code contains a few `\todo` markers where something could be improved in the
code.
More generally, here are a few features that could be added in the future:
- Discouraging non-ASCII field names.
- Logicial stream listing and selection for multiplexed files.
- Escaping control characters with --escape.
- Dump binary packets with --binary.
- Edition of the vendor string.
- Edition of the arbitrary binary block past the comments.
- Support for OpusTags packets spanning multiple pages (> 64 kB).
- Interactive edition of comments inside the EDITOR (--edit).
- Support for cover arts.
- Load tags from a file with --set-all=tags.txt.
- Colored output.
Don't hesitate to contact me before you do anything, I'll give you directions.

View File

@ -1,4 +1,4 @@
Copyright (c) 2013, Frédéric Mangano
Copyright (c) 2013-2018, Frédéric Mangano-Tarumi
All rights reserved.
Redistribution and use in source and binary forms, with or without modification,

View File

@ -1,23 +0,0 @@
DESTDIR=/usr/local
MANDEST=share/man
CFLAGS=-Wall
LDFLAGS=-logg
all: opustags
opustags: opustags.c
man: opustags.1
gzip <opustags.1 >opustags.1.gz
install: opustags man
mkdir -p $(DESTDIR)/bin $(DESTDIR)/$(MANDEST)/man1
install -m 755 opustags $(DESTDIR)/bin/
install -m 644 opustags.1.gz $(DESTDIR)/$(MANDEST)/man1/
uninstall:
rm -f $(DESTDIR)/bin/opustags
rm -f $(DESTDIR)/$(MANDEST)/man1/opustags.1.gz
clean:
rm -f opustags opustags.1.gz

View File

@ -1,35 +1,67 @@
opustags
========
View and edit Opus comments.
View and edit Ogg Opus comments.
opustags is designed to be fast and as conservative as possible, to the point that if you edit tags
then edit them again to their previous values, you should get a bit-perfect copy of the original
file. No under-the-cover operation like writing "edited with opustags" or timestamp tagging will
ever be performed.
It currently has the following limitations:
- The total size of all tags cannot exceed 64 kB, the maximum size of one Ogg page.
- Multiplexed streams are not supported.
- Newlines inside tags are not supported by `--set-all`.
If you'd like one of these limitations lifted, please do open an issue explaining your use case.
Feel free to ask for new features too.
Requirements
------------
* A POSIX-compliant system,
* libogg.
* a POSIX-compliant system,
* a C++17 compiler,
* CMake ≥ 3.9,
* libogg 1.3.3.
The version numbers are indicative, and it's very likely opustags will build and work fine with
other versions too, as CMake and libogg are quite mature.
Installing
----------
opustags is a commonplace CMake project.
Here's how to install it in your `.local`, under your home:
mkdir build
cd build
cmake -DCMAKE_INSTALL_PREFIX=~/.local ..
make
make DESTDIR=/usr/local install
make install
Note that you don't need to install opustags in order to run it, as the executable is standalone.
Documentation
-------------
Usage: opustags --help
opustags [OPTIONS] FILE
opustags OPTIONS -i FILE...
opustags OPTIONS FILE -o FILE
Options:
-h, --help print this help
-o, --output write the modified tags to a file
-y, --overwrite overwrite the output file if it already exists
-d, --delete FIELD delete all the fields of a specified type
-a, --add FIELD=VALUE add a field
-s, --set FIELD=VALUE delete then add a field
-D, --delete-all delete all the fields!
-S, --set-all read the fields from stdin
-h, --help print this help
-o, --output FILE specify the output 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
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
--raw disable encoding conversion
See the man page, `opustags.1`, for extensive documentation.

View File

@ -1,6 +1,6 @@
.TH opustags 1 "January 2013"
.TH opustags 1 "December 2018" "@PROJECT_NAME@ @PROJECT_VERSION@"
.SH NAME
opustags \- Opus comment editor
opustags \- Ogg Opus tag editor
.SH SYNOPSIS
.B opustags --help
.br
@ -10,77 +10,77 @@ opustags \- Opus comment editor
.br
.B opustags
.I OPTIONS
.B -i
.R \fIFILE\fP...
.br
.B opustags
.I OPTIONS
.B -o
.I OUTPUT INPUT
.SH DESCRIPTION
.PP
\fBopustags\fP can read and edit the comment header of an Opus file.
It basically has two modes: read-only and read-write (for tag edition).
\fBopustags\fP can read and edit the comment header of an Ogg Opus file.
It basically has two modes: read-only, and read-write for tag editing.
.PP
In read-only mode, only the beginning of \fIINPUT\fP is read, and the tags are
printed on \fBstdout\fP.
\fIINPUT\fP can either be the name of a file or \fB-\fP to read from \fBstdin\fP.
printed on standard output.
\fIINPUT\fP can either be the name of a file or \fB-\fP to read from standard input.
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
As for the edition mode, you need to specify an output file (or \fB-\fP for
\fBstdout\fP). It must be different from the input file.
You may want to use \fB--overwrite\fP if you know what youre doing.
To overwrite the input file, use \fB--in-place\fP.
In editing mode, you need to specify an output file with \fB--output\fP, or use \fB--in-place\fP to
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
Tag edition can be made with the \fB--add\fP, \fB--delete\fP and \fB--set\fP
options. They can be written in any order and dont conflict with each other.
However, they arent executed in any order: first the specified tags are
deleted, then the new tags are added. “Set” operations are mere convenience
for delete/add.
Tag editing can be performed with the \fB--add\fP, \fB--delete\fP and \fB--set\fP
options. Options can be specified in any order and dont conflict with each other.
First the specified tags are deleted, then the new tags are added.
.PP
You can delete all the tags with \fB--delete-all\fP. This operation can be
combined with \fB--add\fP to set new tags without being bothered by the old
ones. Another way to do this is to use \fB--set-all\fP as explained below.
You can delete all the tags with \fB--delete-all\fP. This operation can be combined with \fB--add\fP
to set new tags without being bothered by the old ones.
.PP
If you want to process tags yourself, you can use the \fB--set-all\fP option
which will cause \fBopustags\fP to read tags from \fBstdin\fP.
The format is the same as the one used for output; that is to say,
newline-separated \fIFIELD=Value\fP assignment. Note that this implies
\fB--delete-all\fP.
If you want to replace all the tags, you can use the \fB--set-all\fP option which will cause
\fBopustags\fP to read tags from standard input.
The format is the same as the one used for output: newline-separated \fIFIELD=Value\fP assignment.
All the previously existing tags as deleted.
.PP
\fBWarning:\fP the Opus format specifications requires tags to be encoded in
\fBUTF-8\fP. This tool ignores the system locale, assuming the encoding is
set to UTF-8, and assume that tags are already encoded in UTF-8.
The Opus format specifications requires that tags are encoded in UTF-8, so that's the only encoding
opustags supports. If your system encoding is different, the tags are automatically converted to and
from your system locale. When the conversion is lossy, the incompatible characters are
transliterated and a warning is displayed. Even if you edit an Opus file whose tags contains
characters unsupported by your system encoding, the original UTF-8 values will be preserved for the
tags you don't explictly modify.
.SH OPTIONS
.TP
.B \-h, \-\-help
Display a brief description of the options.
.TP
.B \-o, \-\-output \fIFILE\fI
Edition mode. The input file will be read, its tags edited, then written to the
specified output file. If \fIFILE\fP is \fB-\fP then the resulting Opus file
will be written to \fBstdout\fP. As the input file is read incrementally, the
output file cant be the same as the input file.
Specify the output file.
The input file will be read, its tags edited, then written to the specified output file. If
\fIFILE\fP is \fB-\fP then the resulting Opus file will be written to standard output.
The output file cant be the same as the input file.
.TP
.B \-i, \-\-in-place \fR[\fP\fISUFFIX\fP\fR]\fP
Use this when you want to modify the input file in-place. This creates a
temporary file with the specified suffix (.otmp by default). This implies
\fB--overwrite\fP in that if a file with the same temporary name already
exists, it will be overwritten without warning. Of course, this overwrites
the input file too. You cannot use this option when the input file is actually
\fBstdin\fP.
.B \-i, \-\-in-place
Overwrite the input file instead of creating a separate output file. It has the same effect as
setting \fB--output\fP to the same path as the input file and enabling \fB--overwrite\fP.
This option conflicts with \fB--output\fP.
.TP
.B \-y, \-\-overwrite
By default, \fBopustags\fP refuses to overwrite an already existent file. Use
this option to allow that. Note that this doesnt allow in-place edition, the
output file needs to be different from the input file.
By default, \fBopustags\fP refuses to overwrite an already-existent file.
Use \fB-y\fP to allow overwriting.
Note that this option is not needed when the output is a special file like \fI/dev/null\fP.
.TP
.B \-d, \-\-delete \fIFIELD\fP
Delete all the tags whose field name is \fIFIELD\fP (they may be several, though
usually there is only one of each type). You can use this option as many times
as you want.
.B \-d, \-\-delete \fIFIELD[=VALUE]\fP
If value is not specified, delete all the tags whose field name is \fIFIELD\fP.
Otherwise, delete all the comments whose field name is \fIFIELD\fP and value is \fIVALUE\fP.
In both cases, the field names are case-insensitive, and expected to be ASCII.
.TP
.B \-a, \-\-add \fIFIELD=VALUE\fP
Add a tag. It doesnt matter if a tag of the same type already exist (think
the case where there are several artists). You can use this option as many
times as needed, with the same field names or not. When the \fB--delete\fP
is used with the same \fIFIELD\fP, only the older tags are deleted.
Add a tag. Note that multiple tags with the same field name are perfectly acceptable, so you can add
multiple fields with the same name, and previously existing tags will also be preserved.
When the \fB--delete\fP is used with the same \fIFIELD\fP, only the older tags are deleted.
.TP
.B \-s, \-\-set \fIFIELD=VALUE\fP
This option is provided for convenience. It delete all the fields of the same
@ -91,18 +91,66 @@ type. As deletion occurs before adding, \fB--set\fP wont erase the tags
added with \fB--add\fP.
.TP
.B \-D, \-\-delete-all
Delete all the tags before adding any. When this option is specified, the
\fB--delete\fP options are ignored. Tags then can be added using \fB--add\fP
or \fB--set\fP, which, in that case, are equivalent.
Delete all the previously existing tags.
.TP
.B \-S, \-\-set-all
Sets the tags from scratch. All the original tags are deleted and new ones are
read from \fBstdin\fP. Each line must specify a \fIFIELD=VALUE\fP pair and be
LF-terminated (except for the last line). Invalid lines are skipped and cause
a warning to be issued. Blank lines are ignored. This mode could be useful for
batch processing tags through an utility like \fBsed\fP.
.SH SEE ALSO
.BR vorbiscomment (1),
.BR sed (1)
Sets the tags from scratch.
All the original tags are deleted and new ones are read from standard input.
Each line must specify a \fIFIELD=VALUE\fP pair and be separated with line feeds.
Blank lines and lines starting with \fI#\fP are ignored.
.TP
.B \-e, \-\-edit
Edit tags interactively by spawning the program specified by the EDITOR
environment variable. The allowed format is the same as \fB--set-all\fP.
If TERM and VISUAL are set, VISUAL takes precedence over EDITOR.
.TP
.B \-\-raw
OpusTags metadata should always be encoded in UTF-8, as per RFC 7845. However, some files may be
corrupted or possibly even contain intentional binary data. In that case, --raw lets you edit that
kind of binary data without ensuring the validity of the tags encoding. This option may also be
useful when your system encoding is different from UTF-8 and you wish to preserve the full UTF-8
character set even though your system cannot display it.
.SH EXAMPLES
.PP
List all the tags in file foo.opus:
.PP
opustags foo.opus
.PP
Copy in.opus to out.opus, with the TITLE tag added:
.PP
opustags in.opus --output out.opus --add "TITLE=Hello world!"
.PP
Replace all the tags in dest.opus with the ones from src.opus:
.PP
opustags src.opus | opustags --in-place dest.opus --set-all
.PP
Remove the previously existing ARTIST tags and add the two X and Y ARTIST tags, then display the new
tags without writing them to the Opus file:
.PP
opustags in.opus --add ARTIST=X --add ARTIST=Y --delete ARTIST
.PP
Edit tags interactively in Vim:
.PP
EDITOR=vim opustags --in-place --edit file.opus
.SH CAVEATS
.PP
\fBopustags\fP currently has the following limitations:
.IP \[bu]
The total size of all tags cannot exceed 64 kB, the maximum size of one Ogg page.
.IP \[bu]
Multiplexed streams are not supported.
.IP \[bu]
Newlines inside tags are not supported by `--set-all`.
.IP \[bu]
Newlines and control characters are not escaped when printing tags.
.PP
Internally, the OpusTags packet in an Ogg Opus file may contain extra arbitrary binary data after
the comments. This block of data is currently not editable, but is always preserved. The same
applies for the vendor string.
.PP
If you need a feature not currently supported, feel free to open an issue or send an email with your
use case.
.SH AUTHOR
Frédéric Mangano <fmang@mg0.fr>
Frédéric Mangano-Tarumi <fmang+opustags@mg0.fr>
.PP
Report bugs at <https://github.com/fmang/opustags/issues>

View File

@ -1,505 +0,0 @@
#include <errno.h>
#include <getopt.h>
#include <limits.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <ogg/ogg.h>
typedef struct {
uint32_t vendor_length;
const char *vendor_string;
uint32_t count;
uint32_t *lengths;
const char **comment;
} opus_tags;
int parse_tags(char *data, long len, opus_tags *tags){
long pos;
if(len < 8+4+4)
return -1;
if(strncmp(data, "OpusTags", 8) != 0)
return -1;
// Vendor
pos = 8;
tags->vendor_length = le32toh(*((uint32_t*) (data + pos)));
tags->vendor_string = data + pos + 4;
pos += 4 + tags->vendor_length;
if(pos + 4 > len)
return -1;
// Count
tags->count = le32toh(*((uint32_t*) (data + pos)));
if(tags->count == 0)
return 0;
tags->lengths = calloc(tags->count, sizeof(uint32_t));
if(tags->lengths == NULL)
return -1;
tags->comment = calloc(tags->count, sizeof(char*));
if(tags->comment == NULL){
free(tags->lengths);
return -1;
}
pos += 4;
// Comment
uint32_t i;
for(i=0; i<tags->count; i++){
tags->lengths[i] = le32toh(*((uint32_t*) (data + pos)));
tags->comment[i] = data + pos + 4;
pos += 4 + tags->lengths[i];
if(pos > len)
return -1;
}
if(pos != len)
return -1;
return 0;
}
int render_tags(opus_tags *tags, ogg_packet *op){
// Note: op->packet must be manually freed.
op->b_o_s = 0;
op->e_o_s = 0;
op->granulepos = 0;
op->packetno = 1;
long len = 8 + 4 + tags->vendor_length + 4;
uint32_t i;
for(i=0; i<tags->count; i++)
len += 4 + tags->lengths[i];
op->bytes = len;
char *data = malloc(len);
if(!data)
return -1;
op->packet = (unsigned char*) data;
uint32_t n;
memcpy(data, "OpusTags", 8);
n = htole32(tags->vendor_length);
memcpy(data+8, &n, 4);
memcpy(data+12, tags->vendor_string, tags->vendor_length);
data += 12 + tags->vendor_length;
n = htole32(tags->count);
memcpy(data, &n, 4);
data += 4;
for(i=0; i<tags->count; i++){
n = htole32(tags->lengths[i]);
memcpy(data, &n, 4);
memcpy(data+4, tags->comment[i], tags->lengths[i]);
data += 4 + tags->lengths[i];
}
return 0;
}
int match_field(const char *comment, uint32_t len, const char *field){
size_t field_len;
for(field_len = 0; field[field_len] != '\0' && field[field_len] != '='; field_len++);
if(len <= field_len)
return 0;
if(comment[field_len] != '=')
return 0;
if(strncmp(comment, field, field_len) != 0)
return 0;
return 1;
}
void delete_tags(opus_tags *tags, const char *field){
uint32_t i;
for(i=0; i<tags->count; i++){
if(match_field(tags->comment[i], tags->lengths[i], field)){
tags->count--;
tags->lengths[i] = tags->lengths[tags->count];
tags->comment[i] = tags->comment[tags->count];
// No need to resize the arrays.
}
}
}
int add_tags(opus_tags *tags, const char **tags_to_add, uint32_t count){
if(count == 0)
return 0;
uint32_t *lengths = realloc(tags->lengths, (tags->count + count) * sizeof(uint32_t));
const char **comment = realloc(tags->comment, (tags->count + count) * sizeof(char*));
if(lengths == NULL || comment == NULL)
return -1;
tags->lengths = lengths;
tags->comment = comment;
uint32_t i;
for(i=0; i<count; i++){
tags->lengths[tags->count + i] = strlen(tags_to_add[i]);
tags->comment[tags->count + i] = tags_to_add[i];
}
tags->count += count;
return 0;
}
void print_tags(opus_tags *tags){
if(tags->count == 0)
puts("no tags");
int i;
for(i=0; i<tags->count; i++){
fwrite(tags->comment[i], 1, tags->lengths[i], stdout);
puts("");
}
}
void free_tags(opus_tags *tags){
if(tags->count > 0){
free(tags->lengths);
free(tags->comment);
}
}
int write_page(ogg_page *og, FILE *stream){
if(fwrite(og->header, 1, og->header_len, stream) < og->header_len)
return -1;
if(fwrite(og->body, 1, og->body_len, stream) < og->body_len)
return -1;
return 0;
}
const char *version = "opustags version 1.1\n";
const char *usage =
"Usage: opustags --help\n"
" opustags [OPTIONS] FILE\n"
" opustags OPTIONS FILE -o FILE\n";
const char *help =
"Options:\n"
" -h, --help print this help\n"
" -o, --output write the modified tags to a file\n"
" -i, --in-place [SUFFIX] use a temporary file then replace the original file\n"
" -y, --overwrite overwrite the output file if it already exists\n"
" -d, --delete FIELD delete all the fields of a specified type\n"
" -a, --add FIELD=VALUE add a field\n"
" -s, --set FIELD=VALUE delete then add a field\n"
" -D, --delete-all delete all the fields!\n"
" -S, --set-all read the fields from stdin\n";
struct option options[] = {
{"help", no_argument, 0, 'h'},
{"output", required_argument, 0, 'o'},
{"in-place", optional_argument, 0, 'i'},
{"overwrite", no_argument, 0, 'y'},
{"delete", required_argument, 0, 'd'},
{"add", required_argument, 0, 'a'},
{"set", required_argument, 0, 's'},
{"delete-all", no_argument, 0, 'D'},
{"set-all", no_argument, 0, 'S'},
{NULL, 0, 0, 0}
};
int main(int argc, char **argv){
if(argc == 1){
fputs(version, stdout);
fputs(usage, stdout);
return EXIT_SUCCESS;
}
char *path_in, *path_out = NULL, *inplace = NULL;
const char* to_add[argc];
const char* to_delete[argc];
int count_add = 0, count_delete = 0;
int delete_all = 0;
int set_all = 0;
int overwrite = 0;
int print_help = 0;
int c;
while((c = getopt_long(argc, argv, "ho:i::yd:a:s:DS", options, NULL)) != -1){
switch(c){
case 'h':
print_help = 1;
break;
case 'o':
path_out = optarg;
break;
case 'i':
inplace = optarg == NULL ? ".otmp" : optarg;
break;
case 'y':
overwrite = 1;
break;
case 'd':
if(strchr(optarg, '=') != NULL){
fprintf(stderr, "invalid field: '%s'\n", optarg);
return EXIT_FAILURE;
}
to_delete[count_delete++] = optarg;
break;
case 'a':
case 's':
if(strchr(optarg, '=') == NULL){
fprintf(stderr, "invalid comment: '%s'\n", optarg);
return EXIT_FAILURE;
}
to_add[count_add++] = optarg;
if(c == 's')
to_delete[count_delete++] = optarg;
break;
case 'S':
set_all = 1;
case 'D':
delete_all = 1;
break;
default:
return EXIT_FAILURE;
}
}
if(print_help){
puts(version);
puts(usage);
puts(help);
puts("See the man page for extensive documentation.");
return EXIT_SUCCESS;
}
if(optind != argc - 1){
fputs("invalid arguments\n", stderr);
return EXIT_FAILURE;
}
if(inplace && path_out){
fputs("cannot combine --in-place and --output\n", stderr);
return EXIT_FAILURE;
}
path_in = argv[optind];
if(path_out != NULL && strcmp(path_in, "-") != 0){
char canon_in[PATH_MAX+1], canon_out[PATH_MAX+1];
if(realpath(path_in, canon_in) && realpath(path_out, canon_out)){
if(strcmp(canon_in, canon_out) == 0){
fputs("error: the input and output files are the same\n", stderr);
return EXIT_FAILURE;
}
}
}
FILE *in;
if(strcmp(path_in, "-") == 0){
if(set_all){
fputs("can't open stdin for input when -S is specified\n", stderr);
return EXIT_FAILURE;
}
if(inplace){
fputs("cannot modify stdin 'in-place'\n", stderr);
return EXIT_FAILURE;
}
in = stdin;
}
else
in = fopen(path_in, "r");
if(!in){
perror("fopen");
return EXIT_FAILURE;
}
FILE *out = NULL;
if(inplace != NULL){
path_out = malloc(strlen(path_in) + strlen(inplace) + 1);
if(path_out == NULL){
fputs("failure to allocate memory\n", stderr);
fclose(in);
return EXIT_FAILURE;
}
strcpy(path_out, path_in);
strcat(path_out, inplace);
}
if(path_out != NULL){
if(strcmp(path_out, "-") == 0)
out = stdout;
else{
if(!overwrite && !inplace){
if(access(path_out, F_OK) == 0){
fprintf(stderr, "'%s' already exists (use -y to overwrite)\n", path_out);
fclose(in);
return EXIT_FAILURE;
}
}
out = fopen(path_out, "w");
if(!out){
perror("fopen");
fclose(in);
if(inplace)
free(path_out);
return EXIT_FAILURE;
}
}
}
ogg_sync_state oy;
ogg_stream_state os, enc;
ogg_page og;
ogg_packet op;
opus_tags tags;
ogg_sync_init(&oy);
char *buf;
size_t len;
char *error = NULL;
int packet_count = -1;
while(error == NULL){
// Read until we complete a page.
if(ogg_sync_pageout(&oy, &og) != 1){
if(feof(in))
break;
buf = ogg_sync_buffer(&oy, 65536);
if(buf == NULL){
error = "ogg_sync_buffer: out of memory";
break;
}
len = fread(buf, 1, 65536, in);
if(ferror(in))
error = strerror(errno);
ogg_sync_wrote(&oy, len);
if(ogg_sync_check(&oy) != 0)
error = "ogg_sync_check: internal error";
continue;
}
// We got a page.
// Short-circuit when the relevant packets have been read.
if(packet_count >= 2 && out){
if(write_page(&og, out) == -1){
error = "write_page: fwrite error";
break;
}
continue;
}
// Initialize the streams from the first page.
if(packet_count == -1){
if(ogg_stream_init(&os, ogg_page_serialno(&og)) == -1){
error = "ogg_stream_init: couldn't create a decoder";
break;
}
if(out){
if(ogg_stream_init(&enc, ogg_page_serialno(&og)) == -1){
error = "ogg_stream_init: couldn't create an encoder";
break;
}
}
packet_count = 0;
}
if(ogg_stream_pagein(&os, &og) == -1){
error = "ogg_stream_pagein: invalid page";
break;
}
// Read all the packets.
while(ogg_stream_packetout(&os, &op) == 1){
packet_count++;
if(packet_count == 1){ // Identification header
if(strncmp((char*) op.packet, "OpusHead", 8) != 0){
error = "opustags: invalid identification header";
break;
}
}
else if(packet_count == 2){ // Comment header
if(parse_tags((char*) op.packet, op.bytes, &tags) == -1){
error = "opustags: invalid comment header";
break;
}
if(delete_all)
tags.count = 0;
else{
int i;
for(i=0; i<count_delete; i++)
delete_tags(&tags, to_delete[i]);
}
char *raw_tags = NULL;
if(set_all){
raw_tags = malloc(16384);
if(raw_tags == NULL){
error = "malloc: not enough memory for buffering stdin";
free(raw_tags);
break;
}
else{
char *raw_comment[256];
size_t raw_len = fread(raw_tags, 1, 16383, stdin);
if(raw_len == 16383)
fputs("warning: truncating comment to 16 KiB\n", stderr);
raw_tags[raw_len] = '\0';
uint32_t raw_count = 0;
size_t field_len = 0;
int caught_eq = 0;
size_t i = 0;
char *cursor = raw_tags;
for(i=0; i <= raw_len && raw_count < 256; i++){
if(raw_tags[i] == '\n' || raw_tags[i] == '\0'){
if(field_len == 0)
continue;
if(caught_eq)
raw_comment[raw_count++] = cursor;
else
fputs("warning: skipping malformed tag\n", stderr);
cursor = raw_tags + i + 1;
field_len = 0;
caught_eq = 0;
raw_tags[i] = '\0';
continue;
}
if(raw_tags[i] == '=')
caught_eq = 1;
field_len++;
}
add_tags(&tags, (const char**) raw_comment, raw_count);
}
}
add_tags(&tags, to_add, count_add);
if(out){
ogg_packet packet;
render_tags(&tags, &packet);
if(ogg_stream_packetin(&enc, &packet) == -1)
error = "ogg_stream_packetin: internal error";
free(packet.packet);
}
else
print_tags(&tags);
free_tags(&tags);
if(raw_tags)
free(raw_tags);
if(error || !out)
break;
else
continue;
}
if(out){
if(ogg_stream_packetin(&enc, &op) == -1){
error = "ogg_stream_packetin: internal error";
break;
}
}
}
if(error != NULL)
break;
if(ogg_stream_check(&os) != 0)
error = "ogg_stream_check: internal error (decoder)";
// Write the page.
if(out){
ogg_stream_flush(&enc, &og);
if(write_page(&og, out) == -1)
error = "write_page: fwrite error";
else if(ogg_stream_check(&enc) != 0)
error = "ogg_stream_check: internal error (encoder)";
}
else if(packet_count >= 2) // Read-only mode
break;
}
if(packet_count >= 0){
ogg_stream_clear(&os);
if(out)
ogg_stream_clear(&enc);
}
ogg_sync_clear(&oy);
fclose(in);
if(out)
fclose(out);
if(!error && packet_count < 2)
error = "opustags: invalid file";
if(error){
fprintf(stderr, "%s\n", error);
if(path_out != NULL && out != stdout)
remove(path_out);
if(inplace)
free(path_out);
return EXIT_FAILURE;
}
else if(inplace){
if(rename(path_out, path_in) == -1){
perror("rename");
free(path_out);
return EXIT_FAILURE;
}
free(path_out);
}
return EXIT_SUCCESS;
}

519
src/cli.cc Normal file
View File

@ -0,0 +1,519 @@
/**
* \file src/cli.cc
* \ingroup cli
*
* Provide all the features of the opustags executable from a C++ API. The main point of separating
* this module from the main one is to allow easy testing.
*/
#include <opustags.h>
#include <errno.h>
#include <getopt.h>
#include <limits.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <unistd.h>
using namespace std::literals::string_literals;
static const char help_message[] =
PROJECT_NAME " version " PROJECT_VERSION
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 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
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
--raw disable encoding conversion
See the man page for extensive documentation.
)raw";
static struct option getopt_options[] = {
{"help", no_argument, 0, 'h'},
{"output", required_argument, 0, 'o'},
{"in-place", optional_argument, 0, 'i'},
{"overwrite", no_argument, 0, 'y'},
{"delete", required_argument, 0, 'd'},
{"add", required_argument, 0, 'a'},
{"set", required_argument, 0, 's'},
{"delete-all", no_argument, 0, 'D'},
{"set-all", no_argument, 0, 'S'},
{"edit", no_argument, 0, 'e'},
{"raw", no_argument, 0, 'r'},
{NULL, 0, 0, 0}
};
ot::status ot::parse_options(int argc, char** argv, ot::options& opt, FILE* comments_input)
{
static ot::encoding_converter to_utf8("", "UTF-8");
std::string utf8;
const char* equal;
ot::status rc;
bool set_all = false;
opt = {};
if (argc == 1)
return {st::bad_arguments, "No arguments specified. Use -h for help."};
int c;
optind = 0;
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSe", getopt_options, NULL)) != -1) {
switch (c) {
case 'h':
opt.print_help = true;
break;
case 'o':
if (opt.path_out)
return {st::bad_arguments, "Cannot specify --output more than once."};
opt.path_out = optarg;
break;
case 'i':
opt.in_place = true;
opt.overwrite = true;
break;
case 'y':
opt.overwrite = true;
break;
case 'd':
opt.to_delete.emplace_back(optarg);
break;
case 'a':
case 's':
equal = strchr(optarg, '=');
if (equal == nullptr)
return {st::bad_arguments, "Comment does not contain an equal sign: "s + optarg + "."};
if (c == 's')
opt.to_delete.emplace_back(optarg, equal - optarg);
opt.to_add.emplace_back(optarg);
break;
case 'S':
opt.delete_all = true;
set_all = true;
break;
case 'D':
opt.delete_all = true;
break;
case 'e':
opt.edit_interactively = true;
break;
case 'r':
opt.raw = true;
break;
case ':':
return {st::bad_arguments,
"Missing value for option '"s + argv[optind - 1] + "'."};
default:
return {st::bad_arguments, "Unrecognized option '" +
(optopt ? "-"s + static_cast<char>(optopt) : argv[optind - 1]) + "'."};
}
}
if (opt.print_help)
return st::ok;
// All non-option arguments are input files.
bool stdin_as_input = false;
for (int i = optind; i < argc; i++) {
stdin_as_input = stdin_as_input || strcmp(argv[i], "-") == 0;
opt.paths_in.emplace_back(argv[i]);
}
// Convert arguments to UTF-8.
if (!opt.raw) {
for (std::list<std::string>* args : { &opt.to_add, &opt.to_delete }) {
for (std::string& arg : *args) {
rc = to_utf8(arg, utf8);
if (rc != ot::st::ok)
return {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
arg = std::move(utf8);
}
}
}
if (opt.in_place && opt.path_out)
return {st::bad_arguments, "Cannot combine --in-place and --output."};
if (opt.in_place && stdin_as_input)
return {st::bad_arguments, "Cannot modify standard input in place."};
if ((!opt.in_place || opt.edit_interactively) && opt.paths_in.size() != 1)
return {st::bad_arguments, "Exactly one input file must be specified."};
if (set_all && stdin_as_input)
return {st::bad_arguments, "Cannot use standard input as input file when --set-all is specified."};
if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-"))
return {st::bad_arguments, "Cannot edit interactively when standard input or standard output are already used."};
if (opt.edit_interactively && !opt.path_out.has_value() && !opt.in_place)
return {st::bad_arguments, "Cannot edit interactively when no output is specified."};
if (opt.edit_interactively && (opt.delete_all || !opt.to_add.empty() || !opt.to_delete.empty()))
return {st::bad_arguments, "Cannot mix --edit with -adDsS."};
if (set_all) {
// Read comments from stdin and prepend them to opt.to_add.
std::list<std::string> comments;
auto rc = read_comments(comments_input, comments, opt.raw);
if (rc != st::ok)
return rc;
opt.to_add.splice(opt.to_add.begin(), std::move(comments));
}
return st::ok;
}
/**
* \todo Find a way to support new lines such that they can be read back by #read_comment without
* ambiguity. We could add a raw mode and separate comments with a \0, or escape control
* characters with a backslash, but we should also preserve compatibiltity with potential
* callers that dont escape backslashes. Maybe add options to select a mode between simple,
* raw, and escaped.
*/
ot::status ot::print_comments(const std::list<std::string>& comments, FILE* output, bool raw)
{
static ot::encoding_converter from_utf8("UTF-8", "");
std::string local;
bool has_newline = false;
bool has_control = false;
for (const std::string& utf8_comment : comments) {
const std::string* comment;
// Convert the comment from UTF-8 to the system encoding if relevant.
if (raw) {
comment = &utf8_comment;
} else {
ot::status rc = from_utf8(utf8_comment, local);
comment = &local;
if (rc != ot::st::ok) {
rc.message += " See --raw.";
return rc;
}
}
for (unsigned char c : *comment) {
if (c == '\n')
has_newline = true;
else if (c < 0x20)
has_control = true;
}
fwrite(comment->data(), 1, comment->size(), output);
putc('\n', output);
}
if (has_newline)
fputs("warning: Some tags contain unsupported newline characters.\n", stderr);
if (has_control)
fputs("warning: Some tags contain control characters.\n", stderr);
return st::ok;
}
ot::status ot::read_comments(FILE* input, std::list<std::string>& comments, bool raw)
{
static ot::encoding_converter to_utf8("", "UTF-8");
comments.clear();
char* line = nullptr;
size_t buflen = 0;
ssize_t nread;
while ((nread = getline(&line, &buflen, input)) != -1) {
if (nread > 0 && line[nread - 1] == '\n')
--nread;
if (nread == 0)
continue;
if (line[0] == '#') // comment
continue;
if (memchr(line, '=', nread) == nullptr) {
ot::status rc = {ot::st::error, "Malformed tag: " + std::string(line, nread)};
free(line);
return rc;
}
if (raw) {
comments.emplace_back(line, nread);
} else {
std::string utf8;
ot::status rc = to_utf8(std::string_view(line, nread), utf8);
if (rc == ot::st::ok) {
comments.emplace_back(std::move(utf8));
} else {
free(line);
return {ot::st::badly_encoded, "UTF-8 conversion error: " + rc.message};
}
}
}
free(line);
return ot::st::ok;
}
void ot::delete_comments(std::list<std::string>& comments, const std::string& selector)
{
auto name = selector.data();
auto equal = selector.find('=');
auto value = (equal == std::string::npos ? nullptr : name + equal + 1);
auto name_len = value ? equal : selector.size();
auto value_len = value ? selector.size() - equal - 1 : 0;
auto it = comments.begin(), end = comments.end();
while (it != end) {
auto current = it++;
bool name_match = current->size() > name_len + 1 &&
(*current)[name_len] == '=' &&
strncasecmp(current->data(), name, name_len) == 0;
if (!name_match)
continue;
bool value_match = value == nullptr ||
(current->size() == selector.size() &&
memcmp(current->data() + equal + 1, value, value_len) == 0);
if (value_match)
comments.erase(current);
}
}
/** Apply the modifications requested by the user to the opustags packet. */
static ot::status edit_tags(ot::opus_tags& tags, const ot::options& opt)
{
if (opt.delete_all) {
tags.comments.clear();
} else for (const std::string& name : opt.to_delete) {
ot::delete_comments(tags.comments, name.c_str());
}
for (const std::string& comment : opt.to_add)
tags.comments.emplace_back(comment);
return ot::st::ok;
}
/** Spawn VISUAL or EDITOR to edit the given tags. */
static ot::status edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, bool raw)
{
const char* editor = nullptr;
if (getenv("TERM") != nullptr)
editor = getenv("VISUAL");
if (editor == nullptr) // without a terminal, or if VISUAL is unset
editor = getenv("EDITOR");
if (editor == nullptr)
return {ot::st::error,
"No editor specified in environment variable VISUAL or EDITOR."};
// Building the temporary tags file.
ot::status rc;
std::string tags_path = base_path.value_or("tags") + ".XXXXXX.opustags";
int fd = mkstemps(const_cast<char*>(tags_path.data()), 9);
ot::file tags_file;
if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
return {ot::st::standard_error,
"Could not open '" + tags_path + "': " + strerror(errno)};
if ((rc = ot::print_comments(tags.comments, tags_file.get(), raw)) != ot::st::ok)
return rc;
tags_file.reset();
// Spawn the editor, and watch the modification timestamps.
timespec before, after;
if ((rc = ot::get_file_timestamp(tags_path.c_str(), before)) != ot::st::ok)
return rc;
ot::status editor_rc = ot::run_editor(editor, tags_path);
if ((rc = ot::get_file_timestamp(tags_path.c_str(), after)) != ot::st::ok)
return rc; // probably because the file was deleted
bool modified = (before.tv_sec != after.tv_sec || before.tv_nsec != after.tv_nsec);
if (editor_rc != ot::st::ok) {
if (modified)
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
else
remove(tags_path.c_str());
return editor_rc;
} else if (!modified) {
remove(tags_path.c_str());
fputs("Cancelling edition because the tags file was not modified.\n", stderr);
return ot::st::cancel;
}
// Applying the new tags.
tags_file = fopen(tags_path.c_str(), "re");
if (tags_file == nullptr)
return {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
if ((rc = ot::read_comments(tags_file.get(), tags.comments, raw)) != ot::st::ok) {
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
return rc;
}
tags_file.reset();
// Remove the temporary tags file only on success, because unlike the
// partial Ogg file that is irrecoverable, the edited tags file
// contains user data, so lets leave users a chance to recover it.
remove(tags_path.c_str());
return ot::st::ok;
}
/**
* Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
* Transform the OpusTags packet on the fly.
*
* The writer is optional. When writer is nullptr, opustags runs in read-only mode.
*/
static ot::status process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::options &opt)
{
bool focused = false; /*< the stream on which we operate is defined */
int focused_serialno; /*< when focused, the serialno of the focused stream */
int absolute_page_no = -1; /*< page number in the physical stream, not logical */
for (;;) {
ot::status rc = reader.next_page();
if (rc == ot::st::end_of_stream)
break;
else if (rc == ot::st::bad_stream && absolute_page_no == -1)
return {ot::st::bad_stream, "Input is not a valid Ogg file."};
else if (rc != ot::st::ok)
return rc;
++absolute_page_no;
auto serialno = ogg_page_serialno(&reader.page);
auto pageno = ogg_page_pageno(&reader.page);
if (!focused) {
focused = true;
focused_serialno = serialno;
} else if (serialno != focused_serialno) {
/** \todo Support mixed streams. */
return {ot::st::error, "Muxed streams are not supported yet."};
}
if (absolute_page_no == 0) { // Identification header
if (!ot::is_opus_stream(reader.page))
return {ot::st::error, "Not an Opus stream."};
if (writer) {
rc = writer->write_page(reader.page);
if (rc != ot::st::ok)
return rc;
}
} else if (absolute_page_no == 1) { // Comment header
ot::opus_tags tags;
rc = reader.process_header_packet(
[&tags](ogg_packet& p) { return ot::parse_tags(p, tags); });
if (rc != ot::st::ok)
return rc;
if ((rc = edit_tags(tags, opt)) != ot::st::ok)
return rc;
if (writer) {
if (opt.edit_interactively) {
fflush(writer->file); // flush before calling the subprocess
if ((rc = edit_tags_interactively(tags, writer->path, opt.raw)) != ot::st::ok)
return rc;
}
auto packet = ot::render_tags(tags);
rc = writer->write_header_packet(serialno, pageno, packet);
if (rc != ot::st::ok)
return rc;
} else {
if ((rc = ot::print_comments(tags.comments, stdout, opt.raw)) != ot::st::ok)
return rc;
break;
}
} else {
if (writer && (rc = writer->write_page(reader.page)) != ot::st::ok)
return rc;
}
}
if (absolute_page_no < 1)
return {ot::st::error, "Expected at least 2 Ogg pages."};
return ot::st::ok;
}
static ot::status run_single(const ot::options& opt, const std::string& path_in, const std::optional<std::string>& path_out)
{
ot::file input;
if (path_in == "-")
input = stdin;
else if ((input = fopen(path_in.c_str(), "re")) == nullptr)
return {ot::st::standard_error,
"Could not open '" + path_in + "' for reading: " + strerror(errno)};
ot::ogg_reader reader(input.get());
/* Read-only mode. */
if (!path_out)
return process(reader, nullptr, opt);
/* Read-write mode.
*
* The output pointer is set to one of:
* - stdout for "-",
* - final_output.get() for special files like /dev/null,
* - temporary_output.get() for regular files.
*
* We use a temporary output file for the following reasons:
* 1. A partial .opus output would be seen by softwares like media players, but a .part
* (for partial) wont.
* 2. If the process crashes badly, or the power cuts off, we don't want to leave a partial
* file at the final location. The temporary file is going to remain though.
* 3. If we're overwriting a regular file, we'd rather avoid wiping its content before we
* even started reading the input file. That way, the original file is always preserved
* on error or crash.
* 4. It is necessary for in-place editing. We can't reliably open the same file as both
* input and output.
*/
FILE* output = nullptr;
ot::partial_file temporary_output;
ot::file final_output;
ot::status rc = ot::st::ok;
struct stat output_info;
if (path_out == "-") {
output = stdout;
} 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(path_out->c_str(), "we")) == nullptr)
rc = {ot::st::standard_error,
"Could not open '" + path_out.value() + "' for writing: " +
strerror(errno)};
output = final_output.get();
} else if (opt.overwrite) {
rc = temporary_output.open(path_out->c_str());
output = temporary_output.get();
} else {
rc = {ot::st::error,
"'" + path_out.value() + "' already exists. Use -y to overwrite."};
}
} else if (errno == ENOENT) {
rc = temporary_output.open(path_out->c_str());
output = temporary_output.get();
} else {
rc = {ot::st::error,
"Could not identify '" + path_in + "': " + strerror(errno)};
}
if (rc != ot::st::ok)
return rc;
ot::ogg_writer writer(output);
writer.path = path_out;
rc = process(reader, &writer, opt);
if (rc == ot::st::ok)
rc = temporary_output.commit();
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;
}

7
src/config.h.in Normal file
View File

@ -0,0 +1,7 @@
#cmakedefine PROJECT_NAME "@PROJECT_NAME@"
#cmakedefine PROJECT_VERSION "@PROJECT_VERSION@"
#cmakedefine HAVE_ENDIAN_H @HAVE_ENDIAN_H@
#cmakedefine HAVE_SYS_ENDIAN_H @HAVE_SYS_ENDIAN_H@
#cmakedefine HAVE_STAT_ST_MTIM @HAVE_STAT_ST_MTIM@
#cmakedefine HAVE_STAT_ST_MTIMESPEC @HAVE_STAT_ST_MTIMESPEC@

116
src/ogg.cc Normal file
View File

@ -0,0 +1,116 @@
/**
* \file src/ogg.c
* \ingroup ogg
*
* High-level interface for libogg.
*
* This module is not meant to be a complete libogg wrapper, but rather a convenient and highly
* specialized layer above libogg and stdio.
*/
#include <opustags.h>
#include <errno.h>
#include <string.h>
using namespace std::literals::string_literals;
bool ot::is_opus_stream(const ogg_page& identification_header)
{
if (ogg_page_bos(&identification_header) == 0)
return false;
if (identification_header.body_len < 8)
return false;
return (memcmp(identification_header.body, "OpusHead", 8) == 0);
}
ot::status ot::ogg_reader::next_page()
{
int rc;
while ((rc = ogg_sync_pageout(&sync, &page)) != 1) {
if (rc == -1)
return {st::bad_stream, "Unsynced data in stream."};
if (ogg_sync_check(&sync) != 0)
return {st::libogg_error, "ogg_sync_check signalled an error."};
if (feof(file)) {
if (sync.fill != sync.returned)
return {st::bad_stream, "Unsynced data at end of stream."};
return {st::end_of_stream, "End of stream was reached."};
}
char* buf = ogg_sync_buffer(&sync, 65536);
if (buf == nullptr)
return {st::libogg_error, "ogg_sync_buffer failed."};
size_t len = fread(buf, 1, 65536, file);
if (ferror(file))
return {st::standard_error, "fread error: "s + strerror(errno)};
if (ogg_sync_wrote(&sync, len) != 0)
return {st::libogg_error, "ogg_sync_wrote failed."};
}
return st::ok;
}
ot::status ot::ogg_reader::process_header_packet(const std::function<status(ogg_packet&)>& f)
{
if (ogg_page_continued(&page))
return {ot::st::error, "Unexpected continued header page."};
ogg_logical_stream stream(ogg_page_serialno(&page));
stream.pageno = ogg_page_pageno(&page);
if (ogg_stream_pagein(&stream, &page) != 0)
return {st::libogg_error, "ogg_stream_pagein failed."};
ogg_packet packet;
int rc = ogg_stream_packetout(&stream, &packet);
if (ogg_stream_check(&stream) != 0 || rc == -1)
return {ot::st::libogg_error, "ogg_stream_packetout failed."};
else if (rc == 0)
return {ot::st::error,
"Reading header packets spanning multiple pages are not yet supported. "
"Please file an issue to make your wish known."};
ot::status f_rc = f(packet);
if (f_rc != ot::st::ok)
return f_rc;
/* Ensure that there are no other segments left in the packet using the lacing state of the
* stream. These are the relevant variables, as far as I understood them:
* - lacing_vals: extensible array containing the lacing values of the segments,
* - lacing_fill: number of elements in lacing_vals (not the capacity),
* - lacing_returned: index of the next segment to be processed. */
if (stream.lacing_returned != stream.lacing_fill)
return {ot::st::error, "Header page contains more than a single packet."};
return ot::st::ok;
}
ot::status ot::ogg_writer::write_page(const ogg_page& page)
{
if (page.header_len < 0 || page.body_len < 0)
return {st::int_overflow, "Overflowing page length"};
auto header_len = static_cast<size_t>(page.header_len);
auto body_len = static_cast<size_t>(page.body_len);
if (fwrite(page.header, 1, header_len, file) < header_len)
return {st::standard_error, "fwrite error: "s + strerror(errno)};
if (fwrite(page.body, 1, body_len, file) < body_len)
return {st::standard_error, "fwrite error: "s + strerror(errno)};
return st::ok;
}
ot::status ot::ogg_writer::write_header_packet(int serialno, int pageno, ogg_packet& packet)
{
ogg_logical_stream stream(serialno);
stream.b_o_s = (pageno != 0);
stream.pageno = pageno;
if (ogg_stream_packetin(&stream, &packet) != 0)
return {ot::st::libogg_error, "ogg_stream_packetin failed"};
ogg_page page;
if (ogg_stream_flush(&stream, &page) != 0) {
ot::status rc = write_page(page);
if (rc != ot::st::ok)
return rc;
} else {
return {ot::st::libogg_error, "ogg_stream_flush failed"};
}
if (ogg_stream_flush(&stream, &page) != 0)
return {ot::st::error,
"Writing header packets spanning multiple pages are not yet supported. "
"Please file an issue to make your wish known."};
if (ogg_stream_check(&stream) != 0)
return {st::libogg_error, "ogg_stream_check failed"};
return ot::st::ok;
}

127
src/opus.cc Normal file
View File

@ -0,0 +1,127 @@
/**
* \file src/opus.cc
* \ingroup opus
*
* The way Opus is encapsulated into an Ogg stream, and the content of the packets we're dealing
* with here is defined by [RFC 7584](https://tools.ietf.org/html/rfc7845.html).
*
* Section 3 "Packet Organization" is critical for us:
*
* - The first page contains exactly 1 packet, the OpusHead, and it contains it entirely.
* - The second page begins the OpusTags packet, which may span several pages.
* - The OpusTags packet must finish the page on which it completes.
*
* The structure of the OpusTags packet is defined in section 5.2 "Comment Header" of the RFC.
*
* OpusTags is similar to [Vorbis Comment](https://www.xiph.org/vorbis/doc/v-comment.html), which
* gives us some context, but let's stick to the RFC for the technical details.
*
* \todo Validate that the vendor string and comments are valid UTF-8.
* \todo Validate that field names are ASCII: 0x20 through 0x7D, 0x3D ('=') excluded.
*
*/
#include <opustags.h>
#include <string.h>
#ifdef HAVE_ENDIAN_H
# include <endian.h>
#endif
#ifdef HAVE_SYS_ENDIAN_H
# include <sys/endian.h>
#endif
#ifdef __APPLE__
#include <libkern/OSByteOrder.h>
#define htole32(x) OSSwapHostToLittleInt32(x)
#define le32toh(x) OSSwapLittleToHostInt32(x)
#endif
ot::status ot::parse_tags(const ogg_packet& packet, opus_tags& tags)
{
if (packet.bytes < 0)
return {st::int_overflow, "Overflowing comment header length"};
size_t size = static_cast<size_t>(packet.bytes);
const char* data = reinterpret_cast<char*>(packet.packet);
size_t pos = 0;
opus_tags my_tags;
// Magic number
if (8 > size)
return {st::cut_magic_number, "Comment header too short for the magic number"};
if (memcmp(data, "OpusTags", 8) != 0)
return {st::bad_magic_number, "Comment header did not start with OpusTags"};
// Vendor
pos = 8;
if (pos + 4 > size)
return {st::cut_vendor_length,
"Vendor string length did not fit the comment header"};
size_t vendor_length = le32toh(*((uint32_t*) (data + pos)));
if (pos + 4 + vendor_length > size)
return {st::cut_vendor_data, "Vendor string did not fit the comment header"};
my_tags.vendor = std::string(data + pos + 4, vendor_length);
pos += 4 + my_tags.vendor.size();
// Comment count
if (pos + 4 > size)
return {st::cut_comment_count, "Comment count did not fit the comment header"};
uint32_t count = le32toh(*((uint32_t*) (data + pos)));
pos += 4;
// Comments' data
for (uint32_t i = 0; i < count; ++i) {
if (pos + 4 > size)
return {st::cut_comment_length,
"Comment length did not fit the comment header"};
uint32_t comment_length = le32toh(*((uint32_t*) (data + pos)));
if (pos + 4 + comment_length > size)
return {st::cut_comment_data,
"Comment string did not fit the comment header"};
const char *comment_value = data + pos + 4;
my_tags.comments.emplace_back(comment_value, comment_length);
pos += 4 + comment_length;
}
// Extra data
my_tags.extra_data = std::string(data + pos, size - pos);
tags = std::move(my_tags);
return st::ok;
}
ot::dynamic_ogg_packet ot::render_tags(const opus_tags& tags)
{
size_t size = 8 + 4 + tags.vendor.size() + 4;
for (const std::string& comment : tags.comments)
size += 4 + comment.size();
size += tags.extra_data.size();
dynamic_ogg_packet op(size);
op.b_o_s = 0;
op.e_o_s = 0;
op.granulepos = 0;
op.packetno = 1;
unsigned char* data = op.packet;
uint32_t n;
memcpy(data, "OpusTags", 8);
n = htole32(tags.vendor.size());
memcpy(data+8, &n, 4);
memcpy(data+12, tags.vendor.data(), tags.vendor.size());
data += 12 + tags.vendor.size();
n = htole32(tags.comments.size());
memcpy(data, &n, 4);
data += 4;
for (const std::string& comment : tags.comments) {
n = htole32(comment.size());
memcpy(data, &n, 4);
memcpy(data+4, comment.data(), comment.size());
data += 4 + comment.size();
}
memcpy(data, tags.extra_data.data(), tags.extra_data.size());
return op;
}

27
src/opustags.cc Normal file
View File

@ -0,0 +1,27 @@
/**
* \file src/opustags.cc
* \brief Main function for opustags.
*
* See opustags.h for the program's documentation.
*/
#include <opustags.h>
#include <locale.h>
/**
* Main function of the opustags binary.
*
* Does practically nothing but call the cli module.
*/
int main(int argc, char** argv) {
setlocale(LC_ALL, "");
ot::options opt;
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());
return rc == ot::st::ok ? EXIT_SUCCESS : EXIT_FAILURE;
}

516
src/opustags.h Normal file
View File

@ -0,0 +1,516 @@
/**
* \file src/opustags.h
*
* Welcome to opustags!
*
* Let's have a quick tour around. The project is split into the following modules:
*
* - The system module provides a few generic tools for interating with the system.
* - The ogg module reads and writes Ogg files, letting you manipulate Ogg pages and packets.
* - The opus module parses the contents of Ogg packets according to the Opus specifications.
* - The cli module implements the main logic of the program.
* - The opustags module contains the main function, which is a simple wrapper around cli.
*
* Each module is implemented in its eponymous .cc file. Their interfaces are all defined and
* documented together in this header file. Look into the .cc files for implementation-specific
* details.
*
* To understand how this program works, you need to know what an Ogg files is made of, in
* particular the streams, pages, and packets. You hardly need any knowledge of the actual Opus
* audio codec, but need the RFC 7845 "Ogg Encapsulation for the Opus Audio Codec" that defines the
* format of the header packets that are essential to opustags.
*
*/
#pragma once
#include <config.h>
#include <iconv.h>
#include <ogg/ogg.h>
#include <stdio.h>
#include <time.h>
#include <functional>
#include <list>
#include <memory>
#include <optional>
#include <string>
#include <string_view>
#include <vector>
namespace ot {
/**
* Possible return status code, ranging from errors to special statuses. They are usually
* accompanied with a message with the #status structure.
*
* Functions that return non-ok status codes to signal special conditions like #end_of_stream should
* have it explictly mentionned in their documentation. By default, a non-ok status should be
* handled like an error.
*
* Error codes do not need to be ultra specific, and are mainly used to report special conditions to
* the caller function. Ultimately, only the error message in the #status is shown to the user.
*
* The cut error family means that the end of packet was reached when attempting to read the
* overflowing value. For example, cut_comment_count means that after reading the vendor string,
* less than 4 bytes were left in the packet.
*/
enum class st {
/* Generic */
ok,
error,
standard_error, /**< Error raised by the C standard library. */
int_overflow,
cancel,
/* System */
badly_encoded,
child_process_failed,
/* Ogg */
bad_stream,
end_of_stream,
libogg_error,
/* Opus */
bad_magic_number,
cut_magic_number,
cut_vendor_length,
cut_vendor_data,
cut_comment_count,
cut_comment_length,
cut_comment_data,
/* CLI */
bad_arguments,
};
/**
* Wraps a status code with an optional message. It is implictly converted to and from a
* #status_code.
*
* All the statuses except #st::ok should be accompanied with a relevant error message, in case it
* propagates back to the main function and is shown to the user.
*
* \todo Instead of being returned, it could be thrown. Most of the error handling code just let the
* status bubble. When we're confident about RAII, we're good to go. When we migrate, let's
* start from main and adapt the functions top-down.
*/
struct status {
status(st code = st::ok) : code(code) {}
template<class T> status(st code, T&& message) : code(code), message(message) {}
operator st() { return code; }
st code;
std::string message;
};
/***********************************************************************************************//**
* \defgroup system System
* \{
*/
/**
* Smart auto-closing FILE* handle.
*
* It implictly converts from an already opened FILE*.
*/
struct file : std::unique_ptr<FILE, decltype(&fclose)> {
file(FILE* f = nullptr) : std::unique_ptr<FILE, decltype(&fclose)>(f, &fclose) {}
};
/**
* A partial file is a temporary file created to store the result of something. When it is complete,
* it is moved to a final destination. Open it with #open and then you can either #commit it to save
* it to its destination, or you can #abort to delete the temporary file. When the #partial_file
* object is destroyed, it deletes the currently opened temporary file, if any.
*/
class partial_file {
public:
~partial_file() { abort(); }
/**
* Open a temporary file meant to be moved to the specified destination file path. The
* temporary file is created in the same directory as its destination in order to make the
* final move operation instant.
*/
ot::status open(const char* destination);
/** Close then move the partial file to its final location. */
ot::status commit();
/** Delete the temporary file. */
void abort();
/** Get the underlying FILE* handle. */
FILE* get() { return file.get(); }
/** Get the name of the temporary file. */
const char* name() const { return file == nullptr ? nullptr : temporary_name.c_str(); }
private:
std::string temporary_name;
std::string final_name;
ot::file file;
};
/** C++ wrapper for iconv. */
class encoding_converter {
public:
/**
* Allocate the iconv conversion state, initializing the given source and destination
* character encodings. If it's okay to have some information lost, make sure `to` ends with
* "//TRANSLIT", otherwise the conversion will fail when a character cannot be represented
* in the target encoding. See the documentation of iconv_open for details.
*/
encoding_converter(const char* from, const char* to);
~encoding_converter();
/**
* Convert text using iconv. If the input sequence is invalid, return #st::badly_encoded and
* abort the processing, leaving out in an undefined state.
*/
status operator()(std::string_view in, std::string& out);
private:
iconv_t cd; /**< conversion descriptor */
};
/** Escape a string so that a POSIX shell interprets it as a single argument. */
std::string shell_escape(std::string_view word);
/**
* Execute the editor process specified in editor. Wait for the process to exit and
* return st::ok on success, or st::child_process_failed if it did not exit with 0.
*
* editor is passed unescaped to the shell, and may contain CLI options.
* path is the name of the file to edit, which will be passed as the last argument to editor.
*/
ot::status run_editor(std::string_view editor, std::string_view path);
/**
* Return the specified paths mtime, i.e. the last data modification
* timestamp.
*/
ot::status get_file_timestamp(const char* path, timespec& mtime);
/** \} */
/***********************************************************************************************//**
* \defgroup ogg Ogg
* \{
*/
/**
* RAII-aware wrapper around libogg's ogg_stream_state. Though it handles automatic destruction, it
* does not prevent copying or implement move semantics correctly, so it's your responsibility to
* ensure these operations don't happen.
*/
struct ogg_logical_stream : ogg_stream_state {
ogg_logical_stream(int serialno) {
if (ogg_stream_init(this, serialno) != 0)
throw std::bad_alloc();
}
~ogg_logical_stream() {
ogg_stream_clear(this);
}
};
/**
* Identify the codec of a logical stream based on the first bytes of the first packet of the first
* page. For Opus, the first 8 bytes must be OpusHead. Any other signature is assumed to be another
* codec.
*/
bool is_opus_stream(const ogg_page& identification_header);
/**
* Ogg reader, combining a FILE input, an ogg_sync_state reading the pages.
*
* Call #read_page repeatedly until #status::end_of_stream to consume the stream, and use #page to
* check its content.
*
* \todo This class could be made more intuitive if it acted like an iterator, to be used like
* `for (ogg_page& page : ogg_reader(input))`, but the prerequisite for this is the ability to
* throw an exception on error.
*/
struct ogg_reader {
/**
* Initialize the reader with the given input file handle. The caller is responsible for
* keeping the file handle alive, and to close it.
*/
ogg_reader(FILE* input) : file(input) { ogg_sync_init(&sync); }
/**
* Clear all the internal memory allocated by libogg for the sync and stream state. The
* page and the packet are owned by these states, so nothing to do with them.
*
* The input file is not closed.
*/
~ogg_reader() { ogg_sync_clear(&sync); }
/**
* Read the next page from the input file. The result, provided the status is #status::ok,
* is made available in the #page field, is owned by the Ogg reader, and is valid until the
* next call to #read_page.
*
* After the last page was read, return #status::end_of_stream.
*/
status next_page();
/**
* Read the single packet contained in the last page read, assuming it's a header page, and
* call the function f on it. This function has no side effect, and calling it twice on the
* same page will read the same packet again.
*
* It is currently limited to packets that fit on a single page, and should be later
* extended to support packets spanning multiple pages.
*/
status process_header_packet(const std::function<status(ogg_packet&)>& f);
/**
* Current page from the sync state.
*
* Its memory is managed by libogg, inside the sync state, and is valid until the next call
* to ogg_sync_pageout, wrapped by #read_page.
*/
ogg_page page;
/**
* The file is our source of binary data. It is not integrated to libogg, so we need to
* handle it ourselves.
*
* The file is not owned by the ogg_reader instance.
*/
FILE* file;
/**
* The sync layer gets binary data and yields a sequence of pages.
*
* A page contains packets that we can extract using the #stream state, but we only do that
* for the headers. Once we got the OpusHead and OpusTags packets, all the following pages
* are simply forwarded to the Ogg writer.
*/
ogg_sync_state sync;
};
/**
* An Ogg writer lets you write ogg_page objets to an output file, and assemble packets into pages.
*
* Its packet writing facility is limited to writing single-page header packets, because that's all
* we need for opustags.
*/
struct ogg_writer {
/**
* Initialize the writer with the given output file handle. The caller is responsible for
* keeping the file handle alive, and to close it.
*/
explicit ogg_writer(FILE* output) : file(output) {}
/**
* Write a whole Ogg page into the output stream.
*
* This is a basic I/O operation and does not even require libogg, or the stream.
*/
status write_page(const ogg_page& page);
/**
* Write a header packet and flush the page. Header packets are always placed alone on their
* pages.
*/
status write_header_packet(int serialno, int pageno, ogg_packet& packet);
/**
* Output file. It should be opened in binary mode. We use it to write whole pages,
* represented as a block of data and a length.
*/
FILE* file;
/**
* Path to the output file.
*/
std::optional<std::string> path;
};
/**
* Ogg packet with dynamically allocated data.
*
* Provides a wrapper around libogg's ogg_packet with RAII.
*/
struct dynamic_ogg_packet : ogg_packet {
/** Construct an ogg_packet of the given size. */
explicit dynamic_ogg_packet(size_t size) {
bytes = size;
data = std::make_unique<unsigned char[]>(size);
packet = data.get();
}
private:
/** Owning reference to the data. Use the packet field from ogg_packet instead. */
std::unique_ptr<unsigned char[]> data;
};
/** \} */
/***********************************************************************************************//**
* \defgroup opus Opus
* \{
*/
/**
* Faithfully represent *all* the data in an OpusTags packet, exactly as they will be written in the
* final stream, disregarding the current system locale or anything else.
*
* The vendor and comment strings are expected to contain valid UTF-8, but we should keep their
* values intact even if the string is not UTF-8 clean, or encoded in any other way.
*/
struct opus_tags {
/**
* OpusTags packets begin with a vendor string, meant to identify the implementation of the
* encoder. It is expected to be an arbitrary UTF-8 string.
*/
std::string vendor;
/**
* Comments are strings in the NAME=Value format. A comment may also be called a field, or a
* tag.
*
* The field name in vorbis comments is usually case-insensitive and ASCII, while the value
* can be any valid UTF-8 string. The specification is not too clear for Opus, but let's
* assume it's the same.
*/
std::list<std::string> comments;
/**
* According to RFC 7845:
* > Immediately following the user comment list, the comment header MAY contain
* > zero-padding or other binary data that is not specified here.
*
* The first byte is supposed to indicate whether this data should be kept or not, but let's
* assume it's here for a reason and always keep it. Better safe than sorry.
*
* In the future, we could add options to manipulate this data: view it, edit it, truncate
* it if it's marked as padding, truncate it unconditionally.
*/
std::string extra_data;
};
/**
* Read the given OpusTags packet and extract its content into an opus_tags object.
*
* On error, the tags object is left unchanged.
*/
status parse_tags(const ogg_packet& packet, opus_tags& tags);
/**
* Serialize an #opus_tags object into an OpusTags Ogg packet.
*/
dynamic_ogg_packet render_tags(const opus_tags& tags);
/** \} */
/***********************************************************************************************//**
* \defgroup cli Command-Line Interface
* \{
*/
/**
* Structured representation of the command-line arguments to opustags.
*/
struct options {
/**
* When true, opustags prints a detailed help and exits. All the other options are ignored.
*
* Option: --help
*/
bool print_help = false;
/**
* Paths to the input files. The special string "-" means stdin.
*
* At least one input file must be given. If `--in-place` is used,
* more than one may be given.
*/
std::vector<std::string> paths_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
*/
std::optional<std::string> path_out;
/**
* By default, opustags won't overwrite the output file if it already exists. This can be
* forced with --overwrite. It is also enabled by --in-place.
*
* Options: --overwrite, --in-place
*/
bool overwrite = false;
/**
* Process files in-place.
*
* Options: --in-place
*/
bool in_place = false;
/**
* Spawn EDITOR to edit tags interactively.
*
* stdin and stdout must be left free for the editor, so paths_in and
* path_out cant take `-`, and --set-all is not supported.
*
* Option: --edit
*/
bool edit_interactively = false;
/**
* List of comments to delete. Each string is a selector according to the definition of
* #delete_comments.
*
* When #delete_all is true, this option is meaningless.
*
* #to_add takes precedence over #to_delete, so if the same comment appears in both lists,
* the one in #to_delete applies only to the previously existing tags.
*
* The strings are stored in UTF-8.
*
* Option: --delete, --set
*/
std::list<std::string> to_delete;
/**
* Delete all the existing comments.
*
* Option: --delete-all, --set-all
*/
bool delete_all = false;
/**
* List of comments to add, in the current system encoding. For exemple `TITLE=a b c`. They
* must be valid.
*
* The strings are stored in UTF-8.
*
* Options: --add, --set, --set-all
*/
std::list<std::string> to_add;
/**
* Disable encoding conversions. OpusTags are specified to always be encoded as UTF-8, but
* if for some reason a specific file contains binary tags that someone would like to
* extract and set as-is, encoding conversion would get in the way.
*/
bool raw = false;
};
/**
* Parse the command-line arguments. Does not perform I/O related validations, but checks the
* consistency of its arguments. Comments are read if necessary from the given stream.
*
* On error, the state of the options structure is unspecified.
*/
status parse_options(int argc, char** argv, options& opt, FILE* comments);
/**
* Print all the comments, separated by line breaks. Since a comment may contain line breaks, this
* output is not completely reliable, but it fits most cases.
*
* The comments must be encoded in UTF-8, and are converted to the system locale when printed.
*
* The output generated is meant to be parseable by #ot::read_comments.
*/
status print_comments(const std::list<std::string>& comments, FILE* output, bool raw);
/**
* Parse the comments outputted by #ot::print_comments.
*
* The comments are converted from the system encoding to UTF-8, and returned as UTF-8.
*/
status read_comments(FILE* input, std::list<std::string>& comments, bool raw);
/**
* Remove all comments matching the specified selector, which may either be a field name or a
* NAME=VALUE pair. The field name is case-insensitive.
*
* The strings are all UTF-8.
*/
void delete_comments(std::list<std::string>& comments, const std::string& selector);
/**
* Main entry point to the opustags program, and pretty much the same as calling opustags from the
* command-line.
*/
status run(const options& opt);
/** \} */
}

190
src/system.cc Normal file
View File

@ -0,0 +1,190 @@
/**
* \file src/system.cc
* \ingroup system
*
* Provide a high-level interface to system-related features, like filesystem manipulations.
*
* Ideally, all OS-specific features should be grouped here.
*
* This modules shoumd not depend on any other opustags module.
*/
#include <opustags.h>
#include <errno.h>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std::string_literals;
ot::status ot::partial_file::open(const char* destination)
{
abort();
final_name = destination;
temporary_name = final_name + ".XXXXXX.part";
int fd = mkstemps(const_cast<char*>(temporary_name.data()), 5);
if (fd == -1)
return {st::standard_error,
"Could not create a partial file for '" + final_name + "': " +
strerror(errno)};
file = fdopen(fd, "w");
if (file == nullptr)
return {st::standard_error,
"Could not get the partial file handle to '" + temporary_name + "': " +
strerror(errno)};
return st::ok;
}
static mode_t get_umask()
{
// libc doesnt seem to provide a way to get umask without changing it, so we need this workaround.
// https://www.gnu.org/software/libc/manual/html_node/Setting-Permissions.html
mode_t mask = umask(0);
umask(mask);
return mask;
}
/**
* Try reproducing the file permissions of file `source` onto file `dest`. If
* this fails for whatever reason, print a warning and leave the current
* permissions. When the source doesnt exist, use the default file creation
* permissions according to umask.
*/
static void copy_permissions(const char* source, const char* dest)
{
mode_t target_mode;
struct stat source_stat;
if (stat(source, &source_stat) == 0) {
// We could technically preserve a bit more than that but who
// would ever need S_ISUID and friends on an Opus file?
target_mode = source_stat.st_mode & 0777;
} else if (errno == ENOENT) {
target_mode = 0666 & ~get_umask();
} else {
fprintf(stderr, "warning: Could not read mode of %s: %s\n", source, strerror(errno));
return;
}
if (chmod(dest, target_mode) == -1)
fprintf(stderr, "warning: Could not set mode of %s: %s\n", dest, strerror(errno));
}
ot::status ot::partial_file::commit()
{
if (file == nullptr)
return st::ok;
file.reset();
copy_permissions(final_name.c_str(), temporary_name.c_str());
if (rename(temporary_name.c_str(), final_name.c_str()) == -1)
return {st::standard_error,
"Could not move the result file '" + temporary_name + "' to '" +
final_name + "': " + strerror(errno) + "."};
return st::ok;
}
void ot::partial_file::abort()
{
if (file == nullptr)
return;
file.reset();
remove(temporary_name.c_str());
}
ot::encoding_converter::encoding_converter(const char* from, const char* to)
{
cd = iconv_open(to, from);
if (cd == (iconv_t) -1)
throw std::bad_alloc();
}
ot::encoding_converter::~encoding_converter()
{
iconv_close(cd);
}
ot::status ot::encoding_converter::operator()(std::string_view in, std::string& out)
{
iconv(cd, nullptr, nullptr, nullptr, nullptr);
out.clear();
out.reserve(in.size());
char* in_cursor = const_cast<char*>(in.data());
size_t in_left = in.size();
constexpr size_t chunk_size = 1024;
char chunk[chunk_size];
for (;;) {
char *out_cursor = chunk;
size_t out_left = chunk_size;
size_t rc = iconv(cd, &in_cursor, &in_left, &out_cursor, &out_left);
if (rc == (size_t) -1 && errno == E2BIG) {
// Loop normally.
} else if (rc == (size_t) -1) {
return {ot::st::badly_encoded, strerror(errno) + "."s};
} else if (rc != 0) {
return {ot::st::badly_encoded,
"Some characters could not be converted into the target encoding."};
}
out.append(chunk, out_cursor - chunk);
if (in_cursor == nullptr)
break;
else if (in_left == 0)
in_cursor = nullptr;
}
return ot::st::ok;
}
std::string ot::shell_escape(std::string_view word)
{
std::string escaped_word;
// Pre-allocate the result, assuming most of the time enclosing it in single quotes is enough.
escaped_word.reserve(2 + word.size());
escaped_word += '\'';
for (char c : word) {
if (c == '\'')
escaped_word += "'\\''";
else if (c == '!')
escaped_word += "'\\!'";
else
escaped_word += c;
}
escaped_word += '\'';
return escaped_word;
}
ot::status ot::run_editor(std::string_view editor, std::string_view path)
{
std::string command = std::string(editor) + " " + shell_escape(path);
int status = system(command.c_str());
if (status == -1)
return {st::standard_error, "waitpid error: "s + strerror(errno)};
else if (!WIFEXITED(status))
return {st::child_process_failed,
"Child process did not terminate normally: "s + strerror(errno)};
else if (WEXITSTATUS(status) != 0)
return {st::child_process_failed,
"Child process exited with " + std::to_string(WEXITSTATUS(status))};
return st::ok;
}
ot::status ot::get_file_timestamp(const char* path, timespec& mtime)
{
struct stat st;
if (stat(path, &st) == -1)
return {st::standard_error, path + ": stat error: "s + strerror(errno)};
#if defined(HAVE_STAT_ST_MTIM)
mtime = st.st_mtim;
#elif defined(HAVE_STAT_ST_MTIMESPEC)
mtime = st.st_mtimespec;
#else
mtime.tv_sec = st.st_mtime;
mtime.tv_nsec = st.st_mtimensec;
#endif
return st::ok;
}

22
t/CMakeLists.txt Normal file
View File

@ -0,0 +1,22 @@
add_executable(system.t EXCLUDE_FROM_ALL system.cc)
target_link_libraries(system.t ot)
add_executable(opus.t EXCLUDE_FROM_ALL opus.cc)
target_link_libraries(opus.t ot)
add_executable(ogg.t EXCLUDE_FROM_ALL ogg.cc)
target_link_libraries(ogg.t ot)
add_executable(cli.t EXCLUDE_FROM_ALL cli.cc)
target_link_libraries(cli.t ot)
add_executable(oggdump EXCLUDE_FROM_ALL oggdump.cc)
target_link_libraries(oggdump ot)
configure_file(gobble.opus . COPYONLY)
add_custom_target(
check
COMMAND prove "${CMAKE_CURRENT_BINARY_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}"
DEPENDS opustags gobble.opus system.t opus.t ogg.t cli.t
)

197
t/cli.cc Normal file
View File

@ -0,0 +1,197 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
using namespace std::literals::string_literals;
void check_read_comments()
{
std::list<std::string> comments;
ot::status rc;
{
std::string txt = "TITLE=a b c\n\nARTIST=X\nArtist=Y\n"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
rc = ot::read_comments(input.get(), comments, false);
if (rc != ot::st::ok)
throw failure("could not read comments");
auto&& expected = {"TITLE=a b c", "ARTIST=X", "Artist=Y"};
if (!std::equal(comments.begin(), comments.end(), expected.begin(), expected.end()))
throw failure("parsed user comments did not match expectations");
}
{
std::string txt = "CORRUPTED=\xFF\xFF\n"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
rc = ot::read_comments(input.get(), comments, false);
if (rc != ot::st::badly_encoded)
throw failure("did not get the expected error reading corrupted data");
}
{
std::string txt = "RAW=\xFF\xFF\n"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
rc = ot::read_comments(input.get(), comments, true);
if (rc != ot::st::ok)
throw failure("could not read comments");
if (comments.front() != "RAW=\xFF\xFF")
throw failure("parsed user comments did not match expectations");
}
{
std::string txt = "MALFORMED\n"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
rc = ot::read_comments(input.get(), comments, false);
if (rc != ot::st::error)
throw failure("did not get the expected error reading malformed comments");
}
}
/**
* Wrap #ot::parse_options with a higher-level interface much more convenient for testing.
* In practice, the argc/argv combo are enough though for the current state of opustags.
*/
static ot::status parse_options(const std::vector<const char*>& args, ot::options& opt, FILE *comments)
{
int argc = args.size();
char* argv[argc];
for (int i = 0; i < argc; ++i)
argv[i] = strdup(args[i]);
ot::status rc = ot::parse_options(argc, argv, opt, comments);
for (int i = 0; i < argc; ++i)
free(argv[i]);
return rc;
}
void check_good_arguments()
{
auto parse = [](std::vector<const char*> args) {
ot::options opt;
std::string txt = "N=1\n"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
ot::status rc = parse_options(args, opt, input.get());
if (rc.code != ot::st::ok)
throw failure("unexpected option parsing error");
return opt;
};
ot::options opt;
opt = parse({"opustags", "--help", "x", "-o", "y"});
if (!opt.print_help)
throw failure("did not catch --help");
opt = parse({"opustags", "x", "--output", "y", "-D", "-s", "X=Y Z", "-d", "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.front() != "X" || *std::next(opt.to_delete.begin()) != "a=b" ||
opt.to_add != std::list<std::string>{"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.paths_in.size() != 1 || opt.paths_in.front() != "x" || opt.path_out ||
!opt.overwrite || opt.to_delete.size() != 0 ||
opt.to_add != std::list<std::string>{"N=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");
opt = parse({"opustags", "-ie", "x"});
if (opt.paths_in.size() != 1 || opt.paths_in[0] != "x" ||
!opt.edit_interactively || !opt.overwrite || !opt.in_place)
throw failure("unexpected option parsing result for case #4");
opt = parse({"opustags", "-a", "X=\xFF", "--raw", "x"});
if (!opt.raw || opt.to_add.front() != "X=\xFF")
throw failure("--raw did not disable transcoding");
}
void check_bad_arguments()
{
auto error_code_case = [](std::vector<const char*> args, const char* message, ot::st error_code, const std::string& name) {
ot::options opt;
std::string txt = "N=1\nINVALID"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
ot::status rc = parse_options(args, opt, input.get());
if (rc.code != error_code)
throw failure("bad error code for case " + name);
if (rc.message != message)
throw failure("bad error message for case " + name + ", got: " + rc.message);
};
auto error_case = [&error_code_case](std::vector<const char*> args, const char* message, const std::string& name) {
error_code_case(args, message, ot::st::bad_arguments, name);
};
error_case({"opustags"}, "No arguments specified. Use -h for help.", "no arguments");
error_case({"opustags", "-a", "X"}, "Comment does not contain an equal sign: X.", "bad comment for -a");
error_case({"opustags", "--set", "X"}, "Comment does not contain an equal sign: X.", "bad comment for --set");
error_case({"opustags", "-a"}, "Missing value for option '-a'.", "short option with missing value");
error_case({"opustags", "--add"}, "Missing value for option '--add'.", "long option with missing value");
error_case({"opustags", "-x"}, "Unrecognized option '-x'.", "unrecognized short option");
error_case({"opustags", "--derp"}, "Unrecognized option '--derp'.", "unrecognized long option");
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", "-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");
error_case({"opustags", "-i", "-"}, "Cannot modify standard input in place.", "write stdin in-place");
error_case({"opustags", "-o", "x", "--output", "y", "z"},
"Cannot specify --output more than once.", "double output");
error_code_case({"opustags", "-S", "x"}, "Malformed tag: INVALID", ot::st::error, "attempt to read invalid argument with -S");
error_case({"opustags", "-o", "", "--output", "y", "z"},
"Cannot specify --output more than once.", "double output with first filename empty");
error_case({"opustags", "-e", "-i", "x", "y"},
"Exactly one input file must be specified.", "editing interactively two files at once");
error_case({"opustags", "--edit", "-", "-o", "x"},
"Cannot edit interactively when standard input or standard output are already used.",
"editing interactively from stdandard intput");
error_case({"opustags", "--edit", "x", "-o", "-"},
"Cannot edit interactively when standard input or standard output are already used.",
"editing interactively to stdandard output");
error_case({"opustags", "--edit", "x"}, "Cannot edit interactively when no output is specified.", "editing without output");
error_case({"opustags", "--edit", "x", "-i", "-a", "X=Y"}, "Cannot mix --edit with -adDsS.", "mixing -e and -a");
error_case({"opustags", "--edit", "x", "-i", "-d", "X"}, "Cannot mix --edit with -adDsS.", "mixing -e and -d");
error_case({"opustags", "--edit", "x", "-i", "-D"}, "Cannot mix --edit with -adDsS.", "mixing -e and -D");
error_case({"opustags", "--edit", "x", "-i", "-S"}, "Cannot mix --edit with -adDsS.", "mixing -e and -S");
error_case({"opustags", "-d", "\xFF", "x"},
"Could not encode argument into UTF-8: Invalid or incomplete multibyte or wide character.",
"-d with binary data");
error_case({"opustags", "-a", "X=\xFF", "x"},
"Could not encode argument into UTF-8: Invalid or incomplete multibyte or wide character.",
"-a with binary data");
error_case({"opustags", "-s", "X=\xFF", "x"},
"Could not encode argument into UTF-8: Invalid or incomplete multibyte or wide character.",
"-s with binary data");
}
static void check_delete_comments()
{
using C = std::list<std::string>;
C original = {"TITLE=X", "Title=Y", "Title=Z", "ARTIST=A", "artIst=B"};
C edited = original;
ot::delete_comments(edited, "derp");
if (!std::equal(edited.begin(), edited.end(), original.begin(), original.end()))
throw failure("should not have deleted anything");
ot::delete_comments(edited, "Title");
C expected = {"ARTIST=A", "artIst=B"};
if (!std::equal(edited.begin(), edited.end(), expected.begin(), expected.end()))
throw failure("did not delete all titles correctly");
edited = original;
ot::delete_comments(edited, "titlE=Y");
ot::delete_comments(edited, "Title=z");
expected = {"TITLE=X", "Title=Z", "ARTIST=A", "artIst=B"};
if (!std::equal(edited.begin(), edited.end(), expected.begin(), expected.end()))
throw failure("did not delete a specific title correctly");
}
int main(int argc, char **argv)
{
std::cout << "1..4\n";
run(check_read_comments, "check tags parsing");
run(check_good_arguments, "check options parsing");
run(check_bad_arguments, "check options parsing errors");
run(check_delete_comments, "delete comments");
return 0;
}

BIN
t/gobble.opus Normal file

Binary file not shown.

170
t/ogg.cc Normal file
View File

@ -0,0 +1,170 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
static void check_ref_ogg()
{
ot::file input = fopen("gobble.opus", "r");
if (input == nullptr)
throw failure("could not open gobble.opus");
ot::ogg_reader reader(input.get());
ot::status rc = reader.next_page();
if (rc != ot::st::ok)
throw failure("could not read the first page");
if (!ot::is_opus_stream(reader.page))
throw failure("failed to identify the stream as opus");
rc = reader.process_header_packet([](ogg_packet& p) {
if (p.bytes != 19)
throw failure("unexpected length for the first packet");
return ot::st::ok;
});
if (rc != ot::st::ok)
throw failure("could not read the first packet");
rc = reader.next_page();
if (rc != ot::st::ok)
throw failure("could not read the second page");
rc = reader.process_header_packet([](ogg_packet& p) {
if (p.bytes != 62)
throw failure("unexpected length for the second packet");
return ot::st::ok;
});
if (rc != ot::st::ok)
throw failure("could not read the second packet");
while (!ogg_page_eos(&reader.page)) {
rc = reader.next_page();
if (rc != ot::st::ok)
throw failure("failure reading a page");
}
rc = reader.next_page();
if (rc != ot::st::end_of_stream)
throw failure("did not correctly detect the end of stream");
}
static ogg_packet make_packet(const char* contents)
{
ogg_packet op {};
op.bytes = strlen(contents);
op.packet = (unsigned char*) contents;
return op;
}
static bool same_packet(const ogg_packet& lhs, const ogg_packet& rhs)
{
return lhs.bytes == rhs.bytes && memcmp(lhs.packet, rhs.packet, lhs.bytes) == 0;
}
/**
* Build an in-memory Ogg stream using ogg_writer, and then read it with ogg_reader.
*/
static void check_memory_ogg()
{
ogg_packet first_packet = make_packet("First");
ogg_packet second_packet = make_packet("Second");
std::vector<unsigned char> my_ogg(128);
size_t my_ogg_size;
ot::status rc;
{
ot::file output = fmemopen(my_ogg.data(), my_ogg.size(), "w");
if (output == nullptr)
throw failure("could not open the output stream");
ot::ogg_writer writer(output.get());
writer.write_header_packet(1234, 0, first_packet);
if (rc != ot::st::ok)
throw failure("could not write the first packet");
writer.write_header_packet(1234, 1, second_packet);
if (rc != ot::st::ok)
throw failure("could not write the second packet");
my_ogg_size = ftell(output.get());
if (my_ogg_size != 67)
throw failure("unexpected output size");
}
{
ot::file input = fmemopen(my_ogg.data(), my_ogg_size, "r");
if (input == nullptr)
throw failure("could not open the input stream");
ot::ogg_reader reader(input.get());
rc = reader.next_page();
if (rc != ot::st::ok)
throw failure("could not read the first page");
rc = reader.process_header_packet([&first_packet](ogg_packet &p) {
if (!same_packet(p, first_packet))
throw failure("unexpected content in the first packet");
return ot::st::ok;
});
if (rc != ot::st::ok)
throw failure("could not read the first packet");
rc = reader.next_page();
if (rc != ot::st::ok)
throw failure("could not read the second page");
rc = reader.process_header_packet([&second_packet](ogg_packet &p) {
if (!same_packet(p, second_packet))
throw failure("unexpected content in the second packet");
return ot::st::ok;
});
if (rc != ot::st::ok)
throw failure("could not read the second packet");
rc = reader.next_page();
if (rc != ot::st::end_of_stream)
throw failure("unexpected third page");
}
}
void check_bad_stream()
{
auto err_msg = "did not detect the stream is not an ogg stream";
ot::file input = fmemopen((void*) err_msg, 20, "r");
ot::ogg_reader reader(input.get());
ot::status rc = reader.next_page();
if (rc != ot::st::bad_stream)
throw failure(err_msg);
}
void check_identification()
{
auto good_header = (unsigned char*)
"\x4f\x67\x67\x53\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x42\xf2"
"\xe6\xc7\x00\x00\x00\x00\x7e\xc3\x57\x2b\x01\x13";
auto good_body = (unsigned char*) "OpusHeadABCD";
ogg_page id;
id.header = good_header;
id.header_len = 28;
id.body = good_body;
id.body_len = 12;
if (!ot::is_opus_stream(id))
throw failure("could not identify opus header");
// Bad body
id.body_len = 7;
if (ot::is_opus_stream(id))
throw failure("opus header was too short to be valid");
id.body_len = 12;
id.body = (unsigned char*) "Not_OpusHead";
if (ot::is_opus_stream(id))
throw failure("was not an opus header");
id.body = good_body;
// Remove the BoS bit from the header.
id.header = (unsigned char*)
"\x4f\x67\x67\x53\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x42\xf2"
"\xe6\xc7\x00\x00\x00\x00\x7e\xc3\x57\x2b\x01\x13";
if (ot::is_opus_stream(id))
throw failure("was not the beginning of a stream");
}
int main(int argc, char **argv)
{
std::cout << "1..4\n";
run(check_ref_ogg, "check a reference ogg stream");
run(check_memory_ogg, "build and check a fresh stream");
run(check_bad_stream, "read a non-ogg stream");
run(check_identification, "stream identification");
return 0;
}

42
t/oggdump.cc Normal file
View File

@ -0,0 +1,42 @@
/**
* \file t/oggdump.cc
*
* Dump brief information about the pages containted in an Ogg file.
*
* This tool is not build by default or installed, and is mainly meant to help understand how Ogg
* files are built, and to debug.
*/
#include <opustags.h>
#include <iostream>
#include <string.h>
int main(int argc, char** argv)
{
if (argc != 2) {
std::cerr << "Usage: oggdump FILE\n";
return 1;
}
ot::file input = fopen(argv[1], "r");
if (input == nullptr) {
std::cerr << "Error opening '" << argv[1] << "': " << strerror(errno) << "\n";
return 1;
}
ot::ogg_reader reader(input.get());
ot::status rc;
while ((rc = reader.read_page()) == ot::st::ok) {
std::cout << "Stream " << ogg_page_serialno(&reader.page) << ", "
"page #" << ogg_page_pageno(&reader.page) << ", "
<< ogg_page_packets(&reader.page) << " packet(s)";
if (ogg_page_bos(&reader.page)) std::cout << ", BoS";
if (ogg_page_eos(&reader.page)) std::cout << ", EoS";
if (ogg_page_continued(&reader.page)) std::cout << ", continued";
std::cout << "\n";
}
if (rc != ot::st::ok && rc != ot::st::end_of_stream) {
std::cerr << "error: " << rc.message << "\n";
return 1;
}
return 0;
}

147
t/opus.cc Normal file
View File

@ -0,0 +1,147 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
using namespace std::literals::string_literals;
static const char standard_OpusTags[] =
"OpusTags"
"\x14\x00\x00\x00" "opustags test packet"
"\x02\x00\x00\x00"
"\x09\x00\x00\x00" "TITLE=Foo"
"\x0a\x00\x00\x00" "ARTIST=Bar";
static void parse_standard()
{
ot::opus_tags tags;
ogg_packet op;
op.bytes = sizeof(standard_OpusTags) - 1;
op.packet = (unsigned char*) standard_OpusTags;
auto rc = ot::parse_tags(op, tags);
if (rc != ot::st::ok)
throw failure("ot::parse_tags did not return ok");
if (tags.vendor != "opustags test packet")
throw failure("bad vendor string");
if (tags.comments.size() != 2)
throw failure("bad number of comments");
auto it = tags.comments.begin();
if (*it != "TITLE=Foo")
throw failure("bad title");
++it;
if (*it != "ARTIST=Bar")
throw failure("bad artist");
if (tags.extra_data.size() != 0)
throw failure("found mysterious padding data");
}
/**
* Try parse_tags with packets that should not valid, or that might even
* corrupt the memory. Run this one with valgrind to ensure we're not
* overflowing.
*/
static void parse_corrupted()
{
size_t size = sizeof(standard_OpusTags);
char packet[size];
memcpy(packet, standard_OpusTags, size);
ot::opus_tags tags;
ogg_packet op;
op.packet = (unsigned char*) packet;
op.bytes = size;
char* header_data = packet;
char* vendor_length = header_data + 8;
char* vendor_string = vendor_length + 4;
char* comment_count = vendor_string + *vendor_length;
char* first_comment_length = comment_count + 4;
char* first_comment_data = first_comment_length + 4;
char* end = packet + size;
op.bytes = 7;
if (ot::parse_tags(op, tags) != ot::st::cut_magic_number)
throw failure("did not detect the overflowing magic number");
op.bytes = 11;
if (ot::parse_tags(op, tags) != ot::st::cut_vendor_length)
throw failure("did not detect the overflowing vendor string length");
op.bytes = size;
header_data[0] = 'o';
if (ot::parse_tags(op, tags) != ot::st::bad_magic_number)
throw failure("did not detect the bad magic number");
header_data[0] = 'O';
*vendor_length = end - vendor_string + 1;
if (ot::parse_tags(op, tags) != ot::st::cut_vendor_data)
throw failure("did not detect the overflowing vendor string");
*vendor_length = end - vendor_string - 3;
if (ot::parse_tags(op, tags) != ot::st::cut_comment_count)
throw failure("did not detect the overflowing comment count");
*vendor_length = comment_count - vendor_string;
++*comment_count;
if (ot::parse_tags(op, tags) != ot::st::cut_comment_length)
throw failure("did not detect the overflowing comment length");
*first_comment_length = end - first_comment_data + 1;
if (ot::parse_tags(op, tags) != ot::st::cut_comment_data)
throw failure("did not detect the overflowing comment data");
}
static void recode_standard()
{
ot::opus_tags tags;
ogg_packet op;
op.bytes = sizeof(standard_OpusTags) - 1;
op.packet = (unsigned char*) standard_OpusTags;
auto rc = ot::parse_tags(op, tags);
if (rc != ot::st::ok)
throw failure("ot::parse_tags did not return ok");
auto packet = ot::render_tags(tags);
if (packet.b_o_s != 0)
throw failure("b_o_s should not be set");
if (packet.e_o_s != 0)
throw failure("e_o_s should not be set");
if (packet.granulepos != 0)
throw failure("granule_post should be 0");
if (packet.packetno != 1)
throw failure("packetno should be 1");
if (packet.bytes != sizeof(standard_OpusTags) - 1)
throw failure("the packet is not the right size");
if (memcmp(packet.packet, standard_OpusTags, packet.bytes) != 0)
throw failure("the rendered packet is not what we expected");
}
static void recode_padding()
{
ot::opus_tags tags;
std::string padded_OpusTags(standard_OpusTags, sizeof(standard_OpusTags));
// ^ note: padded_OpusTags ends with a null byte here
padded_OpusTags += "hello";
ogg_packet op;
op.bytes = padded_OpusTags.size();
op.packet = (unsigned char*) padded_OpusTags.data();
auto rc = ot::parse_tags(op, tags);
if (rc != ot::st::ok)
throw failure("ot::parse_tags did not return ok");
if (tags.extra_data != "\0hello"s)
throw failure("corrupted extra data");
// recode the packet and ensure it's exactly the same
auto packet = ot::render_tags(tags);
if (static_cast<size_t>(packet.bytes) < padded_OpusTags.size())
throw failure("the packet was truncated");
if (static_cast<size_t>(packet.bytes) > padded_OpusTags.size())
throw failure("the packet got too big");
if (memcmp(packet.packet, padded_OpusTags.data(), packet.bytes) != 0)
throw failure("the rendered packet is not what we expected");
}
int main()
{
std::cout << "1..4\n";
run(parse_standard, "parse a standard OpusTags packet");
run(parse_corrupted, "correctly reject invalid packets");
run(recode_standard, "recode a standard OpusTags packet");
run(recode_padding, "recode a OpusTags packet with padding");
return 0;
}

313
t/opustags.t Executable file
View File

@ -0,0 +1,313 @@
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use Test::More tests => 50;
use Digest::MD5;
use File::Basename;
use File::Copy;
use IPC::Open3;
use List::MoreUtils qw(any);
use Symbol 'gensym';
my $opustags = '../opustags';
BAIL_OUT("$opustags does not exist or is not executable") if (! -x $opustags);
my $is_utf8;
open(my $ctype, 'locale -k LC_CTYPE |');
while (<$ctype>) { $is_utf8 = 1 if (/^charmap="UTF-?8"$/i) }
close($ctype);
BAIL_OUT("this test must be run from an UTF-8 environment") unless $is_utf8;
sub opustags {
my %opt;
%opt = %{pop @_} if ref $_[-1];
my ($pid, $pin, $pout, $perr);
$perr = gensym;
$pid = open3($pin, $pout, $perr, $opustags, @_);
binmode($pin, $opt{mode} // ':utf8');
binmode($pout, $opt{mode} // ':utf8');
binmode($perr, ':utf8');
local $/;
print $pin $opt{in} if defined $opt{in};
close $pin;
my $out = <$pout>;
my $err = <$perr>;
waitpid($pid, 0);
[$out, $err, $?]
}
####################################################################################################
# Tests related to the overall opustags executable, like the help message.
# No Opus file is manipulated here.
is_deeply(opustags(), ['', <<EOF, 256], 'no options is a failure');
error: No arguments specified. Use -h for help.
EOF
my $help = opustags('--help');
$help->[0] =~ /^([^\n]*+)/;
my $version = $1;
like($version, qr/^opustags version (\d+\.\d+\.\d+)/, 'get the version string');
my $expected_help = <<"EOF";
$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 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
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
--raw disable encoding conversion
See the man page for extensive documentation.
EOF
is_deeply(opustags('--help'), [$expected_help, '', 0], '--help displays the help message');
is_deeply(opustags('-h'), [$expected_help, '', 0], '-h displays the help message too');
is_deeply(opustags('--derp'), ['', <<"EOF", 256], 'unrecognized option shows an error');
error: Unrecognized option '--derp'.
EOF
is_deeply(opustags('../opustags'), ['', <<"EOF", 256], 'not an Ogg stream');
../opustags: error: Input is not a valid Ogg file.
EOF
####################################################################################################
# Test the main features of opustags on an Ogg Opus sample file.
sub md5 {
my ($file) = @_;
open(my $fh, '<', $file) or return;
my $ctx = Digest::MD5->new;
$ctx->addfile($fh);
$ctx->hexdigest
}
is(md5('gobble.opus'), '111a483596ac32352fbce4d14d16abd2', 'the sample is the one we expect');
is_deeply(opustags('gobble.opus'), [<<'EOF', '', 0], 'read the initial tags');
encoder=Lavc58.18.100 libopus
EOF
unlink('out.opus');
my $previous_umask = umask(0022);
is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', '', 0], 'copy the file without changes');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'the copy is faithful');
is((stat 'out.opus')[2] & 0777, 0644, 'apply umask on new files');
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');
gobble.opus: error: 'out.opus' already exists. Use -y to overwrite.
EOF
is(md5('out.opus'), 'd41d8cd98f00b204e9800998ecf8427e', 'the output wasn\'t written');
is_deeply(opustags(qw(gobble.opus -o /dev/null)), ['', '', 0], 'write to /dev/null');
chmod(0604, 'out.opus');
is_deeply(opustags(qw(gobble.opus -o out.opus --overwrite)), ['', '', 0], 'overwrite');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'successfully overwritten');
is((stat 'out.opus')[2] & 0777, 0604, 'overwriting preserves output file\'s mode');
chmod(0700, 'out.opus');
is_deeply(opustags(qw(--in-place out.opus -a A=B --add=A=C --add), "TITLE=Foo Bar",
qw(--delete A --add TITLE=七面鳥 --set encoder=whatever -s 1=2 -s X=1 -a X=2 -s X=3)),
['', '', 0], 'complex tag editing');
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'check the footprint');
is((stat 'out.opus')[2] & 0777, 0700, 'in-place editing preserves file mode');
is_deeply(opustags('out.opus'), [<<'EOF', '', 0], 'check the tags written');
A=B
A=C
TITLE=Foo Bar
TITLE=七面鳥
encoder=whatever
1=2
X=1
X=2
X=3
EOF
is_deeply(opustags(qw(out.opus -d A -d foo -s X=4 -a TITLE=gobble -d title=七面鳥)), [<<'EOF', '', 0], 'dry editing');
TITLE=Foo Bar
encoder=whatever
1=2
X=4
TITLE=gobble
EOF
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
is_deeply(opustags(qw(-i out.opus -a fatal=yes -a FOO -a BAR)), ['', <<'EOF', 256], 'bad tag with --add');
error: Comment does not contain an equal sign: FOO.
EOF
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
is_deeply(opustags('out.opus', '-D', '-a', "X=foo\nbar\tquux"), [<<'END_OUT', <<'END_ERR', 0], 'control characters');
X=foo
bar quux
END_OUT
warning: Some tags contain unsupported newline characters.
warning: Some tags contain control characters.
END_ERR
is_deeply(opustags(qw(-i out.opus -s fatal=yes -s FOO -s BAR)), ['', <<'EOF', 256], 'bad tag with --set');
error: Comment does not contain an equal sign: FOO.
EOF
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
is_deeply(opustags(qw(out.opus --delete-all -a OK=yes)), [<<'EOF', '', 0], 'delete all');
OK=yes
EOF
is_deeply(opustags(qw(out.opus --set-all -a A=B -s X=Z -d OK), {in => <<'END_IN'}), [<<'END_OUT', '', 0], 'set all');
OK=yes again
ARTIST=七面鳥
A=A
X=Y
#IGNORE=COMMENTS
END_IN
OK=yes again
ARTIST=七面鳥
A=A
X=Y
A=B
X=Z
END_OUT
is_deeply(opustags(qw(out.opus -S), {in => <<'END_IN'}), [<<'END_OUT', <<'END_ERR', 256], 'set all with bad tags');
whatever
wrong=yes
END_IN
END_OUT
error: Malformed tag: whatever
END_ERR
sub slurp {
my ($filename) = @_;
local $/;
open(my $fh, '<', $filename);
binmode($fh);
my $data = <$fh>;
$data
}
my $data = slurp 'out.opus';
is_deeply(opustags('-', '-o', '-', {in => $data, mode => ':raw'}), [$data, '', 0], 'read opus from stdin and write to stdout');
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');
####################################################################################################
# Interactive edition
$ENV{EDITOR} = 'sed -i -e y/aeiou/AEIOU/ `sleep 0.1`';
is_deeply(opustags('gobble.opus', '-eo', "'screaming !'.opus"), ['', '', 0], 'edit a file with EDITOR');
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were modified');
$ENV{EDITOR} = 'true';
is_deeply(opustags('-ie', "'screaming !'.opus"), ['', "Cancelling edition because the tags file was not modified.\n", 256], 'close -e without saving');
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were not modified');
$ENV{EDITOR} = 'false';
is_deeply(opustags('-ie', "'screaming !'.opus"), ['', "'screaming !'.opus: error: Child process exited with 1\n", 256], 'editor exiting with an error');
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were not modified');
unlink("'screaming !'.opus");
####################################################################################################
# Test muxed streams
system('ffmpeg -loglevel error -y -i gobble.opus -c copy -map 0:0 -map 0:0 -shortest muxed.ogg') == 0
or BAIL_OUT('could not create a muxed stream');
is_deeply(opustags('muxed.ogg'), ['', <<'END_ERR', 256], 'muxed streams detection');
muxed.ogg: error: Muxed streams are not supported yet.
END_ERR
unlink('muxed.ogg');
####################################################################################################
# Locale
my $locale = 'en_US.iso88591';
my @all_locales = split(' ', `locale -a`);
SKIP: {
skip "locale $locale is not present", 5 unless (any { $_ eq $locale } @all_locales);
opustags(qw(gobble.opus -a TITLE=七面鳥 -a ARTIST=éàç -o out.opus -y));
local $ENV{LC_ALL} = $locale;
local $ENV{LANGUAGE} = '';
is_deeply(opustags(qw(-S out.opus), {in => <<"END_IN", mode => ':raw'}), [<<"END_OUT", '', 0], 'set all in ISO-8859-1');
T=\xef\xef\xf6
END_IN
T=\xef\xef\xf6
END_OUT
is_deeply(opustags('-i', 'out.opus', "--add=I=\xf9\xce", {mode => ':raw'}), ['', '', 0], 'write tags in ISO-8859-1');
is_deeply(opustags('out.opus', {mode => ':raw'}), [<<"END_OUT", <<"END_ERR", 256], 'read tags in ISO-8859-1 with incompatible characters');
encoder=Lavc58.18.100 libopus
END_OUT
out.opus: error: Invalid or incomplete multibyte or wide character. See --raw.
END_ERR
is_deeply(opustags(qw(out.opus -d TITLE -d ARTIST), {mode => ':raw'}), [<<"END_OUT", '', 0], 'read tags in ISO-8859-1');
encoder=Lavc58.18.100 libopus
I=\xf9\xce
END_OUT
$ENV{LC_ALL} = '';
is_deeply(opustags('out.opus'), [<<"END_OUT", '', 0], 'read tags in UTF-8');
encoder=Lavc58.18.100 libopus
TITLE=七面鳥
ARTIST=éàç
I=ùÎ
END_OUT
unlink('out.opus');
}
####################################################################################################
# Raw edition
is_deeply(opustags(qw(-S gobble.opus -o out.opus --raw -a), "U=\xFE", {in => <<"END_IN", mode => ':raw'}), ['', '', 0], 'raw set-all with binary data');
T=\xFF
END_IN
is_deeply(opustags(qw(out.opus --raw), { mode => ':raw' }), [<<"END_OUT", '', 0], 'raw read');
T=\xFF
U=\xFE
END_OUT
unlink('out.opus');

67
t/system.cc Normal file
View File

@ -0,0 +1,67 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
#include <unistd.h>
void check_partial_files()
{
static const char* result = "partial_file.test";
std::string name;
{
ot::partial_file bad_tmp;
is(bad_tmp.open("/dev/null"), ot::st::standard_error,
"opening a device as a partial file fails");
is(bad_tmp.open(result), ot::st::ok,
"opening a regular partial file works");
name = bad_tmp.name();
if (name.size() != strlen(result) + 12 ||
name.compare(0, strlen(result), result) != 0)
throw failure("the temporary name is surprising: " + name);
}
is(access(name.c_str(), F_OK), -1, "expect the temporary file is deleted");
ot::partial_file good_tmp;
is(good_tmp.open(result), ot::st::ok, "open the partial file");
name = good_tmp.name();
is(good_tmp.commit(), ot::st::ok, "commit the result file");
is(access(name.c_str(), F_OK), -1, "expect the temporary file is deleted");
is(access(result, F_OK), 0, "expect the final result file");
is(remove(result), 0, "remove the result file");
}
void check_converter()
{
const char* ephemere_iso = "\xc9\x70\x68\xe9\x6d\xe8\x72\x65";
ot::encoding_converter to_utf8("ISO_8859-1", "UTF-8");
ot::encoding_converter from_utf8("UTF-8", "ISO_8859-1//IGNORE");
std::string out;
ot::status rc = to_utf8(ephemere_iso, out);
is(rc, ot::st::ok, "conversion to UTF-8 is successful");
is(out, "Éphémère", "conversion to UTF-8 is correct");
rc = from_utf8("Éphémère", out);
is(rc, ot::st::ok, "conversion from UTF-8 is successful");
is(out, ephemere_iso, "conversion from UTF-8 is correct");
rc = from_utf8("\xFF\xFF", out);
is(rc, ot::st::badly_encoded, "conversion from bad UTF-8 fails");
}
void check_shell_esape()
{
is(ot::shell_escape("foo"), "'foo'", "simple string");
is(ot::shell_escape("a'b"), "'a'\\''b'", "string with a simple quote");
is(ot::shell_escape("a!b"), "'a'\\!'b'", "string with a bang");
is(ot::shell_escape("a!b'c!d'e"), "'a'\\!'b'\\''c'\\!'d'\\''e'", "string with a bang");
}
int main(int argc, char **argv)
{
plan(3);
run(check_partial_files, "test partial files");
run(check_converter, "test encoding converter");
run(check_shell_esape, "test shell escaping");
return 0;
}

64
t/tap.h Normal file
View File

@ -0,0 +1,64 @@
/**
* \file t/tap.h
*
* \brief
* Helpers for following the Test Anything Protocol.
*
* Its interface mimics Test::More from Perl:
* https://perldoc.perl.org/Test/More.html
*
* Unlike Test::More, a test failure raises an exception and aborts the whole subtest.
*/
#pragma once
#include <exception>
#include <iostream>
inline namespace tap {
struct failure : std::runtime_error {
failure(const std::string& what) : std::runtime_error(what) {}
};
template <typename F>
static void run(F test, const char *name)
{
bool ok = false;
try {
test();
ok = true;
} catch (failure& e) {
std::cerr << "# fail: " << e.what() << "\n";
}
std::cout << (ok ? "ok" : "not ok") << " - " << name << "\n";
}
void plan(int tests)
{
std::cout << "1.." << tests << "\n";
}
template <typename T, typename U>
void is(const T& got, const U& expected, const char* name)
{
if (got != expected) {
std::cerr << "# got: " << got << "\n"
"# expected: " << expected << "\n";
throw failure(name);
}
}
template <>
void is(const ot::status& got, const ot::st& expected, const char* name)
{
if (got.code != expected) {
if (got.code == ot::st::ok)
std::cerr << "# unexpected success\n";
else
std::cerr << "# unexpected error: " << got.message << "\n";
throw failure(name);
}
}
}