102 Commits
1.4.0 ... next

Author SHA1 Message Date
2ef9a825da warn when a handler didn't complete its job
issue #16
for some reason I couldn't get it to work
it looked like the options weren't properly parsed
2016-05-02 18:09:14 +02:00
4de88d0ed2 include strerror(errno) in error messages
issue #15
2016-05-02 18:09:02 +02:00
0624376fcc tags handler: signal start_of_stream and end_of_file
end_of_file is the old end_of_stream

issue #6
2016-04-08 16:15:28 +02:00
52e4a8ca58 don't warn about unused parameters
it gets annoying, and dropping the names of the parameters in the prototypes
sounds like a terrible solution
2016-04-08 16:14:31 +02:00
dd0656cb07 assign sequence numbers to unknown streams too
issue #14
2016-04-08 16:02:40 +02:00
d3b4a389bc drop mentions of any kind of “smart reordering”
that's the case when the user specifies a --set before a --delete
this shouldn't happen, so we might as well not specify it I think

issue #5
2016-04-07 13:29:22 +02:00
410708e252 list_tags: always call end_of_stream when done
related to issue #6
2016-04-07 13:29:13 +02:00
dad987a8da list_tags: add a test to ensure full scan works
it'd require some effort to craft a file where this option matters, but at
least we want to make sure the full scan code is run once so we know it doesn't
crash

close #11
2016-04-06 10:07:41 +02:00
76bf95a74c main: do use options.full when calling list_tags 2016-04-06 10:05:53 +02:00
2d7f812119 harmonize header inclusion style
close #7
I don't intend this patch as a piece of art, but it carries the main idea
2016-04-06 10:02:47 +02:00
1ff2553f5f assign sequential numbers to streams
see issue #14

I remain unsure of whether we should include unknown streams in the sequence
in a distant future, if we extend the tool to support more stream types, the
numbers would change to include the newly supported streams in the sequence
2016-03-30 17:38:55 +02:00
eb968dc513 list_tags: stop reading after the headers
see #11
remains the command-line to adjust
2016-03-30 17:37:56 +02:00
a21331057b ogg: emit HEADER_READY on unknown streams too 2016-03-30 17:37:56 +02:00
5ae31c9bc9 fix typo in man page 2016-03-30 17:37:56 +02:00
rr-
2957fa3538 Update README to match current install procedure 2016-03-17 08:04:21 +01:00
rr-
74b9cade48 Update README to match current CLI state 2016-03-17 08:03:06 +01:00
rr-
8e9204420b Handlers: removing nonexistent tag is not an error 2016-03-17 08:00:45 +01:00
rr-
7b616aa671 Fix brace style
Breaking habits is difficult
2016-03-17 07:57:36 +01:00
rr-
159340926a Make --list work also when editing the streams 2016-03-17 07:53:19 +01:00
rr-
e1d954388e Add --list by default (closes #9) 2016-03-17 07:52:58 +01:00
rr-
d48573ceef Add preliminary main() implementation 2016-03-17 07:43:42 +01:00
rr-
d8dcc38777 Options: implement --stream=1,2 (closes #8) 2016-03-16 20:08:56 +01:00
rr-
7d20fc70b2 Tests: fix warnings about unused parameter 2016-03-16 20:01:39 +01:00
rr-
a210d1229e Handlers: implement import tags handler 2016-03-16 20:00:28 +01:00
rr-
e60f7f84a0 Fix non deterministic argument parsing 2016-03-16 20:00:27 +01:00
rr-
a3daa0f108 Handlers: implement export tags handler 2016-03-16 20:00:27 +01:00
rr-
84a8d14ae0 Tags: add ability to compare tags 2016-03-16 20:00:27 +01:00
rr-
74904fd516 Tags: make constructible with initializer lists 2016-03-16 19:10:30 +01:00
rr-
fc2a4cb41c Remove old C code
...which is available on master anyway
2016-03-16 17:53:33 +01:00
rr-
1c2232c197 Build: remove old Makefile 2016-03-16 17:53:10 +01:00
rr-
f26de884aa Build: make installation targets
Usage:

    cd build
    cmake ..
    make
    sudo make install
2016-03-16 17:53:10 +01:00
rr-
0d91429435 Change path to auto generated version.h
This is to make it possible to zip just the src/ directory if one wishes
not to use git.
2016-03-16 17:34:50 +01:00
rr-
54571e8bc3 Add git-based version (closes #12) 2016-03-16 17:32:59 +01:00
rr-
a06f337a63 Options/handlers: add stubs regarding new manpage 2016-03-16 13:48:27 +01:00
rr-
b5d2e03a7b Options: require output path to be non empty 2016-03-16 13:08:02 +01:00
rr-
f726eaeb91 Options: throw an error for extra arguments 2016-03-16 13:05:00 +01:00
rr-
8f5a6bb534 Options: parse input path 2016-03-16 13:02:21 +01:00
rr-
7f7766f175 Options: change in_place to contain bool
This should minify conditional statements to just one, after finishing
work, whether to move the path_out back to the original file.
2016-03-16 12:31:43 +01:00
be3984423f man: reorganize + add a few options
including --version, --full, --export, --list, --no-color
2016-03-09 16:35:33 +01:00
2443490b0b update the man page to reflect the new expectations 2016-03-07 17:16:09 +01:00
a96cc9c222 allow inserting already present tags 2016-03-04 15:23:07 +01:00
cd550d8d80 move single tag parsing outside of Tags class 2016-03-04 15:21:05 +01:00
4cd0e34d0d ogg: fix wrapping loop issue for comment_count
since the number of remaining bytes keeps decreasing, it wouldn't cause
infinite looping, but it would allow an indefinite number of comments (more
than 2^32)
2016-03-04 15:02:17 +01:00
0e2b1fec9c tests: make style uniform 2016-03-04 14:50:48 +01:00
817ba5cba6 actions: fix hardcoded serialno in test
I had originally written the tests with a different sample
2016-03-03 21:19:24 +01:00
20663a847f testing actions!
a tough one
2016-03-03 21:14:46 +01:00
40bbc90786 ogg: check the validity of streams 2016-03-03 21:14:46 +01:00
d21517de94 ogg: test a malicious tags packet 2016-03-03 21:14:46 +01:00
a78906a8d4 specify TagsHandler::end_of_stream 2016-03-03 21:14:36 +01:00
rr-
32c5e2b0a6 Tags: make keys case insensitive 2016-03-03 12:30:56 +01:00
rr-
449235ed5a Tests: add missing sample 2016-03-03 12:30:03 +01:00
rr-
661c469bd6 Tags: implement multi values, change .set to .add 2016-03-03 12:30:03 +01:00
7f984e1492 ogg: test multi-stream Ogg files
the sample is generated with this command:
ffmpeg -i mystery.ogg -i beep.ogg -c copy -map 0:0 -map 1:0 -map 0:0 -shortest mystery-beep.ogg

it should be added into the CMakeLists.txt
2016-03-02 16:08:13 +01:00
4f1070f272 ogg: checked the packetno of the OpusTags packet 2016-03-02 15:53:21 +01:00
b4dc544031 ogg: test decoding garbage 2016-03-02 15:35:36 +01:00
5e19657d25 test: tags keys should be case insensitive 2016-03-02 15:20:15 +01:00
898846f1f4 ogg: test up to tags decoding
and it works!
2016-03-02 15:20:05 +01:00
c19233236a ogg: flush packets before paging in 2016-03-02 15:15:13 +01:00
84a0ce55af proposition of logger 2016-03-02 14:44:04 +01:00
95ddd2e7da some more tests for Tags
it needs to be a multimap
2016-03-02 14:33:45 +01:00
9fd629bcc3 ogg: wrong reference magic number
a fairy told me as I was commuting
2016-03-02 09:27:33 +01:00
53fbf533fb ogg: fix memory issue using shared pointers 2016-03-01 16:24:47 +01:00
0e3dfbe381 forbid copy of ogg::Stream and ogg::Decoder
realize they cause serious memory issues
now adding a Stream to the vector doesn't typecheck…
2016-03-01 16:03:11 +01:00
8364667b4c at least call the new opustags 2.x 2016-03-01 15:52:10 +01:00
4d9ee3bf88 ogg decoding test, along with an opus sample 2016-03-01 15:49:04 +01:00
f941464c61 ogg: Decoder and Encoder to use a reference rather than a pointer 2016-03-01 15:01:24 +01:00
f469d76e62 untested tags rendering routine draft 2016-03-01 14:37:09 +01:00
acbf99d276 first untested implementation of tags parsing 2016-03-01 14:08:27 +01:00
fe7f23576a ogg: parse magic number 2016-03-01 13:29:01 +01:00
rr-
c101041bd7 StreamTagsHandler: fix done() for ALL_STREAMS 2016-02-25 10:06:49 +01:00
rr-
e5e7952b89 Improve CompositeTagsHandler::done() 2016-02-25 10:03:58 +01:00
rr-
221c314625 ModificationTagsHandler: small optimization 2016-02-24 23:13:16 +01:00
rr-
7b6ad95b35 Refactor Options to use TagsHandlers; add --stream 2016-02-24 22:14:21 +01:00
rr-
24c2dae49e Add ability to target any stream in TagsHandlers 2016-02-24 22:14:15 +01:00
rr-
02c966bacb Add getters to TagsHandlers for most things 2016-02-24 21:55:48 +01:00
rr-
4f5d33491b RemovalTagsHandler: add ability to delete all tags 2016-02-24 21:38:24 +01:00
rr-
d9f123c84c Add ListingTagsHandler 2016-02-24 21:25:59 +01:00
rr-
a67f8c1472 Change Tags to preserve order of insertion 2016-02-24 21:25:59 +01:00
rr-
d7b187ec59 Remove unused #include 2016-02-24 20:48:26 +01:00
rr-
fca4f81eac Add ModificationTagsHandler 2016-02-24 20:32:03 +01:00
rr-
b362f6565f Add RemovalTagsHandler 2016-02-24 20:18:10 +01:00
rr-
6832cbf3f5 Add InsertionTagsHandler + a place for errors 2016-02-24 20:13:38 +01:00
rr-
2ee7702b9d Add StreamTagsHandler 2016-02-24 20:04:24 +01:00
rr-
aeca60d128 Add CompositeTagsHandler 2016-02-24 20:03:31 +01:00
rr-
1d8d96cdee Use K&R brace style for SECTION macro in tests 2016-02-24 20:03:31 +01:00
rr-
094bed2b35 Make Tags just an string<->string map for now 2016-02-24 19:43:11 +01:00
rr-
b62ac98ca5 Move TagsHandler to own file, make an interface 2016-02-24 19:42:45 +01:00
12327e6f68 style fixes
missing newlines
nullptr instead of NULL
bracket tweak
2016-02-24 18:15:39 +01:00
rr-
326c164e09 Restyle to K&R 2016-02-24 18:10:03 +01:00
a84aeaea96 implement most of ogg::Encoder 2016-02-23 14:23:27 +01:00
8d1ea8d32d implement ogg::Decoder 2016-02-23 13:26:22 +01:00
ff17d35531 partial implementation of ogg::Stream 2016-02-23 11:21:00 +01:00
c2d6763e2b implementation of list_tags and edit_tags (actions) 2016-02-23 10:41:27 +01:00
6baf120a94 more doc in actions.h and ogg.h
example uses
brief description of the states
2016-02-23 10:41:27 +01:00
f1109dd04f specify the expected behavior of the main loops
in particular, the TagsHandler interface
also, rename the Reader/Writer classes to Decoder/Encoder for clarity
2016-02-22 18:32:54 +01:00
390c9268a7 use references to ogg_page instead of pointers 2016-02-20 13:55:25 +01:00
a8a6552f29 sample main loops for ogg manipulation
linking now breaks because the ogg module isn't implemented
2016-02-20 13:35:08 +01:00
e198b5f9ef first design of the Ogg decoder and encoder 2016-02-20 13:35:08 +01:00
rr-
e474ec8420 Fix libogg linkage 2016-02-20 13:35:08 +01:00
rr-
15c9f187a5 Remove dummy plugs 2016-02-20 13:35:08 +01:00
rr-
3a320a7b39 Add basic option parsing + tests 2016-02-20 13:35:08 +01:00
rr-
5cdcb5457f Add CMake; prepare basic structure
- Added Catch (downloaded automatically by cmake) for unit tests
- Added basic tests/ and src/ directories
- Added basic unit test example. Any changes to files other than main.cc
  in src/ will recompile the relevant tests as needed
- Added CMakeLists that compiles the project using cmake
- Moved the man page to docs/
- Moved old source file to src/
- Since libogg doesn't support cmake based builds, I've added also a
  module in CMakeModules that searches for it, but I haven't tested if
  it works yet...
- Haven't really touched Makefile - I've changed it only to refer to the
  new file locations
2016-02-20 13:35:08 +01:00
72 changed files with 3091 additions and 2557 deletions

2
.gitignore vendored
View File

@ -1 +1,3 @@
/build
tests/catch.h
src/version.h

View File

@ -1,55 +0,0 @@
opustags changelog
==================
1.4.0 - 2020-10-04
------------------
- Preserve permissions when overwriting files.
- Support multiple files with --in-place.
- Fix BSD support.
Thanks to Reuben Thomas for contributing the pièce de résistance of this
release!
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.

View File

@ -1,47 +1,78 @@
cmake_minimum_required(VERSION 3.9)
cmake_minimum_required (VERSION 2.8.8)
project (opustags)
set(CMAKE_CXX_STANDARD 14)
set(CMAKE_CXX_STANDARD_REQUIRED on)
set(CMAKE_MODULE_PATH "${PROJECT_SOURCE_DIR}/modules/")
set(CMAKE_SOURCE_DIR "${CMAKE_BINARY_DIR}/../")
project(
opustags
VERSION 1.4.0
LANGUAGES CXX
)
# ------------
# Dependencies
# ------------
find_package(Ogg REQUIRED)
include_directories(${Ogg_INCLUDE_DIR})
link_directories(${Ogg_LIBRARY_DIRS})
set(CMAKE_CXX_STANDARD 17)
set(CMAKE_CXX_STANDARD_REQUIRED ON)
# opustags is mainly developed with glibc, which introduces a few
# incompatibilites with BSDs, like getline not being defined by default.
# _GNU_SOURCE should trigger BSDs libc GNU compatibility mode to fix that.
add_definitions(-D_GNU_SOURCE)
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)
# --------------------
# Global build options
# --------------------
if(CMAKE_COMPILER_IS_GNUCC OR CMAKE_COMPILER_IS_GNUCXX)
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wall")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wextra")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -pedantic")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wold-style-cast")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -Wno-unused-parameter")
set(CMAKE_CXX_FLAGS "${CMAKE_CXX_FLAGS} -std=c++14") # for MinGW-w64
endif()
add_executable(opustags src/opustags.cc)
target_link_libraries(opustags ot)
# -------
# Version
# -------
execute_process(COMMAND git describe --tags --abbrev=0 OUTPUT_VARIABLE VERSION_SHORT OUTPUT_STRIP_TRAILING_WHITESPACE)
execute_process(COMMAND git describe --always --dirty --long --tags OUTPUT_VARIABLE VERSION_LONG OUTPUT_STRIP_TRAILING_WHITESPACE)
if("${VERSION_SHORT}" STREQUAL "")
set(VERSION_SHORT "0.0")
set(VERSION_LONG "?")
endif()
configure_file("${CMAKE_SOURCE_DIR}/src/version.h.in" "${CMAKE_SOURCE_DIR}/src/version.h" @ONLY)
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")
# ------------
# Source files
# ------------
file(GLOB_RECURSE common_sources "${CMAKE_SOURCE_DIR}/src/*.cc")
file(GLOB_RECURSE common_headers "${CMAKE_SOURCE_DIR}/src/*.h")
file(GLOB_RECURSE test_sources "${CMAKE_SOURCE_DIR}/tests/*.cc")
file(GLOB_RECURSE test_headers "${CMAKE_SOURCE_DIR}/tests/*.h")
list(REMOVE_ITEM common_sources "${CMAKE_SOURCE_DIR}/src/main.cc")
list(REMOVE_ITEM test_sources "${CMAKE_SOURCE_DIR}/tests/main.cc")
add_subdirectory(t)
# -------------------
# 3rd party libraries
# -------------------
# Catch
set(CATCH_PATH "${CMAKE_SOURCE_DIR}/tests/catch.h")
if (NOT EXISTS "${CATCH_PATH}")
message("Downloading Catch...")
file(DOWNLOAD "http://raw.githubusercontent.com/philsquared/Catch/master/single_include/catch.hpp" "${CATCH_PATH}")
endif()
# ------------
# Installation
# ------------
install(FILES ${CMAKE_SOURCE_DIR}/doc/opustags.1 DESTINATION ${CMAKE_INSTALL_PREFIX}/man/man1)
install(PROGRAMS ${CMAKE_CURRENT_BINARY_DIR}/opustags DESTINATION ${CMAKE_INSTALL_PREFIX}/bin)
add_custom_target(uninstall COMMAND
rm -f "${CMAKE_INSTALL_PREFIX}/man/man1/opustags.1" &&
rm -f "${CMAKE_INSTALL_PREFIX}/bin/opustags" )
# -------------------
# Linking definitions
# -------------------
add_library(common OBJECT ${common_sources} ${common_headers})
add_executable(opustags "${CMAKE_SOURCE_DIR}/src/main.cc" $<TARGET_OBJECTS:common>)
add_executable(run_tests "${CMAKE_SOURCE_DIR}/tests/main.cc" $<TARGET_OBJECTS:common> ${test_sources} ${test_headers})
target_link_libraries(opustags ${OGG_LIBRARY})
target_link_libraries(run_tests ${OGG_LIBRARY})
target_include_directories(common BEFORE PUBLIC "${CMAKE_SOURCE_DIR}/src")
target_include_directories(opustags BEFORE PUBLIC "${CMAKE_SOURCE_DIR}/src")
target_include_directories(run_tests BEFORE PUBLIC "${CMAKE_SOURCE_DIR}/src")
target_include_directories(run_tests BEFORE PUBLIC "${CMAKE_SOURCE_DIR}/tests")

View File

@ -1,72 +0,0 @@
# 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++ 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.

View File

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

View File

@ -1,65 +1,45 @@
opustags
========
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.
View and edit Opus comments.
Requirements
------------
* a POSIX-compliant system,
* a C++17 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.
* A POSIX-compliant system,
* `libogg`.
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 ..
mkdir build && cd build
cmake ..
make
make install
Note that you don't need to install opustags in order to run it, as the executable is standalone.
Documentation
-------------
Usage: opustags --help
opustags [OPTIONS] FILE
opustags OPTIONS -i FILE...
opustags OPTIONS FILE -o FILE
opustags [OPTIONS] INPUT
opustags [OPTIONS] -o OUTPUT INPUT
Options:
-h, --help print this help
-o, --output FILE specify the output file
-i, --in-place overwrite the input files
-y, --overwrite overwrite the output file if it already exists
-a, --add FIELD=VALUE add a comment
-d, --delete FIELD[=VALUE] delete previously existing comments
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
-h, --help print this help
-V, --version print version
-o, --output FILE write the modified tags to this file
-i, --in-place [SUFFIX] use a temporary file then replace the original file
-y, --overwrite overwrite the output file if it already exists
--stream ID select stream for the next operations
-l, --list display a pretty listing of all tags
--no-color disable colors in --list output
-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!
--full enable full file scan
--export dump the tags to standard output for --import
--import set the tags from scratch basing on stanard input
-e, --edit spawn the $EDITOR and apply --import on the result
See the man page, `opustags.1`, for extensive documentation.

209
doc/opustags.1 Normal file
View File

@ -0,0 +1,209 @@
.TH opustags 1 "2016"
.SH NAME
opustags \- Opus comment editor
.SH SYNOPSIS
.B opustags \-\-help
.br
.B opustags
.RI [ OPTIONS ]
.I INPUT
.br
.B opustags
.RI [ OPTIONS ]
.B \-o
.I OUTPUT INPUT
.SH DESCRIPTION
.PP
\fBopustags\fP can read and edit the comment header of an Opus file.
It has two modes of operation: read\-only for tag listing, and read\-write for
tag edition.
.PP
Edition mode is triggered by the \fB\-\-ouput\fP and \fB\-\-in\-place\fP
options. Otherwise, the default mode is listing.
.PP
Edition options are incompatible in listing mode, the same way as listing
options are incompatible in edition mode.
.PP
\fIINPUT\fP can either be the name of a file or \fB\-\fP to read from standard input.
.SS Listing
.PP
In listing mode, the tags are printed on standard output. Two listing formats
are available: pretty and raw. By default, pretty listing is implied if the
output is a terminal, and raw listing if it isn't.
.PP
Pretty listing is enabled with the option \fB\-\-list\fP. Colors are enabled
unless you use the \fB\-\-no\-color\fP option.
.PP
Dumping with \fB\-\-export\fP outputs the raw tags in UTF-8, for automatic
processing. You can restore exported tags with the \fB\-\-import\fP option.
.SS Editing
.PP
As for the edition mode, you need to specify an output file (or \fB\-\fP for
standard output). 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.
.PP
Tag edition is done with the \fB\-\-add\fP, \fB\-\-delete\fP and \fB\-\-set\fP
options. You can use these options as many times as you wish.
.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.
.PP
If you want to process tags yourself, you can use the \fB\-\-import\fP option
which will cause \fBopustags\fP to read tags from standard input. The format
is the same as the one used for output with \fB\-\-export\fP.
Note that this option implies \fB\-\-delete\-all\fP.
Also, input is read as UTF-8.
.PP
You can use \fB\-\-edit\fP to spawn your \fBEDITOR\fP and edit the tags
interactively. In read\-write mode, if no other option is specified, this one
is implied.
.SS Stream Selection
.PP
In case an Ogg file contains multiple streams, the \fB\-\-stream\fP option lets
you specify the particular streams to modify. You can modify several streams in
different ways by using this option more than once. Only operations specified
after this option and relevant, and until another \fB\-\-stream\fP option is
found.
.PP
opustags \-\-stream 1 \-\-stream 2 \-\-list # BAD
.PP
The above command would only show the second stream. To display both streams,
use instead:
.PP
opustags \-\-stream 1,2 \-\-list
.PP
See also the examples at the end of this page, and the documentation of the
\fB\-\-stream\fP option.
.PP
You can easily find the id's of the available streams by calling opustags with
no options to list the tags. Usually, you'll only have one stream, id 1.
.SH OPTIONS
.SS General
.TP
.B \-h, \-\-help
Display a brief description of the options.
.TP
.B \-V, \-\-version
Display the version of opustags.
.TP
.B \-\-stream \fIID\fP
Specify the stream that will be affected by the options following it. The
special id \fBall\fP selects all the Opus streams found, and is the default
value.
.sp
The identifer of a stream is determined from its position in the file. The
first stream found will have id 1, the second 2, and so on.
.sp
You can select more than one stream with this option by separated id's with commas. For example: \-\-stream 1,3. Ranges are not supported.
.sp
In edition mode, this option should be set if the input file contains more than
one Opus stream, otherwise a warning is generated. If in\-place modification is
selected, this warning becomes a fatal error.
.SS Listing
.TP
.B \-l, \-\-list
Display a pretty listing of all the tags found. This is the default option in
read\-only mode if the standard output is a terminal.
.TP
.B \-\-no\-color
By default, when listing tags with \fB\-\-list\fP, colors are used. Use this
option to disable it.
.TP
.B \-\-export
Dump the tags on standard output in a format compatible with \fB\-\-import\fP.
If only one stream is specified, the output format is compatible with other
tools such as vorbiscomment.
.sp
When exporting, output is encoded in UTF\-8 in
order not to lose information.
.TP
.B \-\-full
For performance reasons, only the beginning of the input is read, because
that's where tags are expected. If, however, you think you have an
unconventional file and you suspect that opustags is missing some streams, you
can use this option to force it to read the whole file.
.sp
This option shouldn't be needed, but if you do find files that require this
option, please submit a bug report (see the bottom of the page).
.SS Edition
.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 standard output. The output file can't be the same as the
input file, use \fB\-\-in\-place\fP instead. This option may be specified at
most once.
.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 (\fI.otmp\fP 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 the standard
input.
.TP
.B \-y, \-\-overwrite
By default, opustags refuses to overwrite an already existent file. Use
this option to allow that.
.TP
.B \-d, \-\-delete \fIFIELD\fP
Delete all the tags whose field name is \fIFIELD\fP. Note that one tag key,
like \fIARTIST\fP, may appear more than once, in which case all of those are
deleted.
.TP
.B \-a, \-\-add \fIFIELD=VALUE\fP
Add a tag. It doesn't matter if a tag of the same type already exist (think
about the case where there are several artists).
.TP
.B \-s, \-\-set \fIFIELD=VALUE\fP
This option is provided for convenience. It deletes all the fields of the same
type that may already exist, then adds it with the wanted value. This is
strictly equivalent to \fB\-\-delete\fP \fIFIELD\fP \fB\-\-add\fP
\fIFIELD=VALUE\fP.
You can combine it with \fB\-\-add\fP to add more tags with that same \fIFIELD\fP.
.TP
.B \-D, \-\-delete\-all
Delete all the tags before adding any.
.TP
.B \-\-import
Set the tags from scratch. All the original tags are deleted and new ones are
read from standard input.
.sp
Each line must specify a \fIFIELD=VALUE\fP pair and be LF\-terminated (except
for the last line). Invalid lines are skipped and issue a warning. Blank lines
are ignored. Lines whose first non-blank character is \fB#\fP are ignored.
Blank characters at the beginning of a line are also skipped.
.sp
Input is read as UTF\-8, disregarding the current locale of your system.
.TP
.B \-e, \-\-edit
Spawn the program specified in the environment variable \fBEDITOR\fP to edit
tags interactively. If this variable can't be read or is empty, an error
message is displayed.
.sp
The expected format is the same as the one \fB\-\-import\fP expects.
.SH EXAMPLES
Here's how you would list all tags in a stream:
.PP
opustags in.ogg
.PP
Here's how you would edit two streams at once, setting the title and artist of
the first, and only the title of the second:
.PP
opustags \-\-stream 1 \-\-set TITLE=X \-\-set ARTIST=Y \-\-stream 2 \-\-set TITLE=Y in.ogg \-o out.ogg
.PP
Here's how you would set two artists:
.PP
opustags \-\-delete ARTIST \-\-add ARTIST=A \-\-add ARTIST=B in.ogg \-o out.ogg
.PP
Hoping that helped!
.SH SEE ALSO
.BR vorbiscomment (1),
.BR sed (1)
.SH AUTHORS
Frédéric Mangano <fmang+opustags@mg0.fr>,
rr\- <https://github.com/rr\->.
.PP
Please report issues on GitHub at <https://github.com/fmang/opustags/issues>.

23
modules/FindOgg.cmake Normal file
View File

@ -0,0 +1,23 @@
# Base Io build system
# Written by Jeremy Tregunna <jeremy.tregunna@me.com>
#
# Find libogg.
FIND_PATH(OGG_INCLUDE_DIR ogg/ogg.h)
SET(OGG_NAMES ${OGG_NAMES} ogg libogg)
FIND_LIBRARY(OGG_LIBRARY NAMES ${OGG_NAMES} PATH)
IF(OGG_INCLUDE_DIR AND OGG_LIBRARY)
SET(OGG_FOUND TRUE)
ENDIF(OGG_INCLUDE_DIR AND OGG_LIBRARY)
IF(OGG_FOUND)
IF(NOT Ogg_FIND_QUIETLY)
MESSAGE(STATUS "Found Ogg: ${OGG_LIBRARY}")
ENDIF (NOT Ogg_FIND_QUIETLY)
ELSE(OGG_FOUND)
IF(Ogg_FIND_REQUIRED)
MESSAGE(FATAL_ERROR "Could not find ogg")
ENDIF(Ogg_FIND_REQUIRED)
ENDIF (OGG_FOUND)

View File

@ -1,140 +0,0 @@
.TH opustags 1 "December 2018" "@PROJECT_NAME@ @PROJECT_VERSION@"
.SH NAME
opustags \- Ogg Opus tag editor
.SH SYNOPSIS
.B opustags --help
.br
.B opustags
.RI [ OPTIONS ]
.I INPUT
.br
.B opustags
.I OPTIONS
.B -i
.R \fIFILE\fP...
.br
.B opustags
.I OPTIONS
.B -o
.I OUTPUT INPUT
.SH DESCRIPTION
.PP
\fBopustags\fP can read and edit the comment header of an Ogg Opus file.
It basically has two modes: read-only, and read-write for tag editing.
.PP
In read-only mode, only the beginning of \fIINPUT\fP is read, and the tags are
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
In editing mode, you need to specify an output file with \fB--output\fP, or use \fB--in-place\fP to
overwrite the input files. 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 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 dont 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.
.PP
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
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
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 cant be the same as the input file.
.TP
.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 \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[=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. 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
type that may already exist, then adds it with the wanted value.
This is strictly equivalent to \fB--delete\fP \fIFIELD\fP \fB--add\fP
\fIFIELD=VALUE\fP. You can combine it with \fB--add\fP to add tags of the same
type. As deletion occurs before adding, \fB--set\fP wont erase the tags
added with \fB--add\fP.
.TP
.B \-D, \-\-delete-all
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 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-Tarumi <fmang+opustags@mg0.fr>
.PP
Report bugs at <https://github.com/fmang/opustags/issues>

83
src/actions.cc Normal file
View File

@ -0,0 +1,83 @@
#include "actions.h"
using namespace opustags;
void opustags::list_tags(ogg::Decoder &dec, ITagsHandler &handler, bool full)
{
std::map<long, int> sequence_numbers;
int stream_count = 0;
int remaining_streams = 0;
std::shared_ptr<ogg::Stream> s;
while (!handler.done()) {
s = dec.read_page();
if (s == nullptr)
break; // end of stream
switch (s->state) {
case ogg::HEADER_READY:
stream_count++;
sequence_numbers[s->stream.serialno] = stream_count;
handler.start_of_stream(stream_count, s->type);
if (!handler.relevant(stream_count))
s->downgrade();
remaining_streams++;
break;
case ogg::TAGS_READY:
handler.list(sequence_numbers[s->stream.serialno], s->tags);
s->downgrade(); // no more use for it
default:
remaining_streams--;
}
if (!full && remaining_streams <= 0) {
break;
// premature exit, but calls end_of_stream anyway
// we want our optimization to be transparent to the TagsHandler
}
}
handler.end_of_file();
}
void opustags::edit_tags(
ogg::Decoder &in, ogg::Encoder &out, ITagsHandler &handler)
{
std::map<long, int> sequence_numbers;
int stream_count = 0;
std::shared_ptr<ogg::Stream> s;
for (;;) {
s = in.read_page();
if (s == nullptr)
break; // end of stream
switch (s->state) {
case ogg::HEADER_READY:
stream_count++;
sequence_numbers[s->stream.serialno] = stream_count;
handler.start_of_stream(stream_count, s->type);
if (!handler.relevant(stream_count))
s->downgrade(); // makes it UNKNOWN
if (s->type == ogg::UNKNOWN_STREAM) {
out.write_raw_page(in.current_page);
} else {
out.forward(*s);
}
break;
case ogg::TAGS_READY:
handler.edit(sequence_numbers[s->stream.serialno], s->tags);
handler.list(sequence_numbers[s->stream.serialno], s->tags);
out.write_tags(s->stream.serialno, s->tags);
break;
case ogg::DATA_READY:
out.forward(*s);
break;
case ogg::RAW_READY:
out.write_raw_page(in.current_page);
break;
default:
;
}
}
handler.end_of_file();
}

33
src/actions.h Normal file
View File

@ -0,0 +1,33 @@
#pragma once
#include "ogg.h"
#include "tags_handler.h"
namespace opustags {
// Decode a file and call the handler's list method every time a tags
// header is read. Set full to true if you want to make sure every single
// page of the stream is read.
//
// Use:
// std::ifstream in("in.ogg");
// ogg::Decoder dec(in);
// TagsLister lister(options);
// list_tags(dec, lister);
//
void list_tags(ogg::Decoder&, ITagsHandler &, bool full = false);
// Forward the input data to the output stream, transforming tags on-the-go
// with the handler's edit method.
//
// Use:
// std::ifstream in("in.ogg");
// ogg::Decoder dec(in);
// std::ofstream out("out.ogg");
// std::Encoder enc(out);
// TagsEditor editor(options);
// edit_tags(dec, enc, editor);
//
void edit_tags(ogg::Decoder &in, ogg::Encoder &out, ITagsHandler &);
}

View File

@ -1,415 +0,0 @@
/**
* \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.
*/
#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 -i FILE...
opustags OPTIONS FILE -o FILE
Options:
-h, --help print this help
-o, --output FILE specify the output file
-i, --in-place overwrite the input files
-y, --overwrite overwrite the output file if it already exists
-a, --add FIELD=VALUE add a comment
-d, --delete FIELD[=VALUE] delete previously existing comments
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
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, FILE* comments_input)
{
static ot::encoding_converter to_utf8("", "UTF-8");
std::string utf8;
std::string::size_type equal;
ot::status rc;
bool set_all = false;
opt = {};
if (argc == 1)
return {st::bad_arguments, "No arguments specified. Use -h for help."};
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)
return {st::bad_arguments, "Cannot specify --output more than once."};
opt.path_out = optarg;
break;
case 'i':
opt.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.delete_all = true;
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 (opt.in_place) {
if (opt.path_out)
return {st::bad_arguments, "Cannot combine --in-place and --output."};
opt.overwrite = true;
for (int i = optind; i < argc; i++) {
if (strcmp(argv[i], "-") == 0)
return {st::bad_arguments, "Cannot modify standard input in place."};
opt.paths_in.emplace_back(argv[i]);
}
} else {
if (optind != argc - 1)
return {st::bad_arguments, "Exactly one input file must be specified."};
if (set_all && strcmp(argv[optind], "-") == 0)
return {st::bad_arguments,
"Cannot use standard input as input file when --set-all is specified."};
opt.paths_in.emplace_back(argv[optind]);
}
if (set_all) {
// Read comments from stdin and prepend them to opt.to_add.
std::vector<std::string> comments;
auto rc = read_comments(comments_input, comments);
if (rc != st::ok)
return rc;
comments.reserve(comments.size() + opt.to_add.size());
std::move(opt.to_add.begin(), opt.to_add.end(), std::back_inserter(comments));
opt.to_add = std::move(comments);
}
return st::ok;
}
/**
* \todo Find a way to support new lines such that they can be read back by #read_comment without
* ambiguity. We could add a raw mode and separate comments with a \0, or escape control
* characters with a backslash, but we should also preserve compatibiltity with potential
* callers that dont escape backslashes. Maybe add options to select a mode between simple,
* raw, and escaped.
*/
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::vector<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.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 */
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) {
/** \todo Support mixed streams. */
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;
}
static ot::status run_single(const ot::options& opt, const std::string& path_in, const std::optional<std::string>& path_out)
{
ot::file input;
if (path_in == "-")
input = stdin;
else if ((input = fopen(path_in.c_str(), "r")) == nullptr)
return {ot::st::standard_error,
"Could not open '" + path_in + "' for reading: " + strerror(errno)};
ot::ogg_reader reader(input.get());
/* Read-only mode. */
if (!path_out)
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. A partial .opus output would be seen by softwares like media players, but a .part
* (for partial) wont.
* 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 going to remain though.
* 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 (path_out == "-") {
output = stdout;
} else if (stat(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(path_out->c_str(), "w")) == nullptr)
rc = {ot::st::standard_error,
"Could not open '" + path_out.value() + "' for writing: " +
strerror(errno)};
output = final_output.get();
} else if (opt.overwrite) {
rc = temporary_output.open(path_out->c_str());
output = temporary_output.get();
} else {
rc = {ot::st::error,
"'" + path_out.value() + "' already exists. Use -y to overwrite."};
}
} else if (errno == ENOENT) {
rc = temporary_output.open(path_out->c_str());
output = temporary_output.get();
} else {
rc = {ot::st::error,
"Could not identify '" + 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;
}
ot::status ot::run(const ot::options& opt)
{
if (opt.print_help) {
fputs(help_message, stdout);
return st::ok;
}
ot::status global_rc = st::ok;
for (const auto& path_in : opt.paths_in) {
ot::status rc = run_single(opt, path_in, opt.in_place ? path_in : opt.path_out);
if (rc != st::ok) {
global_rc = st::error;
if (!rc.message.empty())
fprintf(stderr, "%s: error: %s\n", path_in.c_str(), rc.message.c_str());
}
}
return global_rc;
}

View File

@ -1,2 +0,0 @@
#cmakedefine PROJECT_NAME "@PROJECT_NAME@"
#cmakedefine PROJECT_VERSION "@PROJECT_VERSION@"

29
src/log.h Normal file
View File

@ -0,0 +1,29 @@
#pragma once
namespace opustags {
enum class LogLevel {
LOG_NORMAL = 0,
LOG_VERBOSE = 1,
LOG_DEBUG = 2,
LOG_DEBUG_EXTRA = 3,
};
class Log
{
public:
Log(std::ostream &out);
LogLevel level;
Log& operator<<(LogLevel lvl);
template<class T> Log& operator<<(const T&);
private:
std::ostream &out;
};
extern Log log;
}

88
src/main.cc Normal file
View File

@ -0,0 +1,88 @@
#include "actions.h"
#include "options.h"
#include "version.h"
#include <iostream>
#include <fstream>
#include <cstring>
static void show_usage(const bool include_help)
{
static const auto usage =
"Usage: opustags --help\n"
" opustags [OPTIONS] INPUT\n"
" opustags [OPTIONS] -o OUTPUT INPUT\n";
static const auto help =
"Options:\n"
" -h, --help print this help\n"
" -V, --version print version\n"
" -o, --output FILE write the modified tags to this 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"
" --stream ID select stream for the next operations\n"
" -l, --list display a pretty listing of all tags\n"
" --no-color disable colors in --list output\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"
" --full enable full file scan\n"
" --export dump the tags to standard output for --import\n"
" --import set the tags from scratch basing on stanard input\n"
" -e, --edit spawn the $EDITOR and apply --import on the result\n";
std::cout << "opustags v" << opustags::version_short << "\n";
std::cout << usage;
if (include_help) {
std::cout << "\n";
std::cout << help;
}
}
static void show_version()
{
std::cout << "opustags v" << opustags::version_long << "\n";
}
int main(int argc, char **argv)
{
if (argc == 1) {
show_usage(false);
return EXIT_SUCCESS;
}
try {
auto options = opustags::parse_args(argc, argv);
if (options.show_help) {
show_usage(true);
return EXIT_SUCCESS;
}
if (options.show_version) {
show_version();
return EXIT_SUCCESS;
}
if (options.path_out.empty()) {
std::ifstream in(options.path_in);
opustags::ogg::Decoder dec(in);
list_tags(dec, options.tags_handler, options.full);
// TODO: report errors if user tries to edit the stream
} else {
std::ifstream in(options.path_in);
std::ofstream out(options.path_out);
opustags::ogg::Decoder dec(in);
opustags::ogg::Encoder enc(out);
edit_tags(dec, enc, options.tags_handler);
}
} catch (const std::exception &e) {
if (errno)
std::cerr << "fatal error: " << e.what() << " (" << strerror(errno) << ")" << std::endl;
else
std::cerr << "fatal error: " << e.what() << std::endl;
return EXIT_FAILURE;
}
return EXIT_SUCCESS;
}

View File

@ -1,116 +1,308 @@
/**
* \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 "ogg.h"
#include <opustags.h>
#include <stdexcept>
#include <fstream>
#include <sstream>
#include <cstring>
#include <endian.h>
#include <errno.h>
#include <string.h>
using namespace opustags;
using namespace std::literals::string_literals;
////////////////////////////////////////////////////////////////////////////////
// ogg::Stream
bool ot::is_opus_stream(const ogg_page& identification_header)
ogg::Stream::Stream(int streamno)
{
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);
state = ogg::BEGIN_OF_STREAM;
type = ogg::UNKNOWN_STREAM;
if (ogg_stream_init(&stream, streamno) != 0)
throw std::runtime_error("ogg_stream_init failed");
}
ot::status ot::ogg_reader::next_page()
ogg::Stream::~Stream()
{
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;
ogg_stream_clear(&stream);
}
ot::status ot::ogg_reader::process_header_packet(const std::function<status(ogg_packet&)>& f)
void ogg::Stream::flush_packets()
{
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;
ogg_packet op;
while (ogg_stream_packetout(&stream, &op) > 0);
}
ot::status ot::ogg_writer::write_page(const ogg_page& page)
bool ogg::Stream::page_in(ogg_page &og)
{
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;
if (state != ogg::BEGIN_OF_STREAM && type == ogg::UNKNOWN_STREAM) {
state = ogg::RAW_READY;
return true;
}
flush_packets(); // otherwise packet_out keeps returning the same packet
if (ogg_stream_pagein(&stream, &og) != 0)
throw std::runtime_error("ogg_stream_pagein failed");
if (state == ogg::BEGIN_OF_STREAM || state == ogg::HEADER_READY) {
// We're expecting a header, so we parse it.
return handle_page();
} else {
// We're past the first two headers.
state = ogg::DATA_READY;
return true;
}
}
ot::status ot::ogg_writer::write_header_packet(int serialno, int pageno, ogg_packet& packet)
// Read the first packet of the page and parses it.
bool ogg::Stream::handle_page()
{
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;
ogg_packet op;
int rc = ogg_stream_packetpeek(&stream, &op);
if (rc < 0)
throw std::runtime_error("ogg_stream_packetout failed");
else if (rc == 0) // insufficient data
return false; // asking for a new page
// We've read the first packet successfully.
// The headers are supposed to contain only one packet, so this is enough
// for us. Still, we could ensure there are no other packets.
handle_packet(op);
return true;
}
void ogg::Stream::handle_packet(const ogg_packet &op)
{
if (state == ogg::BEGIN_OF_STREAM)
parse_header(op);
else if (state == ogg::HEADER_READY)
parse_opustags(op);
// else shrug
}
void ogg::Stream::parse_header(const ogg_packet &op)
{
if (op.bytes >= 8 && memcmp(op.packet, "OpusHead", 8) == 0)
type = OPUS_STREAM;
else
type = UNKNOWN_STREAM;
state = HEADER_READY;
}
// For reference:
// https://tools.ietf.org/html/draft-ietf-codec-oggopus-14#section-5.2
void ogg::Stream::parse_opustags(const ogg_packet &op)
{
// This part is gonna be C-ish because I don't see how I'd do this in C++
// without being inefficient, both in volume of code and performance.
char *data = reinterpret_cast<char*>(op.packet);
long remaining = op.bytes;
if (remaining < 8 || memcmp(data, "OpusTags", 8) != 0)
throw std::runtime_error("expected OpusTags header");
data += 8;
remaining -= 8;
// Vendor string
if (remaining < 4)
throw std::runtime_error("no space for vendor string length");
uint32_t vendor_length = le32toh(*reinterpret_cast<uint32_t*>(data));
if (remaining - 4 < vendor_length)
throw std::runtime_error("invalid vendor string length");
tags.vendor = std::string(data + 4, vendor_length);
data += 4 + vendor_length;
remaining -= 4 + vendor_length;
// User comments count
if (remaining < 4)
throw std::runtime_error("no space for user comment list length");
long comment_count = le32toh(*reinterpret_cast<uint32_t*>(data));
data += 4;
remaining -= 4;
// Actual comments
// We iterate on a long type to prevent infinite looping when comment_count == UINT32_MAX.
for (long i = 0; i < comment_count; i++) {
if (remaining < 4)
throw std::runtime_error("no space for user comment length");
uint32_t comment_length = le32toh(*reinterpret_cast<uint32_t*>(data));
if (remaining - 4 < comment_length)
throw std::runtime_error("no space for comment contents");
tags.add(parse_tag(std::string(data + 4, comment_length)));
data += 4 + comment_length;
remaining -= 4 + comment_length;
}
// Extra data to keep if the least significant bit of the first byte is 1
if (remaining > 0 && (*data & 1) == 1 )
tags.extra = std::string(data, remaining);
state = TAGS_READY;
}
void ogg::Stream::downgrade()
{
type = ogg::UNKNOWN_STREAM;
if (state != ogg::BEGIN_OF_STREAM && state != ogg::END_OF_STREAM)
state = RAW_READY;
}
////////////////////////////////////////////////////////////////////////////////
// ogg::Decoder
ogg::Decoder::Decoder(std::istream &in)
: input(in)
{
if (!in)
throw std::runtime_error("invalid stream to decode");
input.exceptions(std::ifstream::badbit);
ogg_sync_init(&sync);
}
ogg::Decoder::~Decoder()
{
ogg_sync_clear(&sync);
}
std::shared_ptr<ogg::Stream> ogg::Decoder::read_page()
{
while (page_out()) {
int streamno = ogg_page_serialno(&current_page);
auto i = streams.find(streamno);
if (i == streams.end()) {
// we could check the page number to detect new streams (pageno = 0)
auto s = std::make_shared<Stream>(streamno);
i = streams.emplace(streamno, s).first;
}
if (i->second->page_in(current_page))
return i->second;
}
return nullptr; // end of stream
}
// Read the next page and return true on success, false on end of stream.
bool ogg::Decoder::page_out()
{
int rc;
for (;;) {
rc = ogg_sync_pageout(&sync, &current_page);
if (rc < 0) {
throw std::runtime_error("ogg_sync_pageout failed");
} else if (rc == 1) {
break; // page complete
} else if (!buff()) {
// more data required but end of file reached
// TODO check sync.unsynced flag in case we've got an incomplete page
return false;
}
}
return true;
}
// Read data from the stream into the sync's buffer.
bool ogg::Decoder::buff()
{
if (input.eof())
return false;
char *buf = ogg_sync_buffer(&sync, 65536);
if (buf == nullptr)
throw std::runtime_error("ogg_sync_buffer failed");
input.read(buf, 65536);
ogg_sync_wrote(&sync, input.gcount());
return true;
}
////////////////////////////////////////////////////////////////////////////////
// ogg::Encoder
ogg::Encoder::Encoder(std::ostream &out)
: output(out)
{
if (!output)
throw std::runtime_error("invalid stream to decode");
output.exceptions(std::ifstream::badbit);
}
ogg::Stream& ogg::Encoder::get_stream(int streamno)
{
auto i = streams.find(streamno);
if (i == streams.end()) {
auto s = std::make_shared<Stream>(streamno);
i = streams.emplace(streamno, s).first;
}
return *(i->second);
}
void ogg::Encoder::forward(ogg::Stream &in)
{
ogg::Stream *out = &get_stream(in.stream.serialno);
forward_stream(in, *out);
flush_stream(*out);
}
void ogg::Encoder::forward_stream(ogg::Stream &in, ogg::Stream &out)
{
int rc;
ogg_packet op;
for (;;) {
rc = ogg_stream_packetout(&in.stream, &op);
if (rc < 0) {
throw std::runtime_error("ogg_stream_packetout failed");
} else if (rc == 0) {
break;
} else {
if (ogg_stream_packetin(&out.stream, &op) != 0)
throw std::runtime_error("ogg_stream_packetin failed");
}
}
}
void ogg::Encoder::flush_stream(ogg::Stream &out)
{
ogg_page og;
if (ogg_stream_flush(&out.stream, &og))
write_raw_page(og);
}
void ogg::Encoder::write_raw_page(const ogg_page &og)
{
output.write(reinterpret_cast<const char*>(og.header), og.header_len);
output.write(reinterpret_cast<const char*>(og.body), og.body_len);
}
void ogg::Encoder::write_tags(int streamno, const Tags &tags)
{
ogg_packet op;
op.b_o_s = 0;
op.e_o_s = 0;
op.granulepos = 0;
op.packetno = 1; // checked on a file from ffmpeg
std::string data = render_opustags(tags);
op.bytes = data.size();
op.packet = reinterpret_cast<unsigned char*>(const_cast<char*>(data.data()));
std::shared_ptr<ogg::Stream> s = streams.at(streamno); // assume it exists
if (ogg_stream_packetin(&s->stream, &op) != 0)
throw std::runtime_error("ogg_stream_packetin failed");
flush_stream(*s);
}
std::string ogg::Encoder::render_opustags(const Tags &tags)
{
std::stringbuf s;
uint32_t length;
s.sputn("OpusTags", 8);
length = htole32(tags.vendor.size());
s.sputn(reinterpret_cast<char*>(&length), 4);
s.sputn(tags.vendor.data(), tags.vendor.size());
auto assocs = tags.get_all();
length = htole32(assocs.size());
s.sputn(reinterpret_cast<char*>(&length), 4);
for (const auto assoc : assocs) {
length = htole32(assoc.key.size() + 1 + assoc.value.size());
s.sputn(reinterpret_cast<char*>(&length), 4);
s.sputn(assoc.key.data(), assoc.key.size());
s.sputc('=');
s.sputn(assoc.value.data(), assoc.value.size());
}
s.sputn(tags.extra.data(), tags.extra.size());
return s.str();
}

129
src/ogg.h Normal file
View File

@ -0,0 +1,129 @@
#pragma once
#include "tags.h"
#include <iostream>
#include <map>
#include <memory>
#include <ogg/ogg.h>
namespace opustags {
namespace ogg
{
enum StreamState {
BEGIN_OF_STREAM,
HEADER_READY,
TAGS_READY,
DATA_READY,
RAW_READY,
END_OF_STREAM,
// Meaning of these states below, in Stream.
};
enum StreamType {
UNKNOWN_STREAM = 0,
OPUS_STREAM,
};
// An Ogg file may contain many logical bitstreams, and among them, many
// Opus streams. This class represents one stream, whether it is Opus or
// not.
struct Stream
{
Stream(int streamno);
Stream(const Stream&) = delete;
~Stream();
// Called by Decoder once a page was read.
// Returns true if it's ready, false if it expects more data.
// In the latter case, Decoder::read_page will keep reading.
bool page_in(ogg_page&);
// Make the stream behave as if it were unknown.
// As a consequence, no more effort would be made in extracting data.
void downgrade();
StreamState state;
StreamType type;
Tags tags;
// Here are the meanings of the state variable:
// BEGIN_OF_STREAM: the stream was just initialized,
// HEADER_READY: the header (first packet) was found,
// TAGS_READY: the tags are parsed and complete,
// DATA_READY: we've read a data page whose meaning is no concern to us,
// RAW_READY: we don't even know what kind of stream that is, so don't alter it,
// END_OF_STREAM: no more thing to read, not even the current page.
// From the state, we decide what to do with the Decoder's current_page.
// The difference between DATA_READY and RAW_DATA is that the former
// might require a reencoding of the current page. For example, if the
// tags grow and span over two pages, all the following pages are gonna
// need a new sequence number.
// For an Opus stream, the sequence will be:
// BEGIN_OF_STREAM → HEADER_READY → TAGS_READY → DATA_READY* → END_OF_STREAM
// For an unknown stream:
// BEGIN_OF_STREAM → HEADER_READY → RAW_READY* → END_OF_STREAM
ogg_stream_state stream;
private:
void flush_packets();
bool handle_page();
void handle_packet(const ogg_packet&);
void parse_header(const ogg_packet&);
void parse_opustags(const ogg_packet&);
};
struct Decoder
{
Decoder(std::istream&);
Decoder(const Decoder&) = delete;
~Decoder();
// Read a page, dispatch it, and return the stream it belongs to.
// The read page is given to Stream::page_in before this function
// returns.
// After the end of the file is reached, it returns NULL.
std::shared_ptr<Stream> read_page();
std::istream &input;
ogg_sync_state sync;
ogg_page current_page;
std::map<int, std::shared_ptr<Stream>> streams;
private:
bool page_out();
bool buff();
};
struct Encoder
{
Encoder(std::ostream&);
// Copy the input stream's current page.
void forward(Stream &in);
// Write the page without even ensuring its page number is correct.
// It would be an efficient way to copy a stream identically, and also
// needed for write_page.
void write_raw_page(const ogg_page&);
void write_tags(int streamno, const Tags&);
std::ostream &output;
// We're gonna need some ogg_stream_state for adjusting the page
// numbers and splitting large packets as it's gotta be done.
std::map<int, std::shared_ptr<Stream>> streams;
private:
Stream& get_stream(int streamno);
void forward_stream(Stream &in, Stream &out);
void flush_stream(Stream &out);
std::string render_opustags(const Tags &tags);
};
}
}

199
src/options.cc Normal file
View File

@ -0,0 +1,199 @@
#include "options.h"
#include "tags_handlers/insertion_tags_handler.h"
#include "tags_handlers/modification_tags_handler.h"
#include "tags_handlers/external_edit_tags_handler.h"
#include "tags_handlers/export_tags_handler.h"
#include "tags_handlers/import_tags_handler.h"
#include "tags_handlers/listing_tags_handler.h"
#include "tags_handlers/removal_tags_handler.h"
#include <getopt.h>
#include <regex>
#include <sstream>
using namespace opustags;
ArgumentError::ArgumentError(const std::string &message)
: std::runtime_error(message.c_str())
{
}
Options::Options() :
show_help(false),
show_version(false),
overwrite(false),
full(false),
in_place(false)
{
}
Options opustags::parse_args(const int argc, char **argv)
{
static const auto short_def = "hVeo:i::yd:a:s:Dl";
static const option long_def[] = {
{"help", no_argument, 0, 'h'},
{"version", no_argument, 0, 'V'},
{"full", no_argument, 0, 0},
{"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'},
{"stream", required_argument, 0, 0},
{"set", required_argument, 0, 's'},
{"list", no_argument, 0, 'l'},
{"delete-all", no_argument, 0, 'D'},
{"edit", no_argument, 0, 'e'},
{"import", no_argument, 0, 0},
{"export", no_argument, 0, 0},
// TODO: parse no-colors
{nullptr, 0, 0, 0}
};
// TODO: use --list as default switch
Options options;
std::vector<int> current_streamnos {StreamTagsHandler::ALL_STREAMS};
int option_index;
char c;
optind = 0;
while ((c = getopt_long(
argc, argv, short_def, long_def, &option_index)) != -1) {
const std::string arg(optarg == nullptr ? "" : optarg);
switch (c) {
case 'h':
options.show_help = true;
break;
case 'V':
options.show_version = true;
break;
case 'o':
if (arg.empty())
throw ArgumentError("Output path cannot be empty");
options.path_out = arg;
break;
case 'i':
// TODO: shouldn't we generate random file name here to which
// we apply the arg, rather than use the arg as a whole?
options.path_out = arg.empty() ? ".otmp" : arg;
options.in_place = true;
break;
case 'y':
options.overwrite = true;
break;
case 'd':
if (arg.find('=') != std::string::npos)
throw ArgumentError("Invalid field: '" + arg + "'");
for (const auto streamno : current_streamnos) {
options.tags_handler.add_handler(
std::make_shared<RemovalTagsHandler>(streamno, arg));
}
break;
case 'a':
case 's':
{
std::smatch match;
std::regex regex("^(\\w+)=(.*)$");
if (!std::regex_match(arg, match, regex))
throw ArgumentError("Invalid field: '" + arg + "'");
for (const auto streamno : current_streamnos) {
if (c == 's') {
options.tags_handler.add_handler(
std::make_shared<ModificationTagsHandler>(
streamno, match[1], match[2]));
} else {
options.tags_handler.add_handler(
std::make_shared<InsertionTagsHandler>(
streamno, match[1], match[2]));
}
}
break;
}
case 'l':
for (const auto streamno : current_streamnos) {
options.tags_handler.add_handler(
std::make_shared<ListingTagsHandler>(
streamno, std::cout));
}
break;
case 'e':
options.tags_handler.add_handler(
std::make_shared<ExternalEditTagsHandler>());
break;
case 'D':
for (const auto streamno : current_streamnos) {
options.tags_handler.add_handler(
std::make_shared<RemovalTagsHandler>(streamno));
}
break;
case 0:
{
std::string long_arg = long_def[option_index].name;
if (long_arg == "stream") {
int i;
current_streamnos.clear();
std::stringstream ss(optarg);
while (ss >> i) {
current_streamnos.push_back(i);
if (ss.peek() == ',')
ss.ignore();
}
}
else if (long_arg == "full")
options.full = true;
else if (long_arg == "export")
options.tags_handler.add_handler(
std::make_shared<ExportTagsHandler>(std::cout));
else if (long_arg == "import")
options.tags_handler.add_handler(
std::make_shared<ImportTagsHandler>(std::cin));
break;
}
default:
throw ArgumentError("Invalid flag");
}
}
std::vector<std::string> stray;
while (optind < argc)
stray.push_back(argv[optind++]);
if (!options.show_help && !options.show_version) {
if (stray.empty())
throw ArgumentError("Missing input path");
options.path_in = stray.at(0);
if (options.path_in.empty())
throw ArgumentError("Input path cannot be empty");
if (stray.size() > 1)
throw ArgumentError("Extra argument: " + stray.at(1));
if (options.path_out.empty()) {
options.tags_handler.add_handler(
std::make_shared<ListingTagsHandler>(
StreamTagsHandler::ALL_STREAMS, std::cout));
}
}
return options;
}

33
src/options.h Normal file
View File

@ -0,0 +1,33 @@
#pragma once
#include "tags_handlers/composite_tags_handler.h"
#include <stdexcept>
#include <map>
#include <vector>
namespace opustags
{
struct Options final
{
Options();
bool show_help;
bool show_version;
bool overwrite;
bool full;
bool in_place;
std::string path_in;
std::string path_out;
CompositeTagsHandler tags_handler;
};
struct ArgumentError : std::runtime_error
{
ArgumentError(const std::string &message);
};
Options parse_args(const int argc, char **argv);
}

View File

@ -1,119 +0,0 @@
/**
* \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.
*
*/
#include <opustags.h>
#include <string.h>
#ifdef __APPLE__
#include <libkern/OSByteOrder.h>
#define htole32(x) OSSwapHostToLittleInt32(x)
#define le32toh(x) OSSwapLittleToHostInt32(x)
#endif
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;
}

View File

@ -1,27 +0,0 @@
/**
* \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, stdin);
if (rc == ot::st::ok)
rc = ot::run(opt);
else if (!rc.message.empty())
fprintf(stderr, "error: %s\n", rc.message.c_str());
return rc == ot::st::ok ? EXIT_SUCCESS : EXIT_FAILURE;
}

View File

@ -1,476 +0,0 @@
/**
* \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;
/**
* Paths to the input files. The special string "-" means stdin.
*
* At least one input file must be given. If `--in-place` is used,
* more than one may be given.
*/
std::vector<std::string> paths_in;
/**
* Optional path to output file. The special string "-" means stdout. For in-place
* editing, the input file name is used. If no output file name is supplied, and
* --in-place is not used, opustags runs in read-only mode.
*
* Options: --output, --in-place
*/
std::optional<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;
/**
* Process files in-place.
*
* Options: --in-place
*/
bool in_place = 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, --set-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;
};
/**
* Parse the command-line arguments. Does not perform I/O related validations, but checks the
* consistency of its arguments. Comments are read if necessary from the given stream.
*
* On error, the state of the options structure is unspecified.
*/
status parse_options(int argc, char** argv, options& opt, FILE* comments);
/**
* 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_comments.
*/
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::vector<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);
/** \} */
}

View File

@ -1,134 +0,0 @@
/**
* \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 <sys/stat.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;
}
static mode_t get_umask()
{
// libc doesnt seem to provide a way to get umask without changing it, so we need this workaround.
// https://www.gnu.org/software/libc/manual/html_node/Setting-Permissions.html
mode_t mask = umask(0);
umask(mask);
return mask;
}
/**
* Try reproducing the file permissions of file `source` onto file `dest`. If
* this fails for whatever reason, print a warning and leave the current
* permissions. When the source doesnt exist, use the default file creation
* permissions according to umask.
*/
static void copy_permissions(const char* source, const char* dest)
{
mode_t target_mode;
struct stat source_stat;
if (stat(source, &source_stat) == 0) {
// We could technically preserve a bit more than that but who
// would ever need S_ISUID and friends on an Opus file?
target_mode = source_stat.st_mode & 0777;
} else if (errno == ENOENT) {
target_mode = 0666 & ~get_umask();
} else {
fprintf(stderr, "warning: Could not read mode of %s: %s\n", source, strerror(errno));
return;
}
if (chmod(dest, target_mode) == -1)
fprintf(stderr, "warning: Could not set mode of %s: %s\n", dest, strerror(errno));
}
ot::status ot::partial_file::commit()
{
if (file == nullptr)
return st::ok;
file.reset();
copy_permissions(final_name.c_str(), temporary_name.c_str());
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;
}

103
src/tags.cc Normal file
View File

@ -0,0 +1,103 @@
#include "tags.h"
#include <algorithm>
using namespace opustags;
// ASCII only, but for tag keys it's good enough
static bool iequals(const std::string &a, const std::string &b)
{
if (a.size() != b.size())
return false;
for (size_t i = 0; i < a.size(); i++)
if (std::tolower(a[i]) != std::tolower(b[i]))
return false;
return true;
}
bool Tag::operator !=(const Tag &other_tag) const
{
return !operator ==(other_tag);
}
bool Tag::operator ==(const Tag &other_tag) const
{
return key == other_tag.key
&& value == other_tag.value;
}
Tags::Tags()
{
}
Tags::Tags(const std::vector<Tag> &tags) : tags(tags)
{
}
const std::vector<Tag> Tags::get_all() const
{
return tags;
}
std::string Tags::get(const std::string &key) const
{
for (auto &tag : tags)
if (iequals(tag.key, key))
return tag.value;
throw std::runtime_error("Tag '" + key + "' not found.");
}
void Tags::add(const Tag &tag)
{
tags.push_back(tag);
}
void Tags::add(const std::string &key, const std::string &value)
{
tags.push_back({key, value});
}
void Tags::clear()
{
tags.clear();
}
void Tags::remove(const std::string &key)
{
std::vector<Tag> new_tags;
std::copy_if(
tags.begin(),
tags.end(),
std::back_inserter(new_tags),
[&](const Tag &tag) { return !iequals(tag.key, key); });
tags = new_tags;
}
bool Tags::contains(const std::string &key) const
{
return std::count_if(
tags.begin(),
tags.end(),
[&](const Tag &tag) { return iequals(tag.key, key); }) > 0;
}
Tag opustags::parse_tag(const std::string &assoc)
{
size_t eq = assoc.find_first_of('=');
if (eq == std::string::npos)
throw std::runtime_error("misconstructed tag");
std::string name = assoc.substr(0, eq);
std::string value = assoc.substr(eq + 1);
return { name, value };
}
bool Tags::operator !=(const Tags &other_tags) const
{
return !operator ==(other_tags);
}
bool Tags::operator ==(const Tags &other_tags) const
{
return vendor == other_tags.vendor
&& extra == other_tags.extra
&& get_all() == other_tags.get_all();
}

49
src/tags.h Normal file
View File

@ -0,0 +1,49 @@
#pragma once
#include <map>
#include <vector>
#include <utility>
namespace opustags {
struct Tag final
{
bool operator !=(const Tag &other_tag) const;
bool operator ==(const Tag &other_tag) const;
std::string key;
std::string value;
};
// A std::map adapter that keeps the order of insertion.
class Tags final
{
public:
Tags();
Tags(const std::vector<Tag> &tags);
const std::vector<Tag> get_all() const;
std::string get(const std::string &key) const;
void add(const Tag &tag);
void add(const std::string &key, const std::string &value);
void remove(const std::string &key);
bool contains(const std::string &key) const;
void clear();
bool operator !=(const Tags &other_tags) const;
bool operator ==(const Tags &other_tags) const;
// Additional fields are required to match the specs:
// https://tools.ietf.org/html/draft-ietf-codec-oggopus-14#section-5.2
std::string vendor;
std::string extra;
private:
std::vector<Tag> tags;
};
Tag parse_tag(const std::string &assoc); // KEY=value
}

50
src/tags_handler.h Normal file
View File

@ -0,0 +1,50 @@
#pragma once
#include "tags.h"
namespace opustags {
// TagsHandler define various operations related to tags and stream in
// order to control the main loop.
// In its implementation, it is expected to receive an option structure.
class ITagsHandler
{
public:
// Irrelevant streams don't even need to be parsed, so we can save some
// effort with this method.
// Returns true if the stream should be parsed, false if it should be
// ignored (list) or copied identically (edit).
virtual bool relevant(const int streamno) = 0;
// The list method is called by list_tags every time it has
// successfully parsed an OpusTags header.
virtual void list(const int streamno, const Tags &) = 0;
// Transform the tags at will.
// Returns true if the tags were indeed modified, false if they weren't.
// The latter case may be used for optimization.
virtual bool edit(const int streamno, Tags &) = 0;
// The work is done.
// When listing tags, once we've caught the streams we wanted, it's no
// use keeping reading the file for new streams. In that case, a true
// return value would abort any further processing.
virtual bool done() = 0;
// Signals a new stream was found.
// The meaning of type is in ogg::StreamType, but all you should assume
// is that when type is null (UNKNOWN_STREAM), list or edit won't be
// called.
virtual void start_of_stream(const int streamno, const int type) {}
// Signals the end of the file (and all the streams).
// If after this function is called, done() returns false, it's an
// error. However, it would be better to raise the error inside
// end_of_stream().
// For example, if you expect to find the stream #1 and reach the
// end-of-stream before finding it, better tell the user that you
// didn't do what he expected.
virtual void end_of_file() {}
};
}

View File

@ -0,0 +1,44 @@
#include "tags_handlers/composite_tags_handler.h"
using namespace opustags;
void CompositeTagsHandler::add_handler(std::shared_ptr<ITagsHandler> handler)
{
handlers.push_back(std::move(handler));
}
bool CompositeTagsHandler::relevant(const int streamno)
{
for (const auto &handler : handlers)
if (handler->relevant(streamno))
return true;
return false;
}
void CompositeTagsHandler::list(const int streamno, const Tags &tags)
{
for (const auto &handler : handlers)
handler->list(streamno, tags);
}
bool CompositeTagsHandler::edit(const int streamno, Tags &tags)
{
bool modified = false;
for (const auto &handler : handlers)
modified |= handler->edit(streamno, tags);
return modified;
}
bool CompositeTagsHandler::done()
{
for (const auto &handler : handlers)
if (!handler->done())
return false;
return true;
}
const std::vector<std::shared_ptr<ITagsHandler>>
CompositeTagsHandler::get_handlers() const
{
return handlers;
}

View File

@ -0,0 +1,25 @@
#pragma once
#include <memory>
#include <vector>
#include "tags_handler.h"
namespace opustags {
class CompositeTagsHandler final : public ITagsHandler
{
public:
void add_handler(std::shared_ptr<ITagsHandler> handler);
bool relevant(const int streamno) override;
void list(const int streamno, const Tags &) override;
bool edit(const int streamno, Tags &) override;
bool done() override;
const std::vector<std::shared_ptr<ITagsHandler>> get_handlers() const;
private:
std::vector<std::shared_ptr<ITagsHandler>> handlers;
};
}

View File

@ -0,0 +1,31 @@
#include "tags_handlers/export_tags_handler.h"
using namespace opustags;
ExportTagsHandler::ExportTagsHandler(std::ostream &output_stream)
: output_stream(output_stream)
{
}
bool ExportTagsHandler::relevant(const int)
{
return true;
}
void ExportTagsHandler::list(const int streamno, const Tags &tags)
{
output_stream << "[Stream " << streamno << "]\n";
for (const auto tag : tags.get_all())
output_stream << tag.key << "=" << tag.value << "\n";
output_stream << "\n";
}
bool ExportTagsHandler::edit(const int, Tags &)
{
return false;
}
bool ExportTagsHandler::done()
{
return false;
}

View File

@ -0,0 +1,22 @@
#pragma once
#include <iostream>
#include "tags_handler.h"
namespace opustags {
class ExportTagsHandler : public ITagsHandler
{
public:
ExportTagsHandler(std::ostream &output_stream);
bool relevant(const int streamno) override;
void list(const int streamno, const Tags &) override;
bool edit(const int streamno, Tags &) override;
bool done() override;
private:
std::ostream &output_stream;
};
}

View File

@ -0,0 +1,22 @@
#include "tags_handlers/external_edit_tags_handler.h"
using namespace opustags;
bool ExternalEditTagsHandler::relevant(const int streamno)
{
return false;
}
void ExternalEditTagsHandler::list(const int streamno, const Tags &)
{
}
bool ExternalEditTagsHandler::edit(const int streamno, Tags &)
{
return false;
}
bool ExternalEditTagsHandler::done()
{
return true;
}

View File

@ -0,0 +1,17 @@
#pragma once
#include <iostream>
#include "tags_handler.h"
namespace opustags {
class ExternalEditTagsHandler : public ITagsHandler
{
public:
bool relevant(const int streamno) override;
void list(const int streamno, const Tags &) override;
bool edit(const int streamno, Tags &) override;
bool done() override;
};
}

View File

@ -0,0 +1,67 @@
#include <regex>
#include "tags_handlers/import_tags_handler.h"
using namespace opustags;
ImportTagsHandler::ImportTagsHandler(std::istream &input_stream)
: parsed(false), input_stream(input_stream)
{
}
bool ImportTagsHandler::relevant(const int)
{
return true;
}
void ImportTagsHandler::list(const int, const Tags &)
{
}
bool ImportTagsHandler::edit(const int streamno, Tags &tags)
{
// the reason why we do it this way is because the tests indirectly create
// this handler with std::cin, and we do not want the constructor to block!
parse_input_stream_if_needed();
const auto old_tags = tags;
tags.clear();
if (tag_map.find(streamno) != tag_map.end()) {
const auto &source_tags = tag_map.at(streamno);
for (const auto &source_tag : source_tags.get_all())
tags.add(source_tag.key, source_tag.value);
}
return old_tags != tags;
}
bool ImportTagsHandler::done()
{
return false;
}
void ImportTagsHandler::parse_input_stream_if_needed()
{
if (parsed)
return;
parsed = true;
const std::regex whitespace_regex("^\\s*$");
const std::regex stream_header_regex(
"^\\s*\\[stream\\s+(\\d+)\\]\\s*$", std::regex_constants::icase);
const std::regex tag_regex(
"^\\s*([a-z0-9]+)\\s*=\\s*(.*?)\\s*$", std::regex_constants::icase);
int current_stream_number = 1;
std::string line;
while (std::getline(input_stream, line)) {
std::smatch match;
if (std::regex_match(line, match, stream_header_regex)) {
current_stream_number = std::atoi(match[1].str().c_str());
} else if (std::regex_match(line, match, tag_regex)) {
tag_map[current_stream_number].add(match[1], match[2]);
} else if (!std::regex_match(line, match, whitespace_regex)) {
throw std::runtime_error("Malformed input data near line " + line);
}
}
}

View File

@ -0,0 +1,27 @@
#pragma once
#include <iostream>
#include <map>
#include "tags_handler.h"
namespace opustags {
class ImportTagsHandler : public ITagsHandler
{
public:
ImportTagsHandler(std::istream &input_stream);
bool relevant(const int streamno) override;
void list(const int streamno, const Tags &) override;
bool edit(const int streamno, Tags &) override;
bool done() override;
private:
void parse_input_stream_if_needed();
bool parsed;
std::istream &input_stream;
std::map<int, Tags> tag_map;
};
}

View File

@ -0,0 +1,28 @@
#include "tags_handlers/insertion_tags_handler.h"
#include "tags_handlers_errors.h"
using namespace opustags;
InsertionTagsHandler::InsertionTagsHandler(
const int streamno,
const std::string &tag_key,
const std::string &tag_value)
: StreamTagsHandler(streamno), tag_key(tag_key), tag_value(tag_value)
{
}
std::string InsertionTagsHandler::get_tag_key() const
{
return tag_key;
}
std::string InsertionTagsHandler::get_tag_value() const
{
return tag_value;
}
bool InsertionTagsHandler::edit_impl(Tags &tags)
{
tags.add(tag_key, tag_value);
return true;
}

View File

@ -0,0 +1,26 @@
#pragma once
#include "tags_handlers/stream_tags_handler.h"
namespace opustags {
class InsertionTagsHandler : public StreamTagsHandler
{
public:
InsertionTagsHandler(
const int streamno,
const std::string &tag_key,
const std::string &tag_value);
std::string get_tag_key() const;
std::string get_tag_value() const;
protected:
bool edit_impl(Tags &) override;
private:
const std::string tag_key;
const std::string tag_value;
};
}

View File

@ -0,0 +1,16 @@
#include "tags_handlers/listing_tags_handler.h"
using namespace opustags;
ListingTagsHandler::ListingTagsHandler(
const int streamno,
std::ostream &output_stream)
: StreamTagsHandler(streamno), output_stream(output_stream)
{
}
void ListingTagsHandler::list_impl(const Tags &tags)
{
for (const auto &tag : tags.get_all())
output_stream << tag.key << "=" << tag.value << "\n";
}

View File

@ -0,0 +1,20 @@
#pragma once
#include <iostream>
#include "tags_handlers/stream_tags_handler.h"
namespace opustags {
class ListingTagsHandler : public StreamTagsHandler
{
public:
ListingTagsHandler(const int streamno, std::ostream &output_stream);
protected:
void list_impl(const Tags &) override;
private:
std::ostream &output_stream;
};
}

View File

@ -0,0 +1,33 @@
#include "tags_handlers/modification_tags_handler.h"
using namespace opustags;
ModificationTagsHandler::ModificationTagsHandler(
const int streamno,
const std::string &tag_key,
const std::string &tag_value)
: StreamTagsHandler(streamno), tag_key(tag_key), tag_value(tag_value)
{
}
std::string ModificationTagsHandler::get_tag_key() const
{
return tag_key;
}
std::string ModificationTagsHandler::get_tag_value() const
{
return tag_value;
}
bool ModificationTagsHandler::edit_impl(Tags &tags)
{
if (tags.contains(tag_key)) {
if (tags.get(tag_key) == tag_value)
return false;
tags.remove(tag_key);
}
tags.add(tag_key, tag_value);
return true;
}

View File

@ -0,0 +1,26 @@
#pragma once
#include "tags_handlers/stream_tags_handler.h"
namespace opustags {
class ModificationTagsHandler : public StreamTagsHandler
{
public:
ModificationTagsHandler(
const int streamno,
const std::string &tag_key,
const std::string &tag_value);
std::string get_tag_key() const;
std::string get_tag_value() const;
protected:
bool edit_impl(Tags &) override;
private:
const std::string tag_key;
const std::string tag_value;
};
}

View File

@ -0,0 +1,35 @@
#include "tags_handlers/removal_tags_handler.h"
#include "tags_handlers_errors.h"
using namespace opustags;
RemovalTagsHandler::RemovalTagsHandler(const int streamno)
: StreamTagsHandler(streamno)
{
}
RemovalTagsHandler::RemovalTagsHandler(
const int streamno, const std::string &tag_key)
: StreamTagsHandler(streamno), tag_key(tag_key)
{
}
std::string RemovalTagsHandler::get_tag_key() const
{
return tag_key;
}
bool RemovalTagsHandler::edit_impl(Tags &tags)
{
if (tag_key.empty()) {
const auto anything_removed = tags.get_all().size() > 0;
tags.clear();
return anything_removed;
} else {
if (!tags.contains(tag_key))
return false;
tags.remove(tag_key);
return true;
}
}

View File

@ -0,0 +1,22 @@
#pragma once
#include "tags_handlers/stream_tags_handler.h"
namespace opustags {
class RemovalTagsHandler : public StreamTagsHandler
{
public:
RemovalTagsHandler(const int streamno);
RemovalTagsHandler(const int streamno, const std::string &tag_key);
std::string get_tag_key() const;
protected:
bool edit_impl(Tags &) override;
private:
const std::string tag_key;
};
}

View File

@ -0,0 +1,59 @@
#include "tags_handlers/stream_tags_handler.h"
#include <iostream>
using namespace opustags;
const int StreamTagsHandler::ALL_STREAMS = -1;
StreamTagsHandler::StreamTagsHandler(const int streamno)
: streamno(streamno), work_finished(false)
{
}
int StreamTagsHandler::get_streamno() const
{
return streamno;
}
bool StreamTagsHandler::relevant(const int streamno)
{
return streamno == this->streamno || this->streamno == ALL_STREAMS;
}
void StreamTagsHandler::list(const int streamno, const Tags &tags)
{
if (!relevant(streamno))
return;
list_impl(tags);
work_finished = this->streamno != ALL_STREAMS;
}
bool StreamTagsHandler::edit(const int streamno, Tags &tags)
{
if (!relevant(streamno))
return false;
const auto ret = edit_impl(tags);
work_finished = this->streamno != ALL_STREAMS;
return ret;
}
bool StreamTagsHandler::done()
{
return work_finished;
}
void StreamTagsHandler::end_of_file()
{
if (!work_finished && streamno != ALL_STREAMS)
std::cerr << "warning: stream " << streamno << " wasn't found" << std::endl;
}
void StreamTagsHandler::list_impl(const Tags &)
{
}
bool StreamTagsHandler::edit_impl(Tags &)
{
return false;
}

View File

@ -0,0 +1,33 @@
#pragma once
#include "tags_handler.h"
namespace opustags {
// Base handler that holds the stream number it's supposed to work with
// and performs the usual boilerplate.
class StreamTagsHandler : public ITagsHandler
{
public:
static const int ALL_STREAMS;
StreamTagsHandler(const int streamno);
int get_streamno() const;
bool relevant(const int streamno) override;
void list(const int streamno, const Tags &) override;
bool edit(const int streamno, Tags &) override;
bool done() override;
void end_of_file() override;
protected:
virtual void list_impl(const Tags &);
virtual bool edit_impl(Tags &);
private:
const int streamno;
bool work_finished;
};
}

View File

@ -0,0 +1,13 @@
#include "tags_handlers_errors.h"
using namespace opustags;
TagAlreadyExistsError::TagAlreadyExistsError(const std::string &tag_key)
: std::runtime_error("Tag already exists: " + tag_key)
{
}
TagDoesNotExistError::TagDoesNotExistError(const std::string &tag_key)
: std::runtime_error("Tag does not exist: " + tag_key)
{
}

View File

@ -0,0 +1,17 @@
#pragma once
#include <stdexcept>
namespace opustags {
struct TagAlreadyExistsError : std::runtime_error
{
TagAlreadyExistsError(const std::string &tag_key);
};
struct TagDoesNotExistError : std::runtime_error
{
TagDoesNotExistError(const std::string &tag_key);
};
}

10
src/version.h.in Normal file
View File

@ -0,0 +1,10 @@
#pragma once
#include <string>
namespace opustags {
static const std::string version_short = "@VERSION_SHORT@";
static const std::string version_long = "@VERSION_LONG@";
}

View File

@ -1,22 +0,0 @@
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
)

157
t/cli.cc
View File

@ -1,157 +0,0 @@
#include <opustags.h>
#include "tap.h"
#include <string.h>
using namespace std::literals::string_literals;
void check_read_comments()
{
std::vector<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, FILE *comments)
{
int argc = args.size();
char* argv[argc];
for (int i = 0; i < argc; ++i)
argv[i] = strdup(args[i]);
ot::status rc = ot::parse_options(argc, argv, opt, comments);
for (int i = 0; i < argc; ++i)
free(argv[i]);
return rc;
}
void check_good_arguments()
{
auto parse = [](std::vector<const char*> args) {
ot::options opt;
std::string txt = "N=1\n"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
ot::status rc = parse_options(args, opt, input.get());
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.paths_in.size() != 1 || opt.paths_in.front() != "x" || !opt.path_out ||
opt.path_out != "y" || !opt.delete_all || opt.overwrite || opt.to_delete.size() != 2 ||
opt.to_delete[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.paths_in.size() != 1 || opt.paths_in.front() != "x" || opt.path_out ||
!opt.overwrite || opt.to_delete.size() != 0 ||
opt.to_add.size() != 2 || opt.to_add[0] != "N=1" || opt.to_add[1] != "x=y z")
throw failure("unexpected option parsing result for case #2");
opt = parse({"opustags", "-i", "x", "y", "z"});
if (opt.paths_in.size() != 3 || opt.paths_in[0] != "x" || opt.paths_in[1] != "y" ||
opt.paths_in[2] != "z" || !opt.overwrite || !opt.in_place)
throw failure("unexpected option parsing result for case #3");
}
void check_bad_arguments()
{
auto error_code_case = [](std::vector<const char*> args, const char* message, ot::st error_code, const std::string& name) {
ot::options opt;
std::string txt = "N=1\nINVALID"s;
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
ot::status rc = parse_options(args, opt, input.get());
if (rc.code != error_code)
throw failure("bad error code for case " + name);
if (rc.message != message)
throw failure("bad error message for case " + name + ", got: " + rc.message);
};
auto error_case = [&error_code_case](std::vector<const char*> args, const char* message, const std::string& name) {
error_code_case(args, message, ot::st::bad_arguments, name);
};
error_case({"opustags"}, "No arguments specified. Use -h for help.", "no arguments");
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", "-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");
error_code_case({"opustags", "-S", "x"}, "Malformed tag: INVALID", ot::st::error, "attempt to read invalid argument with -S");
error_case({"opustags", "-o", "", "--output", "y", "z"},
"Cannot specify --output more than once.", "double output with first filename empty");
}
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;
}

Binary file not shown.

170
t/ogg.cc
View File

@ -1,170 +0,0 @@
#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;
}

View File

@ -1,42 +0,0 @@
/**
* \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
View File

@ -1,147 +0,0 @@
#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;
}

View File

@ -1,274 +0,0 @@
#!/usr/bin/env perl
use strict;
use warnings;
use utf8;
use Test::More tests => 41;
use Digest::MD5;
use File::Basename;
use File::Copy;
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 -i FILE...
opustags OPTIONS FILE -o FILE
Options:
-h, --help print this help
-o, --output FILE specify the output file
-i, --in-place overwrite the input files
-y, --overwrite overwrite the output file if it already exists
-a, --add FIELD=VALUE add a comment
-d, --delete FIELD[=VALUE] delete previously existing comments
-D, --delete-all delete all the previously existing comments
-s, --set FIELD=VALUE replace a comment
-S, --set-all import comments from standard input
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');
../opustags: 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');
my $previous_umask = umask(0022);
is_deeply(opustags(qw(gobble.opus -o out.opus)), ['', '', 0], 'copy the file without changes');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'the copy is faithful');
is((stat 'out.opus')[2] & 0777, 0644, 'apply umask on new files');
umask($previous_umask);
# 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');
gobble.opus: 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');
chmod(0604, 'out.opus');
is_deeply(opustags(qw(gobble.opus -o out.opus --overwrite)), ['', '', 0], 'overwrite');
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'successfully overwritten');
is((stat 'out.opus')[2] & 0777, 0604, 'overwriting preserves output file\'s mode');
chmod(0700, 'out.opus');
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((stat 'out.opus')[2] & 0777, 0700, 'in-place editing preserves file mode');
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 --in-place
unlink('out2.opus');
copy('gobble.opus', 'out.opus');
is_deeply(opustags(qw(out.opus --add BAR=baz -o out2.opus)), ['', '', 0], 'process multiple files with --in-place');
is_deeply(opustags(qw(--in-place --add FOO=bar out.opus out2.opus)), ['', '', 0], 'process multiple files with --in-place');
is(md5('out.opus'), '30ba30c4f236c09429473f36f8f861d2', 'the tags were added correctly (out.opus)');
is(md5('out2.opus'), '0a4d20c287b2e46b26cb0eee353c2069', 'the tags were added correctly (out2.opus)');
unlink('out.opus');
unlink('out2.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');
muxed.ogg: 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
}

View File

@ -1,58 +0,0 @@
#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
View File

@ -1,64 +0,0 @@
/**
* \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);
}
}
}

169
tests/actions_test.cc Normal file
View File

@ -0,0 +1,169 @@
#include "actions.h"
#include "catch.h"
#include "ogg.h"
#include "tags_handlers/insertion_tags_handler.h"
#include "tags_handlers/stream_tags_handler.h"
#include <fstream>
#include <cstring>
using namespace opustags;
static bool same_streams(ogg::Decoder &a, ogg::Decoder &b)
{
std::shared_ptr<ogg::Stream> sa, sb;
ogg_packet pa, pb;
int ra, rb;
for (;;) {
sa = a.read_page();
sb = b.read_page();
if (!sa && !sb)
break; // both reached the end at the same time
if (!sa || !sb)
return false; // one stream is shorter than the other
if (sa->stream.serialno != sb->stream.serialno)
return false;
for (;;) {
ra = ogg_stream_packetout(&sa->stream, &pa);
rb = ogg_stream_packetout(&sb->stream, &pb);
if (ra != rb)
return false;
else if (ra == 0)
break;
else if (ra < 0)
throw std::runtime_error("ogg_stream_packetout failed");
// otherwise we got a valid packet on both sides
if (pa.bytes != pb.bytes || pa.b_o_s != pb.b_o_s || pa.e_o_s != pb.e_o_s)
return false;
if (pa.granulepos != pb.granulepos || pa.packetno != pb.packetno)
return false;
if (memcmp(pa.packet, pb.packet, pa.bytes) != 0)
return false;
}
}
return true;
}
static bool same_files(std::istream &a, std::istream &b)
{
static const size_t block = 1024;
char ba[block], bb[block];
while (a && b) {
a.read(ba, block);
b.read(bb, block);
if (a.gcount() != b.gcount())
return false;
if (memcmp(ba, bb, a.gcount()) != 0)
return false;
}
if (a || b)
return false; // one of them is shorter
return true;
}
TEST_CASE("editing an unknown stream should do nothing", "[actions]")
{
std::ifstream in("../tests/samples/beep.ogg");
std::stringstream out;
ogg::Decoder dec(in);
ogg::Encoder enc(out);
StreamTagsHandler editor(StreamTagsHandler::ALL_STREAMS);
edit_tags(dec, enc, editor);
in.clear();
in.seekg(0, in.beg);
out.clear();
out.seekg(0, out.beg);
REQUIRE(same_files(in, out));
}
TEST_CASE("fake editing of an Opus stream should preserve the stream", "[actions]")
{
std::ifstream in("../tests/samples/mystery-beep.ogg");
std::stringstream out;
ogg::Decoder dec(in);
ogg::Encoder enc(out);
StreamTagsHandler editor(StreamTagsHandler::ALL_STREAMS);
edit_tags(dec, enc, editor);
in.clear();
in.seekg(0, in.beg);
out.clear();
out.seekg(0, out.beg);
REQUIRE(same_files(in, out));
}
TEST_CASE("editing a specific stream", "[actions]")
{
std::ifstream in("../tests/samples/mystery-beep.ogg");
std::stringstream out;
{
ogg::Decoder dec(in);
ogg::Encoder enc(out);
InsertionTagsHandler editor(3, "pwnd", "yes");
edit_tags(dec, enc, editor);
}
in.clear();
in.seekg(0, in.beg);
out.clear();
out.seekg(0, out.beg);
{
ogg::Decoder a(in);
ogg::Decoder b(out);
std::shared_ptr<ogg::Stream> s2[6];
for (int i = 0; i < 6; i++) {
// get the headers
a.read_page();
s2[i] = b.read_page();
}
REQUIRE(s2[0]->type == ogg::OPUS_STREAM);
REQUIRE(s2[1]->type == ogg::UNKNOWN_STREAM);
REQUIRE(s2[2]->type == ogg::OPUS_STREAM);
REQUIRE(!s2[0]->tags.contains("pwnd"));
REQUIRE(s2[2]->tags.get("pwnd") == "yes");
REQUIRE(same_streams(a, b));
}
}
class GetLanguages : public StreamTagsHandler {
public:
GetLanguages() : StreamTagsHandler(StreamTagsHandler::ALL_STREAMS) {}
std::vector<std::string> languages;
protected:
void list_impl(const Tags &tags) {
languages.push_back(tags.get("LANGUAGE"));
}
};
TEST_CASE("listing tags", "[actions]")
{
std::ifstream in("../tests/samples/mystery-beep.ogg");
ogg::Decoder dec(in);
GetLanguages lister;
list_tags(dec, lister);
REQUIRE(lister.languages.size() == 2);
REQUIRE(lister.languages[0] == "und");
REQUIRE(lister.languages[1] == "und");
}
TEST_CASE("listing tags, full scan", "[actions]")
{
std::ifstream in("../tests/samples/mystery-beep.ogg");
ogg::Decoder dec(in);
GetLanguages lister;
list_tags(dec, lister, true);
REQUIRE(lister.languages.size() == 2);
REQUIRE(lister.languages[0] == "und");
REQUIRE(lister.languages[1] == "und");
}

2
tests/main.cc Normal file
View File

@ -0,0 +1,2 @@
#define CATCH_CONFIG_MAIN
#include "catch.h"

148
tests/ogg_test.cc Normal file
View File

@ -0,0 +1,148 @@
#include "ogg.h"
#include "catch.h"
#include <fstream>
using namespace opustags;
TEST_CASE("decoding a single-stream Ogg Opus file", "[ogg]")
{
std::ifstream src("../tests/samples/mystery.ogg");
ogg::Decoder dec(src);
std::shared_ptr<ogg::Stream> s = dec.read_page();
REQUIRE(s != nullptr);
REQUIRE(s->state == ogg::HEADER_READY);
REQUIRE(s->type == ogg::OPUS_STREAM);
std::shared_ptr<ogg::Stream> s2 = dec.read_page();
REQUIRE(s2 == s);
REQUIRE(s->state == ogg::TAGS_READY);
REQUIRE(s->type == ogg::OPUS_STREAM);
REQUIRE(s->tags.get("encoder") == "Lavc57.24.102 libopus");
}
TEST_CASE("decoding garbage", "[ogg]")
{
std::ifstream src("Makefile");
ogg::Decoder dec(src);
REQUIRE_THROWS(dec.read_page());
}
TEST_CASE("decoding an Ogg Vorbis file", "[ogg]")
{
std::ifstream src("../tests/samples/beep.ogg");
ogg::Decoder dec(src);
std::shared_ptr<ogg::Stream> s = dec.read_page();
REQUIRE(s != nullptr);
REQUIRE(s->state == ogg::HEADER_READY);
REQUIRE(s->type == ogg::UNKNOWN_STREAM);
s = dec.read_page();
REQUIRE(s != nullptr);
REQUIRE(s->state == ogg::RAW_READY);
}
TEST_CASE("decoding a multi-stream file", "[ogg]")
{
std::ifstream src("../tests/samples/mystery-beep.ogg");
ogg::Decoder dec(src);
std::shared_ptr<ogg::Stream> first, second, third, s;
s = dec.read_page();
first = s;
REQUIRE(s != nullptr);
REQUIRE(s->state == ogg::HEADER_READY);
REQUIRE(s->type == ogg::OPUS_STREAM);
s = dec.read_page();
second = s;
REQUIRE(s != nullptr);
REQUIRE(s != first);
REQUIRE(s->state == ogg::HEADER_READY);
REQUIRE(s->type == ogg::UNKNOWN_STREAM);
s = dec.read_page();
third = s;
REQUIRE(s != nullptr);
REQUIRE(s != first);
REQUIRE(s != second);
REQUIRE(s->state == ogg::HEADER_READY);
REQUIRE(s->type == ogg::OPUS_STREAM);
s = dec.read_page();
REQUIRE(s == first);
REQUIRE(s->state == ogg::TAGS_READY);
s = dec.read_page();
REQUIRE(s == second);
REQUIRE(s->state == ogg::RAW_READY);
s = dec.read_page();
REQUIRE(s == third);
REQUIRE(s->state == ogg::TAGS_READY);
}
static void craft_stream(std::ostream &out, const std::string &tags_data)
{
ogg::Encoder enc(out);
ogg_stream_state os;
ogg_page og;
ogg_packet op;
ogg_stream_init(&os, 0);
op.packet = reinterpret_cast<unsigned char*>(const_cast<char*>("OpusHead"));
op.bytes = 8;
op.b_o_s = 1;
op.e_o_s = 0;
op.granulepos = 0;
op.packetno = 0;
ogg_stream_packetin(&os, &op);
ogg_stream_flush(&os, &og);
enc.write_raw_page(og);
op.packet = reinterpret_cast<unsigned char*>(const_cast<char*>(tags_data.data()));
op.bytes = tags_data.size();
op.b_o_s = 0;
op.e_o_s = 1;
op.granulepos = 0;
op.packetno = 1;
ogg_stream_packetin(&os, &op);
ogg_stream_flush(&os, &og);
enc.write_raw_page(og);
ogg_stream_clear(&os);
}
const char *evil_tags =
"OpusTags"
"\x00\x00\x00\x00" "" /* vendor */
"\x01\x00\x00\x00" /* one comment */
"\xFA\x00\x00\x00" "TITLE=Evil"
/* ^ should be \x0A as the length of the comment is 10 */
;
TEST_CASE("decoding a malicious Ogg Opus file", "[ogg]")
{
std::stringstream buf;
craft_stream(buf, std::string(evil_tags, 8 + 4 + 4 + 4 + 10));
buf.seekg(0, buf.beg);
ogg::Decoder dec(buf);
std::shared_ptr<ogg::Stream> s = dec.read_page();
REQUIRE(s != nullptr);
REQUIRE(s->state == ogg::HEADER_READY);
REQUIRE(s->type == ogg::OPUS_STREAM);
REQUIRE_THROWS(dec.read_page());
}
TEST_CASE("decoding a bad stream", "[ogg]")
{
std::ifstream in("uioprheuio");
REQUIRE_THROWS(std::make_shared<ogg::Decoder>(in));
}
// Encoding is trickier, and might as well be done in actions_test.cc, given
// opustags::edit_tags covers all of Encoder's regular code.

205
tests/options_test.cc Normal file
View File

@ -0,0 +1,205 @@
#include "options.h"
#include "catch.h"
#include "tags_handlers/export_tags_handler.h"
#include "tags_handlers/external_edit_tags_handler.h"
#include "tags_handlers/import_tags_handler.h"
#include "tags_handlers/insertion_tags_handler.h"
#include "tags_handlers/listing_tags_handler.h"
#include "tags_handlers/modification_tags_handler.h"
#include "tags_handlers/removal_tags_handler.h"
#include <memory>
using namespace opustags;
static std::unique_ptr<char[]> string_to_uptr(const std::string &str)
{
auto ret = std::make_unique<char[]>(str.size() + 1);
for (size_t i = 0; i < str.size(); i++)
ret[i] = str[i];
ret[str.size()] = 0;
return ret;
}
static Options retrieve_options(
std::vector<std::string> args, bool fake_input_path = true)
{
// need to pass non-const char*, but we got const objects. make copies
std::vector<std::unique_ptr<char[]>> arg_holders;
arg_holders.push_back(string_to_uptr("fake/path/to/program"));
for (size_t i = 0; i < args.size(); i++)
arg_holders.push_back(string_to_uptr(args[i]));
if (fake_input_path)
arg_holders.push_back(string_to_uptr("fake/path/to/input"));
auto plain_args = std::make_unique<char*[]>(arg_holders.size());
for (size_t i = 0; i < arg_holders.size(); i++)
plain_args[i] = arg_holders[i].get();
return parse_args(arg_holders.size(), plain_args.get());
}
// retrieve a specific TagsHandler from the CompositeTagsHandler in options
template<typename T> static T *get_handler(
const Options &options, const size_t index)
{
const auto handlers = options.tags_handler.get_handlers();
const auto handler = dynamic_cast<T*>(handlers.at(index).get());
REQUIRE(handler);
return handler;
}
TEST_CASE("option parsing", "[options]")
{
SECTION("--help") {
REQUIRE(retrieve_options({"--help"}).show_help);
REQUIRE(retrieve_options({"-h"}).show_help);
REQUIRE(!retrieve_options({}).show_help);
}
SECTION("--version") {
REQUIRE(retrieve_options({"--version"}).show_version);
REQUIRE(retrieve_options({"-V"}).show_version);
REQUIRE(!retrieve_options({}).show_version);
}
SECTION("--overwrite") {
REQUIRE(retrieve_options({"--overwrite"}).overwrite);
REQUIRE(retrieve_options({"-y"}).overwrite);
REQUIRE(!retrieve_options({}).overwrite);
}
SECTION("--full") {
REQUIRE(retrieve_options({"--full"}).full);
REQUIRE(!retrieve_options({}).full);
}
SECTION("--in-place") {
REQUIRE(!retrieve_options({}).in_place);
REQUIRE(retrieve_options({}).path_out.empty());
REQUIRE(retrieve_options({"--in-place", "ABC"}, false).in_place);
REQUIRE(
retrieve_options({"--in-place", "ABC"}, false).path_out == ".otmp");
REQUIRE(retrieve_options({"--in-place"}).in_place);
REQUIRE(retrieve_options({"--in-place"}).path_out == ".otmp");
REQUIRE(retrieve_options({"--in-place=ABC"}).in_place);
REQUIRE(retrieve_options({"--in-place=ABC"}).path_out == "ABC");
REQUIRE(retrieve_options({"-i", "ABC"}, false).in_place);
REQUIRE(retrieve_options({"-i", "ABC"}, false).path_out == ".otmp");
REQUIRE(retrieve_options({"-i"}).in_place);
REQUIRE(retrieve_options({"-i"}).path_out == ".otmp");
REQUIRE(retrieve_options({"-iABC"}).in_place);
REQUIRE(retrieve_options({"-iABC"}).path_out == "ABC");
}
SECTION("input") {
REQUIRE_THROWS(retrieve_options({}, false));
REQUIRE_THROWS(retrieve_options({""}, false));
REQUIRE(retrieve_options({"input"}, false).path_in == "input");
REQUIRE_THROWS(retrieve_options({"input", "extra argument"}, false));
}
SECTION("--output") {
REQUIRE(retrieve_options({"--output", "ABC"}).path_out == "ABC");
REQUIRE(retrieve_options({"-o", "ABC"}).path_out == "ABC");
REQUIRE_THROWS(retrieve_options({"--output", ""}));
REQUIRE_THROWS(retrieve_options({"-o", ""}));
}
SECTION("--import") {
get_handler<ImportTagsHandler>(retrieve_options({"--import"}), 0);
}
SECTION("--export") {
get_handler<ExportTagsHandler>(retrieve_options({"--export"}), 0);
}
SECTION("--edit") {
get_handler<ExternalEditTagsHandler>(retrieve_options({"--edit"}), 0);
get_handler<ExternalEditTagsHandler>(retrieve_options({"-e"}), 0);
}
SECTION("--list") {
get_handler<ListingTagsHandler>(retrieve_options({"--list"}), 0);
get_handler<ListingTagsHandler>(retrieve_options({"-l"}), 0);
// TODO:
// test enabling / disabling colors, which should be
// contained inside the state of ListingTagsHandler
// TODO:
// it should be the default operation for readonly mode - test this too
}
SECTION("--delete-all") {
REQUIRE(get_handler<RemovalTagsHandler>(
retrieve_options({"--delete-all"}), 0)->get_tag_key().empty());
REQUIRE(get_handler<RemovalTagsHandler>(
retrieve_options({"-D"}), 0)->get_tag_key().empty());
}
SECTION("--delete") {
const auto opts = retrieve_options({"--delete", "A", "-d", "B"});
REQUIRE(get_handler<RemovalTagsHandler>(opts, 0)->get_tag_key() == "A");
REQUIRE(get_handler<RemovalTagsHandler>(opts, 1)->get_tag_key() == "B");
REQUIRE_THROWS(retrieve_options({"--delete", "invalid="}));
}
SECTION("--add") {
const auto opts = retrieve_options({"--add", "A=1", "-a", "B=2"});
const auto handler1 = get_handler<InsertionTagsHandler>(opts, 0);
const auto handler2 = get_handler<InsertionTagsHandler>(opts, 1);
REQUIRE(handler1->get_tag_key() == "A");
REQUIRE(handler1->get_tag_value() == "1");
REQUIRE(handler2->get_tag_key() == "B");
REQUIRE(handler2->get_tag_value() == "2");
REQUIRE_THROWS(retrieve_options({"--add", "invalid"}));
}
SECTION("--set") {
const auto opts = retrieve_options({"--set", "A=1", "-s", "B=2"});
const auto handler1 = get_handler<ModificationTagsHandler>(opts, 0);
const auto handler2 = get_handler<ModificationTagsHandler>(opts, 1);
REQUIRE(handler1->get_tag_key() == "A");
REQUIRE(handler1->get_tag_value() == "1");
REQUIRE(handler2->get_tag_key() == "B");
REQUIRE(handler2->get_tag_value() == "2");
REQUIRE_THROWS(retrieve_options({"--set", "invalid"}));
}
SECTION("--stream") {
// by default, use all the streams
REQUIRE(
get_handler<RemovalTagsHandler>(retrieve_options({"-d", "xyz"}), 0)
->get_streamno() == StreamTagsHandler::ALL_STREAMS);
// ...unless the user supplies an option to use a specific stream
REQUIRE(
get_handler<RemovalTagsHandler>(
retrieve_options({"--stream", "1", "-d", "xyz"}), 0)
->get_streamno() == 1);
// ...which can be combined multiple times
{
const auto opts = retrieve_options({
"--stream", "1",
"-d", "xyz",
"--stream", "2",
"-d", "abc"});
const auto handler1 = get_handler<RemovalTagsHandler>(opts, 0);
const auto handler2 = get_handler<RemovalTagsHandler>(opts, 1);
REQUIRE(handler1->get_streamno() == 1);
REQUIRE(handler2->get_streamno() == 2);
}
// ...or contain comma separated values
{
const auto opts = retrieve_options({
"--stream", "1,2", "-d", "xyz"});
const auto handler1 = get_handler<RemovalTagsHandler>(opts, 0);
const auto handler2 = get_handler<RemovalTagsHandler>(opts, 1);
REQUIRE(handler1->get_streamno() == 1);
REQUIRE(handler2->get_streamno() == 2);
}
}
}

BIN
tests/samples/beep.ogg Normal file

Binary file not shown.

Binary file not shown.

BIN
tests/samples/mystery.ogg Normal file

Binary file not shown.

View File

@ -0,0 +1,96 @@
#include "tags_handlers/composite_tags_handler.h"
#include "catch.h"
using namespace opustags;
namespace
{
struct DummyTagsHandler : ITagsHandler
{
DummyTagsHandler();
bool relevant(const int streamno) override;
void list(const int streamno, const Tags &) override;
bool edit(const int streamno, Tags &) override;
bool done() override;
bool relevant_ret, edit_ret, done_ret, list_fired;
};
}
DummyTagsHandler::DummyTagsHandler()
: relevant_ret(true), edit_ret(true), done_ret(true), list_fired(false)
{
}
bool DummyTagsHandler::relevant(const int)
{
return relevant_ret;
}
void DummyTagsHandler::list(const int, const Tags &)
{
list_fired = true;
}
bool DummyTagsHandler::edit(const int, Tags &)
{
return edit_ret;
}
bool DummyTagsHandler::done()
{
return done_ret;
}
TEST_CASE("composite tags handler", "[tags_handlers]")
{
auto handler1 = std::make_shared<DummyTagsHandler>();
auto handler2 = std::make_shared<DummyTagsHandler>();
CompositeTagsHandler composite_handler;
composite_handler.add_handler(handler1);
composite_handler.add_handler(handler2);
SECTION("relevance") {
const int dummy_streamno = 1;
handler1->relevant_ret = true;
handler2->relevant_ret = true;
REQUIRE(composite_handler.relevant(dummy_streamno));
handler1->relevant_ret = false;
REQUIRE(composite_handler.relevant(dummy_streamno));
handler2->relevant_ret = false;
REQUIRE(!composite_handler.relevant(dummy_streamno));
}
SECTION("listing") {
const int dummy_streamno = 1;
Tags dummy_tags;
REQUIRE(!handler1->list_fired);
REQUIRE(!handler2->list_fired);
composite_handler.list(dummy_streamno, dummy_tags);
REQUIRE(handler1->list_fired);
REQUIRE(handler2->list_fired);
}
SECTION("editing") {
const int dummy_streamno = 1;
Tags dummy_tags;
handler1->edit_ret = true;
handler2->edit_ret = true;
REQUIRE(composite_handler.edit(dummy_streamno, dummy_tags));
handler1->edit_ret = false;
REQUIRE(composite_handler.edit(dummy_streamno, dummy_tags));
handler2->edit_ret = false;
REQUIRE(!composite_handler.edit(dummy_streamno, dummy_tags));
}
SECTION("finish") {
handler1->done_ret = true;
handler2->done_ret = true;
REQUIRE(composite_handler.done());
handler1->done_ret = false;
REQUIRE(!composite_handler.done());
handler2->done_ret = false;
REQUIRE(!composite_handler.done());
}
}

View File

@ -0,0 +1,23 @@
#include "tags_handlers/export_tags_handler.h"
#include "catch.h"
#include <sstream>
using namespace opustags;
TEST_CASE("export tags handler", "[tags_handlers]")
{
std::stringstream ss;
ExportTagsHandler handler(ss);
handler.list(1, {{{"a", "value1"}, {"b", "value2"}}});
handler.list(2, {{{"c", "value3"}, {"d", "value4"}}});
REQUIRE(ss.str() == R"([Stream 1]
a=value1
b=value2
[Stream 2]
c=value3
d=value4
)");
}

View File

@ -0,0 +1,142 @@
#include "tags_handlers/import_tags_handler.h"
#include "catch.h"
#include <sstream>
using namespace opustags;
TEST_CASE("import tags handler", "[tags_handlers]")
{
SECTION("tags for streams not mentioned in import get emptied")
{
Tags tags = {{{"remove", "me"}}};
std::stringstream ss;
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().empty());
}
SECTION("streams that do not exist in the input file are ignored")
{
// TODO: rather than ignoring, at least print a warning
// requires #6
Tags tags;
std::stringstream ss;
ss << "[Stream 5]\nkey = value\n";
ImportTagsHandler handler(ss);
REQUIRE(!handler.edit(1, tags));
REQUIRE(tags.get_all().empty());
}
SECTION("adding unique tags")
{
Tags tags;
std::stringstream ss;
ss << "[Stream 1]\nkey = value\nkey2 = value2\n";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get("key") == "value");
REQUIRE(tags.get("key2") == "value2");
}
SECTION("adding tags with the same key")
{
Tags tags;
std::stringstream ss;
ss << "[Stream 1]\nkey = value1\nkey = value2\n";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get_all().at(0).key == "key");
REQUIRE(tags.get_all().at(1).key == "key");
REQUIRE(tags.get_all().at(0).value == "value1");
REQUIRE(tags.get_all().at(1).value == "value2");
}
SECTION("overwriting existing tags")
{
Tags tags = {{{"remove", "me"}}};
std::stringstream ss;
ss << "[Stream 1]\nkey = value\nkey2 = value2\n";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get("key") == "value");
REQUIRE(tags.get("key2") == "value2");
}
SECTION("various whitespace issues are worked around")
{
Tags tags;
std::stringstream ss;
ss << " [StrEaM 1] \n\n key = value \nkey2=value2\n";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get("key") == "value");
REQUIRE(tags.get("key2") == "value2");
}
SECTION("not specifying stream assumes first stream")
{
Tags tags;
std::stringstream ss;
ss << "key=value";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().size() == 1);
REQUIRE(tags.get("key") == "value");
}
SECTION("multiple streams")
{
Tags tags1;
Tags tags2;
std::stringstream ss;
ss << "[stream 1]\nkey=value\n[stream 2]\nkey2=value2";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags1));
REQUIRE(handler.edit(2, tags2));
REQUIRE(tags1.get_all().size() == 1);
REQUIRE(tags2.get_all().size() == 1);
REQUIRE(tags1.get("key") == "value");
REQUIRE(tags2.get("key2") == "value2");
}
SECTION("sections listed twice are concatenated")
{
Tags tags;
std::stringstream ss;
ss << "[stream 1]\nkey=value\n"
"[stream 2]\nkey=irrelevant\n"
"[Stream 1]\nkey2=value2";
ImportTagsHandler handler(ss);
REQUIRE(handler.edit(1, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get("key") == "value");
REQUIRE(tags.get("key2") == "value2");
}
SECTION("weird input throws errors - malformed section headers")
{
Tags tags;
std::stringstream ss;
ss << "[stream huh]\n";
REQUIRE_THROWS({
ImportTagsHandler handler(ss);
handler.edit(1, tags);
});
}
SECTION("weird input throws errors - malformed lines")
{
Tags tags;
std::stringstream ss;
ss << "tag huh\n";
REQUIRE_THROWS({
ImportTagsHandler handler(ss);
handler.edit(1, tags);
});
}
}

View File

@ -0,0 +1,22 @@
#include "tags_handlers/insertion_tags_handler.h"
#include "catch.h"
using namespace opustags;
TEST_CASE("insertion tags handler", "[tags_handlers]")
{
Tags tags;
const auto streamno = 1;
const auto expected_tag_key = "tag_key";
const auto expected_tag_value = "tag_value";
InsertionTagsHandler handler(
streamno, expected_tag_key, expected_tag_value);
REQUIRE(handler.edit(streamno, tags));
REQUIRE(tags.get_all().size() == 1);
REQUIRE(tags.get(expected_tag_key) == expected_tag_value);
REQUIRE(handler.edit(streamno, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get(expected_tag_key) == expected_tag_value);
}

View File

@ -0,0 +1,22 @@
#include "tags_handlers/listing_tags_handler.h"
#include "catch.h"
#include <sstream>
using namespace opustags;
TEST_CASE("listing tags handler", "[tags_handlers]")
{
const auto streamno = 1;
Tags tags;
tags.add("z", "value1");
tags.add("a", "value2");
tags.add("y", "value3");
tags.add("c", "value4");
std::stringstream ss;
ListingTagsHandler handler(streamno, ss);
handler.list(streamno, tags);
REQUIRE(ss.str() == "z=value1\na=value2\ny=value3\nc=value4\n");
}

View File

@ -0,0 +1,33 @@
#include "tags_handlers/modification_tags_handler.h"
#include "catch.h"
using namespace opustags;
TEST_CASE("modification tags handler", "[tags_handlers]")
{
const auto streamno = 1;
const auto first_tag_key = "tag_key";
const auto other_tag_key = "other_tag_key";
const auto dummy_value = "dummy";
const auto new_value = "dummy 2";
Tags tags;
tags.add(first_tag_key, dummy_value);
REQUIRE(tags.get_all().size() == 1);
// setting nonexistent keys adds them
ModificationTagsHandler handler1(streamno, other_tag_key, dummy_value);
REQUIRE(handler1.edit(streamno, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get(other_tag_key) == dummy_value);
// setting existing keys overrides their values
ModificationTagsHandler handler2(streamno, other_tag_key, new_value);
REQUIRE(handler2.edit(streamno, tags));
REQUIRE(tags.get_all().size() == 2);
REQUIRE(tags.get(other_tag_key) == new_value);
// setting existing keys reports no modifications if values are the same
REQUIRE(!handler2.edit(streamno, tags));
}

View File

@ -0,0 +1,43 @@
#include "tags_handlers/removal_tags_handler.h"
#include "catch.h"
using namespace opustags;
TEST_CASE("removal tags handler", "[tags_handlers]")
{
const auto streamno = 1;
SECTION("removing a single tag")
{
const auto expected_tag_key = "tag_key";
const auto other_tag_key = "other_tag_key";
const auto dummy_value = "dummy";
RemovalTagsHandler handler(streamno, expected_tag_key);
Tags tags;
tags.add(expected_tag_key, dummy_value);
tags.add(other_tag_key, dummy_value);
REQUIRE(tags.get_all().size() == 2);
REQUIRE(handler.edit(streamno, tags));
REQUIRE(tags.get_all().size() == 1);
REQUIRE(tags.contains(other_tag_key));
REQUIRE(!handler.edit(streamno, tags));
}
SECTION("removing all tags")
{
RemovalTagsHandler handler(streamno);
Tags tags;
tags.add("z", "value1");
tags.add("a", "value2");
tags.add("y", "value3");
tags.add("c", "value4");
REQUIRE(tags.get_all().size() == 4);
REQUIRE(handler.edit(streamno, tags));
REQUIRE(tags.get_all().size() == 0);
REQUIRE(!handler.edit(streamno, tags));
}
}

View File

@ -0,0 +1,92 @@
#include "tags_handlers/stream_tags_handler.h"
#include "catch.h"
using namespace opustags;
namespace
{
class DummyTagsHandler final : public StreamTagsHandler
{
public:
DummyTagsHandler(const int streamno);
protected:
void list_impl(const Tags &) override;
bool edit_impl(Tags &) override;
public:
bool list_fired, edit_fired;
};
}
DummyTagsHandler::DummyTagsHandler(const int streamno)
: StreamTagsHandler(streamno), list_fired(false), edit_fired(false)
{
}
void DummyTagsHandler::list_impl(const Tags &)
{
list_fired = true;
}
bool DummyTagsHandler::edit_impl(Tags &)
{
edit_fired = true;
return true;
}
TEST_CASE("stream-based tags handler", "[tags_handlers]")
{
SECTION("concrete stream") {
const auto relevant_stream_number = 1;
const auto irrelevant_stream_number = 2;
Tags dummy_tags;
DummyTagsHandler handler(relevant_stream_number);
SECTION("relevance") {
REQUIRE(!handler.relevant(irrelevant_stream_number));
REQUIRE(handler.relevant(relevant_stream_number));
}
SECTION("listing") {
handler.list(irrelevant_stream_number, dummy_tags);
REQUIRE(!handler.list_fired);
handler.list(relevant_stream_number, dummy_tags);
REQUIRE(handler.list_fired);
}
SECTION("Editing") {
REQUIRE(!handler.edit(irrelevant_stream_number, dummy_tags));
REQUIRE(!handler.edit_fired);
REQUIRE(handler.edit(relevant_stream_number, dummy_tags));
REQUIRE(handler.edit_fired);
}
SECTION("finish through listing") {
REQUIRE(!handler.edit(irrelevant_stream_number, dummy_tags));
REQUIRE(!handler.done());
REQUIRE(handler.edit(relevant_stream_number, dummy_tags));
REQUIRE(handler.done());
}
SECTION("finish through editing") {
handler.list(irrelevant_stream_number, dummy_tags);
REQUIRE(!handler.done());
handler.list(relevant_stream_number, dummy_tags);
REQUIRE(handler.done());
}
}
SECTION("any stream") {
Tags dummy_tags;
DummyTagsHandler handler(StreamTagsHandler::ALL_STREAMS);
REQUIRE(handler.relevant(1));
REQUIRE(handler.relevant(2));
REQUIRE(handler.relevant(3));
REQUIRE(!handler.done());
handler.list(1, dummy_tags);
REQUIRE(!handler.done());
handler.list(2, dummy_tags);
REQUIRE(!handler.done());
}
}

85
tests/tags_test.cc Normal file
View File

@ -0,0 +1,85 @@
#include "tags.h"
#include "catch.h"
using namespace opustags;
TEST_CASE("tag manipulation", "[tags]")
{
SECTION("basic operations") {
Tags tags;
REQUIRE(!tags.contains("a"));
tags.add("a", "1");
REQUIRE(tags.get("a") == "1");
REQUIRE(tags.contains("a"));
tags.remove("a");
REQUIRE(!tags.contains("a"));
REQUIRE_THROWS(tags.get("a"));
}
SECTION("clearing") {
Tags tags;
tags.add("a", "1");
tags.add("b", "2");
REQUIRE(tags.get_all().size() == 2);
tags.clear();
REQUIRE(tags.get_all().empty());
}
SECTION("maintaing order of insertions") {
Tags tags;
tags.add("z", "1");
tags.add("y", "2");
tags.add("x", "3");
tags.add("y", "4");
REQUIRE(tags.get_all().size() == 4);
REQUIRE(tags.get_all()[0].key == "z");
REQUIRE(tags.get_all()[1].key == "y");
REQUIRE(tags.get_all()[2].key == "x");
REQUIRE(tags.get_all()[3].key == "y");
tags.remove("z");
REQUIRE(tags.get_all().size() == 3);
REQUIRE(tags.get_all()[0].key == "y");
REQUIRE(tags.get_all()[1].key == "x");
REQUIRE(tags.get_all()[2].key == "y");
tags.add("gamma", "5");
REQUIRE(tags.get_all().size() == 4);
REQUIRE(tags.get_all()[0].key == "y");
REQUIRE(tags.get_all()[1].key == "x");
REQUIRE(tags.get_all()[2].key == "y");
REQUIRE(tags.get_all()[3].key == "gamma");
}
SECTION("key to multiple values") {
// ARTIST is set once per artist.
// https://www.xiph.org/vorbis/doc/v-comment.html
Tags tags;
tags.add("ARTIST", "You");
tags.add("ARTIST", "Me");
REQUIRE(tags.get_all().size() == 2);
}
SECTION("removing multivalues should remove all of them") {
Tags tags;
tags.add("ARTIST", "You");
tags.add("ARTIST", "Me");
tags.remove("ARTIST");
REQUIRE(tags.get_all().empty());
}
SECTION("tag parsing") {
Tag t = parse_tag("TITLE=Foo=Bar");
REQUIRE(t.key == "TITLE");
REQUIRE(t.value == "Foo=Bar");
}
SECTION("case insensitiveness for keys") {
Tags tags;
tags.add("TITLE", "Boop");
REQUIRE(tags.get("title") == "Boop");
tags.remove("titLE");
REQUIRE(tags.get_all().empty());
}
}