From 558160d5c3bc1e875ad5f6d574dd1d8185bdc886 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Fr=C3=A9d=C3=A9ric=20Mangano?= Date: Tue, 28 Feb 2023 16:16:34 +0900 Subject: [PATCH] Add option --set-cover --- README.md | 2 ++ opustags.1 | 7 ++++++- src/cli.cc | 14 ++++++++++++++ src/opus.cc | 22 ++++++++++++++++++++++ src/opustags.h | 18 ++++++++++++++++++ src/system.cc | 25 +++++++++++++++++++++++++ t/CMakeLists.txt | 1 + t/opus.cc | 19 ++++++++++++++++++- t/opustags.t | 14 +++++++++++++- t/pixel.png | Bin 0 -> 69 bytes t/system.cc | 15 ++++++++++++++- 11 files changed, 133 insertions(+), 4 deletions(-) create mode 100644 t/pixel.png diff --git a/README.md b/README.md index 3c418f9..ee8e6f4 100644 --- a/README.md +++ b/README.md @@ -65,6 +65,8 @@ 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 --raw disable encoding conversion See the man page, `opustags.1`, for extensive documentation. diff --git a/opustags.1 b/opustags.1 index 79d7004..ab031f6 100644 --- a/opustags.1 +++ b/opustags.1 @@ -107,7 +107,7 @@ 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 file at the specified location. +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. @@ -116,6 +116,11 @@ and rely on the magic number to deduce the type. opustags does not add or check 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. +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 \-\-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 diff --git a/src/cli.cc b/src/cli.cc index 595066b..4575348 100644 --- a/src/cli.cc +++ b/src/cli.cc @@ -37,6 +37,7 @@ Options: -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. @@ -54,6 +55,7 @@ static struct option getopt_options[] = { {"set-all", no_argument, 0, 'S'}, {"edit", no_argument, 0, 'e'}, {"output-cover", required_argument, 0, 'c'}, + {"set-cover", required_argument, 0, 'C'}, {"raw", no_argument, 0, 'r'}, {NULL, 0, 0, 0} }; @@ -65,6 +67,7 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input) const char* equal; ot::status rc; bool set_all = false; + std::optional set_cover; opt = {}; if (argc == 1) throw status {st::bad_arguments, "No arguments specified. Use -h for help."}; @@ -114,6 +117,11 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input) 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 'r': opt.raw = true; break; @@ -134,6 +142,12 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input) opt.paths_in.emplace_back(argv[i]); } + if (set_cover) { + byte_string picture_data = ot::slurp_binary_file(set_cover->c_str()); + opt.to_delete.push_back("METADATA_BLOCK_PICTURE"); + opt.to_add.push_back(ot::make_cover(picture_data)); + } + // Convert arguments to UTF-8. if (!opt.raw) { for (std::list* args : { &opt.to_add, &opt.to_delete }) { diff --git a/src/opus.cc b/src/opus.cc index 5466440..eb62e82 100644 --- a/src/opus.cc +++ b/src/opus.cc @@ -147,6 +147,20 @@ ot::picture::picture(ot::byte_string block) 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(&bytes[0]) = htobe32(3); // Picture type: front cover. + *reinterpret_cast(&bytes[mime_offset]) = htobe32(mime_type.size()); + std::copy(mime_type.begin(), mime_type.end(), std::next(bytes.begin(), mime_offset + 4)); + *reinterpret_cast(&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). */ @@ -167,3 +181,11 @@ std::optional ot::extract_cover(const ot::opus_tags& tags) cover_value.remove_prefix(prefix.size()); return picture(decode_base64(cover_value)); } + +std::string ot::make_cover(ot::byte_string_view picture_data) +{ + picture pic; + pic.mime_type = "application/octet-stream"_bsv; + pic.picture_data = picture_data; + return "METADATA_BLOCK_PICTURE=" + encode_base64(pic.serialize()); +} diff --git a/src/opustags.h b/src/opustags.h index 6e27773..36614b0 100644 --- a/src/opustags.h +++ b/src/opustags.h @@ -157,6 +157,9 @@ private: ot::file file; }; +/** Read a whole file into memory and return the read content. */ +byte_string slurp_binary_file(const char* filename); + /** C++ wrapper for iconv. */ class encoding_converter { public: @@ -412,10 +415,19 @@ dynamic_ogg_packet render_tags(const opus_tags& tags); * 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; @@ -424,6 +436,12 @@ struct picture { /** Extract the first picture embedded in the tags, regardless of its type. */ std::optional 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::string make_cover(byte_string_view picture_data); + /** \} */ /***********************************************************************************************//** diff --git a/src/system.cc b/src/system.cc index 20677df..a892067 100644 --- a/src/system.cc +++ b/src/system.cc @@ -12,6 +12,7 @@ #include #include +#include #include #include #include @@ -97,6 +98,30 @@ void ot::partial_file::abort() remove(temporary_name.c_str()); } +/** \todo Support non-seekable files like streams. */ +ot::byte_string ot::slurp_binary_file(const char* filename) +{ + std::ifstream file(filename, std::ios::binary | std::ios::ate); + if (file.fail()) + throw status { st::standard_error, + "Could not open '"s + filename + "': " + strerror(errno) + "." }; + + auto file_size = file.tellg(); + if (file_size == decltype(file)::pos_type(-1)) + throw status { st::standard_error, + "Could not determine the size of '"s + filename + "': " + strerror(errno) + "." }; + + byte_string content; + content.resize(file_size); + file.seekg(0); + file.read(reinterpret_cast(content.data()), file_size); + if (file.fail()) + throw status { st::standard_error, + "Could not read '"s + filename + "': " + strerror(errno) + "." }; + + return content; +} + ot::encoding_converter::encoding_converter(const char* from, const char* to) { cd = iconv_open(to, from); diff --git a/t/CMakeLists.txt b/t/CMakeLists.txt index 55dce09..c1e0e82 100644 --- a/t/CMakeLists.txt +++ b/t/CMakeLists.txt @@ -17,6 +17,7 @@ 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 diff --git a/t/opus.cc b/t/opus.cc index b39926d..e142e39 100644 --- a/t/opus.cc +++ b/t/opus.cc @@ -165,13 +165,30 @@ static void extract_cover() } 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\x18" "application/octet-stream" // 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"; + + std::string expected = "METADATA_BLOCK_PICTURE=" + ot::encode_base64(picture_block); + is(ot::make_cover("Picture data"_bsv), expected, "build the picture tag"); +} + int main() { - std::cout << "1..5\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; } diff --git a/t/opustags.t b/t/opustags.t index 0d60c6e..4915096 100755 --- a/t/opustags.t +++ b/t/opustags.t @@ -4,7 +4,7 @@ use strict; use warnings; use utf8; -use Test::More tests => 55; +use Test::More tests => 58; use Digest::MD5; use File::Basename; @@ -73,6 +73,7 @@ Options: -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. @@ -326,3 +327,14 @@ 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=AAAAAwAAABhhcHBsaWNhdGlvbi9vY3RldC1zdHJlYW0AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEWJUE5HDQoaCgAAAA1JSERSAAAAAQAAAAEIAgAAAJB3U94AAAAMSURBVAjXY/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'); diff --git a/t/pixel.png b/t/pixel.png new file mode 100644 index 0000000000000000000000000000000000000000..5514ad40e9cc1e7a25dfa0d12cfea5fb950e51d6 GIT binary patch literal 69 zcmeAS@N?(olHy`uVBq!ia0vp^j3CUx1SBVv2j2ryJf1F&Asp9}fBgS%&%pYR>EE3* Rk