18 Commits
1.7.0 ... 1.9.0

Author SHA1 Message Date
e2e7e2a5a0 Release 1.9.0 2023-06-07 11:36:15 +09:00
70500a6aac Close the input file before writing the final output 2023-05-28 12:56:06 +09:00
49bb94841e Add option --set-vendor 2023-05-04 11:38:35 +09:00
dcb128f179 Add option --vendor 2023-05-04 11:33:16 +09:00
330fe5e9f2 Release 1.8.0 2023-03-07 10:39:13 +09:00
54136057d8 Remove the old UTF-8 conversion routines 2023-03-03 15:13:56 +09:00
1d13c258e4 Use std::u8string where appropriate 2023-03-03 15:13:44 +09:00
89dc000927 Rework the encoding converter to support std::u8string 2023-03-03 15:03:07 +09:00
befae72d2a Support reading the cover art from a stream 2023-03-02 16:21:25 +09:00
46cc78bfff Deduce the cover’s MIME type from its signature 2023-03-02 15:17:41 +09:00
558160d5c3 Add option --set-cover 2023-03-01 18:32:13 +09:00
74e42ee917 Introduce byte strings 2023-02-28 17:04:03 +09:00
92b320f9d9 Warn on multiple cover arts 2023-02-28 15:41:09 +09:00
ec68f5c0e9 Add option --output-cover 2023-02-27 12:22:28 +09:00
66fb3574a1 Implement embedded picture decoding 2023-02-27 12:22:28 +09:00
9652f50316 Allow std literals everywhere 2023-02-22 17:15:21 +09:00
a435a28e9f Implement base64 encoding and decoding 2023-02-22 17:15:21 +09:00
55e7e9b64e Fix a rare error message in run_single() 2023-02-21 16:02:22 +09:00
18 changed files with 805 additions and 200 deletions

View File

@ -1,6 +1,21 @@
opustags changelog
==================
1.9.0 - 2023-06-07
------------------
- Introduce --vendor and --set-vendor.
- Close the input file before finalizing the output, in order to fix --in-place on SMB drives.
1.8.0 - 2023-03-07
------------------
- Introduce --set-cover and --output-cover.
opustags is now able to extract and edit the cover art of Opus files. The underlying
METADATA_BLOCK_PICTURE tag will still appear as a regular tag, but you wont have to handle it
manually anymore.
1.7.0 - 2023-02-13
------------------

View File

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.11)
project(
opustags
VERSION 1.7.0
VERSION 1.9.0
LANGUAGES CXX
)
@ -36,6 +36,7 @@ include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS} ${Iconv
add_library(
ot
STATIC
src/base64.cc
src/cli.cc
src/ogg.cc
src/opus.cc

View File

@ -3,6 +3,12 @@ opustags
View and edit Ogg Opus comments.
opustags supports the following features:
- interactive editing using your preferred text editor,
- batch editing with command-line flags,
- tags exporting and importing through text files.
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
@ -11,15 +17,6 @@ ever be performed.
opustags is tag-agnostic: you can write arbitrary key-value tags, and none of them will be treated
specially. After all, common tags like TITLE or ARTIST are nothing more than conventions.
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
------------
@ -65,6 +62,10 @@ Documentation
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
--output-cover FILE extract and save the cover art, if any
--set-cover FILE sets the cover art
--vendor print the vendor string
--set-vendor VALUE set the vendor string
--raw disable encoding conversion
See the man page, `opustags.1`, for extensive documentation.

View File

@ -1,4 +1,4 @@
.TH opustags 1 "February 2023" "@PROJECT_NAME@ @PROJECT_VERSION@"
.TH opustags 1 "March 2023" "@PROJECT_NAME@ @PROJECT_VERSION@"
.SH NAME
opustags \- Ogg Opus tag editor
.SH SYNOPSIS
@ -20,7 +20,7 @@ opustags \- Ogg Opus tag editor
.SH DESCRIPTION
.PP
\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.
It 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 standard output. Lines prefixed by tabs are continuation of the previous tag.
@ -47,10 +47,9 @@ All the previously existing tags as deleted.
.PP
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.
from your system locale. When 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
@ -106,6 +105,30 @@ 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 \-\-output-cover \fIFILE\fP
Save the cover art of the input Opus file to the specified location.
If the input file does not contain any cover art, this option has no effect.
To allow overwriting the target location, specify \fB--overwrite\fP.
In the case of multiple pictures embedded in the Opus tags, only the first one is saved.
Note that the since the image format is not fixed, you should consider an extension-less file name
and rely on the magic number to deduce the type. opustags does not add or check the target files
extension.
You can specify \fB-\fP for standard output, in which case the regular output will be suppressed.
.TP
.B \-\-set-cover \fIFILE\fP
Replace or set the cover art to the specified picture.
Specify \fB-\fP to read the picture from standard input.
In theory, an Opus file may contain multiple pictures with different roles, though in practice only
the front cover really matters. opustags can currently only handle one front cover and nothing else.
.TP
.B \-\-vendor
Print the vendor string from the OpusTags packet and do nothing else. Standard tags operations are
not supported when specifying this flag.
.TP
.B \-\-set-vendor \fIVALUE\fP
Replace the vendor string by the specified value. This action can be performed alongside tag
edition.
.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

97
src/base64.cc Normal file
View File

@ -0,0 +1,97 @@
/**
* \file src/base64.cc
* \brief Base64 encoding/decoding (RFC 4648).
*
* Inspired by Jouni Malinens BSD implementation at
* <http://web.mit.edu/freebsd/head/contrib/wpa/src/utils/base64.c>.
*
* This implementation is used to decode the cover arts embedded in the tags. According to
* <https://wiki.xiph.org/VorbisComment>, line feeds are not allowed and padding is required.
*/
#include <opustags.h>
#include <string.h>
static const char8_t base64_table[65] =
u8"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
std::u8string ot::encode_base64(ot::byte_string_view src)
{
size_t len = src.size();
size_t num_blocks = (len + 2) / 3; // Count of 3-byte blocks, rounded up.
size_t olen = num_blocks * 4; // Each 3-byte block becomes 4 base64 bytes.
if (olen < len)
throw std::overflow_error("failed to encode excessively long base64 block");
std::u8string out;
out.resize(olen);
const uint8_t* in = src.data();
const uint8_t* end = in + len;
char8_t* pos = out.data();
while (end - in >= 3) {
*pos++ = base64_table[in[0] >> 2];
*pos++ = base64_table[((in[0] & 0x03) << 4) | (in[1] >> 4)];
*pos++ = base64_table[((in[1] & 0x0f) << 2) | (in[2] >> 6)];
*pos++ = base64_table[in[2] & 0x3f];
in += 3;
}
if (end - in) {
*pos++ = base64_table[in[0] >> 2];
if (end - in == 1) {
*pos++ = base64_table[(in[0] & 0x03) << 4];
*pos++ = '=';
} else { // end - in == 2
*pos++ = base64_table[((in[0] & 0x03) << 4) | (in[1] >> 4)];
*pos++ = base64_table[(in[1] & 0x0f) << 2];
}
*pos++ = '=';
}
return out;
}
ot::byte_string ot::decode_base64(std::u8string_view src)
{
// Remove the padding and rely on the string length instead.
while (src.back() == u8'=')
src.remove_suffix(1);
size_t olen = src.size() / 4 * 3; // Whole blocks;
switch (src.size() % 4) {
case 1: throw status {st::error, "invalid base64 block size"};
case 2: olen += 1; break;
case 3: olen += 2; break;
}
ot::byte_string out;
out.resize(olen);
uint8_t* pos = out.data();
unsigned char dtable[256];
memset(dtable, 0x80, 256);
for (size_t i = 0; i < sizeof(base64_table) - 1; ++i)
dtable[(size_t) base64_table[i]] = (unsigned char) i;
unsigned char block[4];
size_t count = 0;
for (unsigned char c : src) {
unsigned char tmp = dtable[c];
if (tmp == 0x80)
throw status {st::error, "invalid base64 character"};
block[count++] = tmp;
if (count == 2) {
*pos++ = (block[0] << 2) | (block[1] >> 4);
} else if (count == 3) {
*pos++ = (block[1] << 4) | (block[2] >> 2);
} else if (count == 4) {
*pos++ = (block[2] << 6) | block[3];
count = 0;
}
}
return out;
}

View File

@ -16,8 +16,6 @@
#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(
@ -38,6 +36,10 @@ Options:
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-e, --edit edit tags interactively in VISUAL/EDITOR
--output-cover FILE extract and save the cover art, if any
--set-cover FILE sets the cover art
--vendor print the vendor string
--set-vendor VALUE set the vendor string
--raw disable encoding conversion
See the man page for extensive documentation.
@ -54,6 +56,10 @@ static struct option getopt_options[] = {
{"delete-all", no_argument, 0, 'D'},
{"set-all", no_argument, 0, 'S'},
{"edit", no_argument, 0, 'e'},
{"output-cover", required_argument, 0, 'c'},
{"set-cover", required_argument, 0, 'C'},
{"vendor", no_argument, 0, 'v'},
{"set-vendor", required_argument, 0, 'V'},
{"raw", no_argument, 0, 'r'},
{NULL, 0, 0, 0}
};
@ -61,10 +67,13 @@ static struct option getopt_options[] = {
ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
{
options opt;
static ot::encoding_converter to_utf8("", "UTF-8");
const char* equal;
ot::status rc;
std::list<std::string> local_to_add; // opt.to_add before UTF-8 conversion.
std::list<std::string> local_to_delete; // opt.to_delete before UTF-8 conversion.
bool set_all = false;
std::optional<std::string> set_cover;
std::optional<std::string> set_vendor;
opt = {};
if (argc == 1)
throw status {st::bad_arguments, "No arguments specified. Use -h for help."};
@ -88,7 +97,7 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
opt.overwrite = true;
break;
case 'd':
opt.to_delete.emplace_back(optarg);
local_to_delete.emplace_back(optarg);
break;
case 'a':
case 's':
@ -96,8 +105,8 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
if (equal == nullptr)
throw status {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);
local_to_delete.emplace_back(optarg, equal - optarg);
local_to_add.emplace_back(optarg);
break;
case 'S':
opt.delete_all = true;
@ -109,6 +118,24 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
case 'e':
opt.edit_interactively = true;
break;
case 'c':
if (opt.cover_out)
throw status {st::bad_arguments, "Cannot specify --output-cover more than once."};
opt.cover_out = optarg;
break;
case 'C':
if (set_cover)
throw status {st::bad_arguments, "Cannot specify --set-cover more than once."};
set_cover = optarg;
break;
case 'v':
opt.print_vendor = true;
break;
case 'V':
if (set_vendor)
throw status {st::bad_arguments, "Cannot specify --set-vendor more than once."};
set_vendor = optarg;
break;
case 'r':
opt.raw = true;
break;
@ -123,24 +150,47 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
return opt;
// All non-option arguments are input files.
bool stdin_as_input = false;
size_t stdin_uses = 0;
for (int i = optind; i < argc; i++) {
stdin_as_input = stdin_as_input || strcmp(argv[i], "-") == 0;
if (strcmp(argv[i], "-") == 0)
++stdin_uses;
opt.paths_in.emplace_back(argv[i]);
}
bool stdin_as_input = stdin_uses > 0;
if (set_cover == "-")
++stdin_uses;
if (set_all)
++stdin_uses;
if (stdin_uses > 1)
throw status { st::bad_arguments, "Cannot use standard input more than once." };
// Convert arguments to UTF-8.
if (!opt.raw) {
for (std::list<std::string>* args : { &opt.to_add, &opt.to_delete }) {
try {
for (std::string& arg : *args)
arg = to_utf8(arg);
} catch (const ot::status& rc) {
throw status {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
}
if (opt.raw) {
// Cast the user data without any encoding conversion.
auto cast_to_utf8 = [](std::string_view in)
{ return std::u8string(reinterpret_cast<const char8_t*>(in.data()), in.size()); };
std::transform(local_to_add.begin(), local_to_add.end(),
std::back_inserter(opt.to_add), cast_to_utf8);
std::transform(local_to_delete.begin(), local_to_delete.end(),
std::back_inserter(opt.to_delete), cast_to_utf8);
if (set_vendor)
opt.set_vendor = cast_to_utf8(*set_vendor);
} else {
try {
std::transform(local_to_add.begin(), local_to_add.end(),
std::back_inserter(opt.to_add), encode_utf8);
std::transform(local_to_delete.begin(), local_to_delete.end(),
std::back_inserter(opt.to_delete), encode_utf8);
if (set_vendor)
opt.set_vendor = encode_utf8(*set_vendor);
} catch (const ot::status& rc) {
throw status {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
}
}
bool read_only = !opt.in_place && !opt.path_out.has_value();
if (opt.in_place && opt.path_out)
throw status {st::bad_arguments, "Cannot combine --in-place and --output."};
@ -150,21 +200,33 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
if ((!opt.in_place || opt.edit_interactively) && opt.paths_in.size() != 1)
throw status {st::bad_arguments, "Exactly one input file must be specified."};
if (set_all && stdin_as_input)
throw status {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 == "-"))
if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-" || opt.cover_out == "-"))
throw status {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)
if (opt.edit_interactively && read_only)
throw status {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()))
throw status {st::bad_arguments, "Cannot mix --edit with -adDsS."};
if (opt.cover_out == "-" && opt.path_out == "-")
throw status {st::bad_arguments, "Cannot specify standard output for both --output and --output-cover."};
if (opt.cover_out && opt.paths_in.size() > 1)
throw status {st::bad_arguments, "Cannot use --output-cover with multiple input files."};
if (opt.print_vendor && !read_only)
throw status {st::bad_arguments, "--vendor is only supported in read-only mode."};
if (set_cover) {
byte_string picture_data = ot::slurp_binary_file(set_cover->c_str());
opt.to_delete.push_back(u8"METADATA_BLOCK_PICTURE"s);
opt.to_add.push_back(ot::make_cover(picture_data));
}
if (set_all) {
// Read comments from stdin and prepend them to opt.to_add.
std::list<std::string> comments = read_comments(comments_input, opt.raw);
std::list<std::u8string> comments = read_comments(comments_input, opt.raw);
opt.to_add.splice(opt.to_add.begin(), std::move(comments));
}
return opt;
@ -172,37 +234,55 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
/** Format a UTF-8 string by adding tabulations (\t) after line feeds (\n) to mark continuation for
* multiline values. */
static std::string format_value(const std::string& source)
static std::u8string format_value(const std::u8string& source)
{
auto newline_count = std::count(source.begin(), source.end(), '\n');
auto newline_count = std::count(source.begin(), source.end(), u8'\n');
// General case: the value fits on a single line. Use std::strings copy constructor for the
// most efficient copy we could hope for.
if (newline_count == 0)
return source;
std::string formatted;
std::u8string formatted;
formatted.reserve(source.size() + newline_count);
for (auto c : source) {
formatted.push_back(c);
if (c == '\n')
formatted.push_back('\t');
formatted.push_back(u8'\t');
}
return formatted;
}
/**
* Convert the comment from UTF-8 to the system encoding if relevant, and print it with a trailing
* line feed.
*/
static void puts_utf8(std::u8string_view str, FILE* output, bool raw)
{
if (raw) {
fwrite(str.data(), 1, str.size(), output);
} else {
try {
std::string local = ot::decode_utf8(str);
fwrite(local.data(), 1, local.size(), output);
} catch (ot::status& rc) {
rc.message += " See --raw.";
throw;
}
}
putc('\n', output);
}
/**
* Print comments in a human readable format that can also be read back in by #read_comment.
*
* To disambiguate between a newline embedded in a comment and a newline representing the start of
* the next tag, continuation lines always have a single TAB (^I) character added to the beginning.
*/
void ot::print_comments(const std::list<std::string>& comments, FILE* output, bool raw)
void ot::print_comments(const std::list<std::u8string>& comments, FILE* output, bool raw)
{
static ot::encoding_converter from_utf8("UTF-8", "");
std::string local;
bool has_control = false;
for (const std::string& source_comment : comments) {
for (const std::u8string& source_comment : comments) {
if (!has_control) { // Dont bother analyzing comments if the flag is already up.
for (unsigned char c : source_comment) {
if (c < 0x20 && c != '\n') {
@ -211,48 +291,31 @@ void ot::print_comments(const std::list<std::string>& comments, FILE* output, bo
}
}
}
std::string utf8_comment = format_value(source_comment);
const std::string* comment;
// Convert the comment from UTF-8 to the system encoding if relevant.
if (raw) {
comment = &utf8_comment;
} else {
try {
local = from_utf8(utf8_comment);
comment = &local;
} catch (ot::status& rc) {
rc.message += " See --raw.";
throw;
}
}
fwrite(comment->data(), 1, comment->size(), output);
putc('\n', output);
std::u8string utf8_comment = format_value(source_comment);
puts_utf8(utf8_comment, output, raw);
}
if (has_control)
fputs("warning: Some tags contain control characters.\n", stderr);
}
std::list<std::string> ot::read_comments(FILE* input, bool raw)
std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
{
std::list<std::string> comments;
static ot::encoding_converter to_utf8("", "UTF-8");
std::list<std::u8string> comments;
comments.clear();
char* source_line = nullptr;
size_t buflen = 0;
ssize_t nread;
std::string* previous_comment = nullptr;
std::u8string* previous_comment = nullptr;
while ((nread = getline(&source_line, &buflen, input)) != -1) {
if (nread > 0 && source_line[nread - 1] == '\n')
--nread; // Chomp.
std::string line;
std::u8string line;
if (raw) {
line = std::string(source_line, nread);
line = std::u8string(reinterpret_cast<char8_t*>(source_line), nread);
} else {
try {
line = to_utf8(std::string_view(source_line, nread));
line = encode_utf8(std::string_view(source_line, nread));
} catch (const ot::status& rc) {
free(source_line);
throw ot::status {ot::st::badly_encoded, "UTF-8 conversion error: " + rc.message};
@ -262,10 +325,10 @@ std::list<std::string> ot::read_comments(FILE* input, bool raw)
if (line.empty()) {
// Ignore empty lines.
previous_comment = nullptr;
} else if (line[0] == '#') {
} else if (line[0] == u8'#') {
// Ignore comments.
previous_comment = nullptr;
} else if (line[0] == '\t') {
} else if (line[0] == u8'\t') {
// Continuation line: append the current line to the previous tag.
if (previous_comment == nullptr) {
ot::status rc = {ot::st::error, "Unexpected continuation line: " + std::string(source_line, nread)};
@ -275,7 +338,7 @@ std::list<std::string> ot::read_comments(FILE* input, bool raw)
line[0] = '\n';
previous_comment->append(line);
}
} else if (line.find('=') == std::string::npos) {
} else if (line.find(u8'=') == decltype(line)::npos) {
ot::status rc = {ot::st::error, "Malformed tag: " + std::string(source_line, nread)};
free(source_line);
throw rc;
@ -287,19 +350,20 @@ std::list<std::string> ot::read_comments(FILE* input, bool raw)
return comments;
}
void ot::delete_comments(std::list<std::string>& comments, const std::string& selector)
void ot::delete_comments(std::list<std::u8string>& comments, const std::u8string& selector)
{
auto name = selector.data();
auto equal = selector.find('=');
auto value = (equal == std::string::npos ? nullptr : name + equal + 1);
auto equal = selector.find(u8'=');
auto value = (equal == std::u8string::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++;
/** \todo Avoid using strncasecmp because it assumes the system locale is UTF-8. */
bool name_match = current->size() > name_len + 1 &&
(*current)[name_len] == '=' &&
strncasecmp(current->data(), name, name_len) == 0;
strncasecmp((const char*) current->data(), (const char*) name, name_len) == 0;
if (!name_match)
continue;
bool value_match = value == nullptr ||
@ -313,13 +377,16 @@ void ot::delete_comments(std::list<std::string>& comments, const std::string& se
/** Apply the modifications requested by the user to the opustags packet. */
static void edit_tags(ot::opus_tags& tags, const ot::options& opt)
{
if (opt.set_vendor)
tags.vendor = *opt.set_vendor;
if (opt.delete_all) {
tags.comments.clear();
} else for (const std::string& name : opt.to_delete) {
ot::delete_comments(tags.comments, name.c_str());
} else for (const std::u8string& name : opt.to_delete) {
ot::delete_comments(tags.comments, name);
}
for (const std::string& comment : opt.to_add)
for (const std::u8string& comment : opt.to_add)
tags.comments.emplace_back(comment);
}
@ -387,6 +454,35 @@ static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std
remove(tags_path.c_str());
}
static void output_cover(const ot::opus_tags& tags, const ot::options &opt)
{
std::optional<ot::picture> cover = extract_cover(tags);
if (!cover) {
fputs("warning: No cover found.\n", stderr);
return;
}
ot::file output;
if (opt.cover_out == "-") {
output = stdout;
} else {
struct stat output_info;
if (stat(opt.cover_out->c_str(), &output_info) == 0) {
if (S_ISREG(output_info.st_mode) && !opt.overwrite)
throw ot::status {ot::st::error, "'" + opt.cover_out.value() + "' already exists. Use -y to overwrite."};
} else if (errno != ENOENT) {
throw ot::status {ot::st::error, "Could not identify '" + opt.cover_out.value() + "': " + strerror(errno)};
}
output = fopen(opt.cover_out->c_str(), "w");
if (output == nullptr)
throw ot::status {ot::st::standard_error, "Could not open '" + opt.cover_out.value() + "' for writing: " + strerror(errno)};
}
if (fwrite(cover->picture_data.data(), 1, cover->picture_data.size(), output.get()) < cover->picture_data.size())
throw ot::status {ot::st::standard_error, "fwrite error: "s + strerror(errno)};
}
/**
* Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
* Transform the OpusTags packet on the fly.
@ -422,6 +518,8 @@ static void process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::op
} else if (reader.absolute_page_no == 1) { // Comment header
ot::opus_tags tags;
reader.process_header_packet([&tags](ogg_packet& p) { tags = ot::parse_tags(p); });
if (opt.cover_out)
output_cover(tags, opt);
edit_tags(tags, opt);
if (writer) {
if (opt.edit_interactively) {
@ -432,7 +530,12 @@ static void process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::op
writer->write_header_packet(serialno, pageno, packet);
pageno_offset = writer->next_page_no - 1 - reader.absolute_page_no;
} else {
ot::print_comments(tags.comments, stdout, opt.raw);
if (opt.cover_out != "-") {
if (opt.print_vendor)
puts_utf8(tags.vendor, stdout, opt.raw);
else
ot::print_comments(tags.comments, stdout, opt.raw);
}
break;
}
} else if (writer) {
@ -504,12 +607,16 @@ static void run_single(const ot::options& opt, const std::string& path_in, const
temporary_output.open(path_out->c_str());
output = temporary_output.get();
} else {
throw ot::status {ot::st::error, "Could not identify '" + path_in + "': " + strerror(errno)};
throw ot::status {ot::st::error, "Could not identify '" + path_out.value() + "': " + strerror(errno)};
}
ot::ogg_writer writer(output);
writer.path = path_out;
process(reader, &writer, opt);
// Close the input file and finalize the output. When --in-place is specified, some file
// systems like SMB require that the input is closed first.
input.reset();
temporary_output.commit();
}

View File

@ -13,8 +13,6 @@
#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)

View File

@ -3,7 +3,7 @@
* \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).
* with here is defined by [RFC 7845](https://tools.ietf.org/html/rfc7845.html).
*
* Section 3 "Packet Organization" is critical for us:
*
@ -30,14 +30,14 @@ ot::opus_tags ot::parse_tags(const ogg_packet& packet)
if (packet.bytes < 0)
throw status {st::int_overflow, "Overflowing comment header length"};
size_t size = static_cast<size_t>(packet.bytes);
const char* data = reinterpret_cast<char*>(packet.packet);
const uint8_t* data = reinterpret_cast<uint8_t*>(packet.packet);
size_t pos = 0;
opus_tags my_tags;
// Magic number
if (8 > size)
throw status {st::cut_magic_number, "Comment header too short for the magic number"};
if (memcmp(data, "OpusTags", 8) != 0)
if (memcmp(data, u8"OpusTags", 8) != 0)
throw status {st::bad_magic_number, "Comment header did not start with OpusTags"};
// Vendor
@ -48,7 +48,7 @@ ot::opus_tags ot::parse_tags(const ogg_packet& packet)
size_t vendor_length = le32toh(*((uint32_t*) (data + pos)));
if (pos + 4 + vendor_length > size)
throw status {st::cut_vendor_data, "Vendor string did not fit the comment header"};
my_tags.vendor = std::string(data + pos + 4, vendor_length);
my_tags.vendor = std::u8string(reinterpret_cast<const char8_t*>(&data[pos + 4]), vendor_length);
pos += 4 + my_tags.vendor.size();
// Comment count
@ -66,13 +66,13 @@ ot::opus_tags ot::parse_tags(const ogg_packet& packet)
if (pos + 4 + comment_length > size)
throw status {st::cut_comment_data,
"Comment string did not fit the comment header"};
const char *comment_value = data + pos + 4;
auto comment_value = reinterpret_cast<const char8_t*>(&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);
my_tags.extra_data = byte_string(data + pos, size - pos);
return my_tags;
}
@ -80,7 +80,7 @@ ot::opus_tags ot::parse_tags(const ogg_packet& packet)
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)
for (const std::u8string& comment : tags.comments)
size += 4 + comment.size();
size += tags.extra_data.size();
@ -100,7 +100,7 @@ ot::dynamic_ogg_packet ot::render_tags(const opus_tags& tags)
n = htole32(tags.comments.size());
memcpy(data, &n, 4);
data += 4;
for (const std::string& comment : tags.comments) {
for (const std::u8string& comment : tags.comments) {
n = htole32(comment.size());
memcpy(data, &n, 4);
memcpy(data+4, comment.data(), comment.size());
@ -110,3 +110,102 @@ ot::dynamic_ogg_packet ot::render_tags(const opus_tags& tags)
return op;
}
/**
* The METADATA_BLOCK_PICTURE binary data, after base64 decoding, is organized like this:
*
* - 4 bytes for the picture type,
* - 4 + n bytes for the MIME type,
* - 4 + n bytes for the description string,
* - 16 bytes of picture attributes,
* - 4 + n bytes for the picture data.
*
* Integers are all big endian.
*/
ot::picture::picture(ot::byte_string block)
: storage(std::move(block))
{
size_t mime_offset = 4;
if (storage.size() < mime_offset + 4)
throw status { st::invalid_size, "missing MIME type in picture block" };
uint32_t mime_size = be32toh(*reinterpret_cast<const uint32_t*>(&storage[mime_offset]));
size_t desc_offset = mime_offset + 4 + mime_size;
if (storage.size() < desc_offset + 4)
throw status { st::invalid_size, "missing description in picture block" };
uint32_t desc_size = be32toh(*reinterpret_cast<const uint32_t*>(&storage[desc_offset]));
size_t pic_offset = desc_offset + 4 + desc_size + 16;
if (storage.size() < pic_offset + 4)
throw status { st::invalid_size, "missing picture data in picture block" };
uint32_t pic_size = be32toh(*reinterpret_cast<const uint32_t*>(&storage[pic_offset]));
if (storage.size() != pic_offset + 4 + pic_size)
throw status { st::invalid_size, "invalid picture block size" };
mime_type = byte_string_view(&storage[mime_offset + 4], mime_size);
picture_data = byte_string_view(&storage[pic_offset + 4], pic_size);
}
ot::byte_string ot::picture::serialize() const
{
ot::byte_string bytes;
size_t mime_offset = 4;
size_t pic_offset = mime_offset + 4 + mime_type.size() + 4 + 0 + 16;
bytes.resize(pic_offset + 4 + picture_data.size());
*reinterpret_cast<uint32_t*>(&bytes[0]) = htobe32(3); // Picture type: front cover.
*reinterpret_cast<uint32_t*>(&bytes[mime_offset]) = htobe32(mime_type.size());
std::copy(mime_type.begin(), mime_type.end(), std::next(bytes.begin(), mime_offset + 4));
*reinterpret_cast<uint32_t*>(&bytes[pic_offset]) = htobe32(picture_data.size());
std::copy(picture_data.begin(), picture_data.end(), std::next(bytes.begin(), pic_offset + 4));
return bytes;
}
/**
* \todo Take into account the picture types (first 4 bytes of the tag value).
*/
std::optional<ot::picture> ot::extract_cover(const ot::opus_tags& tags)
{
static const std::u8string_view prefix = u8"METADATA_BLOCK_PICTURE="sv;
auto is_cover = [](const std::u8string& tag) { return tag.starts_with(prefix); };
auto cover_tag = std::find_if(tags.comments.begin(), tags.comments.end(), is_cover);
if (cover_tag == tags.comments.end())
return {}; // No cover art.
auto extra_cover_tag = std::find_if(std::next(cover_tag), tags.comments.end(), is_cover);
if (extra_cover_tag != tags.comments.end())
fputs("warning: Found multiple covers; only the first will be extracted."
" Please report your use case if you need a finer selection.\n", stderr);
std::u8string_view cover_value = *cover_tag;
cover_value.remove_prefix(prefix.size());
return picture(decode_base64(cover_value));
}
/**
* Detect the MIME type of the given data block by checking the first bytes. Only the most common
* image formats are currently supported. Using magic(5) would give better results but that level of
* exhaustiveness is probably not necessary.
*/
static ot::byte_string_view detect_mime_type(ot::byte_string_view data)
{
static std::initializer_list<std::pair<ot::byte_string_view, ot::byte_string_view>> magic_numbers = {
{ "\xff\xd8\xff"_bsv, "image/jpeg"_bsv },
{ "\x89PNG"_bsv, "image/png"_bsv },
{ "GIF8"_bsv, "image/gif"_bsv },
};
for (auto [magic, mime] : magic_numbers) {
if (data.starts_with(magic))
return mime;
}
fputs("warning: Could not identify the MIME type of the picture; defaulting to application/octet-stream.\n", stderr);
return "application/octet-stream"_bsv;
}
std::u8string ot::make_cover(ot::byte_string_view picture_data)
{
picture pic;
pic.mime_type = detect_mime_type(picture_data);
pic.picture_data = picture_data;
return u8"METADATA_BLOCK_PICTURE=" + encode_base64(pic.serialize());
}

View File

@ -51,8 +51,12 @@
#include <libkern/OSByteOrder.h>
#define htole32(x) OSSwapHostToLittleInt32(x)
#define le32toh(x) OSSwapLittleToHostInt32(x)
#define htobe32(x) OSSwapHostToBigInt32(x)
#define be32toh(x) OSSwapBigToHostInt32(x)
#endif
using namespace std::literals;
namespace ot {
/**
@ -87,6 +91,7 @@ enum class st {
cut_comment_count,
cut_comment_length,
cut_comment_data,
invalid_size,
/* CLI */
bad_arguments,
};
@ -106,6 +111,9 @@ struct status {
std::string message;
};
using byte_string = std::basic_string<uint8_t>;
using byte_string_view = std::basic_string_view<uint8_t>;
/***********************************************************************************************//**
* \defgroup system System
* \{
@ -149,25 +157,14 @@ private:
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.
*/
std::string operator()(std::string_view in);
private:
iconv_t cd; /**< conversion descriptor */
};
/** Read a whole file into memory and return the read content. */
byte_string slurp_binary_file(const char* filename);
/** Convert a string from the system locales encoding to UTF-8. */
std::u8string encode_utf8(std::string_view);
/** Convert a string from UTF-8 to the system locales encoding. */
std::string decode_utf8(std::u8string_view);
/** Escape a string so that a POSIX shell interprets it as a single argument. */
std::string shell_escape(std::string_view word);
@ -187,6 +184,9 @@ void run_editor(std::string_view editor, std::string_view path);
*/
timespec get_file_timestamp(const char* path);
std::u8string encode_base64(byte_string_view src);
byte_string decode_base64(std::u8string_view src);
/** \} */
/***********************************************************************************************//**
@ -359,7 +359,7 @@ 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;
std::u8string vendor;
/**
* Comments are strings in the NAME=Value format. A comment may also be called a field, or a
* tag.
@ -368,7 +368,7 @@ struct opus_tags {
* 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;
std::list<std::u8string> comments;
/**
* According to RFC 7845:
* > Immediately following the user comment list, the comment header MAY contain
@ -380,7 +380,7 @@ struct opus_tags {
* 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;
byte_string extra_data;
};
/**
@ -393,6 +393,41 @@ opus_tags parse_tags(const ogg_packet& packet);
*/
dynamic_ogg_packet render_tags(const opus_tags& tags);
/**
* Extracted data from the METADATA_BLOCK_PICTURE tag. See
* <https://xiph.org/flac/format.html#metadata_block_picture> for the full specifications.
*
* It may contain all kinds of metadata but most are not used at all. For now, lets assume all
* pictures have picture type 3 (front cover), and empty metadata.
*/
struct picture {
picture() = default;
/** Extract the picture information from serialized binary data.*/
picture(byte_string block);
byte_string_view mime_type;
byte_string_view picture_data;
/**
* Encode the picture attributes (mime_type, picture_data) into a binary block to be stored
* into METADATA_BLOCK_PICTURE.
*/
byte_string serialize() const;
/** To avoid needless copies of the picture data, move the original data block there. The
* string_view attributes will refer to it. */
byte_string storage;
};
/** Extract the first picture embedded in the tags, regardless of its type. */
std::optional<picture> extract_cover(const opus_tags& tags);
/**
* Return a METADATA_BLOCK_PICTURE tag defining the front cover art to the given picture data (JPEG,
* PNG). The MIME type is deduced from the magic number.
*/
std::u8string make_cover(byte_string_view picture_data);
/** \} */
/***********************************************************************************************//**
@ -456,11 +491,9 @@ struct options {
* #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;
std::list<std::u8string> to_delete;
/**
* Delete all the existing comments.
*
@ -471,11 +504,30 @@ struct options {
* 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;
std::list<std::u8string> to_add;
/**
* If set, the input files cover art is exported to the specified file. - for stdout. Does
* not overwrite the file if it already exists unless -y is specified. Does nothing if the
* input file does not contain a cover art.
*
* Option: --output-cover
*/
std::optional<std::string> cover_out;
/**
* Print the vendor string at the beginning of the OpusTags packet instead of printing the
* tags. Only applicable in read-only mode.
*
* Option: --vendor
*/
bool print_vendor = false;
/**
* Replace the vendor string by the one specified by the user.
*
* Option: --set-vendor
*/
std::optional<std::u8string> set_vendor;
/**
* 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
@ -499,21 +551,19 @@ options parse_options(int argc, char** argv, FILE* comments);
*
* The output generated is meant to be parseable by #ot::read_comments.
*/
void print_comments(const std::list<std::string>& comments, FILE* output, bool raw);
void print_comments(const std::list<std::u8string>& comments, FILE* output, bool raw);
/**
* Parse the comments outputted by #ot::print_comments. Unless raw is true, the comments are
* converted from the system encoding to UTF-8, and returned as UTF-8.
*/
std::list<std::string> read_comments(FILE* input, bool raw);
std::list<std::u8string> read_comments(FILE* input, 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);
void delete_comments(std::list<std::u8string>& comments, const std::u8string& selector);
/**
* Main entry point to the opustags program, and pretty much the same as calling opustags from the
@ -524,3 +574,7 @@ void run(const options& opt);
/** \} */
}
/** Handy literal suffix for building byte strings. */
ot::byte_string operator""_bs(const char* data, size_t size);
ot::byte_string_view operator""_bsv(const char* data, size_t size);

View File

@ -12,13 +12,22 @@
#include <opustags.h>
#include <errno.h>
#include <fstream>
#include <stdlib.h>
#include <string.h>
#include <sys/stat.h>
#include <sys/wait.h>
#include <unistd.h>
using namespace std::string_literals;
ot::byte_string operator""_bs(const char* data, size_t size)
{
return ot::byte_string(reinterpret_cast<const uint8_t*>(data), size);
}
ot::byte_string_view operator""_bsv(const char* data, size_t size)
{
return ot::byte_string_view(reinterpret_cast<const uint8_t*>(data), size);
}
void ot::partial_file::open(const char* destination)
{
@ -89,24 +98,92 @@ void ot::partial_file::abort()
remove(temporary_name.c_str());
}
ot::encoding_converter::encoding_converter(const char* from, const char* to)
/**
* Determine the file size, in bytes, of the given file. Return -1 on for streams.
*/
static long get_file_size(FILE* f)
{
if (fseek(f, 0L, SEEK_END) != 0) {
clearerr(f); // Recover.
return -1;
}
long file_size = ftell(f);
rewind(f);
return file_size;
}
ot::byte_string ot::slurp_binary_file(const char* filename)
{
file f = strcmp(filename, "-") == 0 ? freopen(nullptr, "rb", stdin)
: fopen(filename, "rb");
if (f == nullptr)
throw status { st::standard_error,
"Could not open '"s + filename + "': " + strerror(errno) + "." };
byte_string content;
long file_size = get_file_size(f.get());
if (file_size == -1) {
// Read the input stream block by block and resize the output byte string as needed.
uint8_t buffer[4096];
while (!feof(f.get())) {
size_t read_len = fread(buffer, 1, sizeof(buffer), f.get());
content.append(buffer, read_len);
if (ferror(f.get()))
throw status { st::standard_error,
"Could not read '"s + filename + "': " + strerror(errno) + "." };
}
} else {
// Lucky! We know the file size, so lets slurp it at once.
content.resize(file_size);
if (fread(content.data(), 1, file_size, f.get()) < file_size)
throw status { st::standard_error,
"Could not read '"s + filename + "': " + strerror(errno) + "." };
}
return content;
}
/** 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.
*/
template<class InChar, class OutChar>
std::basic_string<OutChar> convert(std::basic_string_view<InChar>);
private:
iconv_t cd; /**< conversion descriptor */
};
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()
encoding_converter::~encoding_converter()
{
iconv_close(cd);
}
std::string ot::encoding_converter::operator()(std::string_view in)
template<class InChar, class OutChar>
std::basic_string<OutChar> encoding_converter::convert(std::basic_string_view<InChar> in)
{
iconv(cd, nullptr, nullptr, nullptr, nullptr);
std::string out;
std::basic_string<OutChar> out;
out.reserve(in.size());
char* in_cursor = const_cast<char*>(in.data());
const char* in_data = reinterpret_cast<const char*>(in.data());
char* in_cursor = const_cast<char*>(in_data);
size_t in_left = in.size();
constexpr size_t chunk_size = 1024;
char chunk[chunk_size];
@ -118,13 +195,13 @@ std::string ot::encoding_converter::operator()(std::string_view in)
if (rc == (size_t) -1 && errno == E2BIG) {
// Loop normally.
} else if (rc == (size_t) -1) {
throw status {ot::st::badly_encoded, strerror(errno) + "."s};
throw ot::status {ot::st::badly_encoded, strerror(errno) + "."s};
} else if (rc != 0) {
throw status {ot::st::badly_encoded,
"Some characters could not be converted into the target encoding."};
throw ot::status {ot::st::badly_encoded,
"Some characters could not be converted into the target encoding."};
}
out.append(chunk, out_cursor - chunk);
out.append(reinterpret_cast<OutChar*>(chunk), out_cursor - chunk);
if (in_cursor == nullptr)
break;
else if (in_left == 0)
@ -133,6 +210,18 @@ std::string ot::encoding_converter::operator()(std::string_view in)
return out;
}
std::u8string ot::encode_utf8(std::string_view in)
{
static encoding_converter to_utf8_cvt("", "UTF-8");
return to_utf8_cvt.convert<char, char8_t>(in);
}
std::string ot::decode_utf8(std::u8string_view in)
{
static encoding_converter from_utf8_cvt("UTF-8", "");
return from_utf8_cvt.convert<char8_t, char>(in);
}
std::string ot::shell_escape(std::string_view word)
{
std::string escaped_word;

View File

@ -10,13 +10,17 @@ target_link_libraries(ogg.t ot)
add_executable(cli.t EXCLUDE_FROM_ALL cli.cc)
target_link_libraries(cli.t ot)
add_executable(base64.t EXCLUDE_FROM_ALL base64.cc)
target_link_libraries(base64.t ot)
add_executable(oggdump EXCLUDE_FROM_ALL oggdump.cc)
target_link_libraries(oggdump ot)
configure_file(gobble.opus . COPYONLY)
configure_file(pixel.png . 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
DEPENDS opustags gobble.opus system.t opus.t ogg.t cli.t base64.t
)

46
t/base64.cc Normal file
View File

@ -0,0 +1,46 @@
#include <opustags.h>
#include "tap.h"
static void check_encode_base64()
{
opaque_is(ot::encode_base64(""_bsv), u8"", "empty");
opaque_is(ot::encode_base64("a"_bsv), u8"YQ==", "1 character");
opaque_is(ot::encode_base64("aa"_bsv), u8"YWE=", "2 characters");
opaque_is(ot::encode_base64("aaa"_bsv), u8"YWFh", "3 characters");
opaque_is(ot::encode_base64("aaaa"_bsv), u8"YWFhYQ==", "4 characters");
opaque_is(ot::encode_base64("\xFF\xFF\xFE"_bsv), u8"///+", "RFC alphabet");
opaque_is(ot::encode_base64("\0x"_bsv), u8"AHg=", "embedded null bytes");
}
static void check_decode_base64()
{
opaque_is(ot::decode_base64(u8""), ""_bsv, "empty");
opaque_is(ot::decode_base64(u8"YQ=="), "a"_bsv, "1 character");
opaque_is(ot::decode_base64(u8"YWE="), "aa"_bsv, "2 characters");
opaque_is(ot::decode_base64(u8"YQ"), "a"_bsv, "padless 1 character");
opaque_is(ot::decode_base64(u8"YWE"), "aa"_bsv, "padless 2 characters");
opaque_is(ot::decode_base64(u8"YWFh"), "aaa"_bsv, "3 characters");
opaque_is(ot::decode_base64(u8"YWFhYQ=="), "aaaa"_bsv, "4 characters");
opaque_is(ot::decode_base64(u8"///+"), "\xFF\xFF\xFE"_bsv, "RFC alphabet");
opaque_is(ot::decode_base64(u8"AHg="), "\0x"_bsv, "embedded null bytes");
try {
ot::decode_base64(u8"Y===");
throw failure("accepted a bad block size");
} catch (const ot::status& e) {
}
try {
ot::decode_base64(u8"\xFF bad message!");
throw failure("accepted an invalid character");
} catch (const ot::status& e) {
}
}
int main(int argc, char **argv)
{
std::cout << "1..2\n";
run(check_encode_base64, "base64 encoding");
run(check_decode_base64, "base64 decoding");
return 0;
}

View File

@ -3,9 +3,7 @@
#include <string.h>
using namespace std::literals::string_literals;
static ot::status read_comments(FILE* input, std::list<std::string>& comments, bool raw)
static ot::status read_comments(FILE* input, std::list<std::u8string>& comments, bool raw)
{
try {
comments = ot::read_comments(input, raw);
@ -17,7 +15,7 @@ static ot::status read_comments(FILE* input, std::list<std::string>& comments, b
void check_read_comments()
{
std::list<std::string> comments;
std::list<std::u8string> comments;
ot::status rc;
{
std::string txt = "TITLE=a b c\n\nARTIST=X\nArtist=Y\n"s;
@ -25,7 +23,7 @@ void check_read_comments()
rc = 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"};
auto&& expected = {u8"TITLE=a b c", u8"ARTIST=X", u8"Artist=Y"};
if (!std::equal(comments.begin(), comments.end(), expected.begin(), expected.end()))
throw failure("parsed user comments did not match expectations");
}
@ -42,7 +40,7 @@ void check_read_comments()
rc = read_comments(input.get(), comments, true);
if (rc != ot::st::ok)
throw failure("could not read comments");
if (comments.front() != "RAW=\xFF\xFF")
if (comments.front() != (char8_t*) "RAW=\xFF\xFF")
throw failure("parsed user comments did not match expectations");
}
{
@ -51,7 +49,7 @@ void check_read_comments()
rc = read_comments(input.get(), comments, true);
if (rc != ot::st::ok)
throw failure("could not read comments");
if (comments.front() != "MULTILINE=First\nSecond")
if (comments.front() != u8"MULTILINE=First\nSecond")
throw failure("parsed user comments did not match expectations");
}
{
@ -111,14 +109,14 @@ void check_good_arguments()
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"})
opt.to_delete.front() != u8"X" || *std::next(opt.to_delete.begin()) != u8"a=b" ||
opt.to_add != std::list<std::u8string>{ u8"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"})
opt.to_add != std::list<std::u8string>{ u8"N=1", u8"x=y z" })
throw failure("unexpected option parsing result for case #2");
opt = parse({"opustags", "-i", "x", "y", "z"});
@ -132,7 +130,7 @@ void check_good_arguments()
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")
if (!opt.raw || opt.to_add.front() != u8"X=\xFF")
throw failure("--raw did not disable transcoding");
}
@ -161,8 +159,7 @@ void check_bad_arguments()
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", "-S", "-"}, "Cannot use standard input more than once.", "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");
@ -182,6 +179,14 @@ void check_bad_arguments()
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", "--output-cover", "x", "--output-cover", "y"},
"Cannot specify --output-cover more than once.", "multiple --output-cover");
error_case({"opustags", "x", "-o", "-", "--output-cover", "-"},
"Cannot specify standard output for both --output and --output-cover.", "-o and --output-cover conflict");
error_case({"opustags", "-i", "x", "y", "--output-cover", "z"},
"Cannot use --output-cover with multiple input files.", "--output-cover with multiple input");
error_case({"opustags", "-i", "--vendor", "x"},
"--vendor is only supported in read-only mode.", "--vendor when editing");
error_case({"opustags", "-d", "\xFF", "x"},
"Could not encode argument into UTF-8:",
"-d with binary data");
@ -195,23 +200,23 @@ void check_bad_arguments()
static void check_delete_comments()
{
using C = std::list<std::string>;
C original = {"TITLE=X", "Title=Y", "Title=Z", "ARTIST=A", "artIst=B"};
using C = std::list<std::u8string>;
C original = {u8"TITLE=X", u8"Title=Y", u8"Title=Z", u8"ARTIST=A", u8"artIst=B"};
C edited = original;
ot::delete_comments(edited, "derp");
ot::delete_comments(edited, u8"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"};
ot::delete_comments(edited, u8"Title");
C expected = {u8"ARTIST=A", u8"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"};
ot::delete_comments(edited, u8"titlE=Y");
ot::delete_comments(edited, u8"Title=z");
expected = {u8"TITLE=X", u8"Title=Z", u8"ARTIST=A", u8"artIst=B"};
if (!std::equal(edited.begin(), edited.end(), expected.begin(), expected.end()))
throw failure("did not delete a specific title correctly");
}

View File

@ -3,8 +3,6 @@
#include <string.h>
using namespace std::literals::string_literals;
static const char standard_OpusTags[] =
"OpusTags"
"\x14\x00\x00\x00" "opustags test packet"
@ -18,15 +16,15 @@ static void parse_standard()
op.bytes = sizeof(standard_OpusTags) - 1;
op.packet = (unsigned char*) standard_OpusTags;
ot::opus_tags tags = ot::parse_tags(op);
if (tags.vendor != "opustags test packet")
if (tags.vendor != u8"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")
if (*it != u8"TITLE=Foo")
throw failure("bad title");
++it;
if (*it != "ARTIST=Bar")
if (*it != u8"ARTIST=Bar")
throw failure("bad artist");
if (tags.extra_data.size() != 0)
throw failure("found mysterious padding data");
@ -125,7 +123,7 @@ static void recode_padding()
op.packet = (unsigned char*) padded_OpusTags.data();
ot::opus_tags tags = ot::parse_tags(op);
if (tags.extra_data != "\0hello"s)
if (tags.extra_data != "\0hello"_bsv)
throw failure("corrupted extra data");
// recode the packet and ensure it's exactly the same
auto packet = ot::render_tags(tags);
@ -137,12 +135,60 @@ static void recode_padding()
throw failure("the rendered packet is not what we expected");
}
static void extract_cover()
{
ot::byte_string_view picture_data = ""_bsv
"\x00\x00\x00\x03" // Picture type 3.
"\x00\x00\x00\x09" "image/foo" // MIME type.
"\x00\x00\x00\x00" "" // Description.
"\x00\x00\x00\x00" // Width.
"\x00\x00\x00\x00" // Height.
"\x00\x00\x00\x00" // Color depth.
"\x00\x00\x00\x00" // Palette size.
"\x00\x00\x00\x0C" "Picture data";
ot::opus_tags tags;
tags.comments = { u8"METADATA_BLOCK_PICTURE=" + ot::encode_base64(picture_data) };
std::optional<ot::picture> cover = ot::extract_cover(tags);
if (!cover)
throw failure("could not extract the cover");
if (cover->mime_type != "image/foo"_bsv)
throw failure("bad extracted MIME type");
if (cover->picture_data != "Picture data"_bsv)
throw failure("bad extracted picture data");
ot::byte_string_view truncated_data = picture_data.substr(0, picture_data.size() - 1);
tags.comments = { u8"METADATA_BLOCK_PICTURE=" + ot::encode_base64(truncated_data) };
try {
ot::extract_cover(tags);
throw failure("accepted a bad picture block");
} catch (const ot::status& rc) {}
}
static void make_cover()
{
ot::byte_string_view picture_block = ""_bsv
"\x00\x00\x00\x03" // Picture type 3.
"\x00\x00\x00\x09" "image/png" // MIME type.
"\x00\x00\x00\x00" "" // Description.
"\x00\x00\x00\x00" // Width.
"\x00\x00\x00\x00" // Height.
"\x00\x00\x00\x00" // Color depth.
"\x00\x00\x00\x00" // Palette size.
"\x00\x00\x00\x11" "\x89PNG Picture data";
std::u8string expected = u8"METADATA_BLOCK_PICTURE=" + ot::encode_base64(picture_block);
opaque_is(ot::make_cover("\x89PNG Picture data"_bsv), expected, "build the picture tag");
}
int main()
{
std::cout << "1..4\n";
std::cout << "1..6\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");
run(extract_cover, "extract the cover art");
run(make_cover, "encode the cover art");
return 0;
}

View File

@ -4,7 +4,8 @@ use strict;
use warnings;
use utf8;
use Test::More tests => 55;
use Test::More tests => 62;
use Test::Deep qw(cmp_deeply re);
use Digest::MD5;
use File::Basename;
@ -53,32 +54,9 @@ $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');
my $expected_help = qr{opustags version .*\n\nUsage: opustags --help\n};
cmp_deeply(opustags('--help'), [re($expected_help), '', 0], '--help displays the help message');
cmp_deeply(opustags('-h'), [re($expected_help), '', 0], '-h displays the help message too');
is_deeply(opustags('--derp'), ['', <<"EOF", 512], 'unrecognized option shows an error');
error: Unrecognized option '--derp'.
@ -325,3 +303,27 @@ is_deeply(opustags('out.opus'), [$big_tags, '', 0], 'read multi-page header');
is_deeply(opustags(qw(out.opus -i -D -a), 'encoder=Lavc58.18.100 libopus'), ['', '', 0], 'shrink the header');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'the result is identical to the original file');
unlink('out.opus');
####################################################################################################
# Cover arts
is_deeply(opustags(qw(-D --set-cover pixel.png gobble.opus -o out.opus)), ['', '', 0], 'set the cover');
is_deeply(opustags(qw(--output-cover out.png out.opus)), [<<'END_OUT', '', 0], 'extract the cover');
METADATA_BLOCK_PICTURE=AAAAAwAAAAlpbWFnZS9wbmcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEWJUE5HDQoaCgAAAA1JSERSAAAAAQAAAAEIAgAAAJB3U94AAAAMSURBVAjXY/j//z8ABf4C/tzMWecAAAAASUVORK5CYII=
END_OUT
is(md5('out.png'), md5('pixel.png'), 'the extracted cover is identical to the one set');
unlink('out.opus');
unlink('out.png');
is_deeply(opustags(qw(-D --set-cover - gobble.opus), { in => "GIF8 x" }), [<<'END_OUT', '', 0], 'read the cover from stdin');
METADATA_BLOCK_PICTURE=AAAAAwAAAAlpbWFnZS9naWYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZHSUY4IHg=
END_OUT
####################################################################################################
# Vendor string
is_deeply(opustags(qw(--vendor gobble.opus)), ["Lavf58.12.100\n", '', 0], 'print the vendor string');
is_deeply(opustags(qw(--set-vendor opustags gobble.opus -o out.opus)), ['', '', 0], 'set the vendor string');
is_deeply(opustags(qw(--vendor out.opus)), ["opustags\n", '', 0], 'the vendor string was updated');
unlink('out.opus');

BIN
t/pixel.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 69 B

View File

@ -34,17 +34,27 @@ void check_partial_files()
is(remove(result), 0, "remove the result file");
}
void check_slurp()
{
static const ot::byte_string_view pixel = ""_bsv
"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d"
"\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01"
"\x08\x02\x00\x00\x00\x90\x77\x53\xde\x00\x00\x00"
"\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xff\xff\x3f"
"\x00\x05\xfe\x02\xfe\xdc\xcc\x59\xe7\x00\x00\x00"
"\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
opaque_is(ot::slurp_binary_file("pixel.png"), pixel, "loads a whole 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");
is(to_utf8(ephemere_iso), "Éphémère", "conversion to UTF-8 is correct");
is(from_utf8("Éphémère"), ephemere_iso, "conversion from UTF-8 is correct");
setlocale(LC_ALL, "");
is(ot::decode_utf8(ot::encode_utf8("Éphémère")), "Éphémère", "decode_utf8 reverts encode_utf8");
opaque_is(ot::encode_utf8(ot::decode_utf8(u8"Éphémère")), u8"Éphémère",
"encode_utf8 reverts decode_utf8");
try {
from_utf8("\xFF\xFF");
ot::decode_utf8((char8_t*) "\xFF\xFF");
throw failure("conversion from bad UTF-8 did not fail");
} catch (const ot::status&) {}
}
@ -59,8 +69,9 @@ void check_shell_esape()
int main(int argc, char **argv)
{
plan(3);
plan(4);
run(check_partial_files, "test partial files");
run(check_slurp, "file slurping");
run(check_converter, "test encoding converter");
run(check_shell_esape, "test shell escaping");
return 0;

View File

@ -51,6 +51,13 @@ void is(const T& got, const U& expected, const char* name)
}
}
template <typename T, typename U>
void opaque_is(const T& got, const U& expected, const char* name)
{
if (got != expected)
throw failure(name);
}
template <>
void is(const ot::status& got, const ot::st& expected, const char* name)
{