mirror of
https://github.com/fmang/opustags.git
synced 2025-07-07 10:04:30 +02:00
Compare commits
22 Commits
Author | SHA1 | Date | |
---|---|---|---|
6ae008befd | |||
0067162ffb | |||
7ec3551f62 | |||
a63c06dc05 | |||
e2e7e2a5a0 | |||
70500a6aac | |||
49bb94841e | |||
dcb128f179 | |||
330fe5e9f2 | |||
54136057d8 | |||
1d13c258e4 | |||
89dc000927 | |||
befae72d2a | |||
46cc78bfff | |||
558160d5c3 | |||
74e42ee917 | |||
92b320f9d9 | |||
ec68f5c0e9 | |||
66fb3574a1 | |||
9652f50316 | |||
a435a28e9f | |||
55e7e9b64e |
23
CHANGELOG.md
23
CHANGELOG.md
@ -1,6 +1,29 @@
|
||||
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
|
||||
------------------
|
||||
|
||||
- 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 won’t have to handle it
|
||||
manually anymore.
|
||||
|
||||
1.7.0 - 2023-02-13
|
||||
------------------
|
||||
|
||||
|
@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.11)
|
||||
|
||||
project(
|
||||
opustags
|
||||
VERSION 1.7.0
|
||||
VERSION 1.10.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
|
||||
@ -50,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)
|
||||
|
@ -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.
|
||||
|
2
LICENSE
2
LICENSE
@ -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,
|
||||
|
20
README.md
20
README.md
@ -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,14 +17,7 @@ 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.
|
||||
The project’s homepage is located at <https://github.com/fmang/opustags>.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
@ -65,6 +64,11 @@ 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
|
||||
-z delimit tags with NUL
|
||||
|
||||
See the man page, `opustags.1`, for extensive documentation.
|
||||
|
57
opustags.1
57
opustags.1
@ -1,4 +1,4 @@
|
||||
.TH opustags 1 "February 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
|
||||
@ -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 explicitly
|
||||
modify.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-h, \-\-help
|
||||
@ -106,12 +105,44 @@ 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 file’s
|
||||
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
|
||||
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 won’t 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:
|
||||
@ -122,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
|
||||
@ -134,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:
|
||||
|
97
src/base64.cc
Normal file
97
src/base64.cc
Normal file
@ -0,0 +1,97 @@
|
||||
/**
|
||||
* \file src/base64.cc
|
||||
* \brief Base64 encoding/decoding (RFC 4648).
|
||||
*
|
||||
* Inspired by Jouni Malinen’s 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;
|
||||
}
|
267
src/cli.cc
267
src/cli.cc
@ -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,7 +36,12 @@ 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
|
||||
-z delimit tags with NUL
|
||||
|
||||
See the man page for extensive documentation.
|
||||
)raw";
|
||||
@ -54,6 +57,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,16 +68,19 @@ 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."};
|
||||
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;
|
||||
@ -88,7 +98,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 +106,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,9 +119,30 @@ 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;
|
||||
case 'z':
|
||||
opt.tag_delimiter = '\0';
|
||||
break;
|
||||
case ':':
|
||||
throw status {st::bad_arguments, "Missing value for option '"s + argv[optind - 1] + "'."};
|
||||
default:
|
||||
@ -123,24 +154,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,59 +204,89 @@ 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);
|
||||
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::string format_value(const std::string& 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(), '\n');
|
||||
auto newline_count = std::count(source.begin(), source.end(), opt.tag_delimiter);
|
||||
|
||||
// General case: the value fits on a single line. Use std::string’s 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');
|
||||
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::string>& comments, FILE* output, bool raw)
|
||||
void ot::print_comments(const std::list<std::u8string>& comments, FILE* output, const ot::options& opt)
|
||||
{
|
||||
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) { // Don’t bother analyzing comments if the flag is already up.
|
||||
for (unsigned char c : source_comment) {
|
||||
if (c < 0x20 && c != '\n') {
|
||||
@ -211,48 +295,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, opt);
|
||||
puts_utf8(utf8_comment, output, opt);
|
||||
}
|
||||
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, const ot::options& opt)
|
||||
{
|
||||
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;
|
||||
while ((nread = getline(&source_line, &buflen, input)) != -1) {
|
||||
if (nread > 0 && source_line[nread - 1] == '\n')
|
||||
std::u8string* previous_comment = nullptr;
|
||||
while ((nread = getdelim(&source_line, &buflen, opt.tag_delimiter, input)) != -1) {
|
||||
if (nread > 0 && source_line[nread - 1] == opt.tag_delimiter)
|
||||
--nread; // Chomp.
|
||||
|
||||
std::string line;
|
||||
if (raw) {
|
||||
line = std::string(source_line, nread);
|
||||
std::u8string line;
|
||||
if (opt.raw) {
|
||||
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,20 +329,20 @@ 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)};
|
||||
free(source_line);
|
||||
throw rc;
|
||||
} else {
|
||||
line[0] = '\n';
|
||||
line[0] = opt.tag_delimiter;
|
||||
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 +354,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,18 +381,21 @@ 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);
|
||||
}
|
||||
|
||||
/** 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)
|
||||
@ -343,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.
|
||||
@ -374,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;
|
||||
@ -387,6 +458,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,17 +522,24 @@ 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) {
|
||||
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 {
|
||||
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) {
|
||||
@ -504,12 +611,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();
|
||||
}
|
||||
|
||||
|
@ -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)
|
||||
|
115
src/opus.cc
115
src/opus.cc
@ -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());
|
||||
}
|
||||
|
127
src/opustags.h
127
src/opustags.h
@ -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 locale’s encoding to UTF-8. */
|
||||
std::u8string encode_utf8(std::string_view);
|
||||
|
||||
/** Convert a string from UTF-8 to the system locale’s 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, let’s 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,17 +504,43 @@ 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 file’s 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
|
||||
* 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';
|
||||
};
|
||||
|
||||
/**
|
||||
@ -499,21 +558,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, 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::string> 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
|
||||
* 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 +581,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);
|
||||
|
109
src/system.cc
109
src/system.cc
@ -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 let’s 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;
|
||||
|
@ -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
46
t/base64.cc
Normal 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;
|
||||
}
|
51
t/cli.cc
51
t/cli.cc
@ -3,12 +3,12 @@
|
||||
|
||||
#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)
|
||||
{
|
||||
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;
|
||||
}
|
||||
@ -17,7 +17,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 +25,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 +42,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 +51,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 +111,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 +132,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 +161,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 +181,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 +202,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");
|
||||
}
|
||||
|
60
t/opus.cc
60
t/opus.cc
@ -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;
|
||||
}
|
||||
|
71
t/opustags.t
71
t/opustags.t
@ -4,7 +4,8 @@ use strict;
|
||||
use warnings;
|
||||
use utf8;
|
||||
|
||||
use Test::More tests => 55;
|
||||
use Test::More tests => 66;
|
||||
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,42 @@ 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');
|
||||
|
||||
####################################################################################################
|
||||
# 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');
|
||||
|
BIN
t/pixel.png
Normal file
BIN
t/pixel.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 69 B |
27
t/system.cc
27
t/system.cc
@ -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;
|
||||
|
7
t/tap.h
7
t/tap.h
@ -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)
|
||||
{
|
||||
|
Reference in New Issue
Block a user