mirror of
https://github.com/fmang/opustags.git
synced 2025-03-13 08:00:07 +01:00
Compare commits
18 Commits
Author | SHA1 | Date | |
---|---|---|---|
|
5c1a7b3a99 | ||
|
b70e65f0d4 | ||
|
fc7e5e939e | ||
|
e8b66a6207 | ||
|
ba5c151b5d | ||
|
76afc0efd5 | ||
|
a54bac8f55 | ||
|
3293647e8f | ||
|
d9b051210b | ||
|
3da23b58c9 | ||
|
6ae008befd | ||
|
0067162ffb | ||
|
7ec3551f62 | ||
|
a63c06dc05 | ||
|
e2e7e2a5a0 | ||
|
70500a6aac | ||
|
49bb94841e | ||
|
dcb128f179 |
30
.github/workflows/ci.yaml
vendored
Normal file
30
.github/workflows/ci.yaml
vendored
Normal file
@ -0,0 +1,30 @@
|
||||
name: Continuous Integration
|
||||
on:
|
||||
push:
|
||||
branches: [master]
|
||||
pull_request:
|
||||
branches: [master]
|
||||
workflow_dispatch:
|
||||
env:
|
||||
LC_CTYPE: C.UTF-8
|
||||
CMAKE_COLOR_DIAGNOSTICS: ON
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout git repository
|
||||
uses: actions/checkout@v4
|
||||
- name: Install dependencies
|
||||
run: |
|
||||
sudo apt install cmake g++ pkg-config libogg-dev ffmpeg libtest-harness-perl libtest-deep-perl liblist-moreutils-perl libtest-utf8-perl
|
||||
- name: Build
|
||||
env:
|
||||
CXX: g++
|
||||
CXXFLAGS: -D_FORTIFY_SOURCE=3 -D_GLIBCXX_ASSERTIONS -D_GLIBCXX_DEBUG -O2 -flto=auto -g -Wall -Wextra -Werror=format-security -fstack-protector-strong -fstack-clash-protection -fcf-protection -fsanitize=address,undefined
|
||||
LDFLAGS: -fsanitize=address,undefined
|
||||
run: |
|
||||
cmake -B target -DCMAKE_VERBOSE_MAKEFILE:BOOL=ON
|
||||
cmake --build target
|
||||
- name: Test
|
||||
run: |
|
||||
cmake --build target --target check
|
19
CHANGELOG.md
19
CHANGELOG.md
@ -1,6 +1,25 @@
|
||||
opustags changelog
|
||||
==================
|
||||
|
||||
1.10.1 - 2024-05-19
|
||||
-------------------
|
||||
|
||||
Fix a build error on recent systems.
|
||||
|
||||
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
|
||||
------------------
|
||||
|
||||
|
@ -2,7 +2,7 @@ cmake_minimum_required(VERSION 3.11)
|
||||
|
||||
project(
|
||||
opustags
|
||||
VERSION 1.8.0
|
||||
VERSION 1.10.1
|
||||
LANGUAGES CXX
|
||||
)
|
||||
|
||||
@ -51,5 +51,6 @@ include(GNUInstallDirs)
|
||||
install(TARGETS opustags DESTINATION "${CMAKE_INSTALL_BINDIR}")
|
||||
configure_file(opustags.1 . @ONLY)
|
||||
install(FILES "${CMAKE_BINARY_DIR}/opustags.1" DESTINATION "${CMAKE_INSTALL_MANDIR}/man1")
|
||||
install(FILES CHANGELOG.md CONTRIBUTING.md LICENSE README.md DESTINATION ${CMAKE_INSTALL_DOCDIR})
|
||||
|
||||
add_subdirectory(t)
|
||||
|
@ -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,
|
||||
|
@ -17,6 +17,8 @@ ever be performed.
|
||||
opustags is tag-agnostic: you can write arbitrary key-value tags, and none of them will be treated
|
||||
specially. After all, common tags like TITLE or ARTIST are nothing more than conventions.
|
||||
|
||||
The project’s homepage is located at <https://github.com/fmang/opustags>.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
@ -64,6 +66,9 @@ Documentation
|
||||
-e, --edit edit tags interactively in VISUAL/EDITOR
|
||||
--output-cover FILE extract and save the cover art, if any
|
||||
--set-cover FILE sets the cover art
|
||||
--vendor print the vendor string
|
||||
--set-vendor VALUE set the vendor string
|
||||
--raw disable encoding conversion
|
||||
-z delimit tags with NUL
|
||||
|
||||
See the man page, `opustags.1`, for extensive documentation.
|
||||
|
73
opustags.1
73
opustags.1
@ -1,4 +1,4 @@
|
||||
.TH opustags 1 "March 2023" "@PROJECT_NAME@ @PROJECT_VERSION@"
|
||||
.TH opustags 1 "March 2025" "@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
|
||||
@ -43,13 +43,13 @@ to set new tags without being bothered by the old ones.
|
||||
If you want to replace all the tags, you can use the \fB--set-all\fP option which will cause
|
||||
\fBopustags\fP to read tags from standard input.
|
||||
The format is the same as the one used for output: newline-separated \fIFIELD=Value\fP assignment.
|
||||
All the previously existing tags as deleted.
|
||||
All the previously existing tags are 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 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.
|
||||
The Opus format specification requires that tags are encoded in UTF-8, so that’s the only encoding
|
||||
\fBopustags\fP supports. If your system encoding is different, the tags are automatically converted
|
||||
to and from your system locale. When you edit an Opus file whose tags contain 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
|
||||
@ -67,7 +67,7 @@ setting \fB--output\fP to the same path as the input file and enabling \fB--over
|
||||
This option conflicts with \fB--output\fP.
|
||||
.TP
|
||||
.B \-y, \-\-overwrite
|
||||
By default, \fBopustags\fP refuses to overwrite an already-existent file.
|
||||
By default, \fBopustags\fP refuses to overwrite an already-existing file.
|
||||
Use \fB-y\fP to allow overwriting.
|
||||
Note that this option is not needed when the output is a special file like \fI/dev/null\fP.
|
||||
.TP
|
||||
@ -79,10 +79,10 @@ In both cases, the field names are case-insensitive, and expected to be ASCII.
|
||||
.B \-a, \-\-add \fIFIELD=VALUE\fP
|
||||
Add a tag. Note that multiple tags with the same field name are perfectly acceptable, so you can add
|
||||
multiple fields with the same name, and previously existing tags will also be preserved.
|
||||
When the \fB--delete\fP is used with the same \fIFIELD\fP, only the older tags are deleted.
|
||||
When \fB--delete\fP is used with the same \fIFIELD\fP, only the older tags are deleted.
|
||||
.TP
|
||||
.B \-s, \-\-set \fIFIELD=VALUE\fP
|
||||
This option is provided for convenience. It delete all the fields of the same
|
||||
This option is provided for convenience. It deletes all the fields of the same
|
||||
type that may already exist, then adds it with the wanted value.
|
||||
This is strictly equivalent to \fB--delete\fP \fIFIELD\fP \fB--add\fP
|
||||
\fIFIELD=VALUE\fP. You can combine it with \fB--add\fP to add tags of the same
|
||||
@ -93,33 +93,43 @@ added with \fB--add\fP.
|
||||
Delete all the previously existing tags.
|
||||
.TP
|
||||
.B \-S, \-\-set-all
|
||||
Sets the tags from scratch.
|
||||
Set the tags from scratch.
|
||||
All the original tags are deleted and new ones are read from standard input.
|
||||
Each line must specify a \fIFIELD=VALUE\fP pair and be separated with line feeds.
|
||||
Empty lines and lines starting with \fI#\fP are ignored.
|
||||
Multiline tags must have their continuation lines prefixed by a single tab (in other words, every
|
||||
Multi-line tags must have their continuation lines prefixed by a single tab (in other words, every
|
||||
\fI\\n\fP must be replaced by \fI\\n\\t\fP).
|
||||
.TP
|
||||
.B \-e, \-\-edit
|
||||
Edit tags interactively by spawning the program specified by the EDITOR
|
||||
environment variable. The allowed format is the same as \fB--set-all\fP.
|
||||
environment variable. The allowed format is the same as with \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.
|
||||
Extract the cover art from the \fBMETADATA_BLOCK_PICTURE\fP tag into 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.
|
||||
Note that 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.
|
||||
\fBopustags\fP 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.
|
||||
Set the cover art by embedding the specified picture into the \fBMETADATA_BLOCK_PICTURE\fP tag,
|
||||
replacing any existing values.
|
||||
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.
|
||||
the front cover really matters.
|
||||
\fBopustags\fP 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
|
||||
@ -127,6 +137,15 @@ corrupted or possibly even contain intentional binary data. In that case, --raw
|
||||
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
|
||||
\fBopustags -z\fP, would make them process the input tag-by-tag instead of line-by-line, thus
|
||||
supporting multi-line tags as well.
|
||||
This option also disables the tab prefix for continuation lines after a line feed.
|
||||
.SH EXAMPLES
|
||||
.PP
|
||||
List all the tags in file foo.opus:
|
||||
@ -137,10 +156,6 @@ Copy in.opus to out.opus, with the TITLE tag added:
|
||||
.PP
|
||||
opustags in.opus --output out.opus --add "TITLE=Hello world!"
|
||||
.PP
|
||||
Replace all the tags in dest.opus with the ones from src.opus:
|
||||
.PP
|
||||
opustags src.opus | opustags --in-place dest.opus --set-all
|
||||
.PP
|
||||
Remove the previously existing ARTIST tags and add the two X and Y ARTIST tags, then display the new
|
||||
tags without writing them to the Opus file:
|
||||
.PP
|
||||
@ -149,10 +164,18 @@ 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:
|
||||
.IP \[bu]
|
||||
.IP \[bu] 2n
|
||||
Multiplexed streams are not supported.
|
||||
.IP \[bu]
|
||||
Control characters inside tags are printed raw rather than being escaped.
|
||||
|
@ -56,7 +56,7 @@ std::u8string ot::encode_base64(ot::byte_string_view src)
|
||||
ot::byte_string ot::decode_base64(std::u8string_view src)
|
||||
{
|
||||
// Remove the padding and rely on the string length instead.
|
||||
while (src.back() == u8'=')
|
||||
while (!src.empty() && src.back() == u8'=')
|
||||
src.remove_suffix(1);
|
||||
|
||||
size_t olen = src.size() / 4 * 3; // Whole blocks;
|
||||
|
113
src/cli.cc
113
src/cli.cc
@ -15,6 +15,7 @@
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <algorithm>
|
||||
|
||||
static const char help_message[] =
|
||||
PROJECT_NAME " version " PROJECT_VERSION
|
||||
@ -38,7 +39,10 @@ Options:
|
||||
-e, --edit edit tags interactively in VISUAL/EDITOR
|
||||
--output-cover FILE extract and save the cover art, if any
|
||||
--set-cover FILE sets the cover art
|
||||
--vendor print the vendor string
|
||||
--set-vendor VALUE set the vendor string
|
||||
--raw disable encoding conversion
|
||||
-z delimit tags with NUL
|
||||
|
||||
See the man page for extensive documentation.
|
||||
)raw";
|
||||
@ -56,6 +60,8 @@ static struct option getopt_options[] = {
|
||||
{"edit", no_argument, 0, 'e'},
|
||||
{"output-cover", required_argument, 0, 'c'},
|
||||
{"set-cover", required_argument, 0, 'C'},
|
||||
{"vendor", no_argument, 0, 'v'},
|
||||
{"set-vendor", required_argument, 0, 'V'},
|
||||
{"raw", no_argument, 0, 'r'},
|
||||
{NULL, 0, 0, 0}
|
||||
};
|
||||
@ -69,12 +75,13 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
std::list<std::string> local_to_delete; // opt.to_delete before UTF-8 conversion.
|
||||
bool set_all = false;
|
||||
std::optional<std::string> set_cover;
|
||||
std::optional<std::string> set_vendor;
|
||||
opt = {};
|
||||
if (argc == 1)
|
||||
throw status {st::bad_arguments, "No arguments specified. Use -h for help."};
|
||||
int c;
|
||||
optind = 0;
|
||||
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSe", getopt_options, NULL)) != -1) {
|
||||
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSez", getopt_options, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'h':
|
||||
opt.print_help = true;
|
||||
@ -123,9 +130,20 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
throw status {st::bad_arguments, "Cannot specify --set-cover more than once."};
|
||||
set_cover = optarg;
|
||||
break;
|
||||
case 'v':
|
||||
opt.print_vendor = true;
|
||||
break;
|
||||
case 'V':
|
||||
if (set_vendor)
|
||||
throw status {st::bad_arguments, "Cannot specify --set-vendor more than once."};
|
||||
set_vendor = optarg;
|
||||
break;
|
||||
case 'r':
|
||||
opt.raw = true;
|
||||
break;
|
||||
case 'z':
|
||||
opt.tag_delimiter = '\0';
|
||||
break;
|
||||
case ':':
|
||||
throw status {st::bad_arguments, "Missing value for option '"s + argv[optind - 1] + "'."};
|
||||
default:
|
||||
@ -161,17 +179,23 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
std::back_inserter(opt.to_add), cast_to_utf8);
|
||||
std::transform(local_to_delete.begin(), local_to_delete.end(),
|
||||
std::back_inserter(opt.to_delete), cast_to_utf8);
|
||||
if (set_vendor)
|
||||
opt.set_vendor = cast_to_utf8(*set_vendor);
|
||||
} else {
|
||||
try {
|
||||
std::transform(local_to_add.begin(), local_to_add.end(),
|
||||
std::back_inserter(opt.to_add), encode_utf8);
|
||||
std::transform(local_to_delete.begin(), local_to_delete.end(),
|
||||
std::back_inserter(opt.to_delete), encode_utf8);
|
||||
if (set_vendor)
|
||||
opt.set_vendor = encode_utf8(*set_vendor);
|
||||
} catch (const ot::status& rc) {
|
||||
throw status {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
|
||||
}
|
||||
}
|
||||
|
||||
bool read_only = !opt.in_place && !opt.path_out.has_value();
|
||||
|
||||
if (opt.in_place && opt.path_out)
|
||||
throw status {st::bad_arguments, "Cannot combine --in-place and --output."};
|
||||
|
||||
@ -184,7 +208,7 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-" || opt.cover_out == "-"))
|
||||
throw status {st::bad_arguments, "Cannot edit interactively when standard input or standard output are already used."};
|
||||
|
||||
if (opt.edit_interactively && !opt.path_out.has_value() && !opt.in_place)
|
||||
if (opt.edit_interactively && read_only)
|
||||
throw status {st::bad_arguments, "Cannot edit interactively when no output is specified."};
|
||||
|
||||
if (opt.edit_interactively && (opt.delete_all || !opt.to_add.empty() || !opt.to_delete.empty()))
|
||||
@ -196,6 +220,9 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
if (opt.cover_out && opt.paths_in.size() > 1)
|
||||
throw status {st::bad_arguments, "Cannot use --output-cover with multiple input files."};
|
||||
|
||||
if (opt.print_vendor && !read_only)
|
||||
throw status {st::bad_arguments, "--vendor is only supported in read-only mode."};
|
||||
|
||||
if (set_cover) {
|
||||
byte_string picture_data = ot::slurp_binary_file(set_cover->c_str());
|
||||
opt.to_delete.push_back(u8"METADATA_BLOCK_PICTURE"s);
|
||||
@ -204,17 +231,17 @@ ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
|
||||
if (set_all) {
|
||||
// Read comments from stdin and prepend them to opt.to_add.
|
||||
std::list<std::u8string> comments = read_comments(comments_input, opt.raw);
|
||||
std::list<std::u8string> comments = read_comments(comments_input, opt);
|
||||
opt.to_add.splice(opt.to_add.begin(), std::move(comments));
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
|
||||
/** Format a UTF-8 string by adding tabulations (\t) after line feeds (\n) to mark continuation for
|
||||
* multiline values. */
|
||||
static std::u8string format_value(const std::u8string& source)
|
||||
* multiline values. With -z, this behavior applies for embedded NUL characters instead of LF. */
|
||||
static std::u8string format_value(const std::u8string& source, const ot::options& opt)
|
||||
{
|
||||
auto newline_count = std::count(source.begin(), source.end(), u8'\n');
|
||||
auto newline_count = std::count(source.begin(), source.end(), opt.tag_delimiter);
|
||||
|
||||
// General case: the value fits on a single line. Use std::string’s copy constructor for the
|
||||
// most efficient copy we could hope for.
|
||||
@ -225,19 +252,39 @@ static std::u8string format_value(const std::u8string& source)
|
||||
formatted.reserve(source.size() + newline_count);
|
||||
for (auto c : source) {
|
||||
formatted.push_back(c);
|
||||
if (c == '\n')
|
||||
if (c == opt.tag_delimiter)
|
||||
formatted.push_back(u8'\t');
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the comment from UTF-8 to the system encoding if relevant, and print it with a trailing
|
||||
* line feed.
|
||||
*/
|
||||
static void puts_utf8(std::u8string_view str, FILE* output, const ot::options& opt)
|
||||
{
|
||||
if (opt.raw) {
|
||||
fwrite(str.data(), 1, str.size(), output);
|
||||
} else {
|
||||
try {
|
||||
std::string local = ot::decode_utf8(str);
|
||||
fwrite(local.data(), 1, local.size(), output);
|
||||
} catch (ot::status& rc) {
|
||||
rc.message += " See --raw.";
|
||||
throw;
|
||||
}
|
||||
}
|
||||
putc(opt.tag_delimiter, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print comments in a human readable format that can also be read back in by #read_comment.
|
||||
*
|
||||
* To disambiguate between a newline embedded in a comment and a newline representing the start of
|
||||
* the next tag, continuation lines always have a single TAB (^I) character added to the beginning.
|
||||
*/
|
||||
void ot::print_comments(const std::list<std::u8string>& comments, FILE* output, bool raw)
|
||||
void ot::print_comments(const std::list<std::u8string>& comments, FILE* output, const ot::options& opt)
|
||||
{
|
||||
bool has_control = false;
|
||||
for (const std::u8string& source_comment : comments) {
|
||||
@ -249,27 +296,14 @@ void ot::print_comments(const std::list<std::u8string>& comments, FILE* output,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
std::u8string utf8_comment = format_value(source_comment);
|
||||
// Convert the comment from UTF-8 to the system encoding if relevant.
|
||||
if (raw) {
|
||||
fwrite(utf8_comment.data(), 1, utf8_comment.size(), output);
|
||||
} else {
|
||||
try {
|
||||
std::string local = decode_utf8(utf8_comment);
|
||||
fwrite(local.data(), 1, local.size(), output);
|
||||
} catch (ot::status& rc) {
|
||||
rc.message += " See --raw.";
|
||||
throw;
|
||||
}
|
||||
}
|
||||
putc('\n', output);
|
||||
std::u8string utf8_comment = format_value(source_comment, opt);
|
||||
puts_utf8(utf8_comment, output, opt);
|
||||
}
|
||||
if (has_control)
|
||||
fputs("warning: Some tags contain control characters.\n", stderr);
|
||||
}
|
||||
|
||||
std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
|
||||
std::list<std::u8string> ot::read_comments(FILE* input, const ot::options& opt)
|
||||
{
|
||||
std::list<std::u8string> comments;
|
||||
comments.clear();
|
||||
@ -277,12 +311,12 @@ std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
|
||||
size_t buflen = 0;
|
||||
ssize_t nread;
|
||||
std::u8string* previous_comment = nullptr;
|
||||
while ((nread = getline(&source_line, &buflen, input)) != -1) {
|
||||
if (nread > 0 && source_line[nread - 1] == '\n')
|
||||
while ((nread = getdelim(&source_line, &buflen, opt.tag_delimiter, input)) != -1) {
|
||||
if (nread > 0 && source_line[nread - 1] == opt.tag_delimiter)
|
||||
--nread; // Chomp.
|
||||
|
||||
std::u8string line;
|
||||
if (raw) {
|
||||
if (opt.raw) {
|
||||
line = std::u8string(reinterpret_cast<char8_t*>(source_line), nread);
|
||||
} else {
|
||||
try {
|
||||
@ -306,7 +340,7 @@ std::list<std::u8string> ot::read_comments(FILE* input, bool raw)
|
||||
free(source_line);
|
||||
throw rc;
|
||||
} else {
|
||||
line[0] = '\n';
|
||||
line[0] = opt.tag_delimiter;
|
||||
previous_comment->append(line);
|
||||
}
|
||||
} else if (line.find(u8'=') == decltype(line)::npos) {
|
||||
@ -348,6 +382,9 @@ void ot::delete_comments(std::list<std::u8string>& comments, const std::u8string
|
||||
/** Apply the modifications requested by the user to the opustags packet. */
|
||||
static void edit_tags(ot::opus_tags& tags, const ot::options& opt)
|
||||
{
|
||||
if (opt.set_vendor)
|
||||
tags.vendor = *opt.set_vendor;
|
||||
|
||||
if (opt.delete_all) {
|
||||
tags.comments.clear();
|
||||
} else for (const std::u8string& name : opt.to_delete) {
|
||||
@ -359,7 +396,7 @@ static void edit_tags(ot::opus_tags& tags, const ot::options& opt)
|
||||
}
|
||||
|
||||
/** Spawn VISUAL or EDITOR to edit the given tags. */
|
||||
static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, bool raw)
|
||||
static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, const ot::options& opt)
|
||||
{
|
||||
const char* editor = nullptr;
|
||||
if (getenv("TERM") != nullptr)
|
||||
@ -378,7 +415,7 @@ static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std
|
||||
if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
|
||||
throw ot::status {ot::st::standard_error,
|
||||
"Could not open '" + tags_path + "': " + strerror(errno)};
|
||||
ot::print_comments(tags.comments, tags_file.get(), raw);
|
||||
ot::print_comments(tags.comments, tags_file.get(), opt);
|
||||
tags_file.reset();
|
||||
|
||||
// Spawn the editor, and watch the modification timestamps.
|
||||
@ -409,7 +446,7 @@ static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std
|
||||
if (tags_file == nullptr)
|
||||
throw ot::status {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
|
||||
try {
|
||||
tags.comments = ot::read_comments(tags_file.get(), raw);
|
||||
tags.comments = ot::read_comments(tags_file.get(), opt);
|
||||
} catch (const ot::status& rc) {
|
||||
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
|
||||
throw;
|
||||
@ -492,14 +529,18 @@ static void process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::op
|
||||
if (writer) {
|
||||
if (opt.edit_interactively) {
|
||||
fflush(writer->file); // flush before calling the subprocess
|
||||
edit_tags_interactively(tags, writer->path, opt.raw);
|
||||
edit_tags_interactively(tags, writer->path, opt);
|
||||
}
|
||||
auto packet = ot::render_tags(tags);
|
||||
writer->write_header_packet(serialno, pageno, packet);
|
||||
pageno_offset = writer->next_page_no - 1 - reader.absolute_page_no;
|
||||
} else {
|
||||
if (opt.cover_out != "-")
|
||||
ot::print_comments(tags.comments, stdout, opt.raw);
|
||||
if (opt.cover_out != "-") {
|
||||
if (opt.print_vendor)
|
||||
puts_utf8(tags.vendor, stdout, opt);
|
||||
else
|
||||
ot::print_comments(tags.comments, stdout, opt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (writer) {
|
||||
@ -577,6 +618,10 @@ static void run_single(const ot::options& opt, const std::string& path_in, const
|
||||
ot::ogg_writer writer(output);
|
||||
writer.path = path_out;
|
||||
process(reader, &writer, opt);
|
||||
|
||||
// Close the input file and finalize the output. When --in-place is specified, some file
|
||||
// systems like SMB require that the input is closed first.
|
||||
input.reset();
|
||||
temporary_output.commit();
|
||||
}
|
||||
|
||||
|
22
src/opus.cc
22
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:
|
||||
*
|
||||
@ -24,6 +24,7 @@
|
||||
#include <opustags.h>
|
||||
|
||||
#include <string.h>
|
||||
#include <algorithm>
|
||||
|
||||
ot::opus_tags ot::parse_tags(const ogg_packet& packet)
|
||||
{
|
||||
@ -54,7 +55,9 @@ ot::opus_tags ot::parse_tags(const ogg_packet& packet)
|
||||
// Comment count
|
||||
if (pos + 4 > size)
|
||||
throw status {st::cut_comment_count, "Comment count did not fit the comment header"};
|
||||
uint32_t count = le32toh(*((uint32_t*) (data + pos)));
|
||||
uint32_t count;
|
||||
memcpy(&count, data + pos, sizeof(count));
|
||||
count = le32toh(count);
|
||||
pos += 4;
|
||||
|
||||
// Comments' data
|
||||
@ -62,7 +65,9 @@ ot::opus_tags ot::parse_tags(const ogg_packet& packet)
|
||||
if (pos + 4 > size)
|
||||
throw status {st::cut_comment_length,
|
||||
"Comment length did not fit the comment header"};
|
||||
uint32_t comment_length = le32toh(*((uint32_t*) (data + pos)));
|
||||
uint32_t comment_length;
|
||||
memcpy(&comment_length, data + pos, sizeof(comment_length));
|
||||
comment_length = le32toh(comment_length);
|
||||
if (pos + 4 + comment_length > size)
|
||||
throw status {st::cut_comment_data,
|
||||
"Comment string did not fit the comment header"};
|
||||
@ -133,12 +138,16 @@ ot::picture::picture(ot::byte_string block)
|
||||
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]));
|
||||
uint32_t desc_size;
|
||||
memcpy(&desc_size, &storage[desc_offset], sizeof(desc_size));
|
||||
desc_size = be32toh(desc_size);
|
||||
|
||||
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]));
|
||||
uint32_t pic_size;
|
||||
memcpy(&pic_size, &storage[pic_offset], sizeof(pic_size));
|
||||
pic_size = be32toh(pic_size);
|
||||
|
||||
if (storage.size() != pic_offset + 4 + pic_size)
|
||||
throw status { st::invalid_size, "invalid picture block size" };
|
||||
@ -156,7 +165,8 @@ ot::byte_string ot::picture::serialize() const
|
||||
*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());
|
||||
uint32_t picture_data_size = htobe32(picture_data.size());
|
||||
memcpy(&bytes[pic_offset], &picture_data_size, sizeof(picture_data_size));
|
||||
std::copy(picture_data.begin(), picture_data.end(), std::next(bytes.begin(), pic_offset + 4));
|
||||
return bytes;
|
||||
}
|
||||
|
@ -119,13 +119,16 @@ using byte_string_view = std::basic_string_view<uint8_t>;
|
||||
* \{
|
||||
*/
|
||||
|
||||
/** fclose wrapper for std::unique_ptr’s deleter. */
|
||||
void close_file(FILE*);
|
||||
|
||||
/**
|
||||
* Smart auto-closing FILE* handle.
|
||||
*
|
||||
* It implictly converts from an already opened FILE*.
|
||||
*/
|
||||
struct file : std::unique_ptr<FILE, decltype(&fclose)> {
|
||||
file(FILE* f = nullptr) : std::unique_ptr<FILE, decltype(&fclose)>(f, &fclose) {}
|
||||
struct file : std::unique_ptr<FILE, decltype(&close_file)> {
|
||||
file(FILE* f = nullptr) : std::unique_ptr<FILE, decltype(&close_file)>(f, &close_file) {}
|
||||
};
|
||||
|
||||
/**
|
||||
@ -515,12 +518,32 @@ struct options {
|
||||
* Option: --output-cover
|
||||
*/
|
||||
std::optional<std::string> cover_out;
|
||||
/**
|
||||
* Print the vendor string at the beginning of the OpusTags packet instead of printing the
|
||||
* tags. Only applicable in read-only mode.
|
||||
*
|
||||
* Option: --vendor
|
||||
*/
|
||||
bool print_vendor = false;
|
||||
/**
|
||||
* Replace the vendor string by the one specified by the user.
|
||||
*
|
||||
* Option: --set-vendor
|
||||
*/
|
||||
std::optional<std::u8string> set_vendor;
|
||||
/**
|
||||
* Disable encoding conversions. OpusTags are specified to always be encoded as UTF-8, but
|
||||
* if for some reason a specific file contains binary tags that someone would like to
|
||||
* extract and set as-is, encoding conversion would get in the way.
|
||||
*/
|
||||
bool raw = false;
|
||||
/**
|
||||
* In text mode (default), tags are separated by a line feed. However, when combining
|
||||
* opustags with grep or other line-based tools, this proves to be a bad separator because
|
||||
* tag values may contain newlines. Changing the delimiter to '\0' with -z eases the
|
||||
* processing of multi-line tags with other tools that support null-terminated lines.
|
||||
*/
|
||||
char tag_delimiter = '\n';
|
||||
};
|
||||
|
||||
/**
|
||||
@ -538,13 +561,13 @@ options parse_options(int argc, char** argv, FILE* comments);
|
||||
*
|
||||
* The output generated is meant to be parseable by #ot::read_comments.
|
||||
*/
|
||||
void print_comments(const std::list<std::u8string>& comments, FILE* output, bool raw);
|
||||
void print_comments(const std::list<std::u8string>& comments, FILE* output, const options& opt);
|
||||
|
||||
/**
|
||||
* Parse the comments outputted by #ot::print_comments. Unless raw is true, the comments are
|
||||
* converted from the system encoding to UTF-8, and returned as UTF-8.
|
||||
*/
|
||||
std::list<std::u8string> read_comments(FILE* input, bool raw);
|
||||
std::list<std::u8string> read_comments(FILE* input, const options& opt);
|
||||
|
||||
/**
|
||||
* Remove all comments matching the specified selector, which may either be a field name or a
|
||||
|
@ -29,6 +29,11 @@ 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::close_file(FILE* file)
|
||||
{
|
||||
fclose(file);
|
||||
}
|
||||
|
||||
void ot::partial_file::open(const char* destination)
|
||||
{
|
||||
final_name = destination;
|
||||
@ -122,7 +127,7 @@ ot::byte_string ot::slurp_binary_file(const char* filename)
|
||||
|
||||
byte_string content;
|
||||
long file_size = get_file_size(f.get());
|
||||
if (file_size == -1) {
|
||||
if (file_size < 0) {
|
||||
// Read the input stream block by block and resize the output byte string as needed.
|
||||
uint8_t buffer[4096];
|
||||
while (!feof(f.get())) {
|
||||
@ -135,7 +140,7 @@ ot::byte_string ot::slurp_binary_file(const char* filename)
|
||||
} 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)
|
||||
if (fread(content.data(), 1, file_size, f.get()) < size_t(file_size))
|
||||
throw status { st::standard_error,
|
||||
"Could not read '"s + filename + "': " + strerror(errno) + "." };
|
||||
}
|
||||
|
6
t/cli.cc
6
t/cli.cc
@ -5,8 +5,10 @@
|
||||
|
||||
static ot::status read_comments(FILE* input, std::list<std::u8string>& comments, bool raw)
|
||||
{
|
||||
ot::options opt;
|
||||
opt.raw = raw;
|
||||
try {
|
||||
comments = ot::read_comments(input, raw);
|
||||
comments = ot::read_comments(input, opt);
|
||||
} catch (const ot::status& rc) {
|
||||
return rc;
|
||||
}
|
||||
@ -185,6 +187,8 @@ void check_bad_arguments()
|
||||
"Cannot specify standard output for both --output and --output-cover.", "-o and --output-cover conflict");
|
||||
error_case({"opustags", "-i", "x", "y", "--output-cover", "z"},
|
||||
"Cannot use --output-cover with multiple input files.", "--output-cover with multiple input");
|
||||
error_case({"opustags", "-i", "--vendor", "x"},
|
||||
"--vendor is only supported in read-only mode.", "--vendor when editing");
|
||||
error_case({"opustags", "-d", "\xFF", "x"},
|
||||
"Could not encode argument into UTF-8:",
|
||||
"-d with binary data");
|
||||
|
58
t/opustags.t
58
t/opustags.t
@ -4,7 +4,8 @@ use strict;
|
||||
use warnings;
|
||||
use utf8;
|
||||
|
||||
use Test::More tests => 59;
|
||||
use Test::More tests => 66;
|
||||
use Test::Deep qw(cmp_deeply re);
|
||||
|
||||
use Digest::MD5;
|
||||
use File::Basename;
|
||||
@ -53,34 +54,9 @@ $help->[0] =~ /^([^\n]*+)/;
|
||||
my $version = $1;
|
||||
like($version, qr/^opustags version (\d+\.\d+\.\d+)/, 'get the version string');
|
||||
|
||||
my $expected_help = <<"EOF";
|
||||
$version
|
||||
|
||||
Usage: opustags --help
|
||||
opustags [OPTIONS] FILE
|
||||
opustags OPTIONS -i FILE...
|
||||
opustags OPTIONS FILE -o FILE
|
||||
|
||||
Options:
|
||||
-h, --help print this help
|
||||
-o, --output FILE specify the output file
|
||||
-i, --in-place overwrite the input files
|
||||
-y, --overwrite overwrite the output file if it already exists
|
||||
-a, --add FIELD=VALUE add a comment
|
||||
-d, --delete FIELD[=VALUE] delete previously existing comments
|
||||
-D, --delete-all delete all the previously existing comments
|
||||
-s, --set FIELD=VALUE replace a comment
|
||||
-S, --set-all import comments from standard input
|
||||
-e, --edit edit tags interactively in VISUAL/EDITOR
|
||||
--output-cover FILE extract and save the cover art, if any
|
||||
--set-cover FILE sets the cover art
|
||||
--raw disable encoding conversion
|
||||
|
||||
See the man page for extensive documentation.
|
||||
EOF
|
||||
|
||||
is_deeply(opustags('--help'), [$expected_help, '', 0], '--help displays the help message');
|
||||
is_deeply(opustags('-h'), [$expected_help, '', 0], '-h displays the help message too');
|
||||
my $expected_help = qr{opustags version .*\n\nUsage: opustags --help\n};
|
||||
cmp_deeply(opustags('--help'), [re($expected_help), '', 0], '--help displays the help message');
|
||||
cmp_deeply(opustags('-h'), [re($expected_help), '', 0], '-h displays the help message too');
|
||||
|
||||
is_deeply(opustags('--derp'), ['', <<"EOF", 512], 'unrecognized option shows an error');
|
||||
error: Unrecognized option '--derp'.
|
||||
@ -342,3 +318,27 @@ unlink('out.png');
|
||||
is_deeply(opustags(qw(-D --set-cover - gobble.opus), { in => "GIF8 x" }), [<<'END_OUT', '', 0], 'read the cover from stdin');
|
||||
METADATA_BLOCK_PICTURE=AAAAAwAAAAlpbWFnZS9naWYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZHSUY4IHg=
|
||||
END_OUT
|
||||
|
||||
####################################################################################################
|
||||
# Vendor string
|
||||
|
||||
is_deeply(opustags(qw(--vendor gobble.opus)), ["Lavf58.12.100\n", '', 0], 'print the vendor string');
|
||||
|
||||
is_deeply(opustags(qw(--set-vendor opustags gobble.opus -o out.opus)), ['', '', 0], 'set the vendor string');
|
||||
is_deeply(opustags(qw(--vendor out.opus)), ["opustags\n", '', 0], 'the vendor string was updated');
|
||||
unlink('out.opus');
|
||||
|
||||
####################################################################################################
|
||||
# Multi-line tags
|
||||
|
||||
is_deeply(opustags(qw(--set-all gobble.opus -o out.opus), { in => "MULTILINE=one\n\ttwo\nSIMPLE=three\n" }), ['', '', 0], 'parses continuation lines');
|
||||
is_deeply(opustags(qw(out.opus -z)), ["MULTILINE=one\ntwo\0SIMPLE=three\0", '', 0], 'delimits output with NUL on -z');
|
||||
unlink('out.opus');
|
||||
|
||||
is_deeply(opustags(qw(--set-all gobble.opus -o out.opus -z), { in => "MULTILINE=one\ntwo\0SIMPLE=three\0" }), ['', '', 0], 'delimits input with NUL on -z');
|
||||
is_deeply(opustags(qw(out.opus)), [<<'END', '', 0], 'indents continuation lines');
|
||||
MULTILINE=one
|
||||
two
|
||||
SIMPLE=three
|
||||
END
|
||||
unlink('out.opus');
|
||||
|
Loading…
x
Reference in New Issue
Block a user