mirror of
https://github.com/fmang/opustags.git
synced 2025-07-06 09:37:50 +02:00
Compare commits
170 Commits
Author | SHA1 | Date | |
---|---|---|---|
4de428bf33 | |||
c774c86286 | |||
51f635d6bf | |||
da8f8a343b | |||
8ba3db8bbd | |||
87bdd6fe22 | |||
a9dd07ae1e | |||
40defdf2e1 | |||
48336b5367 | |||
4d44550d3d | |||
8d287a8070 | |||
d09d7bd634 | |||
191796a3d2 | |||
cacbd43422 | |||
2dbba5a23e | |||
19c1a8361d | |||
4036ce1f39 | |||
28ecbecdf0 | |||
06fff8cbeb | |||
e2a1c06005 | |||
a9adc11cad | |||
f872f71411 | |||
6797e59417 | |||
7df8c5c426 | |||
e26f3f268c | |||
46cd25f744 | |||
70e9b576cf | |||
102f683869 | |||
e471c82605 | |||
cc3bb6397d | |||
bb548f51d3 | |||
ebc8347c9e | |||
ca06c6fb9d | |||
42845e4867 | |||
b2826bf0cc | |||
33ef7ee153 | |||
ccc8417413 | |||
d9dfc29b7d | |||
23049a7ff6 | |||
f080f9da70 | |||
4e3ee61ca3 | |||
c01045172c | |||
7e6d9eae39 | |||
14ae681e61 | |||
7e575ffbc3 | |||
1ff5284b60 | |||
6da1a8703d | |||
71c9dd7209 | |||
fcfb4a2a1d | |||
1d6ca8fc59 | |||
a74ea34352 | |||
289391a9df | |||
5860902084 | |||
614bd6379b | |||
1e69e89ff9 | |||
7189d63c20 | |||
d67ce423d1 | |||
6f290702a8 | |||
067c9240c3 | |||
90bcf0bd71 | |||
b60183c0ca | |||
2b92ee0ce1 | |||
c4acca18d8 | |||
b7e133d6ba | |||
5b5b67a0df | |||
80a4b2ccf6 | |||
d1299360de | |||
bfa46273b9 | |||
26411d3843 | |||
af61b01448 | |||
a043e74e14 | |||
20dc8d0fa2 | |||
407c12c7ac | |||
8949094203 | |||
ddb838ac81 | |||
62d56aafff | |||
b9a0ece567 | |||
5445c5bc7c | |||
cc83a438ae | |||
6ed0326a74 | |||
0980b35ecd | |||
2670b661a8 | |||
c604fdb667 | |||
8334a5617f | |||
cdd591c0c1 | |||
e22a1d381a | |||
121220ea05 | |||
b6c7a90d92 | |||
2e88bdc207 | |||
22bfd05b36 | |||
8a5b80e075 | |||
e41cf918d1 | |||
82ff7f7751 | |||
351d6149c9 | |||
9ed2b82b4a | |||
1866dbd1f0 | |||
5ff99b620c | |||
b0e8813be6 | |||
c17ad7853c | |||
632caae915 | |||
b9dbaf1049 | |||
326ae74afa | |||
497caaa8f3 | |||
6565cb56b3 | |||
f664ed94d4 | |||
b5dc595855 | |||
bf386899ae | |||
51a3eba093 | |||
fae547c4eb | |||
3aeb2097de | |||
132073b842 | |||
2a31c5491b | |||
1b9bd83e8f | |||
f02ff44e43 | |||
b7f85b5fe2 | |||
c338a04196 | |||
0426c369be | |||
74cc6038b2 | |||
702f86a355 | |||
0c4c11032f | |||
72a911c11b | |||
07af78519b | |||
2905b193b1 | |||
9d3e9c20a3 | |||
0b4e01c3b0 | |||
cc5896b1a0 | |||
1744cab9ed | |||
d9b96d471d | |||
590a6814dd | |||
7ae7a50151 | |||
0df7514a83 | |||
bd50fb34d9 | |||
3e77092f85 | |||
a3a6cb4e36 | |||
af988efd8a | |||
f2a60e4220 | |||
002b253c06 | |||
62ea90e5d5 | |||
098eefe60f | |||
3ba7ba8166 | |||
3c0aad169b | |||
06520bf87e | |||
7fb5b49b81 | |||
a2eb11cbe3 | |||
dd364c6262 | |||
a3e7624866 | |||
65aad6f62a | |||
241c9b3071 | |||
dd0faa29bc | |||
1837f0b0ec | |||
1e6698af3e | |||
82d0400207 | |||
24b6268d7a | |||
e91ad48c10 | |||
9c50d7d047 | |||
3624761c7b | |||
f56ade7941 | |||
15335da1f8 | |||
2006431fa8 | |||
63fce2f555 | |||
2181f9f0eb | |||
69561ae05f | |||
5dcf9ec543 | |||
7cf478c9cc | |||
2f98bba07c | |||
e44ad86af3 | |||
7174a1f2f2 | |||
1a8aaff933 | |||
4973a4deab | |||
8e9d98ac62 |
1
.gitignore
vendored
Normal file
1
.gitignore
vendored
Normal file
@ -0,0 +1 @@
|
||||
/build
|
45
CHANGELOG.md
Normal file
45
CHANGELOG.md
Normal file
@ -0,0 +1,45 @@
|
||||
opustags changelog
|
||||
==================
|
||||
|
||||
1.3.0 - 2019-02-02
|
||||
------------------
|
||||
|
||||
- Support for non-Unicode systems. Tags are automatically converted to and from the system locale.
|
||||
- It is now possible to delete specific NAME=VALUE pairs.
|
||||
- Option `--set-all` is now stricter and aborts with an error if the input is not valid.
|
||||
- Printing tags will display a warning if the tags contain control characters.
|
||||
|
||||
opustags is now more aware of its limitations, and will print more helpful error messages when
|
||||
trying to edit an unsupported file. It is also more cautious against corrupted streams.
|
||||
|
||||
1.2.0 - 2018-11-25
|
||||
------------------
|
||||
|
||||
- Preserve extra data in OpusTags past the comments.
|
||||
- Improve error reporting.
|
||||
- Fix various bugs.
|
||||
|
||||
This is the biggest release for opustags. The whole code base was reviewed for robustness and
|
||||
clarity. The program is now built as C++14, and the code refactored without sacrificing the
|
||||
original simplicity. It is shipped with a new test suite.
|
||||
|
||||
1.1.1 - 2018-10-24
|
||||
------------------
|
||||
|
||||
- Mac OS X support.
|
||||
- Tolerate but truncate the data in the OpusTags packet past the comments.
|
||||
|
||||
1.1 - 2013-01-02
|
||||
----------------
|
||||
|
||||
- Add the --in-place option.
|
||||
- Fix a bug is --set-all where the last unterminated line was ignored.
|
||||
- Remove broken output files on failure.
|
||||
|
||||
1.0 - 2013-01-01
|
||||
----------------
|
||||
|
||||
This is the first release of opustags. It supports all the main feature for basic tag editing.
|
||||
|
||||
It was written in a day, and the code is quick and dirty, though the program is simple and
|
||||
efficient.
|
42
CMakeLists.txt
Normal file
42
CMakeLists.txt
Normal file
@ -0,0 +1,42 @@
|
||||
cmake_minimum_required(VERSION 3.9)
|
||||
|
||||
project(
|
||||
opustags
|
||||
VERSION 1.3.0
|
||||
LANGUAGES CXX
|
||||
)
|
||||
|
||||
set(CMAKE_CXX_STANDARD 14)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(OGG REQUIRED ogg)
|
||||
add_compile_options(${OGG_CFLAGS})
|
||||
link_directories(${OGG_LIBRARY_DIRS})
|
||||
|
||||
configure_file(src/config.h.in config.h @ONLY)
|
||||
include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS})
|
||||
|
||||
add_library(
|
||||
ot
|
||||
STATIC
|
||||
src/cli.cc
|
||||
src/ogg.cc
|
||||
src/opus.cc
|
||||
src/system.cc
|
||||
)
|
||||
target_link_libraries(ot PUBLIC ${OGG_LIBRARIES})
|
||||
|
||||
if (APPLE)
|
||||
target_link_libraries(ot PUBLIC iconv)
|
||||
endif()
|
||||
|
||||
add_executable(opustags src/opustags.cc)
|
||||
target_link_libraries(opustags ot)
|
||||
|
||||
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")
|
||||
|
||||
add_subdirectory(t)
|
72
CONTRIBUTING.md
Normal file
72
CONTRIBUTING.md
Normal file
@ -0,0 +1,72 @@
|
||||
# Contributing to opustags
|
||||
|
||||
opustags should now be mature enough, and contributions for new features are
|
||||
welcome.
|
||||
|
||||
Before you open a pull request, you might want to talk about the change you'd
|
||||
like to make to make sure it's relevant. In that case, feel free to open an
|
||||
issue. You can expect a response within a week.
|
||||
|
||||
## Submitting pull requests
|
||||
|
||||
opustags has nothing really special, so basic git etiquette is just enough.
|
||||
|
||||
Please make focused pull requests, one feature at a time. Don't make huge
|
||||
commits. Give clear names to your commits and pull requests. Extended
|
||||
descriptions are welcome.
|
||||
|
||||
Stay objective in your changes. Adding a feature or fixing a bug is a clear
|
||||
improvement, but stylistic changes like renaming a function or moving a few
|
||||
braces around won't help the project move forward.
|
||||
|
||||
You should check that your changes don't break the test suite by running
|
||||
`make check`
|
||||
|
||||
Following these practices is important to keep the history clean, and to allow
|
||||
for better code reviews.
|
||||
|
||||
## History of opustags
|
||||
|
||||
opustags is originally a small project made to fill a need to edit tags in Opus
|
||||
audio files when most taggers didn't support Opus at all. It was written in C
|
||||
with libogg, and should be very light and fast compared to most alternatives.
|
||||
However, because it was written on a whim, the code is hardly structured and
|
||||
might even be fragile, who knows.
|
||||
|
||||
An ambitious desire to rewrite it in C++ with bells and whistles gave birth to
|
||||
the `next` branch, but sadly it wasn't finalized and is currently not usable,
|
||||
though it contains good pieces of code.
|
||||
|
||||
With the growing support of Opus in tag editors, the usefulness of opustags was
|
||||
questioned, and it was thus abandoned for a few years. Judging by the
|
||||
inquiries and contributions, albeit few, on GitHub, it looks like it remains
|
||||
relevant, so let's dust it off a bit.
|
||||
|
||||
Today, opustags is written in C++14 and features a unit test suite in C++, and
|
||||
an integration test suite in Perl. The code was refactored, organized into
|
||||
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.
|
||||
|
||||
## Candidate features
|
||||
|
||||
The code contains a few `\todo` markers where something could be improved in the
|
||||
code.
|
||||
|
||||
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.
|
||||
- Skip encoding conversion with --raw.
|
||||
- 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, Frédéric Mangano
|
||||
Copyright (c) 2013-2018, Frédéric Mangano-Tarumi
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
|
23
Makefile
23
Makefile
@ -1,23 +0,0 @@
|
||||
DESTDIR=/usr/local
|
||||
MANDEST=share/man
|
||||
CFLAGS=-Wall
|
||||
LDFLAGS=-logg
|
||||
|
||||
all: opustags
|
||||
|
||||
opustags: opustags.c
|
||||
|
||||
man: opustags.1
|
||||
gzip <opustags.1 >opustags.1.gz
|
||||
|
||||
install: opustags man
|
||||
mkdir -p $(DESTDIR)/bin $(DESTDIR)/$(MANDEST)/man1
|
||||
install -m 755 opustags $(DESTDIR)/bin/
|
||||
install -m 644 opustags.1.gz $(DESTDIR)/$(MANDEST)/man1/
|
||||
|
||||
uninstall:
|
||||
rm -f $(DESTDIR)/bin/opustags
|
||||
rm -f $(DESTDIR)/$(MANDEST)/man1/opustags.1.gz
|
||||
|
||||
clean:
|
||||
rm -f opustags opustags.1.gz
|
53
README.md
53
README.md
@ -1,19 +1,47 @@
|
||||
opustags
|
||||
========
|
||||
|
||||
View and edit Opus comments.
|
||||
View and edit Ogg Opus comments.
|
||||
|
||||
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
|
||||
ever be performed.
|
||||
|
||||
It currently has the following limitations:
|
||||
|
||||
- The total size of all tags cannot exceed 64 kB, the maximum size of one Ogg page.
|
||||
- Multiplexed streams are not supported.
|
||||
- Newlines inside tags are not supported by `--set-all`.
|
||||
|
||||
If you'd like one of these limitations lifted, please do open an issue explaining your use case.
|
||||
Feel free to ask for new features too.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* A POSIX-compliant system,
|
||||
* libogg.
|
||||
* a POSIX-compliant system,
|
||||
* a C++14 compiler,
|
||||
* CMake ≥ 3.9,
|
||||
* libogg 1.3.3.
|
||||
|
||||
The version numbers are indicative, and it's very likely opustags will build and work fine with
|
||||
other versions too, as CMake and libogg are quite mature.
|
||||
|
||||
Installing
|
||||
----------
|
||||
|
||||
opustags is a commonplace CMake project.
|
||||
|
||||
Here's how to install it in your `.local`, under your home:
|
||||
|
||||
mkdir build
|
||||
cd build
|
||||
cmake -DCMAKE_INSTALL_PREFIX=~/.local ..
|
||||
make
|
||||
make DESTDIR=/usr/local install
|
||||
make install
|
||||
|
||||
Note that you don't need to install opustags in order to run it, as the executable is standalone.
|
||||
|
||||
Documentation
|
||||
-------------
|
||||
@ -23,13 +51,14 @@ Documentation
|
||||
opustags OPTIONS FILE -o FILE
|
||||
|
||||
Options:
|
||||
-h, --help print this help
|
||||
-o, --output write the modified tags to a file
|
||||
-y, --overwrite overwrite the output file if it already exists
|
||||
-d, --delete FIELD delete all the fields of a specified type
|
||||
-a, --add FIELD=VALUE add a field
|
||||
-s, --set FIELD=VALUE delete then add a field
|
||||
-D, --delete-all delete all the fields!
|
||||
-S, --set-all read the fields from stdin
|
||||
-h, --help print this help
|
||||
-o, --output FILE specify the output file
|
||||
-i, --in-place overwrite the input file
|
||||
-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
|
||||
|
||||
See the man page, `opustags.1`, for extensive documentation.
|
||||
|
147
opustags.1
147
opustags.1
@ -1,6 +1,6 @@
|
||||
.TH opustags 1 "January 2013"
|
||||
.TH opustags 1 "December 2018" "@PROJECT_NAME@ @PROJECT_VERSION@"
|
||||
.SH NAME
|
||||
opustags \- Opus comment editor
|
||||
opustags \- Ogg Opus tag editor
|
||||
.SH SYNOPSIS
|
||||
.B opustags --help
|
||||
.br
|
||||
@ -14,73 +14,68 @@ opustags \- Opus comment editor
|
||||
.I OUTPUT INPUT
|
||||
.SH DESCRIPTION
|
||||
.PP
|
||||
\fBopustags\fP can read and edit the comment header of an Opus file.
|
||||
It basically has two modes: read-only and read-write (for tag edition).
|
||||
\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.
|
||||
.PP
|
||||
In read-only mode, only the beginning of \fIINPUT\fP is read, and the tags are
|
||||
printed on \fBstdout\fP.
|
||||
\fIINPUT\fP can either be the name of a file or \fB-\fP to read from \fBstdin\fP.
|
||||
printed on standard output.
|
||||
\fIINPUT\fP can either be the name of a file or \fB-\fP to read from standard input.
|
||||
You can use the options below to edit the tags before printing them.
|
||||
This could be useful to preview some changes before writing them.
|
||||
.PP
|
||||
As for the edition mode, you need to specify an output file (or \fB-\fP for
|
||||
\fBstdout\fP). It must be different from the input file.
|
||||
You may want to use \fB--overwrite\fP if you know what you’re doing.
|
||||
To overwrite the input file, use \fB--in-place\fP.
|
||||
In editing mode, you need to specify an output file with \fB--output\fP, or use \fB--in-place\fP to
|
||||
overwrite the input file. If the output is a regular file, the result is first written to a
|
||||
temporary file and then moved to its final location on success. On error, the temporary output file
|
||||
is deleted.
|
||||
.PP
|
||||
Tag edition can be made with the \fB--add\fP, \fB--delete\fP and \fB--set\fP
|
||||
options. They can be written in any order and don’t conflict with each other.
|
||||
However, they aren’t executed in any order: first the specified tags are
|
||||
deleted, then the new tags are added. “Set” operations are mere convenience
|
||||
for delete/add.
|
||||
Tag editing can be performed with the \fB--add\fP, \fB--delete\fP and \fB--set\fP
|
||||
options. Options can be specified in any order and don’t conflict with each other.
|
||||
First the specified tags are deleted, then the new tags are added.
|
||||
.PP
|
||||
You can delete all the tags with \fB--delete-all\fP. This operation can be
|
||||
combined with \fB--add\fP to set new tags without being bothered by the old
|
||||
ones. Another way to do this is to use \fB--set-all\fP as explained below.
|
||||
You can delete all the tags with \fB--delete-all\fP. This operation can be combined with \fB--add\fP
|
||||
to set new tags without being bothered by the old ones.
|
||||
.PP
|
||||
If you want to process tags yourself, you can use the \fB--set-all\fP option
|
||||
which will cause \fBopustags\fP to read tags from \fBstdin\fP.
|
||||
The format is the same as the one used for output; that is to say,
|
||||
newline-separated \fIFIELD=Value\fP assignment. Note that this implies
|
||||
\fB--delete-all\fP.
|
||||
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.
|
||||
.PP
|
||||
\fBWarning:\fP the Opus format specifications requires tags to be encoded in
|
||||
\fBUTF-8\fP. This tool ignores the system locale, assuming the encoding is
|
||||
set to UTF-8, and assume that tags are already encoded in UTF-8.
|
||||
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.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-h, \-\-help
|
||||
Display a brief description of the options.
|
||||
.TP
|
||||
.B \-o, \-\-output \fIFILE\fI
|
||||
Edition mode. The input file will be read, its tags edited, then written to the
|
||||
specified output file. If \fIFILE\fP is \fB-\fP then the resulting Opus file
|
||||
will be written to \fBstdout\fP. As the input file is read incrementally, the
|
||||
output file can’t be the same as the input file.
|
||||
Specify the output file.
|
||||
The input file will be read, its tags edited, then written to the specified output file. If
|
||||
\fIFILE\fP is \fB-\fP then the resulting Opus file will be written to standard output.
|
||||
The output file can’t be the same as the input file.
|
||||
.TP
|
||||
.B \-i, \-\-in-place \fR[\fP\fISUFFIX\fP\fR]\fP
|
||||
Use this when you want to modify the input file in-place. This creates a
|
||||
temporary file with the specified suffix (.otmp by default). This implies
|
||||
\fB--overwrite\fP in that if a file with the same temporary name already
|
||||
exists, it will be overwritten without warning. Of course, this overwrites
|
||||
the input file too. You cannot use this option when the input file is actually
|
||||
\fBstdin\fP.
|
||||
.B \-i, \-\-in-place
|
||||
Overwrite the input file instead of creating a separate output file. It has the same effect as
|
||||
setting \fB--output\fP to the same path as the input file and enabling \fB--overwrite\fP.
|
||||
This option conflicts with \fB--output\fP.
|
||||
.TP
|
||||
.B \-y, \-\-overwrite
|
||||
By default, \fBopustags\fP refuses to overwrite an already existent file. Use
|
||||
this option to allow that. Note that this doesn’t allow in-place edition, the
|
||||
output file needs to be different from the input file.
|
||||
By default, \fBopustags\fP refuses to overwrite an already-existent 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
|
||||
.B \-d, \-\-delete \fIFIELD\fP
|
||||
Delete all the tags whose field name is \fIFIELD\fP (they may be several, though
|
||||
usually there is only one of each type). You can use this option as many times
|
||||
as you want.
|
||||
.B \-d, \-\-delete \fIFIELD[=VALUE]\fP
|
||||
If value is not specified, delete all the tags whose field name is \fIFIELD\fP.
|
||||
Otherwise, delete all the comments whose field name is \fIFIELD\fP and value is \fIVALUE\fP.
|
||||
In both cases, the field names are case-insensitive, and expected to be ASCII.
|
||||
.TP
|
||||
.B \-a, \-\-add \fIFIELD=VALUE\fP
|
||||
Add a tag. It doesn’t matter if a tag of the same type already exist (think
|
||||
the case where there are several artists). You can use this option as many
|
||||
times as needed, with the same field names or not. When the \fB--delete\fP
|
||||
is used with the same \fIFIELD\fP, only the older tags are deleted.
|
||||
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.
|
||||
.TP
|
||||
.B \-s, \-\-set \fIFIELD=VALUE\fP
|
||||
This option is provided for convenience. It delete all the fields of the same
|
||||
@ -91,18 +86,50 @@ type. As deletion occurs before adding, \fB--set\fP won’t erase the tags
|
||||
added with \fB--add\fP.
|
||||
.TP
|
||||
.B \-D, \-\-delete-all
|
||||
Delete all the tags before adding any. When this option is specified, the
|
||||
\fB--delete\fP options are ignored. Tags then can be added using \fB--add\fP
|
||||
or \fB--set\fP, which, in that case, are equivalent.
|
||||
Delete all the previously existing tags.
|
||||
.TP
|
||||
.B \-S, \-\-set-all
|
||||
Sets the tags from scratch. All the original tags are deleted and new ones are
|
||||
read from \fBstdin\fP. Each line must specify a \fIFIELD=VALUE\fP pair and be
|
||||
LF-terminated (except for the last line). Invalid lines are skipped and cause
|
||||
a warning to be issued. Blank lines are ignored. This mode could be useful for
|
||||
batch processing tags through an utility like \fBsed\fP.
|
||||
.SH SEE ALSO
|
||||
.BR vorbiscomment (1),
|
||||
.BR sed (1)
|
||||
Sets 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.
|
||||
Blank lines are ignored.
|
||||
.SH EXAMPLES
|
||||
.PP
|
||||
List all the tags in file foo.opus:
|
||||
.PP
|
||||
opustags foo.opus
|
||||
.PP
|
||||
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
|
||||
opustags in.opus --add ARTIST=X --add ARTIST=Y --delete ARTIST
|
||||
.SH CAVEATS
|
||||
.PP
|
||||
\fBopustags\fP currently has the following limitations:
|
||||
.IP \[bu]
|
||||
The total size of all tags cannot exceed 64 kB, the maximum size of one Ogg page.
|
||||
.IP \[bu]
|
||||
Multiplexed streams are not supported.
|
||||
.IP \[bu]
|
||||
Newlines inside tags are not supported by `--set-all`.
|
||||
.IP \[bu]
|
||||
Newlines and control characters are not escaped when printing tags.
|
||||
.PP
|
||||
Internally, the OpusTags packet in an Ogg Opus file may contain extra arbitrary binary data after
|
||||
the comments. This block of data is currently not editable, but is always preserved. The same
|
||||
applies for the vendor string.
|
||||
.PP
|
||||
If you need a feature not currently supported, feel free to open an issue or send an email with your
|
||||
use case.
|
||||
.SH AUTHOR
|
||||
Frédéric Mangano <fmang@mg0.fr>
|
||||
Frédéric Mangano-Tarumi <fmang+opustags@mg0.fr>
|
||||
.PP
|
||||
Report bugs at <https://github.com/fmang/opustags/issues>
|
||||
|
505
opustags.c
505
opustags.c
@ -1,505 +0,0 @@
|
||||
#include <errno.h>
|
||||
#include <getopt.h>
|
||||
#include <limits.h>
|
||||
#include <stdio.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
#include <ogg/ogg.h>
|
||||
|
||||
typedef struct {
|
||||
uint32_t vendor_length;
|
||||
const char *vendor_string;
|
||||
uint32_t count;
|
||||
uint32_t *lengths;
|
||||
const char **comment;
|
||||
} opus_tags;
|
||||
|
||||
int parse_tags(char *data, long len, opus_tags *tags){
|
||||
long pos;
|
||||
if(len < 8+4+4)
|
||||
return -1;
|
||||
if(strncmp(data, "OpusTags", 8) != 0)
|
||||
return -1;
|
||||
// Vendor
|
||||
pos = 8;
|
||||
tags->vendor_length = le32toh(*((uint32_t*) (data + pos)));
|
||||
tags->vendor_string = data + pos + 4;
|
||||
pos += 4 + tags->vendor_length;
|
||||
if(pos + 4 > len)
|
||||
return -1;
|
||||
// Count
|
||||
tags->count = le32toh(*((uint32_t*) (data + pos)));
|
||||
if(tags->count == 0)
|
||||
return 0;
|
||||
tags->lengths = calloc(tags->count, sizeof(uint32_t));
|
||||
if(tags->lengths == NULL)
|
||||
return -1;
|
||||
tags->comment = calloc(tags->count, sizeof(char*));
|
||||
if(tags->comment == NULL){
|
||||
free(tags->lengths);
|
||||
return -1;
|
||||
}
|
||||
pos += 4;
|
||||
// Comment
|
||||
uint32_t i;
|
||||
for(i=0; i<tags->count; i++){
|
||||
tags->lengths[i] = le32toh(*((uint32_t*) (data + pos)));
|
||||
tags->comment[i] = data + pos + 4;
|
||||
pos += 4 + tags->lengths[i];
|
||||
if(pos > len)
|
||||
return -1;
|
||||
}
|
||||
if(pos != len)
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
int render_tags(opus_tags *tags, ogg_packet *op){
|
||||
// Note: op->packet must be manually freed.
|
||||
op->b_o_s = 0;
|
||||
op->e_o_s = 0;
|
||||
op->granulepos = 0;
|
||||
op->packetno = 1;
|
||||
long len = 8 + 4 + tags->vendor_length + 4;
|
||||
uint32_t i;
|
||||
for(i=0; i<tags->count; i++)
|
||||
len += 4 + tags->lengths[i];
|
||||
op->bytes = len;
|
||||
char *data = malloc(len);
|
||||
if(!data)
|
||||
return -1;
|
||||
op->packet = (unsigned char*) data;
|
||||
uint32_t n;
|
||||
memcpy(data, "OpusTags", 8);
|
||||
n = htole32(tags->vendor_length);
|
||||
memcpy(data+8, &n, 4);
|
||||
memcpy(data+12, tags->vendor_string, tags->vendor_length);
|
||||
data += 12 + tags->vendor_length;
|
||||
n = htole32(tags->count);
|
||||
memcpy(data, &n, 4);
|
||||
data += 4;
|
||||
for(i=0; i<tags->count; i++){
|
||||
n = htole32(tags->lengths[i]);
|
||||
memcpy(data, &n, 4);
|
||||
memcpy(data+4, tags->comment[i], tags->lengths[i]);
|
||||
data += 4 + tags->lengths[i];
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
int match_field(const char *comment, uint32_t len, const char *field){
|
||||
size_t field_len;
|
||||
for(field_len = 0; field[field_len] != '\0' && field[field_len] != '='; field_len++);
|
||||
if(len <= field_len)
|
||||
return 0;
|
||||
if(comment[field_len] != '=')
|
||||
return 0;
|
||||
if(strncmp(comment, field, field_len) != 0)
|
||||
return 0;
|
||||
return 1;
|
||||
|
||||
}
|
||||
|
||||
void delete_tags(opus_tags *tags, const char *field){
|
||||
uint32_t i;
|
||||
for(i=0; i<tags->count; i++){
|
||||
if(match_field(tags->comment[i], tags->lengths[i], field)){
|
||||
tags->count--;
|
||||
tags->lengths[i] = tags->lengths[tags->count];
|
||||
tags->comment[i] = tags->comment[tags->count];
|
||||
// No need to resize the arrays.
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
int add_tags(opus_tags *tags, const char **tags_to_add, uint32_t count){
|
||||
if(count == 0)
|
||||
return 0;
|
||||
uint32_t *lengths = realloc(tags->lengths, (tags->count + count) * sizeof(uint32_t));
|
||||
const char **comment = realloc(tags->comment, (tags->count + count) * sizeof(char*));
|
||||
if(lengths == NULL || comment == NULL)
|
||||
return -1;
|
||||
tags->lengths = lengths;
|
||||
tags->comment = comment;
|
||||
uint32_t i;
|
||||
for(i=0; i<count; i++){
|
||||
tags->lengths[tags->count + i] = strlen(tags_to_add[i]);
|
||||
tags->comment[tags->count + i] = tags_to_add[i];
|
||||
}
|
||||
tags->count += count;
|
||||
return 0;
|
||||
}
|
||||
|
||||
void print_tags(opus_tags *tags){
|
||||
if(tags->count == 0)
|
||||
puts("no tags");
|
||||
int i;
|
||||
for(i=0; i<tags->count; i++){
|
||||
fwrite(tags->comment[i], 1, tags->lengths[i], stdout);
|
||||
puts("");
|
||||
}
|
||||
}
|
||||
|
||||
void free_tags(opus_tags *tags){
|
||||
if(tags->count > 0){
|
||||
free(tags->lengths);
|
||||
free(tags->comment);
|
||||
}
|
||||
}
|
||||
|
||||
int write_page(ogg_page *og, FILE *stream){
|
||||
if(fwrite(og->header, 1, og->header_len, stream) < og->header_len)
|
||||
return -1;
|
||||
if(fwrite(og->body, 1, og->body_len, stream) < og->body_len)
|
||||
return -1;
|
||||
return 0;
|
||||
}
|
||||
|
||||
const char *version = "opustags version 1.1\n";
|
||||
|
||||
const char *usage =
|
||||
"Usage: opustags --help\n"
|
||||
" opustags [OPTIONS] FILE\n"
|
||||
" opustags OPTIONS FILE -o FILE\n";
|
||||
|
||||
const char *help =
|
||||
"Options:\n"
|
||||
" -h, --help print this help\n"
|
||||
" -o, --output write the modified tags to a file\n"
|
||||
" -i, --in-place [SUFFIX] use a temporary file then replace the original file\n"
|
||||
" -y, --overwrite overwrite the output file if it already exists\n"
|
||||
" -d, --delete FIELD delete all the fields of a specified type\n"
|
||||
" -a, --add FIELD=VALUE add a field\n"
|
||||
" -s, --set FIELD=VALUE delete then add a field\n"
|
||||
" -D, --delete-all delete all the fields!\n"
|
||||
" -S, --set-all read the fields from stdin\n";
|
||||
|
||||
struct option options[] = {
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{"output", required_argument, 0, 'o'},
|
||||
{"in-place", optional_argument, 0, 'i'},
|
||||
{"overwrite", no_argument, 0, 'y'},
|
||||
{"delete", required_argument, 0, 'd'},
|
||||
{"add", required_argument, 0, 'a'},
|
||||
{"set", required_argument, 0, 's'},
|
||||
{"delete-all", no_argument, 0, 'D'},
|
||||
{"set-all", no_argument, 0, 'S'},
|
||||
{NULL, 0, 0, 0}
|
||||
};
|
||||
|
||||
int main(int argc, char **argv){
|
||||
if(argc == 1){
|
||||
fputs(version, stdout);
|
||||
fputs(usage, stdout);
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
char *path_in, *path_out = NULL, *inplace = NULL;
|
||||
const char* to_add[argc];
|
||||
const char* to_delete[argc];
|
||||
int count_add = 0, count_delete = 0;
|
||||
int delete_all = 0;
|
||||
int set_all = 0;
|
||||
int overwrite = 0;
|
||||
int print_help = 0;
|
||||
int c;
|
||||
while((c = getopt_long(argc, argv, "ho:i::yd:a:s:DS", options, NULL)) != -1){
|
||||
switch(c){
|
||||
case 'h':
|
||||
print_help = 1;
|
||||
break;
|
||||
case 'o':
|
||||
path_out = optarg;
|
||||
break;
|
||||
case 'i':
|
||||
inplace = optarg == NULL ? ".otmp" : optarg;
|
||||
break;
|
||||
case 'y':
|
||||
overwrite = 1;
|
||||
break;
|
||||
case 'd':
|
||||
if(strchr(optarg, '=') != NULL){
|
||||
fprintf(stderr, "invalid field: '%s'\n", optarg);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
to_delete[count_delete++] = optarg;
|
||||
break;
|
||||
case 'a':
|
||||
case 's':
|
||||
if(strchr(optarg, '=') == NULL){
|
||||
fprintf(stderr, "invalid comment: '%s'\n", optarg);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
to_add[count_add++] = optarg;
|
||||
if(c == 's')
|
||||
to_delete[count_delete++] = optarg;
|
||||
break;
|
||||
case 'S':
|
||||
set_all = 1;
|
||||
case 'D':
|
||||
delete_all = 1;
|
||||
break;
|
||||
default:
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
if(print_help){
|
||||
puts(version);
|
||||
puts(usage);
|
||||
puts(help);
|
||||
puts("See the man page for extensive documentation.");
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
if(optind != argc - 1){
|
||||
fputs("invalid arguments\n", stderr);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if(inplace && path_out){
|
||||
fputs("cannot combine --in-place and --output\n", stderr);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
path_in = argv[optind];
|
||||
if(path_out != NULL && strcmp(path_in, "-") != 0){
|
||||
char canon_in[PATH_MAX+1], canon_out[PATH_MAX+1];
|
||||
if(realpath(path_in, canon_in) && realpath(path_out, canon_out)){
|
||||
if(strcmp(canon_in, canon_out) == 0){
|
||||
fputs("error: the input and output files are the same\n", stderr);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
FILE *in;
|
||||
if(strcmp(path_in, "-") == 0){
|
||||
if(set_all){
|
||||
fputs("can't open stdin for input when -S is specified\n", stderr);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
if(inplace){
|
||||
fputs("cannot modify stdin 'in-place'\n", stderr);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
in = stdin;
|
||||
}
|
||||
else
|
||||
in = fopen(path_in, "r");
|
||||
if(!in){
|
||||
perror("fopen");
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
FILE *out = NULL;
|
||||
if(inplace != NULL){
|
||||
path_out = malloc(strlen(path_in) + strlen(inplace) + 1);
|
||||
if(path_out == NULL){
|
||||
fputs("failure to allocate memory\n", stderr);
|
||||
fclose(in);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
strcpy(path_out, path_in);
|
||||
strcat(path_out, inplace);
|
||||
}
|
||||
if(path_out != NULL){
|
||||
if(strcmp(path_out, "-") == 0)
|
||||
out = stdout;
|
||||
else{
|
||||
if(!overwrite && !inplace){
|
||||
if(access(path_out, F_OK) == 0){
|
||||
fprintf(stderr, "'%s' already exists (use -y to overwrite)\n", path_out);
|
||||
fclose(in);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
out = fopen(path_out, "w");
|
||||
if(!out){
|
||||
perror("fopen");
|
||||
fclose(in);
|
||||
if(inplace)
|
||||
free(path_out);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
}
|
||||
}
|
||||
ogg_sync_state oy;
|
||||
ogg_stream_state os, enc;
|
||||
ogg_page og;
|
||||
ogg_packet op;
|
||||
opus_tags tags;
|
||||
ogg_sync_init(&oy);
|
||||
char *buf;
|
||||
size_t len;
|
||||
char *error = NULL;
|
||||
int packet_count = -1;
|
||||
while(error == NULL){
|
||||
// Read until we complete a page.
|
||||
if(ogg_sync_pageout(&oy, &og) != 1){
|
||||
if(feof(in))
|
||||
break;
|
||||
buf = ogg_sync_buffer(&oy, 65536);
|
||||
if(buf == NULL){
|
||||
error = "ogg_sync_buffer: out of memory";
|
||||
break;
|
||||
}
|
||||
len = fread(buf, 1, 65536, in);
|
||||
if(ferror(in))
|
||||
error = strerror(errno);
|
||||
ogg_sync_wrote(&oy, len);
|
||||
if(ogg_sync_check(&oy) != 0)
|
||||
error = "ogg_sync_check: internal error";
|
||||
continue;
|
||||
}
|
||||
// We got a page.
|
||||
// Short-circuit when the relevant packets have been read.
|
||||
if(packet_count >= 2 && out){
|
||||
if(write_page(&og, out) == -1){
|
||||
error = "write_page: fwrite error";
|
||||
break;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
// Initialize the streams from the first page.
|
||||
if(packet_count == -1){
|
||||
if(ogg_stream_init(&os, ogg_page_serialno(&og)) == -1){
|
||||
error = "ogg_stream_init: couldn't create a decoder";
|
||||
break;
|
||||
}
|
||||
if(out){
|
||||
if(ogg_stream_init(&enc, ogg_page_serialno(&og)) == -1){
|
||||
error = "ogg_stream_init: couldn't create an encoder";
|
||||
break;
|
||||
}
|
||||
}
|
||||
packet_count = 0;
|
||||
}
|
||||
if(ogg_stream_pagein(&os, &og) == -1){
|
||||
error = "ogg_stream_pagein: invalid page";
|
||||
break;
|
||||
}
|
||||
// Read all the packets.
|
||||
while(ogg_stream_packetout(&os, &op) == 1){
|
||||
packet_count++;
|
||||
if(packet_count == 1){ // Identification header
|
||||
if(strncmp((char*) op.packet, "OpusHead", 8) != 0){
|
||||
error = "opustags: invalid identification header";
|
||||
break;
|
||||
}
|
||||
}
|
||||
else if(packet_count == 2){ // Comment header
|
||||
if(parse_tags((char*) op.packet, op.bytes, &tags) == -1){
|
||||
error = "opustags: invalid comment header";
|
||||
break;
|
||||
}
|
||||
if(delete_all)
|
||||
tags.count = 0;
|
||||
else{
|
||||
int i;
|
||||
for(i=0; i<count_delete; i++)
|
||||
delete_tags(&tags, to_delete[i]);
|
||||
}
|
||||
char *raw_tags = NULL;
|
||||
if(set_all){
|
||||
raw_tags = malloc(16384);
|
||||
if(raw_tags == NULL){
|
||||
error = "malloc: not enough memory for buffering stdin";
|
||||
free(raw_tags);
|
||||
break;
|
||||
}
|
||||
else{
|
||||
char *raw_comment[256];
|
||||
size_t raw_len = fread(raw_tags, 1, 16383, stdin);
|
||||
if(raw_len == 16383)
|
||||
fputs("warning: truncating comment to 16 KiB\n", stderr);
|
||||
raw_tags[raw_len] = '\0';
|
||||
uint32_t raw_count = 0;
|
||||
size_t field_len = 0;
|
||||
int caught_eq = 0;
|
||||
size_t i = 0;
|
||||
char *cursor = raw_tags;
|
||||
for(i=0; i <= raw_len && raw_count < 256; i++){
|
||||
if(raw_tags[i] == '\n' || raw_tags[i] == '\0'){
|
||||
if(field_len == 0)
|
||||
continue;
|
||||
if(caught_eq)
|
||||
raw_comment[raw_count++] = cursor;
|
||||
else
|
||||
fputs("warning: skipping malformed tag\n", stderr);
|
||||
cursor = raw_tags + i + 1;
|
||||
field_len = 0;
|
||||
caught_eq = 0;
|
||||
raw_tags[i] = '\0';
|
||||
continue;
|
||||
}
|
||||
if(raw_tags[i] == '=')
|
||||
caught_eq = 1;
|
||||
field_len++;
|
||||
}
|
||||
add_tags(&tags, (const char**) raw_comment, raw_count);
|
||||
}
|
||||
}
|
||||
add_tags(&tags, to_add, count_add);
|
||||
if(out){
|
||||
ogg_packet packet;
|
||||
render_tags(&tags, &packet);
|
||||
if(ogg_stream_packetin(&enc, &packet) == -1)
|
||||
error = "ogg_stream_packetin: internal error";
|
||||
free(packet.packet);
|
||||
}
|
||||
else
|
||||
print_tags(&tags);
|
||||
free_tags(&tags);
|
||||
if(raw_tags)
|
||||
free(raw_tags);
|
||||
if(error || !out)
|
||||
break;
|
||||
else
|
||||
continue;
|
||||
}
|
||||
if(out){
|
||||
if(ogg_stream_packetin(&enc, &op) == -1){
|
||||
error = "ogg_stream_packetin: internal error";
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if(error != NULL)
|
||||
break;
|
||||
if(ogg_stream_check(&os) != 0)
|
||||
error = "ogg_stream_check: internal error (decoder)";
|
||||
// Write the page.
|
||||
if(out){
|
||||
ogg_stream_flush(&enc, &og);
|
||||
if(write_page(&og, out) == -1)
|
||||
error = "write_page: fwrite error";
|
||||
else if(ogg_stream_check(&enc) != 0)
|
||||
error = "ogg_stream_check: internal error (encoder)";
|
||||
}
|
||||
else if(packet_count >= 2) // Read-only mode
|
||||
break;
|
||||
}
|
||||
if(packet_count >= 0){
|
||||
ogg_stream_clear(&os);
|
||||
if(out)
|
||||
ogg_stream_clear(&enc);
|
||||
}
|
||||
ogg_sync_clear(&oy);
|
||||
fclose(in);
|
||||
if(out)
|
||||
fclose(out);
|
||||
if(!error && packet_count < 2)
|
||||
error = "opustags: invalid file";
|
||||
if(error){
|
||||
fprintf(stderr, "%s\n", error);
|
||||
if(path_out != NULL && out != stdout)
|
||||
remove(path_out);
|
||||
if(inplace)
|
||||
free(path_out);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
else if(inplace){
|
||||
if(rename(path_out, path_in) == -1){
|
||||
perror("rename");
|
||||
free(path_out);
|
||||
return EXIT_FAILURE;
|
||||
}
|
||||
free(path_out);
|
||||
}
|
||||
return EXIT_SUCCESS;
|
||||
}
|
394
src/cli.cc
Normal file
394
src/cli.cc
Normal file
@ -0,0 +1,394 @@
|
||||
/**
|
||||
* \file src/cli.cc
|
||||
* \ingroup cli
|
||||
*
|
||||
* Provide all the features of the opustags executable from a C++ API. The main point of separating
|
||||
* this module from the main one is to allow easy testing.
|
||||
*
|
||||
* \todo Use a safer temporary file name for in-place editing, like tmpnam.
|
||||
* \todo Abort editing with --set-all if one comment is invalid?
|
||||
*/
|
||||
|
||||
#include <config.h>
|
||||
#include <opustags.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <getopt.h>
|
||||
#include <limits.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
|
||||
using namespace std::literals::string_literals;
|
||||
|
||||
static const char help_message[] =
|
||||
PROJECT_NAME " version " PROJECT_VERSION
|
||||
R"raw(
|
||||
|
||||
Usage: opustags --help
|
||||
opustags [OPTIONS] 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 file
|
||||
-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
|
||||
|
||||
See the man page for extensive documentation.
|
||||
)raw";
|
||||
|
||||
static struct option getopt_options[] = {
|
||||
{"help", no_argument, 0, 'h'},
|
||||
{"output", required_argument, 0, 'o'},
|
||||
{"in-place", optional_argument, 0, 'i'},
|
||||
{"overwrite", no_argument, 0, 'y'},
|
||||
{"delete", required_argument, 0, 'd'},
|
||||
{"add", required_argument, 0, 'a'},
|
||||
{"set", required_argument, 0, 's'},
|
||||
{"delete-all", no_argument, 0, 'D'},
|
||||
{"set-all", no_argument, 0, 'S'},
|
||||
{NULL, 0, 0, 0}
|
||||
};
|
||||
|
||||
ot::status ot::parse_options(int argc, char** argv, ot::options& opt)
|
||||
{
|
||||
static ot::encoding_converter to_utf8("", "UTF-8");
|
||||
std::string utf8;
|
||||
std::string::size_type equal;
|
||||
ot::status rc;
|
||||
opt = {};
|
||||
if (argc == 1)
|
||||
return {st::bad_arguments, "No arguments specified. Use -h for help."};
|
||||
bool in_place = false;
|
||||
int c;
|
||||
optind = 0;
|
||||
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DS", getopt_options, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'h':
|
||||
opt.print_help = true;
|
||||
break;
|
||||
case 'o':
|
||||
if (!opt.path_out.empty())
|
||||
return {st::bad_arguments, "Cannot specify --output more than once."};
|
||||
opt.path_out = optarg;
|
||||
if (opt.path_out.empty())
|
||||
return {st::bad_arguments, "Output file path cannot be empty."};
|
||||
break;
|
||||
case 'i':
|
||||
in_place = true;
|
||||
break;
|
||||
case 'y':
|
||||
opt.overwrite = true;
|
||||
break;
|
||||
case 'd':
|
||||
rc = to_utf8(optarg, strlen(optarg), utf8);
|
||||
if (rc != ot::st::ok)
|
||||
return {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
|
||||
opt.to_delete.emplace_back(std::move(utf8));
|
||||
break;
|
||||
case 'a':
|
||||
case 's':
|
||||
rc = to_utf8(optarg, strlen(optarg), utf8);
|
||||
if (rc != ot::st::ok)
|
||||
return {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
|
||||
if ((equal = utf8.find('=')) == std::string::npos)
|
||||
return {st::bad_arguments, "Comment does not contain an equal sign: "s + optarg + "."};
|
||||
if (c == 's')
|
||||
opt.to_delete.emplace_back(utf8.substr(0, equal));
|
||||
opt.to_add.emplace_back(std::move(utf8));
|
||||
break;
|
||||
case 'S':
|
||||
opt.set_all = true;
|
||||
break;
|
||||
case 'D':
|
||||
opt.delete_all = true;
|
||||
break;
|
||||
case ':':
|
||||
return {st::bad_arguments,
|
||||
"Missing value for option '"s + argv[optind - 1] + "'."};
|
||||
default:
|
||||
return {st::bad_arguments, "Unrecognized option '" +
|
||||
(optopt ? "-"s + static_cast<char>(optopt) : argv[optind - 1]) + "'."};
|
||||
}
|
||||
}
|
||||
if (opt.print_help)
|
||||
return st::ok;
|
||||
if (optind != argc - 1)
|
||||
return {st::bad_arguments, "Exactly one input file must be specified."};
|
||||
opt.path_in = argv[optind];
|
||||
if (opt.path_in.empty())
|
||||
return {st::bad_arguments, "Input file path cannot be empty."};
|
||||
if (in_place) {
|
||||
if (!opt.path_out.empty())
|
||||
return {st::bad_arguments, "Cannot combine --in-place and --output."};
|
||||
if (opt.path_in == "-")
|
||||
return {st::bad_arguments, "Cannot modify standard input in place."};
|
||||
opt.path_out = opt.path_in;
|
||||
opt.overwrite = true;
|
||||
}
|
||||
if (opt.path_in == "-" && opt.set_all)
|
||||
return {st::bad_arguments,
|
||||
"Cannot use standard input as input file when --set-all is specified."};
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* \todo Escape new lines.
|
||||
*/
|
||||
void ot::print_comments(const std::list<std::string>& comments, FILE* output)
|
||||
{
|
||||
static ot::encoding_converter from_utf8("UTF-8", "//TRANSLIT");
|
||||
std::string local;
|
||||
bool info_lost = false;
|
||||
bool bad_comments = false;
|
||||
bool has_newline = false;
|
||||
bool has_control = false;
|
||||
for (const std::string& comment : comments) {
|
||||
ot::status rc = from_utf8(comment, local);
|
||||
if (rc == ot::st::information_lost) {
|
||||
info_lost = true;
|
||||
} else if (rc != ot::st::ok) {
|
||||
bad_comments = true;
|
||||
continue;
|
||||
}
|
||||
for (unsigned char c : comment) {
|
||||
if (c == '\n')
|
||||
has_newline = true;
|
||||
else if (c < 0x20)
|
||||
has_control = true;
|
||||
}
|
||||
fwrite(local.data(), 1, local.size(), output);
|
||||
putchar('\n');
|
||||
}
|
||||
if (info_lost)
|
||||
fputs("warning: Some tags have been transliterated to your system encoding.\n", stderr);
|
||||
if (bad_comments)
|
||||
fputs("warning: Some tags are not properly encoded and have not been displayed.\n", stderr);
|
||||
if (has_newline)
|
||||
fputs("warning: Some tags contain newline characters. "
|
||||
"These are not supported by --set-all.\n", stderr);
|
||||
if (has_control)
|
||||
fputs("warning: Some tags contain control characters.\n", stderr);
|
||||
}
|
||||
|
||||
ot::status ot::read_comments(FILE* input, std::list<std::string>& comments)
|
||||
{
|
||||
static ot::encoding_converter to_utf8("", "UTF-8");
|
||||
comments.clear();
|
||||
char* line = nullptr;
|
||||
size_t buflen = 0;
|
||||
ssize_t nread;
|
||||
while ((nread = getline(&line, &buflen, input)) != -1) {
|
||||
if (nread > 0 && line[nread - 1] == '\n')
|
||||
--nread;
|
||||
if (nread == 0)
|
||||
continue;
|
||||
if (memchr(line, '=', nread) == nullptr) {
|
||||
ot::status rc = {ot::st::error, "Malformed tag: " + std::string(line, nread)};
|
||||
free(line);
|
||||
return rc;
|
||||
}
|
||||
std::string utf8;
|
||||
ot::status rc = to_utf8(line, nread, utf8);
|
||||
if (rc == ot::st::ok) {
|
||||
comments.emplace_back(std::move(utf8));
|
||||
} else {
|
||||
free(line);
|
||||
return {ot::st::badly_encoded, "UTF-8 conversion error: " + rc.message};
|
||||
}
|
||||
}
|
||||
free(line);
|
||||
return ot::st::ok;
|
||||
}
|
||||
|
||||
void ot::delete_comments(std::list<std::string>& comments, const std::string& selector)
|
||||
{
|
||||
auto name = selector.data();
|
||||
auto equal = selector.find('=');
|
||||
auto value = (equal == std::string::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++;
|
||||
bool name_match = current->size() > name_len + 1 &&
|
||||
(*current)[name_len] == '=' &&
|
||||
strncasecmp(current->data(), name, name_len) == 0;
|
||||
if (!name_match)
|
||||
continue;
|
||||
bool value_match = value == nullptr ||
|
||||
(current->size() == selector.size() &&
|
||||
memcmp(current->data() + equal + 1, value, value_len) == 0);
|
||||
if (value_match)
|
||||
comments.erase(current);
|
||||
}
|
||||
}
|
||||
|
||||
/** Apply the modifications requested by the user to the opustags packet. */
|
||||
static ot::status edit_tags(ot::opus_tags& tags, const ot::options& opt)
|
||||
{
|
||||
if (opt.set_all) {
|
||||
auto rc = ot::read_comments(stdin, tags.comments);
|
||||
if (rc != ot::st::ok)
|
||||
return rc;
|
||||
} else if (opt.delete_all) {
|
||||
tags.comments.clear();
|
||||
} else for (const std::string& name : opt.to_delete) {
|
||||
ot::delete_comments(tags.comments, name.c_str());
|
||||
}
|
||||
|
||||
for (const std::string& comment : opt.to_add)
|
||||
tags.comments.emplace_back(comment);
|
||||
|
||||
return ot::st::ok;
|
||||
}
|
||||
|
||||
/**
|
||||
* Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
|
||||
* Transform the OpusTags packet on the fly.
|
||||
*
|
||||
* The writer is optional. When writer is nullptr, opustags runs in read-only mode.
|
||||
*/
|
||||
static ot::status process(ot::ogg_reader& reader, ot::ogg_writer* writer, const ot::options &opt)
|
||||
{
|
||||
bool focused = false; /*< the stream on which we operate is defined */
|
||||
int focused_serialno; /*< when focused, the serialno of the focused stream */
|
||||
/** \todo Become stream-aware instead of counting the pages of all streams together. */
|
||||
int absolute_page_no = -1; /*< page number in the physical stream, not logical */
|
||||
for (;;) {
|
||||
ot::status rc = reader.next_page();
|
||||
if (rc == ot::st::end_of_stream)
|
||||
break;
|
||||
else if (rc == ot::st::bad_stream && absolute_page_no == -1)
|
||||
return {ot::st::bad_stream, "Input is not a valid Ogg file."};
|
||||
else if (rc != ot::st::ok)
|
||||
return rc;
|
||||
++absolute_page_no;
|
||||
auto serialno = ogg_page_serialno(&reader.page);
|
||||
auto pageno = ogg_page_pageno(&reader.page);
|
||||
if (!focused) {
|
||||
focused = true;
|
||||
focused_serialno = serialno;
|
||||
} else if (serialno != focused_serialno) {
|
||||
return {ot::st::error, "Muxed streams are not supported yet."};
|
||||
}
|
||||
if (absolute_page_no == 0) { // Identification header
|
||||
if (!ot::is_opus_stream(reader.page))
|
||||
return {ot::st::error, "Not an Opus stream."};
|
||||
if (writer) {
|
||||
rc = writer->write_page(reader.page);
|
||||
if (rc != ot::st::ok)
|
||||
return rc;
|
||||
}
|
||||
} else if (absolute_page_no == 1) { // Comment header
|
||||
ot::opus_tags tags;
|
||||
rc = reader.process_header_packet(
|
||||
[&tags](ogg_packet& p) { return ot::parse_tags(p, tags); });
|
||||
if (rc != ot::st::ok)
|
||||
return rc;
|
||||
if ((rc = edit_tags(tags, opt)) != ot::st::ok)
|
||||
return rc;
|
||||
if (writer) {
|
||||
auto packet = ot::render_tags(tags);
|
||||
rc = writer->write_header_packet(serialno, pageno, packet);
|
||||
if (rc != ot::st::ok)
|
||||
return rc;
|
||||
} else {
|
||||
ot::print_comments(tags.comments, stdout);
|
||||
break;
|
||||
}
|
||||
} else {
|
||||
if (writer && (rc = writer->write_page(reader.page)) != ot::st::ok)
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
if (absolute_page_no < 1)
|
||||
return {ot::st::error, "Expected at least 2 Ogg pages."};
|
||||
return ot::st::ok;
|
||||
}
|
||||
|
||||
ot::status ot::run(const ot::options& opt)
|
||||
{
|
||||
if (opt.print_help) {
|
||||
fputs(help_message, stdout);
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
ot::file input;
|
||||
if (opt.path_in == "-")
|
||||
input = stdin;
|
||||
else if ((input = fopen(opt.path_in.c_str(), "r")) == nullptr)
|
||||
return {ot::st::standard_error,
|
||||
"Could not open '" + opt.path_in + "' for reading: " + strerror(errno)};
|
||||
ot::ogg_reader reader(input.get());
|
||||
|
||||
/* Read-only mode. */
|
||||
if (opt.path_out.empty())
|
||||
return process(reader, nullptr, opt);
|
||||
|
||||
/* Read-write mode.
|
||||
*
|
||||
* The output pointer is set to one of:
|
||||
* - stdout for "-",
|
||||
* - final_output.get() for special files like /dev/null,
|
||||
* - temporary_output.get() for regular files.
|
||||
*
|
||||
* We use a temporary output file for the following reasons:
|
||||
* 1. The partial .opus output may be seen by softwares like media players, or through
|
||||
* inotify for the most attentive process.
|
||||
* 2. If the process crashes badly, or the power cuts off, we don't want to leave a partial
|
||||
* file at the final location. The temporary file is still going to stay but will have an
|
||||
* obvious name.
|
||||
* 3. If we're overwriting a regular file, we'd rather avoid wiping its content before we
|
||||
* even started reading the input file. That way, the original file is always preserved
|
||||
* on error or crash.
|
||||
* 4. It is necessary for in-place editing. We can't reliably open the same file as both
|
||||
* input and output.
|
||||
*/
|
||||
|
||||
FILE* output = nullptr;
|
||||
ot::partial_file temporary_output;
|
||||
ot::file final_output;
|
||||
|
||||
ot::status rc = ot::st::ok;
|
||||
struct stat output_info;
|
||||
if (opt.path_out == "-") {
|
||||
output = stdout;
|
||||
} else if (stat(opt.path_out.c_str(), &output_info) == 0) {
|
||||
/* The output file exists. */
|
||||
if (!S_ISREG(output_info.st_mode)) {
|
||||
/* Special files are opened for writing directly. */
|
||||
if ((final_output = fopen(opt.path_out.c_str(), "w")) == nullptr)
|
||||
rc = {ot::st::standard_error,
|
||||
"Could not open '" + opt.path_out + "' for writing: " +
|
||||
strerror(errno)};
|
||||
output = final_output.get();
|
||||
} else if (opt.overwrite) {
|
||||
rc = temporary_output.open(opt.path_out.c_str());
|
||||
output = temporary_output.get();
|
||||
} else {
|
||||
rc = {ot::st::error,
|
||||
"'" + opt.path_out + "' already exists. Use -y to overwrite."};
|
||||
}
|
||||
} else if (errno == ENOENT) {
|
||||
rc = temporary_output.open(opt.path_out.c_str());
|
||||
output = temporary_output.get();
|
||||
} else {
|
||||
rc = {ot::st::error,
|
||||
"Could not identify '" + opt.path_in + "': " + strerror(errno)};
|
||||
}
|
||||
if (rc != ot::st::ok)
|
||||
return rc;
|
||||
|
||||
ot::ogg_writer writer(output);
|
||||
rc = process(reader, &writer, opt);
|
||||
if (rc == ot::st::ok)
|
||||
rc = temporary_output.commit();
|
||||
|
||||
return rc;
|
||||
}
|
2
src/config.h.in
Normal file
2
src/config.h.in
Normal file
@ -0,0 +1,2 @@
|
||||
#cmakedefine PROJECT_NAME "@PROJECT_NAME@"
|
||||
#cmakedefine PROJECT_VERSION "@PROJECT_VERSION@"
|
116
src/ogg.cc
Normal file
116
src/ogg.cc
Normal file
@ -0,0 +1,116 @@
|
||||
/**
|
||||
* \file src/ogg.c
|
||||
* \ingroup ogg
|
||||
*
|
||||
* High-level interface for libogg.
|
||||
*
|
||||
* This module is not meant to be a complete libogg wrapper, but rather a convenient and highly
|
||||
* specialized layer above libogg and stdio.
|
||||
*/
|
||||
|
||||
#include <opustags.h>
|
||||
|
||||
#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)
|
||||
return false;
|
||||
if (identification_header.body_len < 8)
|
||||
return false;
|
||||
return (memcmp(identification_header.body, "OpusHead", 8) == 0);
|
||||
}
|
||||
|
||||
ot::status ot::ogg_reader::next_page()
|
||||
{
|
||||
int rc;
|
||||
while ((rc = ogg_sync_pageout(&sync, &page)) != 1) {
|
||||
if (rc == -1)
|
||||
return {st::bad_stream, "Unsynced data in stream."};
|
||||
if (ogg_sync_check(&sync) != 0)
|
||||
return {st::libogg_error, "ogg_sync_check signalled an error."};
|
||||
if (feof(file)) {
|
||||
if (sync.fill != sync.returned)
|
||||
return {st::bad_stream, "Unsynced data at end of stream."};
|
||||
return {st::end_of_stream, "End of stream was reached."};
|
||||
}
|
||||
char* buf = ogg_sync_buffer(&sync, 65536);
|
||||
if (buf == nullptr)
|
||||
return {st::libogg_error, "ogg_sync_buffer failed."};
|
||||
size_t len = fread(buf, 1, 65536, file);
|
||||
if (ferror(file))
|
||||
return {st::standard_error, "fread error: "s + strerror(errno)};
|
||||
if (ogg_sync_wrote(&sync, len) != 0)
|
||||
return {st::libogg_error, "ogg_sync_wrote failed."};
|
||||
}
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
ot::status ot::ogg_reader::process_header_packet(const std::function<status(ogg_packet&)>& f)
|
||||
{
|
||||
if (ogg_page_continued(&page))
|
||||
return {ot::st::error, "Unexpected continued header page."};
|
||||
ogg_logical_stream stream(ogg_page_serialno(&page));
|
||||
stream.pageno = ogg_page_pageno(&page);
|
||||
if (ogg_stream_pagein(&stream, &page) != 0)
|
||||
return {st::libogg_error, "ogg_stream_pagein failed."};
|
||||
ogg_packet packet;
|
||||
int rc = ogg_stream_packetout(&stream, &packet);
|
||||
if (ogg_stream_check(&stream) != 0 || rc == -1)
|
||||
return {ot::st::libogg_error, "ogg_stream_packetout failed."};
|
||||
else if (rc == 0)
|
||||
return {ot::st::error,
|
||||
"Reading header packets spanning multiple pages are not yet supported. "
|
||||
"Please file an issue to make your wish known."};
|
||||
ot::status f_rc = f(packet);
|
||||
if (f_rc != ot::st::ok)
|
||||
return f_rc;
|
||||
/* Ensure that there are no other segments left in the packet using the lacing state of the
|
||||
* stream. These are the relevant variables, as far as I understood them:
|
||||
* - lacing_vals: extensible array containing the lacing values of the segments,
|
||||
* - lacing_fill: number of elements in lacing_vals (not the capacity),
|
||||
* - lacing_returned: index of the next segment to be processed. */
|
||||
if (stream.lacing_returned != stream.lacing_fill)
|
||||
return {ot::st::error, "Header page contains more than a single packet."};
|
||||
return ot::st::ok;
|
||||
}
|
||||
|
||||
ot::status ot::ogg_writer::write_page(const ogg_page& page)
|
||||
{
|
||||
if (page.header_len < 0 || page.body_len < 0)
|
||||
return {st::int_overflow, "Overflowing page length"};
|
||||
auto header_len = static_cast<size_t>(page.header_len);
|
||||
auto body_len = static_cast<size_t>(page.body_len);
|
||||
if (fwrite(page.header, 1, header_len, file) < header_len)
|
||||
return {st::standard_error, "fwrite error: "s + strerror(errno)};
|
||||
if (fwrite(page.body, 1, body_len, file) < body_len)
|
||||
return {st::standard_error, "fwrite error: "s + strerror(errno)};
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
ot::status ot::ogg_writer::write_header_packet(int serialno, int pageno, ogg_packet& packet)
|
||||
{
|
||||
ogg_logical_stream stream(serialno);
|
||||
stream.b_o_s = (pageno != 0);
|
||||
stream.pageno = pageno;
|
||||
if (ogg_stream_packetin(&stream, &packet) != 0)
|
||||
return {ot::st::libogg_error, "ogg_stream_packetin failed"};
|
||||
ogg_page page;
|
||||
if (ogg_stream_flush(&stream, &page) != 0) {
|
||||
ot::status rc = write_page(page);
|
||||
if (rc != ot::st::ok)
|
||||
return rc;
|
||||
} else {
|
||||
return {ot::st::libogg_error, "ogg_stream_flush failed"};
|
||||
}
|
||||
if (ogg_stream_flush(&stream, &page) != 0)
|
||||
return {ot::st::error,
|
||||
"Writing header packets spanning multiple pages are not yet supported. "
|
||||
"Please file an issue to make your wish known."};
|
||||
if (ogg_stream_check(&stream) != 0)
|
||||
return {st::libogg_error, "ogg_stream_check failed"};
|
||||
return ot::st::ok;
|
||||
}
|
123
src/opus.cc
Normal file
123
src/opus.cc
Normal file
@ -0,0 +1,123 @@
|
||||
/**
|
||||
* \file src/opus.cc
|
||||
* \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).
|
||||
*
|
||||
* Section 3 "Packet Organization" is critical for us:
|
||||
*
|
||||
* - The first page contains exactly 1 packet, the OpusHead, and it contains it entirely.
|
||||
* - The second page begins the OpusTags packet, which may span several pages.
|
||||
* - The OpusTags packet must finish the page on which it completes.
|
||||
*
|
||||
* The structure of the OpusTags packet is defined in section 5.2 "Comment Header" of the RFC.
|
||||
*
|
||||
* OpusTags is similar to [Vorbis Comment](https://www.xiph.org/vorbis/doc/v-comment.html), which
|
||||
* gives us some context, but let's stick to the RFC for the technical details.
|
||||
*
|
||||
* \todo Validate that the vendor string and comments are valid UTF-8.
|
||||
* \todo Validate that field names are ASCII: 0x20 through 0x7D, 0x3D ('=') excluded.
|
||||
* \todo Field names are case insensitive, respect that.
|
||||
*
|
||||
*/
|
||||
|
||||
#include <opustags.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <libkern/OSByteOrder.h>
|
||||
#define htole32(x) OSSwapHostToLittleInt32(x)
|
||||
#define le32toh(x) OSSwapLittleToHostInt32(x)
|
||||
#endif
|
||||
|
||||
/**
|
||||
* \todo See if the packet's data could be casted more nicely into a string.
|
||||
*/
|
||||
ot::status ot::parse_tags(const ogg_packet& packet, opus_tags& tags)
|
||||
{
|
||||
if (packet.bytes < 0)
|
||||
return {st::int_overflow, "Overflowing comment header length"};
|
||||
size_t size = static_cast<size_t>(packet.bytes);
|
||||
const char* data = reinterpret_cast<char*>(packet.packet);
|
||||
size_t pos = 0;
|
||||
opus_tags my_tags;
|
||||
|
||||
// Magic number
|
||||
if (8 > size)
|
||||
return {st::cut_magic_number, "Comment header too short for the magic number"};
|
||||
if (memcmp(data, "OpusTags", 8) != 0)
|
||||
return {st::bad_magic_number, "Comment header did not start with OpusTags"};
|
||||
|
||||
// Vendor
|
||||
pos = 8;
|
||||
if (pos + 4 > size)
|
||||
return {st::cut_vendor_length,
|
||||
"Vendor string length did not fit the comment header"};
|
||||
size_t vendor_length = le32toh(*((uint32_t*) (data + pos)));
|
||||
if (pos + 4 + vendor_length > size)
|
||||
return {st::cut_vendor_data, "Vendor string did not fit the comment header"};
|
||||
my_tags.vendor = std::string(data + pos + 4, vendor_length);
|
||||
pos += 4 + my_tags.vendor.size();
|
||||
|
||||
// Comment count
|
||||
if (pos + 4 > size)
|
||||
return {st::cut_comment_count, "Comment count did not fit the comment header"};
|
||||
uint32_t count = le32toh(*((uint32_t*) (data + pos)));
|
||||
pos += 4;
|
||||
|
||||
// Comments' data
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
if (pos + 4 > size)
|
||||
return {st::cut_comment_length,
|
||||
"Comment length did not fit the comment header"};
|
||||
uint32_t comment_length = le32toh(*((uint32_t*) (data + pos)));
|
||||
if (pos + 4 + comment_length > size)
|
||||
return {st::cut_comment_data,
|
||||
"Comment string did not fit the comment header"};
|
||||
const char *comment_value = 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);
|
||||
|
||||
tags = std::move(my_tags);
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
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)
|
||||
size += 4 + comment.size();
|
||||
size += tags.extra_data.size();
|
||||
|
||||
dynamic_ogg_packet op(size);
|
||||
op.b_o_s = 0;
|
||||
op.e_o_s = 0;
|
||||
op.granulepos = 0;
|
||||
op.packetno = 1;
|
||||
|
||||
unsigned char* data = op.packet;
|
||||
uint32_t n;
|
||||
memcpy(data, "OpusTags", 8);
|
||||
n = htole32(tags.vendor.size());
|
||||
memcpy(data+8, &n, 4);
|
||||
memcpy(data+12, tags.vendor.data(), tags.vendor.size());
|
||||
data += 12 + tags.vendor.size();
|
||||
n = htole32(tags.comments.size());
|
||||
memcpy(data, &n, 4);
|
||||
data += 4;
|
||||
for (const std::string& comment : tags.comments) {
|
||||
n = htole32(comment.size());
|
||||
memcpy(data, &n, 4);
|
||||
memcpy(data+4, comment.data(), comment.size());
|
||||
data += 4 + comment.size();
|
||||
}
|
||||
memcpy(data, tags.extra_data.data(), tags.extra_data.size());
|
||||
|
||||
return op;
|
||||
}
|
31
src/opustags.cc
Normal file
31
src/opustags.cc
Normal file
@ -0,0 +1,31 @@
|
||||
/**
|
||||
* \file src/opustags.cc
|
||||
* \brief Main function for opustags.
|
||||
*
|
||||
* See opustags.h for the program's documentation.
|
||||
*/
|
||||
|
||||
#include <opustags.h>
|
||||
|
||||
#include <locale.h>
|
||||
|
||||
/**
|
||||
* Main function of the opustags binary.
|
||||
*
|
||||
* Does practically nothing but call the cli module.
|
||||
*/
|
||||
int main(int argc, char** argv) {
|
||||
setlocale(LC_ALL, "");
|
||||
ot::options opt;
|
||||
ot::status rc = ot::parse_options(argc, argv, opt);
|
||||
if (rc == ot::st::ok)
|
||||
rc = ot::run(opt);
|
||||
|
||||
if (rc != ot::st::ok) {
|
||||
if (!rc.message.empty())
|
||||
fprintf(stderr, "error: %s\n", rc.message.c_str());
|
||||
return EXIT_FAILURE;
|
||||
} else {
|
||||
return EXIT_SUCCESS;
|
||||
}
|
||||
}
|
477
src/opustags.h
Normal file
477
src/opustags.h
Normal file
@ -0,0 +1,477 @@
|
||||
/**
|
||||
* \file src/opustags.h
|
||||
*
|
||||
* Welcome to opustags!
|
||||
*
|
||||
* Let's have a quick tour around. The project is split into the following modules:
|
||||
*
|
||||
* - The system module provides a few generic tools for interating with the system.
|
||||
* - The ogg module reads and writes Ogg files, letting you manipulate Ogg pages and packets.
|
||||
* - The opus module parses the contents of Ogg packets according to the Opus specifications.
|
||||
* - The cli module implements the main logic of the program.
|
||||
* - The opustags module contains the main function, which is a simple wrapper around cli.
|
||||
*
|
||||
* Each module is implemented in its eponymous .cc file. Their interfaces are all defined and
|
||||
* documented together in this header file. Look into the .cc files for implementation-specific
|
||||
* details.
|
||||
*
|
||||
* To understand how this program works, you need to know what an Ogg files is made of, in
|
||||
* particular the streams, pages, and packets. You hardly need any knowledge of the actual Opus
|
||||
* audio codec, but need the RFC 7845 "Ogg Encapsulation for the Opus Audio Codec" that defines the
|
||||
* format of the header packets that are essential to opustags.
|
||||
*
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <iconv.h>
|
||||
#include <ogg/ogg.h>
|
||||
#include <stdio.h>
|
||||
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <string>
|
||||
#include <vector>
|
||||
|
||||
namespace ot {
|
||||
|
||||
/**
|
||||
* Possible return status code, ranging from errors to special statuses. They are usually
|
||||
* accompanied with a message with the #status structure.
|
||||
*
|
||||
* Functions that return non-ok status codes to signal special conditions like #end_of_stream should
|
||||
* have it explictly mentionned in their documentation. By default, a non-ok status should be
|
||||
* handled like an error.
|
||||
*
|
||||
* Error codes do not need to be ultra specific, and are mainly used to report special conditions to
|
||||
* the caller function. Ultimately, only the error message in the #status is shown to the user.
|
||||
*
|
||||
* The cut error family means that the end of packet was reached when attempting to read the
|
||||
* overflowing value. For example, cut_comment_count means that after reading the vendor string,
|
||||
* less than 4 bytes were left in the packet.
|
||||
*/
|
||||
enum class st {
|
||||
/* Generic */
|
||||
ok,
|
||||
error,
|
||||
standard_error, /**< Error raised by the C standard library. */
|
||||
int_overflow,
|
||||
/* System */
|
||||
badly_encoded,
|
||||
information_lost,
|
||||
/* Ogg */
|
||||
bad_stream,
|
||||
end_of_stream,
|
||||
libogg_error,
|
||||
/* Opus */
|
||||
bad_magic_number,
|
||||
cut_magic_number,
|
||||
cut_vendor_length,
|
||||
cut_vendor_data,
|
||||
cut_comment_count,
|
||||
cut_comment_length,
|
||||
cut_comment_data,
|
||||
/* CLI */
|
||||
bad_arguments,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a status code with an optional message. It is implictly converted to and from a
|
||||
* #status_code.
|
||||
*
|
||||
* All the statuses except #st::ok should be accompanied with a relevant error message, in case it
|
||||
* propagates back to the main function and is shown to the user.
|
||||
*
|
||||
* \todo Instead of being returned, it could be thrown. Most of the error handling code just let the
|
||||
* status bubble. When we're confident about RAII, we're good to go. When we migrate, let's
|
||||
* start from main and adapt the functions top-down.
|
||||
*/
|
||||
struct status {
|
||||
status(st code = st::ok) : code(code) {}
|
||||
template<class T> status(st code, T&& message) : code(code), message(message) {}
|
||||
operator st() { return code; }
|
||||
st code;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \defgroup system System
|
||||
* \{
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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) {}
|
||||
};
|
||||
|
||||
/**
|
||||
* A partial file is a temporary file created to store the result of something. When it is complete,
|
||||
* it is moved to a final destination. Open it with #open and then you can either #commit it to save
|
||||
* it to its destination, or you can #abort to delete the temporary file. When the #partial_file
|
||||
* object is destroyed, it deletes the currently opened temporary file, if any.
|
||||
*/
|
||||
class partial_file {
|
||||
public:
|
||||
~partial_file() { abort(); }
|
||||
/**
|
||||
* Open a temporary file meant to be moved to the specified destination file path. The
|
||||
* temporary file is created in the same directory as its destination in order to make the
|
||||
* final move operation instant.
|
||||
*/
|
||||
ot::status open(const char* destination);
|
||||
/** Close then move the partial file to its final location. */
|
||||
ot::status commit();
|
||||
/** Delete the temporary file. */
|
||||
void abort();
|
||||
/** Get the underlying FILE* handle. */
|
||||
FILE* get() { return file.get(); }
|
||||
/** Get the name of the temporary file. */
|
||||
const char* name() const { return file == nullptr ? nullptr : temporary_name.c_str(); }
|
||||
private:
|
||||
std::string temporary_name;
|
||||
std::string final_name;
|
||||
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. If some character could not be converted perfectly, keep converting
|
||||
* the string and finally return #st::information_lost.
|
||||
*/
|
||||
status operator()(const std::string& in, std::string& out)
|
||||
{ return (*this)(in.data(), in.size(), out); }
|
||||
status operator()(const char* in, size_t n, std::string& out);
|
||||
private:
|
||||
iconv_t cd; /**< conversion descriptor */
|
||||
};
|
||||
|
||||
/** \} */
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \defgroup ogg Ogg
|
||||
* \{
|
||||
*/
|
||||
|
||||
/**
|
||||
* RAII-aware wrapper around libogg's ogg_stream_state. Though it handles automatic destruction, it
|
||||
* does not prevent copying or implement move semantics correctly, so it's your responsibility to
|
||||
* ensure these operations don't happen.
|
||||
*/
|
||||
struct ogg_logical_stream : ogg_stream_state {
|
||||
ogg_logical_stream(int serialno) {
|
||||
if (ogg_stream_init(this, serialno) != 0)
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
~ogg_logical_stream() {
|
||||
ogg_stream_clear(this);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Identify the codec of a logical stream based on the first bytes of the first packet of the first
|
||||
* page. For Opus, the first 8 bytes must be OpusHead. Any other signature is assumed to be another
|
||||
* codec.
|
||||
*/
|
||||
bool is_opus_stream(const ogg_page& identification_header);
|
||||
|
||||
/**
|
||||
* Ogg reader, combining a FILE input, an ogg_sync_state reading the pages.
|
||||
*
|
||||
* Call #read_page repeatedly until #status::end_of_stream to consume the stream, and use #page to
|
||||
* check its content.
|
||||
*
|
||||
* \todo This class could be made more intuitive if it acted like an iterator, to be used like
|
||||
* `for (ogg_page& page : ogg_reader(input))`, but the prerequisite for this is the ability to
|
||||
* throw an exception on error.
|
||||
*/
|
||||
struct ogg_reader {
|
||||
/**
|
||||
* Initialize the reader with the given input file handle. The caller is responsible for
|
||||
* keeping the file handle alive, and to close it.
|
||||
*/
|
||||
ogg_reader(FILE* input) : file(input) { ogg_sync_init(&sync); }
|
||||
/**
|
||||
* Clear all the internal memory allocated by libogg for the sync and stream state. The
|
||||
* page and the packet are owned by these states, so nothing to do with them.
|
||||
*
|
||||
* The input file is not closed.
|
||||
*/
|
||||
~ogg_reader() { ogg_sync_clear(&sync); }
|
||||
/**
|
||||
* Read the next page from the input file. The result, provided the status is #status::ok,
|
||||
* is made available in the #page field, is owned by the Ogg reader, and is valid until the
|
||||
* next call to #read_page.
|
||||
*
|
||||
* After the last page was read, return #status::end_of_stream.
|
||||
*/
|
||||
status next_page();
|
||||
/**
|
||||
* Read the single packet contained in the last page read, assuming it's a header page, and
|
||||
* call the function f on it. This function has no side effect, and calling it twice on the
|
||||
* same page will read the same packet again.
|
||||
*
|
||||
* It is currently limited to packets that fit on a single page, and should be later
|
||||
* extended to support packets spanning multiple pages.
|
||||
*/
|
||||
status process_header_packet(const std::function<status(ogg_packet&)>& f);
|
||||
/**
|
||||
* Current page from the sync state.
|
||||
*
|
||||
* Its memory is managed by libogg, inside the sync state, and is valid until the next call
|
||||
* to ogg_sync_pageout, wrapped by #read_page.
|
||||
*/
|
||||
ogg_page page;
|
||||
/**
|
||||
* The file is our source of binary data. It is not integrated to libogg, so we need to
|
||||
* handle it ourselves.
|
||||
*
|
||||
* The file is not owned by the ogg_reader instance.
|
||||
*/
|
||||
FILE* file;
|
||||
/**
|
||||
* The sync layer gets binary data and yields a sequence of pages.
|
||||
*
|
||||
* A page contains packets that we can extract using the #stream state, but we only do that
|
||||
* for the headers. Once we got the OpusHead and OpusTags packets, all the following pages
|
||||
* are simply forwarded to the Ogg writer.
|
||||
*/
|
||||
ogg_sync_state sync;
|
||||
};
|
||||
|
||||
/**
|
||||
* An Ogg writer lets you write ogg_page objets to an output file, and assemble packets into pages.
|
||||
*
|
||||
* Its packet writing facility is limited to writing single-page header packets, because that's all
|
||||
* we need for opustags.
|
||||
*/
|
||||
struct ogg_writer {
|
||||
/**
|
||||
* Initialize the writer with the given output file handle. The caller is responsible for
|
||||
* keeping the file handle alive, and to close it.
|
||||
*/
|
||||
explicit ogg_writer(FILE* output) : file(output) {}
|
||||
/**
|
||||
* Write a whole Ogg page into the output stream.
|
||||
*
|
||||
* This is a basic I/O operation and does not even require libogg, or the stream.
|
||||
*/
|
||||
status write_page(const ogg_page& page);
|
||||
/**
|
||||
* Write a header packet and flush the page. Header packets are always placed alone on their
|
||||
* pages.
|
||||
*/
|
||||
status write_header_packet(int serialno, int pageno, ogg_packet& packet);
|
||||
/**
|
||||
* Output file. It should be opened in binary mode. We use it to write whole pages,
|
||||
* represented as a block of data and a length.
|
||||
*/
|
||||
FILE* file;
|
||||
};
|
||||
|
||||
/**
|
||||
* Ogg packet with dynamically allocated data.
|
||||
*
|
||||
* Provides a wrapper around libogg's ogg_packet with RAII.
|
||||
*/
|
||||
struct dynamic_ogg_packet : ogg_packet {
|
||||
/** Construct an ogg_packet of the given size. */
|
||||
explicit dynamic_ogg_packet(size_t size) {
|
||||
bytes = size;
|
||||
data = std::make_unique<unsigned char[]>(size);
|
||||
packet = data.get();
|
||||
}
|
||||
private:
|
||||
/** Owning reference to the data. Use the packet field from ogg_packet instead. */
|
||||
std::unique_ptr<unsigned char[]> data;
|
||||
};
|
||||
|
||||
/** \} */
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \defgroup opus Opus
|
||||
* \{
|
||||
*/
|
||||
|
||||
/**
|
||||
* Faithfully represent *all* the data in an OpusTags packet, exactly as they will be written in the
|
||||
* final stream, disregarding the current system locale or anything else.
|
||||
*
|
||||
* The vendor and comment strings are expected to contain valid UTF-8, but we should keep their
|
||||
* values intact even if the string is not UTF-8 clean, or encoded in any other way.
|
||||
*/
|
||||
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;
|
||||
/**
|
||||
* Comments are strings in the NAME=Value format. A comment may also be called a field, or a
|
||||
* tag.
|
||||
*
|
||||
* The field name in vorbis comments is usually case-insensitive and ASCII, while the value
|
||||
* 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;
|
||||
/**
|
||||
* According to RFC 7845:
|
||||
* > Immediately following the user comment list, the comment header MAY contain
|
||||
* > zero-padding or other binary data that is not specified here.
|
||||
*
|
||||
* The first byte is supposed to indicate whether this data should be kept or not, but let's
|
||||
* assume it's here for a reason and always keep it. Better safe than sorry.
|
||||
*
|
||||
* 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;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the given OpusTags packet and extract its content into an opus_tags object.
|
||||
*
|
||||
* On error, the tags object is left unchanged.
|
||||
*/
|
||||
status parse_tags(const ogg_packet& packet, opus_tags& tags);
|
||||
|
||||
/**
|
||||
* Serialize an #opus_tags object into an OpusTags Ogg packet.
|
||||
*/
|
||||
dynamic_ogg_packet render_tags(const opus_tags& tags);
|
||||
|
||||
/** \} */
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \defgroup cli Command-Line Interface
|
||||
* \{
|
||||
*/
|
||||
|
||||
/**
|
||||
* Structured representation of the command-line arguments to opustags.
|
||||
*/
|
||||
struct options {
|
||||
/**
|
||||
* When true, opustags prints a detailed help and exits. All the other options are ignored.
|
||||
*
|
||||
* Option: --help
|
||||
*/
|
||||
bool print_help = false;
|
||||
/**
|
||||
* Path to the input file. It cannot be empty. The special "-" string means stdin.
|
||||
*
|
||||
* This is the mandatory non-flagged parameter.
|
||||
*/
|
||||
std::string path_in;
|
||||
/**
|
||||
* Path to the optional file. The special "-" string means stdout. When empty, opustags runs
|
||||
* in read-only mode. For in-place editing, path_out is defined equal to path_in.
|
||||
*
|
||||
* Options: --output, --in-place
|
||||
*/
|
||||
std::string path_out;
|
||||
/**
|
||||
* By default, opustags won't overwrite the output file if it already exists. This can be
|
||||
* forced with --overwrite. It is also enabled by --in-place.
|
||||
*
|
||||
* Options: --overwrite, --in-place
|
||||
*/
|
||||
bool overwrite = false;
|
||||
/**
|
||||
* List of comments to delete. Each string is a selector according to the definition of
|
||||
* #delete_comments.
|
||||
*
|
||||
* When #delete_all is true, this option is meaningless.
|
||||
*
|
||||
* #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::vector<std::string> to_delete;
|
||||
/**
|
||||
* Delete all the existing comments.
|
||||
*
|
||||
* Option: --delete-all
|
||||
*/
|
||||
bool delete_all = false;
|
||||
/**
|
||||
* 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::vector<std::string> to_add;
|
||||
/**
|
||||
* Replace the previous comments by the ones supplied by the user.
|
||||
*
|
||||
* Read a list of comments from stdin and populate #to_add. Further comments may be added
|
||||
* with the --add option.
|
||||
*
|
||||
* Option: --set-all
|
||||
*/
|
||||
bool set_all = false;
|
||||
};
|
||||
|
||||
/**
|
||||
* Parse the command-line arguments. Does not perform I/O related validations, but checks the
|
||||
* consistency of its arguments.
|
||||
*
|
||||
* On error, the state of the options structure is unspecified.
|
||||
*/
|
||||
status parse_options(int argc, char** argv, options& opt);
|
||||
|
||||
/**
|
||||
* Print all the comments, separated by line breaks. Since a comment may contain line breaks, this
|
||||
* output is not completely reliable, but it fits most cases.
|
||||
*
|
||||
* The comments must be encoded in UTF-8, and are converted to the system locale when printed.
|
||||
*
|
||||
* The output generated is meant to be parseable by #ot::read_tags.
|
||||
*/
|
||||
void print_comments(const std::list<std::string>& comments, FILE* output);
|
||||
|
||||
/**
|
||||
* Parse the comments outputted by #ot::print_comments.
|
||||
*
|
||||
* The comments are converted from the system encoding to UTF-8, and returned as UTF-8.
|
||||
*/
|
||||
status read_comments(FILE* input, std::list<std::string>& comments);
|
||||
|
||||
/**
|
||||
* 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);
|
||||
|
||||
/**
|
||||
* Main entry point to the opustags program, and pretty much the same as calling opustags from the
|
||||
* command-line.
|
||||
*/
|
||||
status run(const options& opt);
|
||||
|
||||
/** \} */
|
||||
|
||||
}
|
99
src/system.cc
Normal file
99
src/system.cc
Normal file
@ -0,0 +1,99 @@
|
||||
/**
|
||||
* \file src/system.cc
|
||||
* \ingroup system
|
||||
*
|
||||
* Provide a high-level interface to system-related features, like filesystem manipulations.
|
||||
*
|
||||
* Ideally, all OS-specific features should be grouped here.
|
||||
*
|
||||
* This modules shoumd not depend on any other opustags module.
|
||||
*/
|
||||
|
||||
#include <opustags.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
ot::status ot::partial_file::open(const char* destination)
|
||||
{
|
||||
abort();
|
||||
final_name = destination;
|
||||
temporary_name = final_name + ".XXXXXX.part";
|
||||
int fd = mkstemps(const_cast<char*>(temporary_name.data()), 5);
|
||||
if (fd == -1)
|
||||
return {st::standard_error,
|
||||
"Could not create a partial file for '" + final_name + "': " +
|
||||
strerror(errno)};
|
||||
file = fdopen(fd, "w");
|
||||
if (file == nullptr)
|
||||
return {st::standard_error,
|
||||
"Could not get the partial file handle to '" + temporary_name + "': " +
|
||||
strerror(errno)};
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
ot::status ot::partial_file::commit()
|
||||
{
|
||||
if (file == nullptr)
|
||||
return st::ok;
|
||||
file.reset();
|
||||
if (rename(temporary_name.c_str(), final_name.c_str()) == -1)
|
||||
return {st::standard_error,
|
||||
"Could not move the result file '" + temporary_name + "' to '" +
|
||||
final_name + "': " + strerror(errno) + "."};
|
||||
return st::ok;
|
||||
}
|
||||
|
||||
void ot::partial_file::abort()
|
||||
{
|
||||
if (file == nullptr)
|
||||
return;
|
||||
file.reset();
|
||||
remove(temporary_name.c_str());
|
||||
}
|
||||
|
||||
ot::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()
|
||||
{
|
||||
iconv_close(cd);
|
||||
}
|
||||
|
||||
ot::status ot::encoding_converter::operator()(const char* in, size_t n, std::string& out)
|
||||
{
|
||||
iconv(cd, nullptr, nullptr, nullptr, nullptr);
|
||||
out.clear();
|
||||
out.reserve(n);
|
||||
char* in_cursor = const_cast<char*>(in);
|
||||
size_t in_left = n;
|
||||
constexpr size_t chunk_size = 1024;
|
||||
char chunk[chunk_size];
|
||||
bool lost_information = false;
|
||||
for (;;) {
|
||||
char *out_cursor = chunk;
|
||||
size_t out_left = chunk_size;
|
||||
size_t rc = iconv(cd, &in_cursor, &in_left, &out_cursor, &out_left);
|
||||
if (rc == (size_t) -1 && errno != E2BIG)
|
||||
return {ot::st::badly_encoded,
|
||||
"Could not convert string '" + std::string(in, n) + "': " +
|
||||
strerror(errno)};
|
||||
if (rc != 0)
|
||||
lost_information = true;
|
||||
out.append(chunk, out_cursor - chunk);
|
||||
if (in_cursor == nullptr)
|
||||
break;
|
||||
else if (in_left == 0)
|
||||
in_cursor = nullptr;
|
||||
}
|
||||
if (lost_information)
|
||||
return {ot::st::information_lost,
|
||||
"Some characters could not be converted into the target encoding "
|
||||
"in string '" + std::string(in, n) + "'."};
|
||||
return ot::st::ok;
|
||||
}
|
22
t/CMakeLists.txt
Normal file
22
t/CMakeLists.txt
Normal file
@ -0,0 +1,22 @@
|
||||
add_executable(system.t EXCLUDE_FROM_ALL system.cc)
|
||||
target_link_libraries(system.t ot)
|
||||
|
||||
add_executable(opus.t EXCLUDE_FROM_ALL opus.cc)
|
||||
target_link_libraries(opus.t ot)
|
||||
|
||||
add_executable(ogg.t EXCLUDE_FROM_ALL ogg.cc)
|
||||
target_link_libraries(ogg.t ot)
|
||||
|
||||
add_executable(cli.t EXCLUDE_FROM_ALL cli.cc)
|
||||
target_link_libraries(cli.t ot)
|
||||
|
||||
add_executable(oggdump EXCLUDE_FROM_ALL oggdump.cc)
|
||||
target_link_libraries(oggdump ot)
|
||||
|
||||
configure_file(gobble.opus . 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
|
||||
)
|
142
t/cli.cc
Normal file
142
t/cli.cc
Normal file
@ -0,0 +1,142 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
using namespace std::literals::string_literals;
|
||||
|
||||
void check_read_comments()
|
||||
{
|
||||
std::list<std::string> comments;
|
||||
ot::status rc;
|
||||
{
|
||||
std::string txt = "TITLE=a b c\n\nARTIST=X\nArtist=Y\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = ot::read_comments(input.get(), comments);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read comments");
|
||||
auto&& expected = {"TITLE=a b c", "ARTIST=X", "Artist=Y"};
|
||||
if (!std::equal(comments.begin(), comments.end(), expected.begin(), expected.end()))
|
||||
throw failure("parsed user comments did not match expectations");
|
||||
}
|
||||
{
|
||||
std::string txt = "CORRUPTED=\xFF\xFF\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = ot::read_comments(input.get(), comments);
|
||||
if (rc != ot::st::badly_encoded)
|
||||
throw failure("did not get the expected error reading corrupted data");
|
||||
}
|
||||
{
|
||||
std::string txt = "MALFORMED\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = ot::read_comments(input.get(), comments);
|
||||
if (rc != ot::st::error)
|
||||
throw failure("did not get the expected error reading malformed comments");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Wrap #ot::parse_options with a higher-level interface much more convenient for testing.
|
||||
* In practice, the argc/argv combo are enough though for the current state of opustags.
|
||||
*/
|
||||
static ot::status parse_options(const std::vector<const char*>& args, ot::options& opt)
|
||||
{
|
||||
int argc = args.size();
|
||||
char* argv[argc];
|
||||
for (size_t i = 0; i < argc; ++i)
|
||||
argv[i] = strdup(args[i]);
|
||||
ot::status rc = ot::parse_options(argc, argv, opt);
|
||||
for (size_t i = 0; i < argc; ++i)
|
||||
free(argv[i]);
|
||||
return rc;
|
||||
}
|
||||
|
||||
void check_good_arguments()
|
||||
{
|
||||
auto parse = [](std::vector<const char*> args) {
|
||||
ot::options opt;
|
||||
ot::status rc = parse_options(args, opt);
|
||||
if (rc.code != ot::st::ok)
|
||||
throw failure("unexpected option parsing error");
|
||||
return opt;
|
||||
};
|
||||
|
||||
ot::options opt;
|
||||
opt = parse({"opustags", "--help", "x", "-o", "y"});
|
||||
if (!opt.print_help)
|
||||
throw failure("did not catch --help");
|
||||
|
||||
opt = parse({"opustags", "x", "--output", "y", "-D", "-s", "X=Y Z", "-d", "a=b"});
|
||||
if (opt.path_in != "x" || opt.path_out != "y" || !opt.delete_all || opt.overwrite ||
|
||||
opt.to_delete.size() != 2 || opt.to_delete[0] != "X" || opt.to_delete[1] != "a=b" ||
|
||||
opt.to_add.size() != 1 || opt.to_add[0] != "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.path_in != "x" || opt.path_out != "x" || !opt.set_all || !opt.overwrite ||
|
||||
opt.to_delete.size() != 0 || opt.to_add.size() != 1 || opt.to_add[0] != "x=y z")
|
||||
throw failure("unexpected option parsing result for case #2");
|
||||
}
|
||||
|
||||
void check_bad_arguments()
|
||||
{
|
||||
auto error_case = [](std::vector<const char*> args, const char* message, const std::string& name) {
|
||||
ot::options opt;
|
||||
ot::status rc = parse_options(args, opt);
|
||||
if (rc.code != ot::st::bad_arguments)
|
||||
throw failure("bad error code for case " + name);
|
||||
if (rc.message != message)
|
||||
throw failure("bad error message for case " + name + ", got: " + rc.message);
|
||||
};
|
||||
error_case({"opustags"}, "No arguments specified. Use -h for help.", "no arguments");
|
||||
error_case({"opustags", "--output", ""}, "Output file path cannot be empty.", "empty output path");
|
||||
error_case({"opustags", "-a", "X"}, "Comment does not contain an equal sign: X.", "bad comment for -a");
|
||||
error_case({"opustags", "--set", "X"}, "Comment does not contain an equal sign: X.", "bad comment for --set");
|
||||
error_case({"opustags", "-a"}, "Missing value for option '-a'.", "short option with missing value");
|
||||
error_case({"opustags", "--add"}, "Missing value for option '--add'.", "long option with missing value");
|
||||
error_case({"opustags", "-x"}, "Unrecognized option '-x'.", "unrecognized short option");
|
||||
error_case({"opustags", "--derp"}, "Unrecognized option '--derp'.", "unrecognized long option");
|
||||
error_case({"opustags", "-x=y"}, "Unrecognized option '-x'.", "unrecognized short option with value");
|
||||
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", ""}, "Input file path cannot be empty.", "empty input file path");
|
||||
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", "-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");
|
||||
}
|
||||
|
||||
static void check_delete_comments()
|
||||
{
|
||||
using C = std::list<std::string>;
|
||||
C original = {"TITLE=X", "Title=Y", "Title=Z", "ARTIST=A", "artIst=B"};
|
||||
|
||||
C edited = original;
|
||||
ot::delete_comments(edited, "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"};
|
||||
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"};
|
||||
if (!std::equal(edited.begin(), edited.end(), expected.begin(), expected.end()))
|
||||
throw failure("did not delete a specific title correctly");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
std::cout << "1..4\n";
|
||||
run(check_read_comments, "check tags parsing");
|
||||
run(check_good_arguments, "check options parsing");
|
||||
run(check_bad_arguments, "check options parsing errors");
|
||||
run(check_delete_comments, "delete comments");
|
||||
return 0;
|
||||
}
|
BIN
t/gobble.opus
Normal file
BIN
t/gobble.opus
Normal file
Binary file not shown.
170
t/ogg.cc
Normal file
170
t/ogg.cc
Normal file
@ -0,0 +1,170 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static void check_ref_ogg()
|
||||
{
|
||||
ot::file input = fopen("gobble.opus", "r");
|
||||
if (input == nullptr)
|
||||
throw failure("could not open gobble.opus");
|
||||
|
||||
ot::ogg_reader reader(input.get());
|
||||
|
||||
ot::status rc = reader.next_page();
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the first page");
|
||||
if (!ot::is_opus_stream(reader.page))
|
||||
throw failure("failed to identify the stream as opus");
|
||||
rc = reader.process_header_packet([](ogg_packet& p) {
|
||||
if (p.bytes != 19)
|
||||
throw failure("unexpected length for the first packet");
|
||||
return ot::st::ok;
|
||||
});
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the first packet");
|
||||
|
||||
rc = reader.next_page();
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the second page");
|
||||
rc = reader.process_header_packet([](ogg_packet& p) {
|
||||
if (p.bytes != 62)
|
||||
throw failure("unexpected length for the second packet");
|
||||
return ot::st::ok;
|
||||
});
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the second packet");
|
||||
|
||||
while (!ogg_page_eos(&reader.page)) {
|
||||
rc = reader.next_page();
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("failure reading a page");
|
||||
}
|
||||
rc = reader.next_page();
|
||||
if (rc != ot::st::end_of_stream)
|
||||
throw failure("did not correctly detect the end of stream");
|
||||
}
|
||||
|
||||
static ogg_packet make_packet(const char* contents)
|
||||
{
|
||||
ogg_packet op {};
|
||||
op.bytes = strlen(contents);
|
||||
op.packet = (unsigned char*) contents;
|
||||
return op;
|
||||
}
|
||||
|
||||
static bool same_packet(const ogg_packet& lhs, const ogg_packet& rhs)
|
||||
{
|
||||
return lhs.bytes == rhs.bytes && memcmp(lhs.packet, rhs.packet, lhs.bytes) == 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Build an in-memory Ogg stream using ogg_writer, and then read it with ogg_reader.
|
||||
*/
|
||||
static void check_memory_ogg()
|
||||
{
|
||||
ogg_packet first_packet = make_packet("First");
|
||||
ogg_packet second_packet = make_packet("Second");
|
||||
std::vector<unsigned char> my_ogg(128);
|
||||
size_t my_ogg_size;
|
||||
ot::status rc;
|
||||
|
||||
{
|
||||
ot::file output = fmemopen(my_ogg.data(), my_ogg.size(), "w");
|
||||
if (output == nullptr)
|
||||
throw failure("could not open the output stream");
|
||||
ot::ogg_writer writer(output.get());
|
||||
writer.write_header_packet(1234, 0, first_packet);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not write the first packet");
|
||||
writer.write_header_packet(1234, 1, second_packet);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not write the second packet");
|
||||
my_ogg_size = ftell(output.get());
|
||||
if (my_ogg_size != 67)
|
||||
throw failure("unexpected output size");
|
||||
}
|
||||
|
||||
{
|
||||
ot::file input = fmemopen(my_ogg.data(), my_ogg_size, "r");
|
||||
if (input == nullptr)
|
||||
throw failure("could not open the input stream");
|
||||
ot::ogg_reader reader(input.get());
|
||||
rc = reader.next_page();
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the first page");
|
||||
rc = reader.process_header_packet([&first_packet](ogg_packet &p) {
|
||||
if (!same_packet(p, first_packet))
|
||||
throw failure("unexpected content in the first packet");
|
||||
return ot::st::ok;
|
||||
});
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the first packet");
|
||||
rc = reader.next_page();
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the second page");
|
||||
rc = reader.process_header_packet([&second_packet](ogg_packet &p) {
|
||||
if (!same_packet(p, second_packet))
|
||||
throw failure("unexpected content in the second packet");
|
||||
return ot::st::ok;
|
||||
});
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read the second packet");
|
||||
rc = reader.next_page();
|
||||
if (rc != ot::st::end_of_stream)
|
||||
throw failure("unexpected third page");
|
||||
}
|
||||
}
|
||||
|
||||
void check_bad_stream()
|
||||
{
|
||||
auto err_msg = "did not detect the stream is not an ogg stream";
|
||||
ot::file input = fmemopen((void*) err_msg, 20, "r");
|
||||
ot::ogg_reader reader(input.get());
|
||||
ot::status rc = reader.next_page();
|
||||
if (rc != ot::st::bad_stream)
|
||||
throw failure(err_msg);
|
||||
}
|
||||
|
||||
void check_identification()
|
||||
{
|
||||
auto good_header = (unsigned char*)
|
||||
"\x4f\x67\x67\x53\x00\x02\x00\x00\x00\x00\x00\x00\x00\x00\x42\xf2"
|
||||
"\xe6\xc7\x00\x00\x00\x00\x7e\xc3\x57\x2b\x01\x13";
|
||||
auto good_body = (unsigned char*) "OpusHeadABCD";
|
||||
|
||||
ogg_page id;
|
||||
id.header = good_header;
|
||||
id.header_len = 28;
|
||||
id.body = good_body;
|
||||
id.body_len = 12;
|
||||
if (!ot::is_opus_stream(id))
|
||||
throw failure("could not identify opus header");
|
||||
|
||||
// Bad body
|
||||
id.body_len = 7;
|
||||
if (ot::is_opus_stream(id))
|
||||
throw failure("opus header was too short to be valid");
|
||||
id.body_len = 12;
|
||||
id.body = (unsigned char*) "Not_OpusHead";
|
||||
if (ot::is_opus_stream(id))
|
||||
throw failure("was not an opus header");
|
||||
id.body = good_body;
|
||||
|
||||
// Remove the BoS bit from the header.
|
||||
id.header = (unsigned char*)
|
||||
"\x4f\x67\x67\x53\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x42\xf2"
|
||||
"\xe6\xc7\x00\x00\x00\x00\x7e\xc3\x57\x2b\x01\x13";
|
||||
if (ot::is_opus_stream(id))
|
||||
throw failure("was not the beginning of a stream");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
std::cout << "1..4\n";
|
||||
run(check_ref_ogg, "check a reference ogg stream");
|
||||
run(check_memory_ogg, "build and check a fresh stream");
|
||||
run(check_bad_stream, "read a non-ogg stream");
|
||||
run(check_identification, "stream identification");
|
||||
return 0;
|
||||
}
|
42
t/oggdump.cc
Normal file
42
t/oggdump.cc
Normal file
@ -0,0 +1,42 @@
|
||||
/**
|
||||
* \file t/oggdump.cc
|
||||
*
|
||||
* Dump brief information about the pages containted in an Ogg file.
|
||||
*
|
||||
* This tool is not build by default or installed, and is mainly meant to help understand how Ogg
|
||||
* files are built, and to debug.
|
||||
*/
|
||||
|
||||
#include <opustags.h>
|
||||
|
||||
#include <iostream>
|
||||
#include <string.h>
|
||||
|
||||
int main(int argc, char** argv)
|
||||
{
|
||||
if (argc != 2) {
|
||||
std::cerr << "Usage: oggdump FILE\n";
|
||||
return 1;
|
||||
}
|
||||
ot::file input = fopen(argv[1], "r");
|
||||
if (input == nullptr) {
|
||||
std::cerr << "Error opening '" << argv[1] << "': " << strerror(errno) << "\n";
|
||||
return 1;
|
||||
}
|
||||
ot::ogg_reader reader(input.get());
|
||||
ot::status rc;
|
||||
while ((rc = reader.read_page()) == ot::st::ok) {
|
||||
std::cout << "Stream " << ogg_page_serialno(&reader.page) << ", "
|
||||
"page #" << ogg_page_pageno(&reader.page) << ", "
|
||||
<< ogg_page_packets(&reader.page) << " packet(s)";
|
||||
if (ogg_page_bos(&reader.page)) std::cout << ", BoS";
|
||||
if (ogg_page_eos(&reader.page)) std::cout << ", EoS";
|
||||
if (ogg_page_continued(&reader.page)) std::cout << ", continued";
|
||||
std::cout << "\n";
|
||||
}
|
||||
if (rc != ot::st::ok && rc != ot::st::end_of_stream) {
|
||||
std::cerr << "error: " << rc.message << "\n";
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
147
t/opus.cc
Normal file
147
t/opus.cc
Normal file
@ -0,0 +1,147 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
using namespace std::literals::string_literals;
|
||||
|
||||
static const char standard_OpusTags[] =
|
||||
"OpusTags"
|
||||
"\x14\x00\x00\x00" "opustags test packet"
|
||||
"\x02\x00\x00\x00"
|
||||
"\x09\x00\x00\x00" "TITLE=Foo"
|
||||
"\x0a\x00\x00\x00" "ARTIST=Bar";
|
||||
|
||||
static void parse_standard()
|
||||
{
|
||||
ot::opus_tags tags;
|
||||
ogg_packet op;
|
||||
op.bytes = sizeof(standard_OpusTags) - 1;
|
||||
op.packet = (unsigned char*) standard_OpusTags;
|
||||
auto rc = ot::parse_tags(op, tags);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("ot::parse_tags did not return ok");
|
||||
if (tags.vendor != "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")
|
||||
throw failure("bad title");
|
||||
++it;
|
||||
if (*it != "ARTIST=Bar")
|
||||
throw failure("bad artist");
|
||||
if (tags.extra_data.size() != 0)
|
||||
throw failure("found mysterious padding data");
|
||||
}
|
||||
|
||||
/**
|
||||
* Try parse_tags with packets that should not valid, or that might even
|
||||
* corrupt the memory. Run this one with valgrind to ensure we're not
|
||||
* overflowing.
|
||||
*/
|
||||
static void parse_corrupted()
|
||||
{
|
||||
size_t size = sizeof(standard_OpusTags);
|
||||
char packet[size];
|
||||
memcpy(packet, standard_OpusTags, size);
|
||||
ot::opus_tags tags;
|
||||
ogg_packet op;
|
||||
op.packet = (unsigned char*) packet;
|
||||
op.bytes = size;
|
||||
|
||||
char* header_data = packet;
|
||||
char* vendor_length = header_data + 8;
|
||||
char* vendor_string = vendor_length + 4;
|
||||
char* comment_count = vendor_string + *vendor_length;
|
||||
char* first_comment_length = comment_count + 4;
|
||||
char* first_comment_data = first_comment_length + 4;
|
||||
char* end = packet + size;
|
||||
|
||||
op.bytes = 7;
|
||||
if (ot::parse_tags(op, tags) != ot::st::cut_magic_number)
|
||||
throw failure("did not detect the overflowing magic number");
|
||||
op.bytes = 11;
|
||||
if (ot::parse_tags(op, tags) != ot::st::cut_vendor_length)
|
||||
throw failure("did not detect the overflowing vendor string length");
|
||||
op.bytes = size;
|
||||
|
||||
header_data[0] = 'o';
|
||||
if (ot::parse_tags(op, tags) != ot::st::bad_magic_number)
|
||||
throw failure("did not detect the bad magic number");
|
||||
header_data[0] = 'O';
|
||||
|
||||
*vendor_length = end - vendor_string + 1;
|
||||
if (ot::parse_tags(op, tags) != ot::st::cut_vendor_data)
|
||||
throw failure("did not detect the overflowing vendor string");
|
||||
*vendor_length = end - vendor_string - 3;
|
||||
if (ot::parse_tags(op, tags) != ot::st::cut_comment_count)
|
||||
throw failure("did not detect the overflowing comment count");
|
||||
*vendor_length = comment_count - vendor_string;
|
||||
|
||||
++*comment_count;
|
||||
if (ot::parse_tags(op, tags) != ot::st::cut_comment_length)
|
||||
throw failure("did not detect the overflowing comment length");
|
||||
*first_comment_length = end - first_comment_data + 1;
|
||||
if (ot::parse_tags(op, tags) != ot::st::cut_comment_data)
|
||||
throw failure("did not detect the overflowing comment data");
|
||||
}
|
||||
|
||||
static void recode_standard()
|
||||
{
|
||||
ot::opus_tags tags;
|
||||
ogg_packet op;
|
||||
op.bytes = sizeof(standard_OpusTags) - 1;
|
||||
op.packet = (unsigned char*) standard_OpusTags;
|
||||
auto rc = ot::parse_tags(op, tags);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("ot::parse_tags did not return ok");
|
||||
auto packet = ot::render_tags(tags);
|
||||
if (packet.b_o_s != 0)
|
||||
throw failure("b_o_s should not be set");
|
||||
if (packet.e_o_s != 0)
|
||||
throw failure("e_o_s should not be set");
|
||||
if (packet.granulepos != 0)
|
||||
throw failure("granule_post should be 0");
|
||||
if (packet.packetno != 1)
|
||||
throw failure("packetno should be 1");
|
||||
if (packet.bytes != sizeof(standard_OpusTags) - 1)
|
||||
throw failure("the packet is not the right size");
|
||||
if (memcmp(packet.packet, standard_OpusTags, packet.bytes) != 0)
|
||||
throw failure("the rendered packet is not what we expected");
|
||||
}
|
||||
|
||||
static void recode_padding()
|
||||
{
|
||||
ot::opus_tags tags;
|
||||
std::string padded_OpusTags(standard_OpusTags, sizeof(standard_OpusTags));
|
||||
// ^ note: padded_OpusTags ends with a null byte here
|
||||
padded_OpusTags += "hello";
|
||||
ogg_packet op;
|
||||
op.bytes = padded_OpusTags.size();
|
||||
op.packet = (unsigned char*) padded_OpusTags.data();
|
||||
|
||||
auto rc = ot::parse_tags(op, tags);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("ot::parse_tags did not return ok");
|
||||
if (tags.extra_data != "\0hello"s)
|
||||
throw failure("corrupted extra data");
|
||||
// recode the packet and ensure it's exactly the same
|
||||
auto packet = ot::render_tags(tags);
|
||||
if (static_cast<size_t>(packet.bytes) < padded_OpusTags.size())
|
||||
throw failure("the packet was truncated");
|
||||
if (static_cast<size_t>(packet.bytes) > padded_OpusTags.size())
|
||||
throw failure("the packet got too big");
|
||||
if (memcmp(packet.packet, padded_OpusTags.data(), packet.bytes) != 0)
|
||||
throw failure("the rendered packet is not what we expected");
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
std::cout << "1..4\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");
|
||||
return 0;
|
||||
}
|
254
t/opustags.t
Executable file
254
t/opustags.t
Executable file
@ -0,0 +1,254 @@
|
||||
#!/usr/bin/env perl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use utf8;
|
||||
|
||||
use Test::More tests => 34;
|
||||
|
||||
use Digest::MD5;
|
||||
use File::Basename;
|
||||
use IPC::Open3;
|
||||
use List::MoreUtils qw(any);
|
||||
use Symbol 'gensym';
|
||||
|
||||
my $opustags = '../opustags';
|
||||
BAIL_OUT("$opustags does not exist or is not executable") if (! -x $opustags);
|
||||
|
||||
my $is_utf8;
|
||||
open(my $ctype, 'locale -k LC_CTYPE |');
|
||||
while (<$ctype>) { $is_utf8 = 1 if (/^charmap="UTF-?8"$/i) }
|
||||
close($ctype);
|
||||
BAIL_OUT("this test must be run from an UTF-8 environment") unless $is_utf8;
|
||||
|
||||
sub opustags {
|
||||
my %opt;
|
||||
%opt = %{pop @_} if ref $_[-1];
|
||||
my ($pid, $pin, $pout, $perr);
|
||||
$perr = gensym;
|
||||
$pid = open3($pin, $pout, $perr, $opustags, @_);
|
||||
binmode($pin, $opt{mode} // ':utf8');
|
||||
binmode($pout, $opt{mode} // ':utf8');
|
||||
binmode($perr, ':utf8');
|
||||
local $/;
|
||||
print $pin $opt{in} if defined $opt{in};
|
||||
close $pin;
|
||||
my $out = <$pout>;
|
||||
my $err = <$perr>;
|
||||
waitpid($pid, 0);
|
||||
[$out, $err, $?]
|
||||
}
|
||||
|
||||
####################################################################################################
|
||||
# Tests related to the overall opustags executable, like the help message.
|
||||
# No Opus file is manipulated here.
|
||||
|
||||
is_deeply(opustags(), ['', <<EOF, 256], 'no options is a failure');
|
||||
error: No arguments specified. Use -h for help.
|
||||
EOF
|
||||
|
||||
my $help = opustags('--help');
|
||||
$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 FILE -o FILE
|
||||
|
||||
Options:
|
||||
-h, --help print this help
|
||||
-o, --output FILE specify the output file
|
||||
-i, --in-place overwrite the input file
|
||||
-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
|
||||
|
||||
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');
|
||||
|
||||
is_deeply(opustags('--derp'), ['', <<"EOF", 256], 'unrecognized option shows an error');
|
||||
error: Unrecognized option '--derp'.
|
||||
EOF
|
||||
|
||||
is_deeply(opustags('../opustags'), ['', <<"EOF", 256], 'not an Ogg stream');
|
||||
error: Input is not a valid Ogg file.
|
||||
EOF
|
||||
|
||||
####################################################################################################
|
||||
# Test the main features of opustags on an Ogg Opus sample file.
|
||||
|
||||
sub md5 {
|
||||
my ($file) = @_;
|
||||
open(my $fh, '<', $file) or return;
|
||||
my $ctx = Digest::MD5->new;
|
||||
$ctx->addfile($fh);
|
||||
$ctx->hexdigest
|
||||
}
|
||||
|
||||
is(md5('gobble.opus'), '111a483596ac32352fbce4d14d16abd2', 'the sample is the one we expect');
|
||||
is_deeply(opustags('gobble.opus'), [<<'EOF', '', 0], 'read the initial tags');
|
||||
encoder=Lavc58.18.100 libopus
|
||||
EOF
|
||||
|
||||
unlink('out.opus');
|
||||
is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', '', 0], 'copy the file without changes');
|
||||
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'the copy is faithful');
|
||||
|
||||
# empty out.opus
|
||||
{ my $fh; open($fh, '>', 'out.opus') and close($fh) or die }
|
||||
is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', <<'EOF', 256], 'refuse to override');
|
||||
error: 'out.opus' already exists. Use -y to overwrite.
|
||||
EOF
|
||||
is(md5('out.opus'), 'd41d8cd98f00b204e9800998ecf8427e', 'the output wasn\'t written');
|
||||
|
||||
is_deeply(opustags(qw(gobble.opus -o /dev/null)), ['', '', 0], 'write to /dev/null');
|
||||
|
||||
is_deeply(opustags(qw(gobble.opus -o out.opus --overwrite)), ['', '', 0], 'overwrite');
|
||||
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'successfully overwritten');
|
||||
|
||||
is_deeply(opustags(qw(--in-place out.opus -a A=B --add=A=C --add), "TITLE=Foo Bar",
|
||||
qw(--delete A --add TITLE=七面鳥 --set encoder=whatever -s 1=2 -s X=1 -a X=2 -s X=3)),
|
||||
['', '', 0], 'complex tag editing');
|
||||
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'check the footprint');
|
||||
|
||||
is_deeply(opustags('out.opus'), [<<'EOF', '', 0], 'check the tags written');
|
||||
A=B
|
||||
A=C
|
||||
TITLE=Foo Bar
|
||||
TITLE=七面鳥
|
||||
encoder=whatever
|
||||
1=2
|
||||
X=1
|
||||
X=2
|
||||
X=3
|
||||
EOF
|
||||
|
||||
is_deeply(opustags(qw(out.opus -d A -d foo -s X=4 -a TITLE=gobble -d title=七面鳥)), [<<'EOF', '', 0], 'dry editing');
|
||||
TITLE=Foo Bar
|
||||
encoder=whatever
|
||||
1=2
|
||||
X=4
|
||||
TITLE=gobble
|
||||
EOF
|
||||
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
|
||||
|
||||
is_deeply(opustags(qw(-i out.opus -a fatal=yes -a FOO -a BAR)), ['', <<'EOF', 256], 'bad tag with --add');
|
||||
error: Comment does not contain an equal sign: FOO.
|
||||
EOF
|
||||
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
|
||||
|
||||
is_deeply(opustags('out.opus', '-D', '-a', "X=foo\nbar\tquux"), [<<'END_OUT', <<'END_ERR', 0], 'control characters');
|
||||
X=foo
|
||||
bar quux
|
||||
END_OUT
|
||||
warning: Some tags contain newline characters. These are not supported by --set-all.
|
||||
warning: Some tags contain control characters.
|
||||
END_ERR
|
||||
|
||||
is_deeply(opustags(qw(-i out.opus -s fatal=yes -s FOO -s BAR)), ['', <<'EOF', 256], 'bad tag with --set');
|
||||
error: Comment does not contain an equal sign: FOO.
|
||||
EOF
|
||||
is(md5('out.opus'), '66780307a6081523dc9040f3c47b0448', 'the file did not change');
|
||||
|
||||
is_deeply(opustags(qw(out.opus --delete-all -a OK=yes)), [<<'EOF', '', 0], 'delete all');
|
||||
OK=yes
|
||||
EOF
|
||||
|
||||
is_deeply(opustags(qw(out.opus --set-all -a A=B -s X=Z -d OK), {in => <<'END_IN'}), [<<'END_OUT', '', 0], 'set all');
|
||||
OK=yes again
|
||||
ARTIST=七面鳥
|
||||
|
||||
A=A
|
||||
X=Y
|
||||
END_IN
|
||||
OK=yes again
|
||||
ARTIST=七面鳥
|
||||
A=A
|
||||
X=Y
|
||||
A=B
|
||||
X=Z
|
||||
END_OUT
|
||||
|
||||
is_deeply(opustags(qw(out.opus -S), {in => <<'END_IN'}), [<<'END_OUT', <<'END_ERR', 256], 'set all with bad tags');
|
||||
whatever
|
||||
wrong=yes
|
||||
END_IN
|
||||
END_OUT
|
||||
error: Malformed tag: whatever
|
||||
END_ERR
|
||||
|
||||
sub slurp {
|
||||
my ($filename) = @_;
|
||||
local $/;
|
||||
open(my $fh, '<', $filename);
|
||||
binmode($fh);
|
||||
my $data = <$fh>;
|
||||
$data
|
||||
}
|
||||
|
||||
my $data = slurp 'out.opus';
|
||||
is_deeply(opustags('-', '-o', '-', {in => $data, mode => ':raw'}), [$data, '', 0], 'read opus from stdin and write to stdout');
|
||||
|
||||
unlink('out.opus');
|
||||
|
||||
####################################################################################################
|
||||
# Test muxed streams
|
||||
|
||||
system('ffmpeg -loglevel error -y -i gobble.opus -c copy -map 0:0 -map 0:0 -shortest muxed.ogg') == 0
|
||||
or BAIL_OUT('could not create a muxed stream');
|
||||
|
||||
is_deeply(opustags('muxed.ogg'), ['', <<'END_ERR', 256], 'muxed streams detection');
|
||||
error: Muxed streams are not supported yet.
|
||||
END_ERR
|
||||
|
||||
unlink('muxed.ogg');
|
||||
|
||||
####################################################################################################
|
||||
# Locale
|
||||
|
||||
my $locale = 'fr_FR.iso88591';
|
||||
my @all_locales = split(' ', `locale -a`);
|
||||
|
||||
SKIP: {
|
||||
skip "locale $locale is not present", 4 unless (any { $_ eq $locale } @all_locales);
|
||||
|
||||
opustags(qw(gobble.opus -a TITLE=七面鳥 -a ARTIST=éàç -o out.opus -y));
|
||||
|
||||
local $ENV{LC_ALL} = $locale;
|
||||
|
||||
is_deeply(opustags(qw(-S out.opus), {in => <<"END_IN", mode => ':raw'}), [<<"END_OUT", '', 0], 'set all in ISO-8859-1');
|
||||
T=\xef\xef\xf6
|
||||
END_IN
|
||||
T=\xef\xef\xf6
|
||||
END_OUT
|
||||
|
||||
is_deeply(opustags('-i', 'out.opus', "--add=I=\xf9\xce", {mode => ':raw'}), ['', '', 0], 'write tags in ISO-8859-1');
|
||||
|
||||
is_deeply(opustags('out.opus', {mode => ':raw'}), [<<"END_OUT", <<'END_ERR', 0], 'read tags in ISO-8859-1');
|
||||
encoder=Lavc58.18.100 libopus
|
||||
TITLE=???
|
||||
ARTIST=\xe9\xe0\xe7
|
||||
I=\xf9\xce
|
||||
END_OUT
|
||||
warning: Some tags have been transliterated to your system encoding.
|
||||
END_ERR
|
||||
|
||||
$ENV{LC_ALL} = '';
|
||||
|
||||
is_deeply(opustags('out.opus'), [<<"END_OUT", '', 0], 'read tags in UTF-8');
|
||||
encoder=Lavc58.18.100 libopus
|
||||
TITLE=七面鳥
|
||||
ARTIST=éàç
|
||||
I=ùÎ
|
||||
END_OUT
|
||||
}
|
58
t/system.cc
Normal file
58
t/system.cc
Normal file
@ -0,0 +1,58 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
#include <string.h>
|
||||
#include <unistd.h>
|
||||
|
||||
void check_partial_files()
|
||||
{
|
||||
static const char* result = "partial_file.test";
|
||||
std::string name;
|
||||
{
|
||||
ot::partial_file bad_tmp;
|
||||
is(bad_tmp.open("/dev/null"), ot::st::standard_error,
|
||||
"opening a device as a partial file fails");
|
||||
is(bad_tmp.open(result), ot::st::ok,
|
||||
"opening a regular partial file works");
|
||||
name = bad_tmp.name();
|
||||
if (name.size() != strlen(result) + 12 ||
|
||||
name.compare(0, strlen(result), result) != 0)
|
||||
throw failure("the temporary name is surprising: " + name);
|
||||
}
|
||||
is(access(name.c_str(), F_OK), -1, "expect the temporary file is deleted");
|
||||
|
||||
ot::partial_file good_tmp;
|
||||
is(good_tmp.open(result), ot::st::ok, "open the partial file");
|
||||
name = good_tmp.name();
|
||||
is(good_tmp.commit(), ot::st::ok, "commit the result file");
|
||||
is(access(name.c_str(), F_OK), -1, "expect the temporary file is deleted");
|
||||
is(access(result, F_OK), 0, "expect the final result file");
|
||||
is(remove(result), 0, "remove the result 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//TRANSLIT");
|
||||
std::string out;
|
||||
|
||||
ot::status rc = to_utf8(ephemere_iso, out);
|
||||
is(rc, ot::st::ok, "conversion to UTF-8 is successful");
|
||||
is(out, "Éphémère", "conversion to UTF-8 is correct");
|
||||
|
||||
rc = from_utf8("Éphémère", out);
|
||||
is(rc, ot::st::ok, "conversion from UTF-8 is successful");
|
||||
is(out, ephemere_iso, "conversion from UTF-8 is correct");
|
||||
|
||||
rc = from_utf8("\xFF\xFF", out);
|
||||
is(rc, ot::st::badly_encoded, "conversion from bad UTF-8 fails");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
plan(2);
|
||||
run(check_partial_files, "test partial files");
|
||||
run(check_converter, "test encoding converter");
|
||||
return 0;
|
||||
}
|
64
t/tap.h
Normal file
64
t/tap.h
Normal file
@ -0,0 +1,64 @@
|
||||
/**
|
||||
* \file t/tap.h
|
||||
*
|
||||
* \brief
|
||||
* Helpers for following the Test Anything Protocol.
|
||||
*
|
||||
* Its interface mimics Test::More from Perl:
|
||||
* https://perldoc.perl.org/Test/More.html
|
||||
*
|
||||
* Unlike Test::More, a test failure raises an exception and aborts the whole subtest.
|
||||
*/
|
||||
|
||||
#pragma once
|
||||
|
||||
#include <exception>
|
||||
#include <iostream>
|
||||
|
||||
inline namespace tap {
|
||||
|
||||
struct failure : std::runtime_error {
|
||||
failure(const std::string& what) : std::runtime_error(what) {}
|
||||
};
|
||||
|
||||
template <typename F>
|
||||
static void run(F test, const char *name)
|
||||
{
|
||||
bool ok = false;
|
||||
try {
|
||||
test();
|
||||
ok = true;
|
||||
} catch (failure& e) {
|
||||
std::cerr << "# fail: " << e.what() << "\n";
|
||||
}
|
||||
std::cout << (ok ? "ok" : "not ok") << " - " << name << "\n";
|
||||
}
|
||||
|
||||
void plan(int tests)
|
||||
{
|
||||
std::cout << "1.." << tests << "\n";
|
||||
}
|
||||
|
||||
template <typename T, typename U>
|
||||
void is(const T& got, const U& expected, const char* name)
|
||||
{
|
||||
if (got != expected) {
|
||||
std::cerr << "# got: " << got << "\n"
|
||||
"# expected: " << expected << "\n";
|
||||
throw failure(name);
|
||||
}
|
||||
}
|
||||
|
||||
template <>
|
||||
void is(const ot::status& got, const ot::st& expected, const char* name)
|
||||
{
|
||||
if (got.code != expected) {
|
||||
if (got.code == ot::st::ok)
|
||||
std::cerr << "# unexpected success\n";
|
||||
else
|
||||
std::cerr << "# unexpected error: " << got.message << "\n";
|
||||
throw failure(name);
|
||||
}
|
||||
}
|
||||
|
||||
}
|
Reference in New Issue
Block a user