8 Commits

Author SHA1 Message Date
6ae008befd Release 1.10.0 2024-05-03 18:50:03 +09:00
0067162ffb Support NUL delimiters with -z 2024-04-30 16:24:58 +09:00
7ec3551f62 Refresh and install the documentation files 2024-02-15 15:00:38 +09:00
a63c06dc05 opustags.1: Fix typo (#64)
* opustags.1: Fix typo

Fix a minor typo in the man page

* opustags.1: remove broken macro
2023-11-26 17:06:08 +09:00
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
11 changed files with 189 additions and 82 deletions

View File

@ -1,6 +1,20 @@
opustags changelog
==================
1.10.0 - 2024-05-03
-------------------
- Introduce -z to delimit tags with null bytes.
This option makes it possible to leverage GNU sed or GNU grep for automated tag edition with
`opustags -z … | sed -z … | opustags -z -S …`, while also supporting multi-line tags.
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
------------------

View File

@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.11)
project(
opustags
VERSION 1.8.0
VERSION 1.10.0
LANGUAGES CXX
)
@ -51,5 +51,6 @@ 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")
install(FILES CHANGELOG.md CONTRIBUTING.md LICENSE README.md DESTINATION ${CMAKE_INSTALL_DOCDIR})
add_subdirectory(t)

View File

@ -25,6 +25,9 @@ You should check that your changes don't break the test suite by running
Following these practices is important to keep the history clean, and to allow
for better code reviews.
You can submit pull requests on GitHub at <https://github.com/fmang/opustags>,
or email me your patches at <fmang+opustags@mg0.fr>.
## History of opustags
opustags is originally a small project made to fill a need to edit tags in Opus
@ -49,6 +52,8 @@ modules, and reviewed for safety.
1.3.0 was focused on correctness, and detects edge cases as early as possible,
instead of hoping something will eventually fail if something is weird.
Subsequent releases have been adding new features.
## Candidate features
The code contains a few `\todo` markers where something could be improved in the
@ -59,13 +64,7 @@ More generally, here are a few features that could be added in the future:
- Discouraging non-ASCII field names.
- Logicial stream listing and selection for multiplexed files.
- Escaping control characters with --escape.
- Dump binary packets with --binary.
- Edition of the vendor string.
- Edition of the arbitrary binary block past the comments.
- Support for OpusTags packets spanning multiple pages (> 64 kB).
- Interactive edition of comments inside the EDITOR (--edit).
- Support for cover arts.
- Load tags from a file with --set-all=tags.txt.
- Colored output.
Don't hesitate to contact me before you do anything, I'll give you directions.

View File

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

View File

@ -17,6 +17,8 @@ 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.
The projects homepage is located at <https://github.com/fmang/opustags>.
Requirements
------------
@ -64,6 +66,9 @@ Documentation
-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
-z delimit tags with NUL
See the man page, `opustags.1`, for extensive documentation.

View File

@ -1,4 +1,4 @@
.TH opustags 1 "March 2023" "@PROJECT_NAME@ @PROJECT_VERSION@"
.TH opustags 1 "April 2024" "@PROJECT_NAME@ @PROJECT_VERSION@"
.SH NAME
opustags \- Ogg Opus tag editor
.SH SYNOPSIS
@ -11,7 +11,7 @@ opustags \- Ogg Opus tag editor
.B opustags
.I OPTIONS
.B -i
.R \fIFILE\fP...
\fIFILE\fP...
.br
.B opustags
.I OPTIONS
@ -48,7 +48,7 @@ All the previously existing tags as deleted.
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 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
your system encoding, the original UTF-8 values will be preserved for the tags you don't explicitly
modify.
.SH OPTIONS
.TP
@ -121,12 +121,28 @@ 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
kind of binary data without ensuring the validity of the tags encoding. This option may also be
useful when your system encoding is different from UTF-8 and you wish to preserve the full UTF-8
character set even though your system cannot display it.
.TP
.B \-z
When editing tags programmatically with line-based tools like grep or sed, tags containing newlines
are likely to corrupt the result because these tools wont interpret multi-line tags as a whole. To
make automatic processing easier, \fB-z\fP delimits tags by a null byte (ASCII NUL) instead of line
feeds. That same \fB-z\fP flag is also supported by GNU grep or GNU sed and, combined with opustags
-z, would make them process the input tag-by-tag instead of line-by-line, thus supporting multi-line
tags as well. This option also disables the TAB prefix for continuation lines after a line feed.
.SH EXAMPLES
.PP
List all the tags in file foo.opus:
@ -137,10 +153,6 @@ 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
@ -149,6 +161,14 @@ tags without writing them to the Opus file:
Edit tags interactively in Vim:
.PP
EDITOR=vim opustags --in-place --edit file.opus
.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
Use GNU grep to remove all the CHAPTER* tags, with -z to support multi-line tags:
.PP
opustags -z file.opus | grep -z -v ^CHAPTER | opustags -z --in-place file.opus --set-all
.SH CAVEATS
.PP
\fBopustags\fP currently has the following limitations:

View File

@ -38,7 +38,10 @@ Options:
-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
-z delimit tags with NUL
See the man page for extensive documentation.
)raw";
@ -56,6 +59,8 @@ static struct option getopt_options[] = {
{"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}
};
@ -69,12 +74,13 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
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."};
int c;
optind = 0;
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSe", getopt_options, NULL)) != -1) {
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSez", getopt_options, NULL)) != -1) {
switch (c) {
case 'h':
opt.print_help = true;
@ -123,9 +129,20 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
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;
case 'z':
opt.tag_delimiter = '\0';
break;
case ':':
throw status {st::bad_arguments, "Missing value for option '"s + argv[optind - 1] + "'."};
default:
@ -161,17 +178,23 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
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."};
@ -184,7 +207,7 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
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()))
@ -196,6 +219,9 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
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);
@ -204,17 +230,17 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
if (set_all) {
// Read comments from stdin and prepend them to opt.to_add.
std::list<std::u8string> comments = read_comments(comments_input, opt.raw);
std::list<std::u8string> comments = read_comments(comments_input, opt);
opt.to_add.splice(opt.to_add.begin(), std::move(comments));
}
return opt;
}
/** Format a UTF-8 string by adding tabulations (\t) after line feeds (\n) to mark continuation for
* multiline values. */
static std::u8string format_value(const std::u8string& source)
* multiline values. With -z, this behavior applies for embedded NUL characters instead of LF. */
static std::u8string format_value(const std::u8string& source, const ot::options& opt)
{
auto newline_count = std::count(source.begin(), source.end(), u8'\n');
auto newline_count = std::count(source.begin(), source.end(), opt.tag_delimiter);
// General case: the value fits on a single line. Use std::strings copy constructor for the
// most efficient copy we could hope for.
@ -225,19 +251,39 @@ static std::u8string format_value(const std::u8string& source)
formatted.reserve(source.size() + newline_count);
for (auto c : source) {
formatted.push_back(c);
if (c == '\n')
if (c == opt.tag_delimiter)
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, const ot::options& opt)
{
if (opt.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(opt.tag_delimiter, 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::u8string>& comments, FILE* output, bool raw)
void ot::print_comments(const std::list<std::u8string>& comments, FILE* output, const ot::options& opt)
{
bool has_control = false;
for (const std::u8string& source_comment : comments) {
@ -249,27 +295,14 @@ void ot::print_comments(const std::list<std::u8string>& comments, FILE* output,
}
}
}
std::u8string utf8_comment = format_value(source_comment);
// Convert the comment from UTF-8 to the system encoding if relevant.
if (raw) {
fwrite(utf8_comment.data(), 1, utf8_comment.size(), output);
} else {
try {
std::string local = decode_utf8(utf8_comment);
fwrite(local.data(), 1, local.size(), output);
} catch (ot::status& rc) {
rc.message += " See --raw.";
throw;
}
}
putc('\n', output);
std::u8string utf8_comment = format_value(source_comment, opt);
puts_utf8(utf8_comment, output, opt);
}
if (has_control)
fputs("warning: Some tags contain control characters.\n", stderr);
}
std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
std::list<std::u8string> ot::read_comments(FILE* input, const ot::options& opt)
{
std::list<std::u8string> comments;
comments.clear();
@ -277,12 +310,12 @@ std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
size_t buflen = 0;
ssize_t nread;
std::u8string* previous_comment = nullptr;
while ((nread = getline(&source_line, &buflen, input)) != -1) {
if (nread > 0 && source_line[nread - 1] == '\n')
while ((nread = getdelim(&source_line, &buflen, opt.tag_delimiter, input)) != -1) {
if (nread > 0 && source_line[nread - 1] == opt.tag_delimiter)
--nread; // Chomp.
std::u8string line;
if (raw) {
if (opt.raw) {
line = std::u8string(reinterpret_cast<char8_t*>(source_line), nread);
} else {
try {
@ -306,7 +339,7 @@ std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
free(source_line);
throw rc;
} else {
line[0] = '\n';
line[0] = opt.tag_delimiter;
previous_comment->append(line);
}
} else if (line.find(u8'=') == decltype(line)::npos) {
@ -348,6 +381,9 @@ void ot::delete_comments(std::list<std::u8string>& comments, const std::u8string
/** 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::u8string& name : opt.to_delete) {
@ -359,7 +395,7 @@ static void edit_tags(ot::opus_tags& tags, const ot::options& opt)
}
/** Spawn VISUAL or EDITOR to edit the given tags. */
static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, bool raw)
static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, const ot::options& opt)
{
const char* editor = nullptr;
if (getenv("TERM") != nullptr)
@ -378,7 +414,7 @@ static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std
if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
throw ot::status {ot::st::standard_error,
"Could not open '" + tags_path + "': " + strerror(errno)};
ot::print_comments(tags.comments, tags_file.get(), raw);
ot::print_comments(tags.comments, tags_file.get(), opt);
tags_file.reset();
// Spawn the editor, and watch the modification timestamps.
@ -409,7 +445,7 @@ static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std
if (tags_file == nullptr)
throw ot::status {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
try {
tags.comments = ot::read_comments(tags_file.get(), raw);
tags.comments = ot::read_comments(tags_file.get(), opt);
} catch (const ot::status& rc) {
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
throw;
@ -492,14 +528,18 @@ static void process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::op
if (writer) {
if (opt.edit_interactively) {
fflush(writer->file); // flush before calling the subprocess
edit_tags_interactively(tags, writer->path, opt.raw);
edit_tags_interactively(tags, writer->path, opt);
}
auto packet = ot::render_tags(tags);
writer->write_header_packet(serialno, pageno, packet);
pageno_offset = writer->next_page_no - 1 - reader.absolute_page_no;
} else {
if (opt.cover_out != "-")
ot::print_comments(tags.comments, stdout, opt.raw);
if (opt.cover_out != "-") {
if (opt.print_vendor)
puts_utf8(tags.vendor, stdout, opt);
else
ot::print_comments(tags.comments, stdout, opt);
}
break;
}
} else if (writer) {
@ -577,6 +617,10 @@ static void run_single(const ot::options& opt, const std::string& path_in, const
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

@ -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:
*

View File

@ -515,12 +515,32 @@ struct options {
* 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
* extract and set as-is, encoding conversion would get in the way.
*/
bool raw = false;
/**
* In text mode (default), tags are separated by a line feed. However, when combining
* opustags with grep or other line-based tools, this proves to be a bad separator because
* tag values may contain newlines. Changing the delimiter to '\0' with -z eases the
* processing of multi-line tags with other tools that support null-terminated lines.
*/
char tag_delimiter = '\n';
};
/**
@ -538,13 +558,13 @@ 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::u8string>& comments, FILE* output, bool raw);
void print_comments(const std::list<std::u8string>& comments, FILE* output, const options& opt);
/**
* 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::u8string> read_comments(FILE* input, bool raw);
std::list<std::u8string> read_comments(FILE* input, const options& opt);
/**
* Remove all comments matching the specified selector, which may either be a field name or a

View File

@ -5,8 +5,10 @@
static ot::status read_comments(FILE* input, std::list<std::u8string>& comments, bool raw)
{
ot::options opt;
opt.raw = raw;
try {
comments = ot::read_comments(input, raw);
comments = ot::read_comments(input, opt);
} catch (const ot::status& rc) {
return rc;
}
@ -185,6 +187,8 @@ void check_bad_arguments()
"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");

View File

@ -4,7 +4,8 @@ use strict;
use warnings;
use utf8;
use Test::More tests => 59;
use Test::More tests => 66;
use Test::Deep qw(cmp_deeply re);
use Digest::MD5;
use File::Basename;
@ -53,34 +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
--output-cover FILE extract and save the cover art, if any
--set-cover FILE sets the cover art
--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'.
@ -342,3 +318,27 @@ 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');
####################################################################################################
# Multi-line tags
is_deeply(opustags(qw(--set-all gobble.opus -o out.opus), { in => "MULTILINE=one\n\ttwo\nSIMPLE=three\n" }), ['', '', 0], 'parses continuation lines');
is_deeply(opustags(qw(out.opus -z)), ["MULTILINE=one\ntwo\0SIMPLE=three\0", '', 0], 'delimits output with NUL on -z');
unlink('out.opus');
is_deeply(opustags(qw(--set-all gobble.opus -o out.opus -z), { in => "MULTILINE=one\ntwo\0SIMPLE=three\0" }), ['', '', 0], 'delimits input with NUL on -z');
is_deeply(opustags(qw(out.opus)), [<<'END', '', 0], 'indents continuation lines');
MULTILINE=one
two
SIMPLE=three
END
unlink('out.opus');