109 Commits
1.1 ... 1.2.0

Author SHA1 Message Date
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
22 changed files with 1935 additions and 587 deletions

1
.gitignore vendored Normal file
View File

@ -0,0 +1 @@
/build

34
CHANGELOG.md Normal file
View File

@ -0,0 +1,34 @@
opustags changelog
==================
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.

36
CMakeLists.txt Normal file
View File

@ -0,0 +1,36 @@
cmake_minimum_required(VERSION 3.9)
project(
opustags
VERSION 1.2.0
LANGUAGES CXX
)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
find_package(PkgConfig REQUIRED)
pkg_check_modules(OGG REQUIRED ogg)
add_compile_options(${OGG_CFLAGS})
configure_file(src/config.h.in config.h @ONLY)
include_directories(BEFORE src "${CMAKE_BINARY_DIR}")
add_library(
libopustags
OBJECT
src/cli.cc
src/ogg.cc
src/opus.cc
)
target_link_libraries(libopustags PUBLIC ${OGG_LIBRARIES})
add_executable(opustags src/opustags.cc)
target_link_libraries(opustags libopustags)
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)

59
CONTRIBUTING.md Normal file
View File

@ -0,0 +1,59 @@
# Contributing to opustags
opustags is slowing getting more mature, and contributions 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++14 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.
The next release will focus on correctness, with the following technical
objectives:
1. Validate the comments: field name in ASCII and value in UTF-8.
2. Allow selecting the stream to edit, instead of assuming the Ogg contains only
one Opus stream.
3. Provide an --escape option for escaping the newlines inside comment strings.
4. Take into account the system's encoding: the tags must always be stored as
UTF-8, and converted from and to the console encoding when reading input or
printing.
5. Maybe provide a --binary option to dump the raw OpusTags packet, that can be
combined to --set-all to read it back.

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

@ -3,17 +3,46 @@ opustags
View and edit Opus comments.
The current code quality of this project is getting better, and is suitable for reliably editing any
Opus file provided it does not contain other multiplexed streams. Only UTF-8 is currently supported.
Until opustags becomes top-quality software, if it ever does, you might want to
check out these more mature tag editors:
- [EasyTAG](https://wiki.gnome.org/Apps/EasyTAG)
- [Beets](http://beets.io/)
- [Picard](https://picard.musicbrainz.org/)
- [puddletag](http://docs.puddletag.net/)
- [Quod Libet](https://quodlibet.readthedocs.io/en/latest/)
- [Goggles Music Manager](https://gogglesmm.github.io/)
See also these libraries if you need a lower-level access:
- [TagLib](http://taglib.org/)
- [mutagen](https://mutagen.readthedocs.io/en/latest/)
Requirements
------------
* A POSIX-compliant system,
* a C++14 compiler,
* CMake,
* a POSIX-compliant system,
* libogg.
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
-------------
@ -24,12 +53,13 @@ Documentation
Options:
-h, --help print this help
-o, --output write the modified tags to a file
-o, --output FILE set the output file
-i, --in-place overwrite the input file instead of writing a different output 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
-a, --add FIELD=VALUE add a comment
-d, --delete FIELD delete all previously existing comments of a specific type
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment (shorthand for --delete FIELD --add FIELD=VALUE)
-S, --set-all replace all the comments with the ones read from standard input
See the man page, `opustags.1`, for extensive documentation.

View File

@ -1,4 +1,4 @@
.TH opustags 1 "January 2013"
.TH opustags 1 "November 2018" "@PROJECT_NAME@ @PROJECT_VERSION@"
.SH NAME
opustags \- Opus comment editor
.SH SYNOPSIS
@ -15,34 +15,28 @@ opustags \- Opus comment editor
.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).
It basically has two modes: read-only, and read-write for tag edition.
.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 edition mode, you need to specify an output file (or \fB-\fP for standard output). It must be
different from the input file. To overwrite the input file, use \fB--in-place\fP.
.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 edition 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
@ -53,34 +47,30 @@ set to UTF-8, and assume that tags are already encoded in UTF-8.
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\fR[=\fP\fISUFFIX\fP\fR]\fP
Use this when you want to modify the input file in-place. opustags will create a temporary output
file with the specified suffix (.otmp by default), and move it to the location of the input file on
success. If a file with the same name as the temporary file already exists, it will be overwritten
without warning.
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.
this option to allow that.
.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.
Delete all the tags whose field name is \fIFIELD\fP. They may be several one of them, though usually
there is only one of each type.
.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 +81,36 @@ 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.
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.
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 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
.SH SEE ALSO
.BR vorbiscomment (1),
.BR sed (1)
.SH AUTHOR
Frédéric Mangano <fmang@mg0.fr>
Frédéric Mangano-Tarumi <fmang@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;
}

331
src/cli.cc Normal file
View File

@ -0,0 +1,331 @@
/**
* \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.
*
* \todo Use a safer temporary file name for in-place editing, like tmpnam.
* \todo Abort editing with --set-all if one comment is invalid?
*/
#include <config.h>
#include <opustags.h>
#include <getopt.h>
#include <limits.h>
#include <string.h>
#include <unistd.h>
static const char* version = PROJECT_NAME " version " PROJECT_VERSION "\n";
static const char* usage = 1 + R"raw(
Usage: opustags --help
opustags [OPTIONS] FILE
opustags OPTIONS FILE -o FILE
)raw";
static const char* help = 1 + R"raw(
Options:
-h, --help print this help
-o, --output FILE set the output file
-i, --in-place overwrite the input file instead of writing a different output file
-y, --overwrite overwrite the output file if it already exists
-a, --add FIELD=VALUE add a comment
-d, --delete FIELD delete all previously existing comments of a specific type
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment (shorthand for --delete FIELD --add FIELD=VALUE)
-S, --set-all replace all the comments with the ones read from standard input
)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'},
{NULL, 0, 0, 0}
};
ot::status ot::process_options(int argc, char** argv, ot::options& opt)
{
if (argc == 1) {
fputs(version, stdout);
fputs(usage, stdout);
return st::exit_now;
}
int c;
while ((c = getopt_long(argc, argv, "ho:i::yd:a:s:DS", getopt_options, NULL)) != -1) {
switch (c) {
case 'h':
opt.print_help = true;
break;
case 'o':
opt.path_out = optarg;
if (opt.path_out.empty()) {
fputs("output's file path cannot be empty\n", stderr);
return st::bad_arguments;
}
break;
case 'i':
opt.inplace = optarg == nullptr ? ".otmp" : optarg;
if (strcmp(opt.inplace, "") == 0) {
fputs("the in-place suffix cannot be empty\n", stderr);
return st::bad_arguments;
}
break;
case 'y':
opt.overwrite = true;
break;
case 'd':
if (strchr(optarg, '=') != nullptr) {
fprintf(stderr, "invalid field name: '%s'\n", optarg);
return st::bad_arguments;
}
opt.to_delete.emplace_back(optarg);
break;
case 'a':
case 's':
if (strchr(optarg, '=') == NULL) {
fprintf(stderr, "invalid comment: '%s'\n", optarg);
return st::bad_arguments;
}
opt.to_add.emplace_back(optarg);
if (c == 's')
opt.to_delete.emplace_back(optarg);
break;
case 'S':
opt.set_all = true;
/* fall through */
case 'D':
opt.delete_all = true;
break;
default:
/* getopt printed a message */
return st::bad_arguments;
}
}
if (opt.print_help) {
puts(version);
puts(usage);
puts(help);
puts("See the man page for extensive documentation.");
return st::exit_now;
}
if (optind != argc - 1) {
fputs("exactly one input file must be specified\n", stderr);
return st::bad_arguments;
}
opt.path_in = argv[optind];
if (opt.path_in.empty()) {
fputs("input's file path cannot be empty\n", stderr);
return st::bad_arguments;
}
if (opt.inplace != nullptr) {
if (!opt.path_out.empty()) {
fputs("cannot combine --in-place and --output\n", stderr);
return st::bad_arguments;
}
opt.path_out = opt.path_in + opt.inplace;
}
if (opt.path_in == "-" && opt.set_all) {
fputs("can't open standard input for input when --set-all is specified\n", stderr);
return st::bad_arguments;
}
if (opt.path_in == "-" && opt.inplace) {
fputs("cannot modify standard input in-place\n", stderr);
return st::bad_arguments;
}
return st::ok;
}
/**
* \todo Escape new lines.
*/
void ot::print_comments(const std::list<std::string>& comments, FILE* output)
{
for (const std::string& comment : comments) {
fwrite(comment.data(), 1, comment.size(), output);
puts("");
}
}
std::list<std::string> ot::read_comments(FILE* input)
{
std::list<std::string> comments;
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 (memchr(line, '=', nread) == nullptr) {
fputs("warning: skipping malformed tag\n", stderr);
continue;
}
comments.emplace_back(line, nread);
}
free(line);
return comments;
}
/**
* Parse the packet as an OpusTags comment header, apply the user's modifications, and write the new
* packet to the writer.
*/
static ot::status process_tags(const ogg_packet& packet, const ot::options& opt, ot::ogg_writer* writer)
{
ot::opus_tags tags;
ot::status rc = ot::parse_tags(packet, tags);
if (rc != ot::st::ok)
return rc;
if (opt.delete_all) {
tags.comments.clear();
} else {
for (const std::string& name : opt.to_delete)
ot::delete_comments(tags, name.c_str());
}
if (opt.set_all)
tags.comments = ot::read_comments(stdin);
for (const std::string& comment : opt.to_add)
tags.comments.emplace_back(comment);
if (writer) {
auto packet = ot::render_tags(tags);
return writer->write_packet(packet);
} else {
ot::print_comments(tags.comments, stdout);
return ot::st::ok;
}
}
ot::status ot::process(ogg_reader& reader, ogg_writer* writer, const ot::options &opt)
{
int packet_count = 0;
for (;;) {
// Read the next page.
ot::status rc = reader.read_page();
if (rc == ot::st::end_of_stream)
break;
else if (rc != ot::st::ok)
return rc;
// Short-circuit when the relevant packets have been read.
if (packet_count >= 2 && writer) {
if ((rc = writer->write_page(reader.page)) != ot::st::ok)
return rc;
continue;
}
auto serialno = ogg_page_serialno(&reader.page);
if (writer && (rc = writer->prepare_stream(serialno)) != ot::st::ok)
return rc;
// Read all the packets.
for (;;) {
rc = reader.read_packet();
if (rc == ot::st::end_of_page)
break;
else if (rc != ot::st::ok)
return rc;
packet_count++;
if (packet_count == 1) { // Identification header
rc = ot::validate_identification_header(reader.packet);
if (rc != ot::st::ok)
return rc;
} else if (packet_count == 2) { // Comment header
rc = process_tags(reader.packet, opt, writer);
if (rc != ot::st::ok)
return rc;
if (!writer)
return ot::st::ok; /* nothing else to do */
else
continue; /* process_tags wrote the new packet */
}
if (writer && (rc = writer->write_packet(reader.packet)) != ot::st::ok)
return rc;
}
// Write the assembled page.
if (writer && (rc = writer->flush_page()) != ot::st::ok)
return rc;
}
if (packet_count < 2)
return {ot::st::fatal_error, "Expected at least 2 Ogg packets"};
return ot::st::ok;
}
/**
* Check if two filepaths point to the same file, after path canonicalization.
* The path "-" is treated specially, meaning stdin for path_in and stdout for path_out.
*/
static bool same_file(const std::string& path_in, const std::string& path_out)
{
if (path_in == "-" || path_out == "-")
return false;
char canon_in[PATH_MAX+1], canon_out[PATH_MAX+1];
if (realpath(path_in.c_str(), canon_in) && realpath(path_out.c_str(), canon_out)) {
return (strcmp(canon_in, canon_out) == 0);
}
return false;
}
ot::status ot::run(ot::options& opt)
{
if (!opt.path_out.empty() && same_file(opt.path_in, opt.path_out))
return {ot::st::fatal_error, "Input and output files are the same"};
ot::file input;
if (opt.path_in == "-") {
input = stdin;
} else {
input = fopen(opt.path_in.c_str(), "r");
if (input == nullptr)
return {ot::st::standard_error,
"Could not open '" + opt.path_in + "' for reading: " + strerror(errno)};
}
ot::file output;
if (opt.path_out == "-") {
output.reset(stdout);
} else if (!opt.path_out.empty()) {
if (!opt.overwrite && access(opt.path_out.c_str(), F_OK) == 0)
return {ot::st::fatal_error,
"'" + opt.path_out + "' already exists (use -y to overwrite)"};
output = fopen(opt.path_out.c_str(), "w");
if (output == nullptr)
return {ot::st::standard_error,
"Could not open '" + opt.path_out + "' for writing: " + strerror(errno)};
}
ot::status rc;
{
ot::ogg_reader reader(input.get());
std::unique_ptr<ot::ogg_writer> writer;
if (output != nullptr)
writer = std::make_unique<ot::ogg_writer>(output.get());
rc = process(reader, writer.get(), opt);
/* delete reader and writer before closing the files */
}
input.reset();
output.reset();
if (rc != ot::st::ok) {
if (!opt.path_out.empty() && opt.path_out != "-")
remove(opt.path_out.c_str());
return rc;
}
if (opt.inplace) {
if (rename(opt.path_out.c_str(), opt.path_in.c_str()) == -1)
return {ot::st::fatal_error,
"Could not move the result to '" + opt.path_in + "': " + strerror(errno)};
}
return ot::st::ok;
}

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

@ -0,0 +1,2 @@
#cmakedefine PROJECT_NAME "@PROJECT_NAME@"
#cmakedefine PROJECT_VERSION "@PROJECT_VERSION@"

124
src/ogg.cc Normal file
View File

@ -0,0 +1,124 @@
/**
* \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 <string.h>
using namespace std::literals::string_literals;
ot::ogg_reader::ogg_reader(FILE* input)
: file(input)
{
ogg_sync_init(&sync);
}
ot::ogg_reader::~ogg_reader()
{
if (stream_ready)
ogg_stream_clear(&stream);
ogg_sync_clear(&sync);
}
ot::status ot::ogg_reader::read_page()
{
while (ogg_sync_pageout(&sync, &page) != 1) {
if (feof(file))
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"};
if (ogg_sync_check(&sync) != 0)
return {st::libogg_error, "ogg_sync_check failed"};
}
/* at this point, we've got a good page */
if (!stream_ready) {
if (ogg_stream_init(&stream, ogg_page_serialno(&page)) != 0)
return {st::libogg_error, "ogg_stream_init failed"};
stream_ready = true;
}
stream_in_sync = false;
return st::ok;
}
ot::status ot::ogg_reader::read_packet()
{
if (!stream_ready)
return {st::stream_not_ready, "Stream was not initialized"};
if (!stream_in_sync) {
if (ogg_stream_pagein(&stream, &page) != 0)
return {st::libogg_error, "ogg_stream_pagein failed"};
stream_in_sync = true;
}
int rc = ogg_stream_packetout(&stream, &packet);
if (rc == 1)
return st::ok;
else if (rc == 0 && ogg_stream_check(&stream) == 0)
return {st::end_of_page, "End of page was reached"};
else
return {st::libogg_error, "ogg_stream_packetout failed"};
}
ot::ogg_writer::ogg_writer(FILE* output)
: file(output)
{
if (ogg_stream_init(&stream, 0) != 0)
throw std::bad_alloc();
}
ot::ogg_writer::~ogg_writer()
{
ogg_stream_clear(&stream);
}
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::prepare_stream(long serialno)
{
if (stream.serialno != serialno) {
if (ogg_stream_reset_serialno(&stream, serialno) != 0)
return {st::libogg_error, "ogg_stream_reset_serialno failed"};
}
return st::ok;
}
ot::status ot::ogg_writer::write_packet(const ogg_packet& packet)
{
if (ogg_stream_packetin(&stream, const_cast<ogg_packet*>(&packet)) != 0)
return {st::libogg_error, "ogg_stream_packetin failed"};
else
return st::ok;
}
ot::status ot::ogg_writer::flush_page()
{
ogg_page page;
if (ogg_stream_flush(&stream, &page) != 0)
return write_page(page);
if (ogg_stream_check(&stream) != 0)
return {st::libogg_error, "ogg_stream_check failed"};
return st::ok; /* nothing was done */
}

163
src/opus.cc Normal file
View File

@ -0,0 +1,163 @@
/**
* \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.
* \todo Field names are case insensitive, respect that.
*
*/
#include <opustags.h>
#include <string.h>
#ifdef __APPLE__
#include <libkern/OSByteOrder.h>
#define htole32(x) OSSwapHostToLittleInt32(x)
#define le32toh(x) OSSwapLittleToHostInt32(x)
#endif
/**
* \todo Validate more properties of the packet, like the sequence number.
*/
ot::status ot::validate_identification_header(const ogg_packet& packet)
{
if (packet.bytes < 8)
return {ot::st::cut_magic_number,
"Identification header too short for the magic number"};
if (memcmp(packet.packet, "OpusHead", 8) != 0)
return {ot::st::bad_magic_number,
"Identification header did not start with OpusHead"};
return ot::st::ok;
}
/**
* \todo See if the packet's data could be casted more nicely into a string.
*/
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;
}
/**
* \todo Make the field name case-insensitive?
*/
static 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 ot::delete_comments(opus_tags& tags, const char* field_name)
{
auto it = tags.comments.begin(), end = tags.comments.end();
while (it != end) {
auto current = it++;
if (match_field(current->data(), current->size(), field_name))
tags.comments.erase(current);
}
}

35
src/opustags.cc Normal file
View File

@ -0,0 +1,35 @@
/**
* \file src/opustags.cc
* \brief Main function for opustags.
*
* See opustags.h for the program's documentation.
*/
#include <opustags.h>
/**
* Main entry point to the opustags binary.
*
* Does practically nothing but call the cli module.
*/
int main(int argc, char** argv) {
ot::status rc;
ot::options opt;
rc = process_options(argc, argv, opt);
if (rc == ot::st::exit_now) {
return EXIT_SUCCESS;
} else if (rc != ot::st::ok) {
if (!rc.message.empty())
fprintf(stderr, "error: %s\n", rc.message.c_str());
return EXIT_FAILURE;
}
rc = run(opt);
if (rc != ot::st::ok && rc != ot::st::exit_now) {
if (!rc.message.empty())
fprintf(stderr, "error: %s\n", rc.message.c_str());
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

475
src/opustags.h Normal file
View File

@ -0,0 +1,475 @@
/**
* \file src/opustags.h
*
* Welcome to opustags!
*
* Let's have a quick tour around. The project is split into the following modules:
*
* - 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 <ogg/ogg.h>
#include <stdio.h>
#include <list>
#include <memory>
#include <string>
#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.
*
* 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,
int_overflow,
standard_error,
/* Ogg */
end_of_stream,
end_of_page,
stream_not_ready,
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,
exit_now, /**< The program should terminate successfully. */
fatal_error,
};
/**
* 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.
*/
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;
};
/**
* 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) {}
};
/***********************************************************************************************//**
* \defgroup ogg Ogg
* \{
*/
/**
* Ogg reader, combining a FILE input, an ogg_sync_state reading the pages, and an ogg_stream_state
* extracting the packets from the page.
*
* Call #read_page repeatedly until #status::end_of_stream to consume the stream, and use #page to
* check its content. To extract its packets, call #read_packet until #status::end_of_packet.
*/
class ogg_reader {
public:
/**
* 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);
/**
* 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();
/**
* 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 read_page();
/**
* Read the next available packet from the current #page. The packet is made available in
* the #packet field.
*
* No packet can be read until a page has been loaded with #read_page. If that happens,
* return #status::stream_not_ready.
*
* After the last packet was read, return #status::end_of_page.
*/
status read_packet();
/**
* 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;
/**
* Current packet from the stream state.
*
* Its memory is managed by libogg, inside the stream state, and is valid until the next
* call to ogg_stream_packetout, wrapped by #read_packet.
*/
ogg_packet packet;
private:
/**
* 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;
/**
* Indicates whether the stream has been initialized or not.
*
* To initialize it properly, we need the serialno of the stream, which is available only
* after the first page was read.
*/
bool stream_ready = false;
/**
* Indicates if the stream's last fed page is the current one.
*
* Its state is irrelevant if the stream is not ready.
*/
bool stream_in_sync;
/**
* The stream layer receives pages and yields a sequence of packets.
*
* A single page may contain several packets, and a single packet may span on multiple
* pages. The 2 packets we're interested in occupy whole pages though, in theory, but we'd
* better ensure there are no extra packets anyway.
*
* After we've read OpusHead and OpusTags, we don't need the stream layer anymore.
*/
ogg_stream_state stream;
};
/**
* An Ogg writer lets you write ogg_page objets to an output file, and assemble packets into pages.
*
* It has two modes of operations :
* 1. call #write_page, or
* 2. call #prepare_stream, then #write_packet one or more times, followed by #flush_page.
*
* You can switch between the two modes, but must not start writing packets and then pages without
* flushing.
*/
class ogg_writer {
public:
/**
* Initialize the writer with the given output file handle. The caller is responsible for
* keeping the file handle alive, and to close it.
*/
ogg_writer(FILE* output);
/**
* Clears the stream state and any internal memory. Does not close the output file.
*/
~ogg_writer();
/**
* 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);
/**
* Prepare the stream with the given Ogg serial number.
*
* If the stream is already configured with the right serial number, it doesn't do anything
* and is cheap to call.
*
* If the stream contains unflushed packets, they will be lost.
*/
status prepare_stream(long serialno);
/**
* Add a packet to the current page under assembly.
*
* If the packet is coming from a different page, make sure the serial number fits by
* calling #prepare_stream.
*
* When the page is complete, you should call #flush_page to finalize the page.
*
* You must not call #write_page after it, until you call #flush_page.
*/
status write_packet(const ogg_packet& packet);
/**
* Write the page under assembly. Future calls to #write_packet will be written in a new
* page.
*/
status flush_page();
private:
/**
* The stream state receives packets and generates pages.
*
* In our specific use case, we only need it to put the OpusHead and OpusTags packets into
* their own pages. The other pages are naively written to the output stream.
*/
ogg_stream_state stream;
/**
* 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;
};
/**
* 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 should be an arbitrary UTF-8 string.
*/
std::string vendor;
/**
* Comments. These are a list of string following the NAME=Value format. A comment may also
* be called a field, or a tag.
*
* The field name in vorbis comment is 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;
};
/**
* Validate the content of the first packet of an Ogg stream to ensure it's a valid OpusHead.
*
* Returns #ot::status::ok on success, #ot::status::bad_identification_header on error.
*/
status validate_identification_header(const ogg_packet& packet);
/**
* 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);
/**
* Remove all the comments whose field name is equal to the special one, case-sensitive.
*/
void delete_comments(opus_tags& tags, const char* field_name);
/** \} */
/***********************************************************************************************//**
* \defgroup cli Command-Line Interface
* \{
*/
/**
* Structured representation of the arguments to opustags.
*/
struct options {
/**
* Path to the input file. It cannot be empty. The special "-" string means stdin.
*
* This is the mandatory non-flagged parameter.
*/
std::string path_in;
/**
* Path to the optional file. The special "-" string means stdout. When empty, opustags runs
* in read-only mode.
*
* Option: --output
*/
std::string path_out;
/**
* If null, in-place editing is disabled. Otherwise, it points to the suffix to add to the
* file name.
*
* Option: --in-place
*/
const char* inplace = nullptr;
/**
* List of field names to delete. `{"ARTIST"}` will delete *all* the comments `ARTIST=*`. It
* is currently case-sensitive. When #delete_all is true, it becomes 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.
*
* \todo Consider making it case-insensitive.
* \todo Allow values like `ARTIST=x` to delete only the ARTIST comment whose value is x.
*
* Option: --delete, --set
*/
std::vector<std::string> to_delete;
/**
* List of comments to add, in the current system encoding. For exemple `TITLE=a b c`. They
* must be valid.
*
* Options: --add, --set, --set-all
*/
std::vector<std::string> to_add;
/**
* Delete all the existing comments.
*
* Option: --delete-all
*/
bool delete_all = false;
/**
* Replace the previous comments by the ones supplied by the user.
*
* Read a list of comments from stdin and populate #to_add. Implies #delete_all. Further
* comments may be added with the --add option.
*
* Option: --set-all
*/
bool set_all = false;
/**
* By default, opustags won't overwrite the output file if it already exists.
*
* Option: --overwrite
*/
bool overwrite = false;
/**
* When true, opustags prints a detailed help and exits. All the other options are ignored.
*
* Option: --help
*/
bool print_help = false;
};
/**
* Process the command-line arguments.
*
* This function does not perform I/O related validations, but checks the consistency of its
* arguments.
*
* It returns one of :
* - #ot::st::ok, meaning the process may continue normally.
* - #ot::st::exit_now, meaning there is nothing to do and process should exit successfully.
* This happens when all the user wants is see the help or usage.
* - #ot::st::bad_arguments, meaning the arguments were invalid and the process should exit with
* an error.
*
* Help messages are written on standard output, and error messages on standard error.
*/
status process_options(int argc, char** argv, options& opt);
/**
* 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 output generated is meant to be parseable by #ot::read_tags.
*/
void print_comments(const std::list<std::string>& comments, FILE* output);
/**
* Parse the comments outputted by #ot::print_comments.
*/
std::list<std::string> read_comments(FILE* input);
/**
* 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.
*/
status process(ogg_reader& reader, ogg_writer* writer, const options &opt);
/**
* Open the input and output streams, then call #ot::process.
*
* This is the main entry point to the opustags program, and pretty much the same as calling
* opustags from the command-line.
*/
status run(options& opt);
/** \} */
}

16
t/CMakeLists.txt Normal file
View File

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

26
t/cli.cc Normal file
View File

@ -0,0 +1,26 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
const char *user_comments = R"raw(
TITLE=a b c
ARTIST=X
Artist=Y)raw";
void check_read_comments()
{
ot::file input = fmemopen(const_cast<char*>(user_comments), strlen(user_comments), "r");
auto comments = ot::read_comments(input.get());
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");
}
int main(int argc, char **argv)
{
std::cout << "1..1\n";
run(check_read_comments, "check tags parsing");
return 0;
}

BIN
t/gobble.opus Normal file

Binary file not shown.

148
t/ogg.cc Normal file
View File

@ -0,0 +1,148 @@
#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.read_page();
if (rc != ot::st::ok)
throw failure("could not read the first page");
rc = reader.read_packet();
if (rc != ot::st::ok)
throw failure("could not read the first packet");
if (reader.packet.bytes != 19)
throw failure("unexpected length for the first packet");
rc = reader.read_packet();
if (rc != ot::st::end_of_page)
throw failure("got an unexpected second packet on the first page");
rc = reader.read_page();
if (rc != ot::st::ok)
throw failure("could not read the second page");
rc = reader.read_packet();
if (rc != ot::st::ok)
throw failure("could not read the second packet");
if (reader.packet.bytes != 62)
throw failure("unexpected length for the first packet");
rc = reader.read_packet();
if (rc != ot::st::end_of_page)
throw failure("got an unexpected second packet on the second page");
while (!ogg_page_eos(&reader.page)) {
rc = reader.read_page();
if (rc != ot::st::ok)
throw failure("failure reading a page");
}
rc = reader.read_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()
{
const ogg_packet first_packet = make_packet("First");
const ogg_packet second_packet = make_packet("Second");
const ogg_packet third_packet = make_packet("Third");
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());
rc = writer.prepare_stream(1234);
if (rc != ot::st::ok)
throw failure("could not prepare the stream for the first page");
writer.write_packet(first_packet);
if (rc != ot::st::ok)
throw failure("could not write the first packet");
writer.flush_page();
if (rc != ot::st::ok)
throw failure("could not flush the first page");
writer.prepare_stream(1234);
if (rc != ot::st::ok)
throw failure("could not prepare the stream for the second page");
writer.write_packet(second_packet);
if (rc != ot::st::ok)
throw failure("could not write the second packet");
writer.write_packet(third_packet);
if (rc != ot::st::ok)
throw failure("could not write the third packet");
writer.flush_page();
if (rc != ot::st::ok)
throw failure("could not flush the second page");
my_ogg_size = ftell(output.get());
if (my_ogg_size != 73)
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.read_page();
if (rc != ot::st::ok)
throw failure("could not read the first page");
rc = reader.read_packet();
if (rc != ot::st::ok)
throw failure("could not read the first packet");
if (!same_packet(reader.packet, first_packet))
throw failure("unexpected content in the first packet");
rc = reader.read_packet();
if (rc != ot::st::end_of_page)
throw failure("unexpected second packet in the first page");
rc = reader.read_page();
if (rc != ot::st::ok)
throw failure("could not read the second page");
rc = reader.read_packet();
if (rc != ot::st::ok)
throw failure("could not read the second packet");
if (!same_packet(reader.packet, second_packet))
throw failure("unexpected content in the second packet");
rc = reader.read_packet();
if (rc != ot::st::ok)
throw failure("could not read the third packet");
if (!same_packet(reader.packet, third_packet))
throw failure("unexpected content in the third packet");
rc = reader.read_packet();
if (rc != ot::st::end_of_page)
throw failure("unexpected third packet in the second page");
rc = reader.read_page();
if (rc != ot::st::end_of_stream)
throw failure("unexpected third page");
}
}
int main(int argc, char **argv)
{
std::cout << "1..2\n";
run(check_ref_ogg, "check a reference ogg stream");
run(check_memory_ogg, "build and check a fresh stream");
return 0;
}

166
t/opus.cc Normal file
View File

@ -0,0 +1,166 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
using namespace std::literals::string_literals;
static void check_identification()
{
ogg_packet packet {};
packet.packet = (unsigned char*) "OpusHead..";
packet.bytes = 10;
if (ot::validate_identification_header(packet) != ot::st::ok)
throw failure("did not accept a good OpusHead");
packet.bytes = 7;
if (ot::validate_identification_header(packet) != ot::st::cut_magic_number)
throw failure("accepted an OpusHead that is too short");
packet.packet = (unsigned char*) "NotOpusHead";
packet.bytes = 11;
if (ot::validate_identification_header(packet) != ot::st::bad_magic_number)
throw failure("did not report the right status for a bad OpusHead");
}
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..5\n";
run(check_identification, "check the OpusHead packet");
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;
}

193
t/opustags.t Normal file
View File

@ -0,0 +1,193 @@
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use Test::More tests => 27;
use Digest::MD5;
use File::Basename;
use IPC::Open3;
use Symbol 'gensym';
my $opustags = '../opustags';
BAIL_OUT("$opustags does not exist or is not executable") if (! -x $opustags);
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.
my $usage = opustags();
$usage->[0] =~ /^([^\n]*+)/;
my $version = $1;
like($version, qr/^opustags version (\d+\.\d+\.\d+)/, 'get the version string');
is_deeply($usage, [<<"EOF", "", 0], 'no options show the usage');
$version
Usage: opustags --help
opustags [OPTIONS] FILE
opustags OPTIONS FILE -o FILE
EOF
my $help = <<"EOF";
$version
Usage: opustags --help
opustags [OPTIONS] FILE
opustags OPTIONS FILE -o FILE
Options:
-h, --help print this help
-o, --output FILE set the output file
-i, --in-place overwrite the input file instead of writing a different output file
-y, --overwrite overwrite the output file if it already exists
-a, --add FIELD=VALUE add a comment
-d, --delete FIELD delete all previously existing comments of a specific type
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment (shorthand for --delete FIELD --add FIELD=VALUE)
-S, --set-all replace all the comments with the ones read from standard input
See the man page for extensive documentation.
EOF
is_deeply(opustags('--help'), [$help, '', 0], '--help displays the help message');
is_deeply(opustags('-h'), [$help, '', 0], '-h displays the help message too');
is_deeply(opustags('--derp'), ['', <<"EOF", 256], 'unrecognized option shows an error');
$opustags: unrecognized option '--derp'
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');
is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', '', 0], 'copy the file without changes');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'the copy is faithful');
# empty out.opus
{ my $fh; open($fh, '>', 'out.opus') and close($fh) or die }
is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', <<'EOF', 256], 'refuse to override');
error: 'out.opus' already exists (use -y to overwrite)
EOF
is(md5('out.opus'), 'd41d8cd98f00b204e9800998ecf8427e', 'the output wasn\'t written');
is_deeply(opustags(qw(out.opus -o out.opus)), ['', <<'EOF', 256], 'output and input can\'t be the same');
error: Input and output files are the same
EOF
is_deeply(opustags(qw(gobble.opus -o out.opus --overwrite)), ['', '', 0], 'overwrite');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'successfully overwritten');
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_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');
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');
invalid comment: 'FOO'
EOF
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
is_deeply(opustags(qw(-i out.opus -s fatal=yes -s FOO -s BAR)), ['', <<'EOF', 256], 'bad tag with --set');
invalid comment: '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
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', 0], 'set all with bad tags');
whatever
# thing
!
wrong=yes
END_IN
wrong=yes
END_OUT
warning: skipping malformed tag
warning: skipping malformed tag
warning: skipping malformed tag
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');

29
t/tap.h Normal file
View File

@ -0,0 +1,29 @@
/**
* \file t/tap.h
*
* \brief
* Helpers for following the Test Anything Protocol.
*/
#pragma once
#include <exception>
#include <iostream>
class failure : public std::runtime_error {
public:
failure(const char *message) : std::runtime_error(message) {}
};
template <typename F>
static void run(F test, const char *name)
{
bool ok = false;
try {
test();
ok = true;
} catch (failure& e) {
std::cout << "# " << e.what() << "\n";
}
std::cout << (ok ? "ok" : "not ok") << " - " << name << "\n";
}