mirror of
https://github.com/fmang/opustags.git
synced 2025-07-06 01:27:50 +02:00
Compare commits
102 Commits
Author | SHA1 | Date | |
---|---|---|---|
2ef9a825da | |||
4de88d0ed2 | |||
0624376fcc | |||
52e4a8ca58 | |||
dd0656cb07 | |||
d3b4a389bc | |||
410708e252 | |||
dad987a8da | |||
76bf95a74c | |||
2d7f812119 | |||
1ff2553f5f | |||
eb968dc513 | |||
a21331057b | |||
5ae31c9bc9 | |||
2957fa3538 | |||
74b9cade48 | |||
8e9204420b | |||
7b616aa671 | |||
159340926a | |||
e1d954388e | |||
d48573ceef | |||
d8dcc38777 | |||
7d20fc70b2 | |||
a210d1229e | |||
e60f7f84a0 | |||
a3daa0f108 | |||
84a8d14ae0 | |||
74904fd516 | |||
fc2a4cb41c | |||
1c2232c197 | |||
f26de884aa | |||
0d91429435 | |||
54571e8bc3 | |||
a06f337a63 | |||
b5d2e03a7b | |||
f726eaeb91 | |||
8f5a6bb534 | |||
7f7766f175 | |||
be3984423f | |||
2443490b0b | |||
a96cc9c222 | |||
cd550d8d80 | |||
4cd0e34d0d | |||
0e2b1fec9c | |||
817ba5cba6 | |||
20663a847f | |||
40bbc90786 | |||
d21517de94 | |||
a78906a8d4 | |||
32c5e2b0a6 | |||
449235ed5a | |||
661c469bd6 | |||
7f984e1492 | |||
4f1070f272 | |||
b4dc544031 | |||
5e19657d25 | |||
898846f1f4 | |||
c19233236a | |||
84a0ce55af | |||
95ddd2e7da | |||
9fd629bcc3 | |||
53fbf533fb | |||
0e3dfbe381 | |||
8364667b4c | |||
4d9ee3bf88 | |||
f941464c61 | |||
f469d76e62 | |||
acbf99d276 | |||
fe7f23576a | |||
c101041bd7 | |||
e5e7952b89 | |||
221c314625 | |||
7b6ad95b35 | |||
24c2dae49e | |||
02c966bacb | |||
4f5d33491b | |||
d9f123c84c | |||
a67f8c1472 | |||
d7b187ec59 | |||
fca4f81eac | |||
b362f6565f | |||
6832cbf3f5 | |||
2ee7702b9d | |||
aeca60d128 | |||
1d8d96cdee | |||
094bed2b35 | |||
b62ac98ca5 | |||
12327e6f68 | |||
326c164e09 | |||
a84aeaea96 | |||
8d1ea8d32d | |||
ff17d35531 | |||
c2d6763e2b | |||
6baf120a94 | |||
f1109dd04f | |||
390c9268a7 | |||
a8a6552f29 | |||
e198b5f9ef | |||
e474ec8420 | |||
15c9f187a5 | |||
3a320a7b39 | |||
5cdcb5457f |
@ -1,8 +0,0 @@
|
||||
root = true
|
||||
|
||||
[*]
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
charset = utf-8
|
||||
ident_style = tab
|
||||
max_line_length = 100
|
2
.gitignore
vendored
2
.gitignore
vendored
@ -1 +1,3 @@
|
||||
/build
|
||||
tests/catch.h
|
||||
src/version.h
|
||||
|
108
CHANGELOG.md
108
CHANGELOG.md
@ -1,108 +0,0 @@
|
||||
opustags changelog
|
||||
==================
|
||||
|
||||
1.10.1 - 2024-05-19
|
||||
-------------------
|
||||
|
||||
Fix a build error on recent systems.
|
||||
|
||||
1.10.0 - 2024-05-03
|
||||
-------------------
|
||||
|
||||
- Introduce -z to delimit tags with null bytes.
|
||||
|
||||
This option makes it possible to leverage GNU sed or GNU grep for automated tag edition with
|
||||
`opustags -z … | sed -z … | opustags -z -S …`, while also supporting multi-line tags.
|
||||
|
||||
1.9.0 - 2023-06-07
|
||||
------------------
|
||||
|
||||
- Introduce --vendor and --set-vendor.
|
||||
- Close the input file before finalizing the output, in order to fix --in-place on SMB drives.
|
||||
|
||||
1.8.0 - 2023-03-07
|
||||
------------------
|
||||
|
||||
- Introduce --set-cover and --output-cover.
|
||||
|
||||
opustags is now able to extract and edit the cover art of Opus files. The underlying
|
||||
METADATA_BLOCK_PICTURE tag will still appear as a regular tag, but you won’t have to handle it
|
||||
manually anymore.
|
||||
|
||||
1.7.0 - 2023-02-13
|
||||
------------------
|
||||
|
||||
- Support arbitrary large OpusTags headers.
|
||||
- Handle multiline tags by prefixing their continuation lines with tabs.
|
||||
|
||||
1.6.0 - 2021-01-01
|
||||
------------------
|
||||
|
||||
- UTF-8 conversion errors are now fatal.
|
||||
- Introduce --raw for disabling encoding conversions.
|
||||
- Improve platform compatibility.
|
||||
|
||||
This also happens to be opustags’s 8-year anniversary!
|
||||
|
||||
1.5.1 - 2020-11-21
|
||||
------------------
|
||||
|
||||
- Improve BSD support.
|
||||
|
||||
1.5.0 - 2020-11-08
|
||||
------------------
|
||||
|
||||
- Introduce --edit for interactive edition.
|
||||
|
||||
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.
|
120
CMakeLists.txt
120
CMakeLists.txt
@ -1,56 +1,78 @@
|
||||
cmake_minimum_required(VERSION 3.11)
|
||||
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.10.1
|
||||
LANGUAGES CXX
|
||||
)
|
||||
# ------------
|
||||
# Dependencies
|
||||
# ------------
|
||||
find_package(Ogg REQUIRED)
|
||||
include_directories(${Ogg_INCLUDE_DIR})
|
||||
link_directories(${Ogg_LIBRARY_DIRS})
|
||||
|
||||
set(CMAKE_CXX_STANDARD 20)
|
||||
set(CMAKE_CXX_STANDARD_REQUIRED ON)
|
||||
# --------------------
|
||||
# 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()
|
||||
|
||||
# opustags is mainly developed with glibc, which introduces a few
|
||||
# incompatibilites with BSDs, like getline not being defined by default.
|
||||
# _GNU_SOURCE should trigger BSD’s libc GNU compatibility mode to fix that.
|
||||
add_definitions(-D_GNU_SOURCE)
|
||||
# -------
|
||||
# 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)
|
||||
|
||||
find_package(PkgConfig REQUIRED)
|
||||
pkg_check_modules(OGG REQUIRED ogg)
|
||||
add_compile_options(${OGG_CFLAGS})
|
||||
link_directories(${OGG_LIBRARY_DIRS})
|
||||
# ------------
|
||||
# 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")
|
||||
|
||||
include(FindIconv)
|
||||
# -------------------
|
||||
# 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()
|
||||
|
||||
# We need endian.h on Linux, and sys/endian.h on BSD.
|
||||
include(CheckIncludeFileCXX)
|
||||
check_include_file_cxx(endian.h HAVE_ENDIAN_H)
|
||||
check_include_file_cxx(sys/endian.h HAVE_SYS_ENDIAN_H)
|
||||
# ------------
|
||||
# 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" )
|
||||
|
||||
include(CheckStructHasMember)
|
||||
check_struct_has_member("struct stat" st_mtim sys/stat.h HAVE_STAT_ST_MTIM LANGUAGE CXX)
|
||||
check_struct_has_member("struct stat" st_mtimespec sys/stat.h HAVE_STAT_ST_MTIMESPEC LANGUAGE CXX)
|
||||
|
||||
configure_file(src/config.h.in config.h @ONLY)
|
||||
include_directories(BEFORE src "${CMAKE_BINARY_DIR}" ${OGG_INCLUDE_DIRS} ${Iconv_INCLUDE_DIRS})
|
||||
|
||||
add_library(
|
||||
ot
|
||||
STATIC
|
||||
src/base64.cc
|
||||
src/cli.cc
|
||||
src/ogg.cc
|
||||
src/opus.cc
|
||||
src/system.cc
|
||||
)
|
||||
target_link_libraries(ot PUBLIC ${OGG_LIBRARIES} ${Iconv_LIBRARIES})
|
||||
|
||||
add_executable(opustags src/opustags.cc)
|
||||
target_link_libraries(opustags ot)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
install(TARGETS opustags DESTINATION "${CMAKE_INSTALL_BINDIR}")
|
||||
configure_file(opustags.1 . @ONLY)
|
||||
install(FILES "${CMAKE_BINARY_DIR}/opustags.1" DESTINATION "${CMAKE_INSTALL_MANDIR}/man1")
|
||||
install(FILES CHANGELOG.md CONTRIBUTING.md LICENSE README.md DESTINATION ${CMAKE_INSTALL_DOCDIR})
|
||||
|
||||
add_subdirectory(t)
|
||||
# -------------------
|
||||
# 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")
|
||||
|
@ -1,70 +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.
|
||||
|
||||
You can submit pull requests on GitHub at <https://github.com/fmang/opustags>,
|
||||
or email me your patches at <fmang+opustags@mg0.fr>.
|
||||
|
||||
## History of opustags
|
||||
|
||||
opustags is originally a small project made to fill a need to edit tags in Opus
|
||||
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.
|
||||
|
||||
Subsequent releases have been adding new features.
|
||||
|
||||
## 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.
|
||||
- Edition of the arbitrary binary block past the comments.
|
||||
- Colored output.
|
||||
|
||||
Don't hesitate to contact me before you do anything, I'll give you directions.
|
2
LICENSE
2
LICENSE
@ -1,4 +1,4 @@
|
||||
Copyright (c) 2013-2024, Frédéric Mangano
|
||||
Copyright (c) 2013, Frédéric Mangano
|
||||
All rights reserved.
|
||||
|
||||
Redistribution and use in source and binary forms, with or without modification,
|
||||
|
75
README.md
75
README.md
@ -1,74 +1,45 @@
|
||||
opustags
|
||||
========
|
||||
|
||||
View and edit Ogg Opus comments.
|
||||
|
||||
opustags supports the following features:
|
||||
|
||||
- interactive editing using your preferred text editor,
|
||||
- batch editing with command-line flags,
|
||||
- tags exporting and importing through text files.
|
||||
|
||||
opustags is designed to be fast and as conservative as possible, to the point that if you edit tags
|
||||
then edit them again to their previous values, you should get a bit-perfect copy of the original
|
||||
file. No under-the-cover operation like writing "edited with opustags" or timestamp tagging will
|
||||
ever be performed.
|
||||
|
||||
opustags is tag-agnostic: you can write arbitrary key-value tags, and none of them will be treated
|
||||
specially. After all, common tags like TITLE or ARTIST are nothing more than conventions.
|
||||
|
||||
The project’s homepage is located at <https://github.com/fmang/opustags>.
|
||||
View and edit Opus comments.
|
||||
|
||||
Requirements
|
||||
------------
|
||||
|
||||
* a POSIX-compliant system,
|
||||
* a C++20 compiler,
|
||||
* CMake ≥ 3.11,
|
||||
* 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
|
||||
-e, --edit edit tags interactively in VISUAL/EDITOR
|
||||
--output-cover FILE extract and save the cover art, if any
|
||||
--set-cover FILE sets the cover art
|
||||
--vendor print the vendor string
|
||||
--set-vendor VALUE set the vendor string
|
||||
--raw disable encoding conversion
|
||||
-z delimit tags with NUL
|
||||
-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
209
doc/opustags.1
Normal 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
23
modules/FindOgg.cmake
Normal 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)
|
189
opustags.1
189
opustags.1
@ -1,189 +0,0 @@
|
||||
.TH opustags 1 "April 2024" "@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
|
||||
\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 has two modes: read-only, and read-write for tag editing.
|
||||
.PP
|
||||
In read-only mode, only the beginning of \fIINPUT\fP is read, and the tags are
|
||||
printed on standard output. Lines prefixed by tabs are continuation of the previous tag.
|
||||
\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 don’t conflict with each other.
|
||||
First the specified tags are deleted, then the new tags are added.
|
||||
.PP
|
||||
You can delete all the tags with \fB--delete-all\fP. This operation can be combined with \fB--add\fP
|
||||
to set new tags without being bothered by the old ones.
|
||||
.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 you edit an Opus file whose tags contains characters unsupported by
|
||||
your system encoding, the original UTF-8 values will be preserved for the tags you don't explicitly
|
||||
modify.
|
||||
.SH OPTIONS
|
||||
.TP
|
||||
.B \-h, \-\-help
|
||||
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 can’t 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 won’t 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.
|
||||
Empty lines and lines starting with \fI#\fP are ignored.
|
||||
Multiline tags must have their continuation lines prefixed by a single tab (in other words, every
|
||||
\fI\\n\fP must be replaced by \fI\\n\\t\fP).
|
||||
.TP
|
||||
.B \-e, \-\-edit
|
||||
Edit tags interactively by spawning the program specified by the EDITOR
|
||||
environment variable. The allowed format is the same as \fB--set-all\fP.
|
||||
If TERM and VISUAL are set, VISUAL takes precedence over EDITOR.
|
||||
.TP
|
||||
.B \-\-output-cover \fIFILE\fP
|
||||
Save the cover art of the input Opus file to the specified location.
|
||||
If the input file does not contain any cover art, this option has no effect.
|
||||
To allow overwriting the target location, specify \fB--overwrite\fP.
|
||||
In the case of multiple pictures embedded in the Opus tags, only the first one is saved.
|
||||
Note that the since the image format is not fixed, you should consider an extension-less file name
|
||||
and rely on the magic number to deduce the type. opustags does not add or check the target file’s
|
||||
extension.
|
||||
You can specify \fB-\fP for standard output, in which case the regular output will be suppressed.
|
||||
.TP
|
||||
.B \-\-set-cover \fIFILE\fP
|
||||
Replace or set the cover art to the specified picture.
|
||||
Specify \fB-\fP to read the picture from standard input.
|
||||
In theory, an Opus file may contain multiple pictures with different roles, though in practice only
|
||||
the front cover really matters. opustags can currently only handle one front cover and nothing else.
|
||||
.TP
|
||||
.B \-\-vendor
|
||||
Print the vendor string from the OpusTags packet and do nothing else. Standard tags operations are
|
||||
not supported when specifying this flag.
|
||||
.TP
|
||||
.B \-\-set-vendor \fIVALUE\fP
|
||||
Replace the vendor string by the specified value. This action can be performed alongside tag
|
||||
edition.
|
||||
.TP
|
||||
.B \-\-raw
|
||||
OpusTags metadata should always be encoded in UTF-8, as per RFC 7845. However, some files may be
|
||||
corrupted or possibly even contain intentional binary data. In that case, --raw lets you edit that
|
||||
kind of binary data without ensuring the validity of the tags encoding. This option may also be
|
||||
useful when your system encoding is different from UTF-8 and you wish to preserve the full UTF-8
|
||||
character set even though your system cannot display it.
|
||||
.TP
|
||||
.B \-z
|
||||
When editing tags programmatically with line-based tools like grep or sed, tags containing newlines
|
||||
are likely to corrupt the result because these tools won’t interpret multi-line tags as a whole. To
|
||||
make automatic processing easier, \fB-z\fP delimits tags by a null byte (ASCII NUL) instead of line
|
||||
feeds. That same \fB-z\fP flag is also supported by GNU grep or GNU sed and, combined with opustags
|
||||
-z, would make them process the input tag-by-tag instead of line-by-line, thus supporting multi-line
|
||||
tags as well. This option also disables the TAB prefix for continuation lines after a line feed.
|
||||
.SH EXAMPLES
|
||||
.PP
|
||||
List all the tags in file foo.opus:
|
||||
.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
|
||||
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
|
||||
.PP
|
||||
Edit tags interactively in Vim:
|
||||
.PP
|
||||
EDITOR=vim opustags --in-place --edit file.opus
|
||||
.PP
|
||||
Replace all the tags in dest.opus with the ones from src.opus:
|
||||
.PP
|
||||
opustags src.opus | opustags --in-place dest.opus --set-all
|
||||
.PP
|
||||
Use GNU grep to remove all the CHAPTER* tags, with -z to support multi-line tags:
|
||||
.PP
|
||||
opustags -z file.opus | grep -z -v ^CHAPTER | opustags -z --in-place file.opus --set-all
|
||||
.SH CAVEATS
|
||||
.PP
|
||||
\fBopustags\fP currently has the following limitations:
|
||||
.IP \[bu]
|
||||
Multiplexed streams are not supported.
|
||||
.IP \[bu]
|
||||
Control characters inside tags are printed raw rather than being escaped.
|
||||
.PP
|
||||
Internally, the OpusTags packet in an Ogg Opus file may contain extra arbitrary binary data after
|
||||
the comments. This block of data is currently not editable, but is always preserved. The same
|
||||
applies for the vendor string.
|
||||
.PP
|
||||
If you need a feature not currently supported, feel free to open an issue or send an email with your
|
||||
use case.
|
||||
.SH AUTHOR
|
||||
Frédéric Mangano <fmang+opustags@mg0.fr>
|
||||
.PP
|
||||
Report bugs at <https://github.com/fmang/opustags/issues>
|
83
src/actions.cc
Normal file
83
src/actions.cc
Normal 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
33
src/actions.h
Normal 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 &);
|
||||
|
||||
}
|
@ -1,97 +0,0 @@
|
||||
/**
|
||||
* \file src/base64.cc
|
||||
* \brief Base64 encoding/decoding (RFC 4648).
|
||||
*
|
||||
* Inspired by Jouni Malinen’s BSD implementation at
|
||||
* <http://web.mit.edu/freebsd/head/contrib/wpa/src/utils/base64.c>.
|
||||
*
|
||||
* This implementation is used to decode the cover arts embedded in the tags. According to
|
||||
* <https://wiki.xiph.org/VorbisComment>, line feeds are not allowed and padding is required.
|
||||
*/
|
||||
|
||||
#include <opustags.h>
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static const char8_t base64_table[65] =
|
||||
u8"ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
|
||||
|
||||
std::u8string ot::encode_base64(ot::byte_string_view src)
|
||||
{
|
||||
size_t len = src.size();
|
||||
size_t num_blocks = (len + 2) / 3; // Count of 3-byte blocks, rounded up.
|
||||
size_t olen = num_blocks * 4; // Each 3-byte block becomes 4 base64 bytes.
|
||||
if (olen < len)
|
||||
throw std::overflow_error("failed to encode excessively long base64 block");
|
||||
|
||||
std::u8string out;
|
||||
out.resize(olen);
|
||||
|
||||
const uint8_t* in = src.data();
|
||||
const uint8_t* end = in + len;
|
||||
char8_t* pos = out.data();
|
||||
while (end - in >= 3) {
|
||||
*pos++ = base64_table[in[0] >> 2];
|
||||
*pos++ = base64_table[((in[0] & 0x03) << 4) | (in[1] >> 4)];
|
||||
*pos++ = base64_table[((in[1] & 0x0f) << 2) | (in[2] >> 6)];
|
||||
*pos++ = base64_table[in[2] & 0x3f];
|
||||
in += 3;
|
||||
}
|
||||
|
||||
if (end - in) {
|
||||
*pos++ = base64_table[in[0] >> 2];
|
||||
if (end - in == 1) {
|
||||
*pos++ = base64_table[(in[0] & 0x03) << 4];
|
||||
*pos++ = '=';
|
||||
} else { // end - in == 2
|
||||
*pos++ = base64_table[((in[0] & 0x03) << 4) | (in[1] >> 4)];
|
||||
*pos++ = base64_table[(in[1] & 0x0f) << 2];
|
||||
}
|
||||
*pos++ = '=';
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
||||
|
||||
ot::byte_string ot::decode_base64(std::u8string_view src)
|
||||
{
|
||||
// Remove the padding and rely on the string length instead.
|
||||
while (src.back() == u8'=')
|
||||
src.remove_suffix(1);
|
||||
|
||||
size_t olen = src.size() / 4 * 3; // Whole blocks;
|
||||
switch (src.size() % 4) {
|
||||
case 1: throw status {st::error, "invalid base64 block size"};
|
||||
case 2: olen += 1; break;
|
||||
case 3: olen += 2; break;
|
||||
}
|
||||
|
||||
ot::byte_string out;
|
||||
out.resize(olen);
|
||||
uint8_t* pos = out.data();
|
||||
|
||||
unsigned char dtable[256];
|
||||
memset(dtable, 0x80, 256);
|
||||
for (size_t i = 0; i < sizeof(base64_table) - 1; ++i)
|
||||
dtable[(size_t) base64_table[i]] = (unsigned char) i;
|
||||
|
||||
unsigned char block[4];
|
||||
size_t count = 0;
|
||||
for (unsigned char c : src) {
|
||||
unsigned char tmp = dtable[c];
|
||||
if (tmp == 0x80)
|
||||
throw status {st::error, "invalid base64 character"};
|
||||
|
||||
block[count++] = tmp;
|
||||
if (count == 2) {
|
||||
*pos++ = (block[0] << 2) | (block[1] >> 4);
|
||||
} else if (count == 3) {
|
||||
*pos++ = (block[1] << 4) | (block[2] >> 2);
|
||||
} else if (count == 4) {
|
||||
*pos++ = (block[2] << 6) | block[3];
|
||||
count = 0;
|
||||
}
|
||||
}
|
||||
|
||||
return out;
|
||||
}
|
647
src/cli.cc
647
src/cli.cc
@ -1,647 +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 <opustags.h>
|
||||
|
||||
#include <errno.h>
|
||||
#include <getopt.h>
|
||||
#include <limits.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <unistd.h>
|
||||
#include <algorithm>
|
||||
|
||||
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
|
||||
-e, --edit edit tags interactively in VISUAL/EDITOR
|
||||
--output-cover FILE extract and save the cover art, if any
|
||||
--set-cover FILE sets the cover art
|
||||
--vendor print the vendor string
|
||||
--set-vendor VALUE set the vendor string
|
||||
--raw disable encoding conversion
|
||||
-z delimit tags with NUL
|
||||
|
||||
See the man page for extensive documentation.
|
||||
)raw";
|
||||
|
||||
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'},
|
||||
{"edit", no_argument, 0, 'e'},
|
||||
{"output-cover", required_argument, 0, 'c'},
|
||||
{"set-cover", required_argument, 0, 'C'},
|
||||
{"vendor", no_argument, 0, 'v'},
|
||||
{"set-vendor", required_argument, 0, 'V'},
|
||||
{"raw", no_argument, 0, 'r'},
|
||||
{NULL, 0, 0, 0}
|
||||
};
|
||||
|
||||
ot::options ot::parse_options(int argc, char** argv, FILE* comments_input)
|
||||
{
|
||||
options opt;
|
||||
const char* equal;
|
||||
ot::status rc;
|
||||
std::list<std::string> local_to_add; // opt.to_add before UTF-8 conversion.
|
||||
std::list<std::string> local_to_delete; // opt.to_delete before UTF-8 conversion.
|
||||
bool set_all = false;
|
||||
std::optional<std::string> set_cover;
|
||||
std::optional<std::string> set_vendor;
|
||||
opt = {};
|
||||
if (argc == 1)
|
||||
throw status {st::bad_arguments, "No arguments specified. Use -h for help."};
|
||||
int c;
|
||||
optind = 0;
|
||||
while ((c = getopt_long(argc, argv, ":ho:iyd:a:s:DSez", getopt_options, NULL)) != -1) {
|
||||
switch (c) {
|
||||
case 'h':
|
||||
opt.print_help = true;
|
||||
break;
|
||||
case 'o':
|
||||
if (opt.path_out)
|
||||
throw status {st::bad_arguments, "Cannot specify --output more than once."};
|
||||
opt.path_out = optarg;
|
||||
break;
|
||||
case 'i':
|
||||
opt.in_place = true;
|
||||
opt.overwrite = true;
|
||||
break;
|
||||
case 'y':
|
||||
opt.overwrite = true;
|
||||
break;
|
||||
case 'd':
|
||||
local_to_delete.emplace_back(optarg);
|
||||
break;
|
||||
case 'a':
|
||||
case 's':
|
||||
equal = strchr(optarg, '=');
|
||||
if (equal == nullptr)
|
||||
throw status {st::bad_arguments, "Comment does not contain an equal sign: "s + optarg + "."};
|
||||
if (c == 's')
|
||||
local_to_delete.emplace_back(optarg, equal - optarg);
|
||||
local_to_add.emplace_back(optarg);
|
||||
break;
|
||||
case 'S':
|
||||
opt.delete_all = true;
|
||||
set_all = true;
|
||||
break;
|
||||
case 'D':
|
||||
opt.delete_all = true;
|
||||
break;
|
||||
case 'e':
|
||||
opt.edit_interactively = true;
|
||||
break;
|
||||
case 'c':
|
||||
if (opt.cover_out)
|
||||
throw status {st::bad_arguments, "Cannot specify --output-cover more than once."};
|
||||
opt.cover_out = optarg;
|
||||
break;
|
||||
case 'C':
|
||||
if (set_cover)
|
||||
throw status {st::bad_arguments, "Cannot specify --set-cover more than once."};
|
||||
set_cover = optarg;
|
||||
break;
|
||||
case 'v':
|
||||
opt.print_vendor = true;
|
||||
break;
|
||||
case 'V':
|
||||
if (set_vendor)
|
||||
throw status {st::bad_arguments, "Cannot specify --set-vendor more than once."};
|
||||
set_vendor = optarg;
|
||||
break;
|
||||
case 'r':
|
||||
opt.raw = true;
|
||||
break;
|
||||
case 'z':
|
||||
opt.tag_delimiter = '\0';
|
||||
break;
|
||||
case ':':
|
||||
throw status {st::bad_arguments, "Missing value for option '"s + argv[optind - 1] + "'."};
|
||||
default:
|
||||
throw status {st::bad_arguments, "Unrecognized option '" +
|
||||
(optopt ? "-"s + static_cast<char>(optopt) : argv[optind - 1]) + "'."};
|
||||
}
|
||||
}
|
||||
if (opt.print_help)
|
||||
return opt;
|
||||
|
||||
// All non-option arguments are input files.
|
||||
size_t stdin_uses = 0;
|
||||
for (int i = optind; i < argc; i++) {
|
||||
if (strcmp(argv[i], "-") == 0)
|
||||
++stdin_uses;
|
||||
opt.paths_in.emplace_back(argv[i]);
|
||||
}
|
||||
bool stdin_as_input = stdin_uses > 0;
|
||||
|
||||
if (set_cover == "-")
|
||||
++stdin_uses;
|
||||
if (set_all)
|
||||
++stdin_uses;
|
||||
if (stdin_uses > 1)
|
||||
throw status { st::bad_arguments, "Cannot use standard input more than once." };
|
||||
|
||||
// Convert arguments to UTF-8.
|
||||
if (opt.raw) {
|
||||
// Cast the user data without any encoding conversion.
|
||||
auto cast_to_utf8 = [](std::string_view in)
|
||||
{ return std::u8string(reinterpret_cast<const char8_t*>(in.data()), in.size()); };
|
||||
std::transform(local_to_add.begin(), local_to_add.end(),
|
||||
std::back_inserter(opt.to_add), cast_to_utf8);
|
||||
std::transform(local_to_delete.begin(), local_to_delete.end(),
|
||||
std::back_inserter(opt.to_delete), cast_to_utf8);
|
||||
if (set_vendor)
|
||||
opt.set_vendor = cast_to_utf8(*set_vendor);
|
||||
} else {
|
||||
try {
|
||||
std::transform(local_to_add.begin(), local_to_add.end(),
|
||||
std::back_inserter(opt.to_add), encode_utf8);
|
||||
std::transform(local_to_delete.begin(), local_to_delete.end(),
|
||||
std::back_inserter(opt.to_delete), encode_utf8);
|
||||
if (set_vendor)
|
||||
opt.set_vendor = encode_utf8(*set_vendor);
|
||||
} catch (const ot::status& rc) {
|
||||
throw status {st::bad_arguments, "Could not encode argument into UTF-8: " + rc.message};
|
||||
}
|
||||
}
|
||||
|
||||
bool read_only = !opt.in_place && !opt.path_out.has_value();
|
||||
|
||||
if (opt.in_place && opt.path_out)
|
||||
throw status {st::bad_arguments, "Cannot combine --in-place and --output."};
|
||||
|
||||
if (opt.in_place && stdin_as_input)
|
||||
throw status {st::bad_arguments, "Cannot modify standard input in place."};
|
||||
|
||||
if ((!opt.in_place || opt.edit_interactively) && opt.paths_in.size() != 1)
|
||||
throw status {st::bad_arguments, "Exactly one input file must be specified."};
|
||||
|
||||
if (opt.edit_interactively && (stdin_as_input || opt.path_out == "-" || opt.cover_out == "-"))
|
||||
throw status {st::bad_arguments, "Cannot edit interactively when standard input or standard output are already used."};
|
||||
|
||||
if (opt.edit_interactively && read_only)
|
||||
throw status {st::bad_arguments, "Cannot edit interactively when no output is specified."};
|
||||
|
||||
if (opt.edit_interactively && (opt.delete_all || !opt.to_add.empty() || !opt.to_delete.empty()))
|
||||
throw status {st::bad_arguments, "Cannot mix --edit with -adDsS."};
|
||||
|
||||
if (opt.cover_out == "-" && opt.path_out == "-")
|
||||
throw status {st::bad_arguments, "Cannot specify standard output for both --output and --output-cover."};
|
||||
|
||||
if (opt.cover_out && opt.paths_in.size() > 1)
|
||||
throw status {st::bad_arguments, "Cannot use --output-cover with multiple input files."};
|
||||
|
||||
if (opt.print_vendor && !read_only)
|
||||
throw status {st::bad_arguments, "--vendor is only supported in read-only mode."};
|
||||
|
||||
if (set_cover) {
|
||||
byte_string picture_data = ot::slurp_binary_file(set_cover->c_str());
|
||||
opt.to_delete.push_back(u8"METADATA_BLOCK_PICTURE"s);
|
||||
opt.to_add.push_back(ot::make_cover(picture_data));
|
||||
}
|
||||
|
||||
if (set_all) {
|
||||
// Read comments from stdin and prepend them to opt.to_add.
|
||||
std::list<std::u8string> comments = read_comments(comments_input, opt);
|
||||
opt.to_add.splice(opt.to_add.begin(), std::move(comments));
|
||||
}
|
||||
return opt;
|
||||
}
|
||||
|
||||
/** Format a UTF-8 string by adding tabulations (\t) after line feeds (\n) to mark continuation for
|
||||
* multiline values. With -z, this behavior applies for embedded NUL characters instead of LF. */
|
||||
static std::u8string format_value(const std::u8string& source, const ot::options& opt)
|
||||
{
|
||||
auto newline_count = std::count(source.begin(), source.end(), opt.tag_delimiter);
|
||||
|
||||
// General case: the value fits on a single line. Use std::string’s copy constructor for the
|
||||
// most efficient copy we could hope for.
|
||||
if (newline_count == 0)
|
||||
return source;
|
||||
|
||||
std::u8string formatted;
|
||||
formatted.reserve(source.size() + newline_count);
|
||||
for (auto c : source) {
|
||||
formatted.push_back(c);
|
||||
if (c == opt.tag_delimiter)
|
||||
formatted.push_back(u8'\t');
|
||||
}
|
||||
return formatted;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert the comment from UTF-8 to the system encoding if relevant, and print it with a trailing
|
||||
* line feed.
|
||||
*/
|
||||
static void puts_utf8(std::u8string_view str, FILE* output, const ot::options& opt)
|
||||
{
|
||||
if (opt.raw) {
|
||||
fwrite(str.data(), 1, str.size(), output);
|
||||
} else {
|
||||
try {
|
||||
std::string local = ot::decode_utf8(str);
|
||||
fwrite(local.data(), 1, local.size(), output);
|
||||
} catch (ot::status& rc) {
|
||||
rc.message += " See --raw.";
|
||||
throw;
|
||||
}
|
||||
}
|
||||
putc(opt.tag_delimiter, output);
|
||||
}
|
||||
|
||||
/**
|
||||
* Print comments in a human readable format that can also be read back in by #read_comment.
|
||||
*
|
||||
* To disambiguate between a newline embedded in a comment and a newline representing the start of
|
||||
* the next tag, continuation lines always have a single TAB (^I) character added to the beginning.
|
||||
*/
|
||||
void ot::print_comments(const std::list<std::u8string>& comments, FILE* output, const ot::options& opt)
|
||||
{
|
||||
bool has_control = false;
|
||||
for (const std::u8string& source_comment : comments) {
|
||||
if (!has_control) { // Don’t bother analyzing comments if the flag is already up.
|
||||
for (unsigned char c : source_comment) {
|
||||
if (c < 0x20 && c != '\n') {
|
||||
has_control = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
std::u8string utf8_comment = format_value(source_comment, opt);
|
||||
puts_utf8(utf8_comment, output, opt);
|
||||
}
|
||||
if (has_control)
|
||||
fputs("warning: Some tags contain control characters.\n", stderr);
|
||||
}
|
||||
|
||||
std::list<std::u8string> ot::read_comments(FILE* input, const ot::options& opt)
|
||||
{
|
||||
std::list<std::u8string> comments;
|
||||
comments.clear();
|
||||
char* source_line = nullptr;
|
||||
size_t buflen = 0;
|
||||
ssize_t nread;
|
||||
std::u8string* previous_comment = nullptr;
|
||||
while ((nread = getdelim(&source_line, &buflen, opt.tag_delimiter, input)) != -1) {
|
||||
if (nread > 0 && source_line[nread - 1] == opt.tag_delimiter)
|
||||
--nread; // Chomp.
|
||||
|
||||
std::u8string line;
|
||||
if (opt.raw) {
|
||||
line = std::u8string(reinterpret_cast<char8_t*>(source_line), nread);
|
||||
} else {
|
||||
try {
|
||||
line = encode_utf8(std::string_view(source_line, nread));
|
||||
} catch (const ot::status& rc) {
|
||||
free(source_line);
|
||||
throw ot::status {ot::st::badly_encoded, "UTF-8 conversion error: " + rc.message};
|
||||
}
|
||||
}
|
||||
|
||||
if (line.empty()) {
|
||||
// Ignore empty lines.
|
||||
previous_comment = nullptr;
|
||||
} else if (line[0] == u8'#') {
|
||||
// Ignore comments.
|
||||
previous_comment = nullptr;
|
||||
} else if (line[0] == u8'\t') {
|
||||
// Continuation line: append the current line to the previous tag.
|
||||
if (previous_comment == nullptr) {
|
||||
ot::status rc = {ot::st::error, "Unexpected continuation line: " + std::string(source_line, nread)};
|
||||
free(source_line);
|
||||
throw rc;
|
||||
} else {
|
||||
line[0] = opt.tag_delimiter;
|
||||
previous_comment->append(line);
|
||||
}
|
||||
} else if (line.find(u8'=') == decltype(line)::npos) {
|
||||
ot::status rc = {ot::st::error, "Malformed tag: " + std::string(source_line, nread)};
|
||||
free(source_line);
|
||||
throw rc;
|
||||
} else {
|
||||
previous_comment = &comments.emplace_back(std::move(line));
|
||||
}
|
||||
}
|
||||
free(source_line);
|
||||
return comments;
|
||||
}
|
||||
|
||||
void ot::delete_comments(std::list<std::u8string>& comments, const std::u8string& selector)
|
||||
{
|
||||
auto name = selector.data();
|
||||
auto equal = selector.find(u8'=');
|
||||
auto value = (equal == std::u8string::npos ? nullptr : name + equal + 1);
|
||||
auto name_len = value ? equal : selector.size();
|
||||
auto value_len = value ? selector.size() - equal - 1 : 0;
|
||||
auto it = comments.begin(), end = comments.end();
|
||||
while (it != end) {
|
||||
auto current = it++;
|
||||
/** \todo Avoid using strncasecmp because it assumes the system locale is UTF-8. */
|
||||
bool name_match = current->size() > name_len + 1 &&
|
||||
(*current)[name_len] == '=' &&
|
||||
strncasecmp((const char*) current->data(), (const char*) 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 void edit_tags(ot::opus_tags& tags, const ot::options& opt)
|
||||
{
|
||||
if (opt.set_vendor)
|
||||
tags.vendor = *opt.set_vendor;
|
||||
|
||||
if (opt.delete_all) {
|
||||
tags.comments.clear();
|
||||
} else for (const std::u8string& name : opt.to_delete) {
|
||||
ot::delete_comments(tags.comments, name);
|
||||
}
|
||||
|
||||
for (const std::u8string& comment : opt.to_add)
|
||||
tags.comments.emplace_back(comment);
|
||||
}
|
||||
|
||||
/** Spawn VISUAL or EDITOR to edit the given tags. */
|
||||
static void edit_tags_interactively(ot::opus_tags& tags, const std::optional<std::string>& base_path, const ot::options& opt)
|
||||
{
|
||||
const char* editor = nullptr;
|
||||
if (getenv("TERM") != nullptr)
|
||||
editor = getenv("VISUAL");
|
||||
if (editor == nullptr) // without a terminal, or if VISUAL is unset
|
||||
editor = getenv("EDITOR");
|
||||
if (editor == nullptr)
|
||||
throw ot::status {ot::st::error,
|
||||
"No editor specified in environment variable VISUAL or EDITOR."};
|
||||
|
||||
// Building the temporary tags file.
|
||||
ot::status rc;
|
||||
std::string tags_path = base_path.value_or("tags") + ".XXXXXX.opustags";
|
||||
int fd = mkstemps(const_cast<char*>(tags_path.data()), 9);
|
||||
ot::file tags_file;
|
||||
if (fd == -1 || (tags_file = fdopen(fd, "w")) == nullptr)
|
||||
throw ot::status {ot::st::standard_error,
|
||||
"Could not open '" + tags_path + "': " + strerror(errno)};
|
||||
ot::print_comments(tags.comments, tags_file.get(), opt);
|
||||
tags_file.reset();
|
||||
|
||||
// Spawn the editor, and watch the modification timestamps.
|
||||
timespec before = ot::get_file_timestamp(tags_path.c_str());
|
||||
ot::status editor_rc;
|
||||
try {
|
||||
ot::run_editor(editor, tags_path);
|
||||
editor_rc = ot::st::ok;
|
||||
} catch (const ot::status& rc) {
|
||||
editor_rc = rc;
|
||||
}
|
||||
timespec after = ot::get_file_timestamp(tags_path.c_str());
|
||||
bool modified = (before.tv_sec != after.tv_sec || before.tv_nsec != after.tv_nsec);
|
||||
if (editor_rc != ot::st::ok) {
|
||||
if (modified)
|
||||
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
|
||||
else
|
||||
remove(tags_path.c_str());
|
||||
throw editor_rc;
|
||||
} else if (!modified) {
|
||||
remove(tags_path.c_str());
|
||||
fputs("Cancelling edition because the tags file was not modified.\n", stderr);
|
||||
throw ot::status {ot::st::cancel};
|
||||
}
|
||||
|
||||
// Applying the new tags.
|
||||
tags_file = fopen(tags_path.c_str(), "re");
|
||||
if (tags_file == nullptr)
|
||||
throw ot::status {ot::st::standard_error, "Error opening " + tags_path + ": " + strerror(errno)};
|
||||
try {
|
||||
tags.comments = ot::read_comments(tags_file.get(), opt);
|
||||
} catch (const ot::status& rc) {
|
||||
fprintf(stderr, "warning: Leaving %s on the disk.\n", tags_path.c_str());
|
||||
throw;
|
||||
}
|
||||
tags_file.reset();
|
||||
|
||||
// Remove the temporary tags file only on success, because unlike the
|
||||
// partial Ogg file that is irrecoverable, the edited tags file
|
||||
// contains user data, so let’s leave users a chance to recover it.
|
||||
remove(tags_path.c_str());
|
||||
}
|
||||
|
||||
static void output_cover(const ot::opus_tags& tags, const ot::options &opt)
|
||||
{
|
||||
std::optional<ot::picture> cover = extract_cover(tags);
|
||||
if (!cover) {
|
||||
fputs("warning: No cover found.\n", stderr);
|
||||
return;
|
||||
}
|
||||
|
||||
ot::file output;
|
||||
if (opt.cover_out == "-") {
|
||||
output = stdout;
|
||||
} else {
|
||||
struct stat output_info;
|
||||
if (stat(opt.cover_out->c_str(), &output_info) == 0) {
|
||||
if (S_ISREG(output_info.st_mode) && !opt.overwrite)
|
||||
throw ot::status {ot::st::error, "'" + opt.cover_out.value() + "' already exists. Use -y to overwrite."};
|
||||
} else if (errno != ENOENT) {
|
||||
throw ot::status {ot::st::error, "Could not identify '" + opt.cover_out.value() + "': " + strerror(errno)};
|
||||
}
|
||||
|
||||
output = fopen(opt.cover_out->c_str(), "w");
|
||||
if (output == nullptr)
|
||||
throw ot::status {ot::st::standard_error, "Could not open '" + opt.cover_out.value() + "' for writing: " + strerror(errno)};
|
||||
}
|
||||
|
||||
if (fwrite(cover->picture_data.data(), 1, cover->picture_data.size(), output.get()) < cover->picture_data.size())
|
||||
throw ot::status {ot::st::standard_error, "fwrite error: "s + strerror(errno)};
|
||||
}
|
||||
|
||||
/**
|
||||
* Main loop of opustags. Read the packets from the reader, and forwards them to the writer.
|
||||
* Transform the OpusTags packet on the fly.
|
||||
*
|
||||
* The writer is optional. When writer is nullptr, opustags runs in read-only mode.
|
||||
*/
|
||||
static void 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 */
|
||||
|
||||
/** When the number of pages the OpusTags packet takes differs from the input stream to the
|
||||
* output stream, we need to renumber all the succeeding pages. If the input stream
|
||||
* contains gaps, the offset will naively reproduce the gaps: page numbers 0 (1) 2 4 will
|
||||
* become 0 (1 2) 3 5, where (…) is the OpusTags packet, and not 0 (1 2) 3 4. */
|
||||
int pageno_offset = 0;
|
||||
|
||||
while (reader.next_page()) {
|
||||
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. */
|
||||
throw ot::status {ot::st::error, "Muxed streams are not supported yet."};
|
||||
}
|
||||
if (reader.absolute_page_no == 0) { // Identification header
|
||||
if (!ot::is_opus_stream(reader.page))
|
||||
throw ot::status {ot::st::error, "Not an Opus stream."};
|
||||
if (writer)
|
||||
writer->write_page(reader.page);
|
||||
} else if (reader.absolute_page_no == 1) { // Comment header
|
||||
ot::opus_tags tags;
|
||||
reader.process_header_packet([&tags](ogg_packet& p) { tags = ot::parse_tags(p); });
|
||||
if (opt.cover_out)
|
||||
output_cover(tags, opt);
|
||||
edit_tags(tags, opt);
|
||||
if (writer) {
|
||||
if (opt.edit_interactively) {
|
||||
fflush(writer->file); // flush before calling the subprocess
|
||||
edit_tags_interactively(tags, writer->path, opt);
|
||||
}
|
||||
auto packet = ot::render_tags(tags);
|
||||
writer->write_header_packet(serialno, pageno, packet);
|
||||
pageno_offset = writer->next_page_no - 1 - reader.absolute_page_no;
|
||||
} else {
|
||||
if (opt.cover_out != "-") {
|
||||
if (opt.print_vendor)
|
||||
puts_utf8(tags.vendor, stdout, opt);
|
||||
else
|
||||
ot::print_comments(tags.comments, stdout, opt);
|
||||
}
|
||||
break;
|
||||
}
|
||||
} else if (writer) {
|
||||
ot::renumber_page(reader.page, pageno + pageno_offset);
|
||||
writer->write_page(reader.page);
|
||||
}
|
||||
}
|
||||
if (reader.absolute_page_no < 1)
|
||||
throw ot::status {ot::st::error, "Expected at least 2 Ogg pages."};
|
||||
}
|
||||
|
||||
static void 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(), "re")) == nullptr)
|
||||
throw ot::status {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) {
|
||||
process(reader, nullptr, opt);
|
||||
return;
|
||||
}
|
||||
|
||||
/* 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) won’t.
|
||||
* 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;
|
||||
|
||||
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(), "we")) == nullptr)
|
||||
throw ot::status {ot::st::standard_error,
|
||||
"Could not open '" + path_out.value() + "' for writing: " + strerror(errno)};
|
||||
output = final_output.get();
|
||||
} else if (opt.overwrite) {
|
||||
temporary_output.open(path_out->c_str());
|
||||
output = temporary_output.get();
|
||||
} else {
|
||||
throw ot::status {ot::st::error, "'" + path_out.value() + "' already exists. Use -y to overwrite."};
|
||||
}
|
||||
} else if (errno == ENOENT) {
|
||||
temporary_output.open(path_out->c_str());
|
||||
output = temporary_output.get();
|
||||
} else {
|
||||
throw ot::status {ot::st::error, "Could not identify '" + path_out.value() + "': " + strerror(errno)};
|
||||
}
|
||||
|
||||
ot::ogg_writer writer(output);
|
||||
writer.path = path_out;
|
||||
process(reader, &writer, opt);
|
||||
|
||||
// Close the input file and finalize the output. When --in-place is specified, some file
|
||||
// systems like SMB require that the input is closed first.
|
||||
input.reset();
|
||||
temporary_output.commit();
|
||||
}
|
||||
|
||||
void ot::run(const ot::options& opt)
|
||||
{
|
||||
if (opt.print_help) {
|
||||
fputs(help_message, stdout);
|
||||
return;
|
||||
}
|
||||
|
||||
ot::status global_rc = st::ok;
|
||||
for (const auto& path_in : opt.paths_in) {
|
||||
try {
|
||||
run_single(opt, path_in, opt.in_place ? path_in : opt.path_out);
|
||||
} catch (const ot::status& rc) {
|
||||
global_rc = st::error;
|
||||
if (!rc.message.empty())
|
||||
fprintf(stderr, "%s: error: %s\n", path_in.c_str(), rc.message.c_str());
|
||||
}
|
||||
}
|
||||
if (global_rc != st::ok)
|
||||
throw global_rc;
|
||||
}
|
@ -1,7 +0,0 @@
|
||||
#cmakedefine PROJECT_NAME "@PROJECT_NAME@"
|
||||
#cmakedefine PROJECT_VERSION "@PROJECT_VERSION@"
|
||||
|
||||
#cmakedefine HAVE_ENDIAN_H @HAVE_ENDIAN_H@
|
||||
#cmakedefine HAVE_SYS_ENDIAN_H @HAVE_SYS_ENDIAN_H@
|
||||
#cmakedefine HAVE_STAT_ST_MTIM @HAVE_STAT_ST_MTIM@
|
||||
#cmakedefine HAVE_STAT_ST_MTIMESPEC @HAVE_STAT_ST_MTIMESPEC@
|
29
src/log.h
Normal file
29
src/log.h
Normal 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
88
src/main.cc
Normal 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;
|
||||
}
|
403
src/ogg.cc
403
src/ogg.cc
@ -1,137 +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;
|
||||
|
||||
bool ot::is_opus_stream(const ogg_page& identification_header)
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// ogg::Stream
|
||||
|
||||
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");
|
||||
}
|
||||
|
||||
bool ot::ogg_reader::next_page()
|
||||
ogg::Stream::~Stream()
|
||||
{
|
||||
int rc;
|
||||
while ((rc = ogg_sync_pageout(&sync, &page)) != 1) {
|
||||
if (rc == -1) {
|
||||
throw status {st::bad_stream,
|
||||
absolute_page_no == (size_t) -1 ? "Input is not a valid Ogg file."
|
||||
: "Unsynced data in stream."};
|
||||
}
|
||||
if (ogg_sync_check(&sync) != 0)
|
||||
throw status {st::libogg_error, "ogg_sync_check signalled an error."};
|
||||
if (feof(file)) {
|
||||
if (sync.fill != sync.returned)
|
||||
throw status {st::bad_stream, "Unsynced data at end of stream."};
|
||||
return false; // end of sream
|
||||
}
|
||||
char* buf = ogg_sync_buffer(&sync, 65536);
|
||||
if (buf == nullptr)
|
||||
throw status {st::libogg_error, "ogg_sync_buffer failed."};
|
||||
size_t len = fread(buf, 1, 65536, file);
|
||||
if (ferror(file))
|
||||
throw status {st::standard_error, "fread error: "s + strerror(errno)};
|
||||
if (ogg_sync_wrote(&sync, len) != 0)
|
||||
throw status {st::libogg_error, "ogg_sync_wrote failed."};
|
||||
}
|
||||
++absolute_page_no;
|
||||
return true;
|
||||
ogg_stream_clear(&stream);
|
||||
}
|
||||
|
||||
void ot::ogg_reader::process_header_packet(const std::function<void(ogg_packet&)>& f)
|
||||
void ogg::Stream::flush_packets()
|
||||
{
|
||||
if (ogg_page_continued(&page))
|
||||
throw status {ot::st::error, "Unexpected continued header page."};
|
||||
|
||||
ogg_packet packet;
|
||||
ogg_logical_stream stream(ogg_page_serialno(&page));
|
||||
stream.pageno = ogg_page_pageno(&page);
|
||||
|
||||
for (;;) {
|
||||
if (ogg_stream_pagein(&stream, &page) != 0)
|
||||
throw status {st::libogg_error, "ogg_stream_pagein failed."};
|
||||
|
||||
int rc = ogg_stream_packetout(&stream, &packet);
|
||||
if (ogg_stream_check(&stream) != 0 || rc == -1) {
|
||||
throw status {ot::st::libogg_error, "ogg_stream_packetout failed."};
|
||||
} else if (rc == 0) {
|
||||
// Not enough data: read the next page.
|
||||
if (!next_page())
|
||||
throw status {ot::st::error, "Unterminated header packet."};
|
||||
continue;
|
||||
} else {
|
||||
// The packet was successfully read.
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
f(packet);
|
||||
|
||||
/* 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)
|
||||
throw status {ot::st::error, "Header page contains more than a single packet."};
|
||||
ogg_packet op;
|
||||
while (ogg_stream_packetout(&stream, &op) > 0);
|
||||
}
|
||||
|
||||
void 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)
|
||||
throw status {st::int_overflow, "Overflowing page length"};
|
||||
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");
|
||||
|
||||
long pageno = ogg_page_pageno(&page);
|
||||
if (pageno != next_page_no)
|
||||
fprintf(stderr, "Output page number mismatch: expected %ld, got %ld.\n", next_page_no, pageno);
|
||||
next_page_no = pageno + 1;
|
||||
|
||||
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)
|
||||
throw status {st::standard_error, "fwrite error: "s + strerror(errno)};
|
||||
if (fwrite(page.body, 1, body_len, file) < body_len)
|
||||
throw status {st::standard_error, "fwrite error: "s + strerror(errno)};
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
void 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)
|
||||
throw status {ot::st::libogg_error, "ogg_stream_packetin failed"};
|
||||
|
||||
ogg_page page;
|
||||
while (ogg_stream_flush(&stream, &page) != 0)
|
||||
write_page(page);
|
||||
|
||||
if (ogg_stream_check(&stream) != 0)
|
||||
throw status {st::libogg_error, "ogg_stream_check failed"};
|
||||
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 ot::renumber_page(ogg_page& page, long new_pageno)
|
||||
void ogg::Stream::handle_packet(const ogg_packet &op)
|
||||
{
|
||||
// Quick optimization: don’t bother recomputing the CRC if the pageno did not change.
|
||||
long old_pageno = ogg_page_pageno(&page);
|
||||
if (old_pageno == new_pageno)
|
||||
return;
|
||||
|
||||
/** The pageno field is located at bytes 18 to 21 (0-indexed, little-endian). */
|
||||
uint32_t le_pageno = htole32(new_pageno);
|
||||
memcpy(&page.header[18], &le_pageno, 4);
|
||||
ogg_page_checksum_set(&page);
|
||||
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(¤t_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, ¤t_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
129
src/ogg.h
Normal 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
199
src/options.cc
Normal 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
33
src/options.h
Normal 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);
|
||||
}
|
212
src/opus.cc
212
src/opus.cc
@ -1,212 +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 7845](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>
|
||||
#include <algorithm>
|
||||
|
||||
ot::opus_tags ot::parse_tags(const ogg_packet& packet)
|
||||
{
|
||||
if (packet.bytes < 0)
|
||||
throw status {st::int_overflow, "Overflowing comment header length"};
|
||||
size_t size = static_cast<size_t>(packet.bytes);
|
||||
const uint8_t* data = reinterpret_cast<uint8_t*>(packet.packet);
|
||||
size_t pos = 0;
|
||||
opus_tags my_tags;
|
||||
|
||||
// Magic number
|
||||
if (8 > size)
|
||||
throw status {st::cut_magic_number, "Comment header too short for the magic number"};
|
||||
if (memcmp(data, u8"OpusTags", 8) != 0)
|
||||
throw status {st::bad_magic_number, "Comment header did not start with OpusTags"};
|
||||
|
||||
// Vendor
|
||||
pos = 8;
|
||||
if (pos + 4 > size)
|
||||
throw status {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)
|
||||
throw status {st::cut_vendor_data, "Vendor string did not fit the comment header"};
|
||||
my_tags.vendor = std::u8string(reinterpret_cast<const char8_t*>(&data[pos + 4]), vendor_length);
|
||||
pos += 4 + my_tags.vendor.size();
|
||||
|
||||
// Comment count
|
||||
if (pos + 4 > size)
|
||||
throw status {st::cut_comment_count, "Comment count did not fit the comment header"};
|
||||
uint32_t count = le32toh(*((uint32_t*) (data + pos)));
|
||||
pos += 4;
|
||||
|
||||
// Comments' data
|
||||
for (uint32_t i = 0; i < count; ++i) {
|
||||
if (pos + 4 > size)
|
||||
throw status {st::cut_comment_length,
|
||||
"Comment length did not fit the comment header"};
|
||||
uint32_t comment_length = le32toh(*((uint32_t*) (data + pos)));
|
||||
if (pos + 4 + comment_length > size)
|
||||
throw status {st::cut_comment_data,
|
||||
"Comment string did not fit the comment header"};
|
||||
auto comment_value = reinterpret_cast<const char8_t*>(&data[pos + 4]);
|
||||
my_tags.comments.emplace_back(comment_value, comment_length);
|
||||
pos += 4 + comment_length;
|
||||
}
|
||||
|
||||
// Extra data
|
||||
my_tags.extra_data = byte_string(data + pos, size - pos);
|
||||
|
||||
return my_tags;
|
||||
}
|
||||
|
||||
ot::dynamic_ogg_packet ot::render_tags(const opus_tags& tags)
|
||||
{
|
||||
size_t size = 8 + 4 + tags.vendor.size() + 4;
|
||||
for (const std::u8string& 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::u8string& 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* The METADATA_BLOCK_PICTURE binary data, after base64 decoding, is organized like this:
|
||||
*
|
||||
* - 4 bytes for the picture type,
|
||||
* - 4 + n bytes for the MIME type,
|
||||
* - 4 + n bytes for the description string,
|
||||
* - 16 bytes of picture attributes,
|
||||
* - 4 + n bytes for the picture data.
|
||||
*
|
||||
* Integers are all big endian.
|
||||
*/
|
||||
ot::picture::picture(ot::byte_string block)
|
||||
: storage(std::move(block))
|
||||
{
|
||||
size_t mime_offset = 4;
|
||||
if (storage.size() < mime_offset + 4)
|
||||
throw status { st::invalid_size, "missing MIME type in picture block" };
|
||||
uint32_t mime_size = be32toh(*reinterpret_cast<const uint32_t*>(&storage[mime_offset]));
|
||||
|
||||
size_t desc_offset = mime_offset + 4 + mime_size;
|
||||
if (storage.size() < desc_offset + 4)
|
||||
throw status { st::invalid_size, "missing description in picture block" };
|
||||
uint32_t desc_size = be32toh(*reinterpret_cast<const uint32_t*>(&storage[desc_offset]));
|
||||
|
||||
size_t pic_offset = desc_offset + 4 + desc_size + 16;
|
||||
if (storage.size() < pic_offset + 4)
|
||||
throw status { st::invalid_size, "missing picture data in picture block" };
|
||||
uint32_t pic_size = be32toh(*reinterpret_cast<const uint32_t*>(&storage[pic_offset]));
|
||||
|
||||
if (storage.size() != pic_offset + 4 + pic_size)
|
||||
throw status { st::invalid_size, "invalid picture block size" };
|
||||
|
||||
mime_type = byte_string_view(&storage[mime_offset + 4], mime_size);
|
||||
picture_data = byte_string_view(&storage[pic_offset + 4], pic_size);
|
||||
}
|
||||
|
||||
ot::byte_string ot::picture::serialize() const
|
||||
{
|
||||
ot::byte_string bytes;
|
||||
size_t mime_offset = 4;
|
||||
size_t pic_offset = mime_offset + 4 + mime_type.size() + 4 + 0 + 16;
|
||||
bytes.resize(pic_offset + 4 + picture_data.size());
|
||||
*reinterpret_cast<uint32_t*>(&bytes[0]) = htobe32(3); // Picture type: front cover.
|
||||
*reinterpret_cast<uint32_t*>(&bytes[mime_offset]) = htobe32(mime_type.size());
|
||||
std::copy(mime_type.begin(), mime_type.end(), std::next(bytes.begin(), mime_offset + 4));
|
||||
*reinterpret_cast<uint32_t*>(&bytes[pic_offset]) = htobe32(picture_data.size());
|
||||
std::copy(picture_data.begin(), picture_data.end(), std::next(bytes.begin(), pic_offset + 4));
|
||||
return bytes;
|
||||
}
|
||||
|
||||
/**
|
||||
* \todo Take into account the picture types (first 4 bytes of the tag value).
|
||||
*/
|
||||
std::optional<ot::picture> ot::extract_cover(const ot::opus_tags& tags)
|
||||
{
|
||||
static const std::u8string_view prefix = u8"METADATA_BLOCK_PICTURE="sv;
|
||||
auto is_cover = [](const std::u8string& tag) { return tag.starts_with(prefix); };
|
||||
auto cover_tag = std::find_if(tags.comments.begin(), tags.comments.end(), is_cover);
|
||||
if (cover_tag == tags.comments.end())
|
||||
return {}; // No cover art.
|
||||
|
||||
auto extra_cover_tag = std::find_if(std::next(cover_tag), tags.comments.end(), is_cover);
|
||||
if (extra_cover_tag != tags.comments.end())
|
||||
fputs("warning: Found multiple covers; only the first will be extracted."
|
||||
" Please report your use case if you need a finer selection.\n", stderr);
|
||||
|
||||
std::u8string_view cover_value = *cover_tag;
|
||||
cover_value.remove_prefix(prefix.size());
|
||||
return picture(decode_base64(cover_value));
|
||||
}
|
||||
|
||||
/**
|
||||
* Detect the MIME type of the given data block by checking the first bytes. Only the most common
|
||||
* image formats are currently supported. Using magic(5) would give better results but that level of
|
||||
* exhaustiveness is probably not necessary.
|
||||
*/
|
||||
static ot::byte_string_view detect_mime_type(ot::byte_string_view data)
|
||||
{
|
||||
static std::initializer_list<std::pair<ot::byte_string_view, ot::byte_string_view>> magic_numbers = {
|
||||
{ "\xff\xd8\xff"_bsv, "image/jpeg"_bsv },
|
||||
{ "\x89PNG"_bsv, "image/png"_bsv },
|
||||
{ "GIF8"_bsv, "image/gif"_bsv },
|
||||
};
|
||||
for (auto [magic, mime] : magic_numbers) {
|
||||
if (data.starts_with(magic))
|
||||
return mime;
|
||||
}
|
||||
fputs("warning: Could not identify the MIME type of the picture; defaulting to application/octet-stream.\n", stderr);
|
||||
return "application/octet-stream"_bsv;
|
||||
}
|
||||
|
||||
std::u8string ot::make_cover(ot::byte_string_view picture_data)
|
||||
{
|
||||
picture pic;
|
||||
pic.mime_type = detect_mime_type(picture_data);
|
||||
pic.picture_data = picture_data;
|
||||
return u8"METADATA_BLOCK_PICTURE=" + encode_base64(pic.serialize());
|
||||
}
|
@ -1,28 +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) {
|
||||
try {
|
||||
setlocale(LC_ALL, "");
|
||||
ot::options opt = ot::parse_options(argc, argv, stdin);
|
||||
ot::run(opt);
|
||||
return 0;
|
||||
} catch (const ot::status& rc) {
|
||||
if (!rc.message.empty())
|
||||
fprintf(stderr, "error: %s\n", rc.message.c_str());
|
||||
return rc == ot::st::bad_arguments ? 2 : 1;
|
||||
}
|
||||
}
|
587
src/opustags.h
587
src/opustags.h
@ -1,587 +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 <config.h>
|
||||
|
||||
#include <iconv.h>
|
||||
#include <ogg/ogg.h>
|
||||
#include <stdio.h>
|
||||
#include <time.h>
|
||||
|
||||
#include <functional>
|
||||
#include <list>
|
||||
#include <memory>
|
||||
#include <optional>
|
||||
#include <string>
|
||||
#include <string_view>
|
||||
#include <vector>
|
||||
|
||||
#ifdef HAVE_ENDIAN_H
|
||||
# include <endian.h>
|
||||
#endif
|
||||
|
||||
#ifdef HAVE_SYS_ENDIAN_H
|
||||
# include <sys/endian.h>
|
||||
#endif
|
||||
|
||||
#ifdef __APPLE__
|
||||
#include <libkern/OSByteOrder.h>
|
||||
#define htole32(x) OSSwapHostToLittleInt32(x)
|
||||
#define le32toh(x) OSSwapLittleToHostInt32(x)
|
||||
#define htobe32(x) OSSwapHostToBigInt32(x)
|
||||
#define be32toh(x) OSSwapBigToHostInt32(x)
|
||||
#endif
|
||||
|
||||
using namespace std::literals;
|
||||
|
||||
namespace ot {
|
||||
|
||||
/**
|
||||
* Possible return status code, ranging from errors to special statuses. They are usually
|
||||
* accompanied with a message with the #status structure.
|
||||
*
|
||||
* 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,
|
||||
cancel,
|
||||
/* System */
|
||||
badly_encoded,
|
||||
child_process_failed,
|
||||
/* Ogg */
|
||||
bad_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,
|
||||
invalid_size,
|
||||
/* CLI */
|
||||
bad_arguments,
|
||||
};
|
||||
|
||||
/**
|
||||
* Wraps a status code with an optional message. It is implictly converted to and from a
|
||||
* #status_code. It may be thrown on error by any of the ot:: functions.
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
struct status {
|
||||
status(st code = st::ok) : code(code) {}
|
||||
template<class T> status(st code, T&& message) : code(code), message(message) {}
|
||||
operator st() const { return code; }
|
||||
st code;
|
||||
std::string message;
|
||||
};
|
||||
|
||||
using byte_string = std::basic_string<uint8_t>;
|
||||
using byte_string_view = std::basic_string_view<uint8_t>;
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \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.
|
||||
*/
|
||||
void open(const char* destination);
|
||||
/** Close then move the partial file to its final location. */
|
||||
void 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;
|
||||
};
|
||||
|
||||
/** Read a whole file into memory and return the read content. */
|
||||
byte_string slurp_binary_file(const char* filename);
|
||||
|
||||
/** Convert a string from the system locale’s encoding to UTF-8. */
|
||||
std::u8string encode_utf8(std::string_view);
|
||||
|
||||
/** Convert a string from UTF-8 to the system locale’s encoding. */
|
||||
std::string decode_utf8(std::u8string_view);
|
||||
|
||||
/** Escape a string so that a POSIX shell interprets it as a single argument. */
|
||||
std::string shell_escape(std::string_view word);
|
||||
|
||||
/**
|
||||
* Execute the editor process specified in editor. Wait for the process to exit and
|
||||
* return st::ok on success, or st::child_process_failed if it did not exit with 0.
|
||||
*
|
||||
* editor is passed unescaped to the shell, and may contain CLI options.
|
||||
* path is the name of the file to edit, which will be passed as the last argument to editor.
|
||||
*/
|
||||
void run_editor(std::string_view editor, std::string_view path);
|
||||
|
||||
/**
|
||||
* Return the specified path’s mtime, i.e. the last data modification
|
||||
* timestamp.
|
||||
*/
|
||||
timespec get_file_timestamp(const char* path);
|
||||
|
||||
std::u8string encode_base64(byte_string_view src);
|
||||
byte_string decode_base64(std::u8string_view src);
|
||||
|
||||
/** \} */
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \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 it returns false to consume the stream, and use #page to check
|
||||
* its content.
|
||||
*/
|
||||
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 is made available in the #page field,
|
||||
* is owned by the Ogg reader, and is valid until the next call to #read_page.
|
||||
*
|
||||
* Return true if a page was read, false on end of stream.
|
||||
*/
|
||||
bool 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.
|
||||
*/
|
||||
void process_header_packet(const std::function<void(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;
|
||||
/**
|
||||
* Page number in the physical stream of the last read page, disregarding multiplexed
|
||||
* streams. The first page number is 0. When no page has been read, its value is
|
||||
* (size_t) -1.
|
||||
*/
|
||||
size_t absolute_page_no = -1;
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
void write_page(const ogg_page& page);
|
||||
/**
|
||||
* Write a header packet and flush the page. Header packets are always placed alone on their
|
||||
* pages.
|
||||
*/
|
||||
void 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;
|
||||
/**
|
||||
* Path to the output file.
|
||||
*/
|
||||
std::optional<std::string> path;
|
||||
/**
|
||||
* Custom counter for the sequential page number to be written. It allows us to detect
|
||||
* ogg_page_pageno mismatches and renumber the pages if needed.
|
||||
*/
|
||||
long next_page_no = 0;
|
||||
};
|
||||
|
||||
/**
|
||||
* 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;
|
||||
};
|
||||
|
||||
/** Update the Ogg pageno field in the given page. The CRC is recomputed if needed. */
|
||||
void renumber_page(ogg_page& page, long new_pageno);
|
||||
|
||||
/** \} */
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \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::u8string 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::u8string> 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.
|
||||
*/
|
||||
byte_string extra_data;
|
||||
};
|
||||
|
||||
/**
|
||||
* Read the given OpusTags packet and extract its content into an opus_tags object.
|
||||
*/
|
||||
opus_tags parse_tags(const ogg_packet& packet);
|
||||
|
||||
/**
|
||||
* Serialize an #opus_tags object into an OpusTags Ogg packet.
|
||||
*/
|
||||
dynamic_ogg_packet render_tags(const opus_tags& tags);
|
||||
|
||||
/**
|
||||
* Extracted data from the METADATA_BLOCK_PICTURE tag. See
|
||||
* <https://xiph.org/flac/format.html#metadata_block_picture> for the full specifications.
|
||||
*
|
||||
* It may contain all kinds of metadata but most are not used at all. For now, let’s assume all
|
||||
* pictures have picture type 3 (front cover), and empty metadata.
|
||||
*/
|
||||
struct picture {
|
||||
picture() = default;
|
||||
|
||||
/** Extract the picture information from serialized binary data.*/
|
||||
picture(byte_string block);
|
||||
byte_string_view mime_type;
|
||||
byte_string_view picture_data;
|
||||
|
||||
/**
|
||||
* Encode the picture attributes (mime_type, picture_data) into a binary block to be stored
|
||||
* into METADATA_BLOCK_PICTURE.
|
||||
*/
|
||||
byte_string serialize() const;
|
||||
|
||||
/** To avoid needless copies of the picture data, move the original data block there. The
|
||||
* string_view attributes will refer to it. */
|
||||
byte_string storage;
|
||||
};
|
||||
|
||||
/** Extract the first picture embedded in the tags, regardless of its type. */
|
||||
std::optional<picture> extract_cover(const opus_tags& tags);
|
||||
|
||||
/**
|
||||
* Return a METADATA_BLOCK_PICTURE tag defining the front cover art to the given picture data (JPEG,
|
||||
* PNG). The MIME type is deduced from the magic number.
|
||||
*/
|
||||
std::u8string make_cover(byte_string_view picture_data);
|
||||
|
||||
/** \} */
|
||||
|
||||
/***********************************************************************************************//**
|
||||
* \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;
|
||||
/**
|
||||
* Spawn EDITOR to edit tags interactively.
|
||||
*
|
||||
* stdin and stdout must be left free for the editor, so paths_in and
|
||||
* path_out can’t take `-`, and --set-all is not supported.
|
||||
*
|
||||
* Option: --edit
|
||||
*/
|
||||
bool edit_interactively = 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.
|
||||
*
|
||||
* Option: --delete, --set
|
||||
*/
|
||||
std::list<std::u8string> 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.
|
||||
*
|
||||
* Options: --add, --set, --set-all
|
||||
*/
|
||||
std::list<std::u8string> to_add;
|
||||
/**
|
||||
* If set, the input file’s cover art is exported to the specified file. - for stdout. Does
|
||||
* not overwrite the file if it already exists unless -y is specified. Does nothing if the
|
||||
* input file does not contain a cover art.
|
||||
*
|
||||
* Option: --output-cover
|
||||
*/
|
||||
std::optional<std::string> cover_out;
|
||||
/**
|
||||
* Print the vendor string at the beginning of the OpusTags packet instead of printing the
|
||||
* tags. Only applicable in read-only mode.
|
||||
*
|
||||
* Option: --vendor
|
||||
*/
|
||||
bool print_vendor = false;
|
||||
/**
|
||||
* Replace the vendor string by the one specified by the user.
|
||||
*
|
||||
* Option: --set-vendor
|
||||
*/
|
||||
std::optional<std::u8string> set_vendor;
|
||||
/**
|
||||
* Disable encoding conversions. OpusTags are specified to always be encoded as UTF-8, but
|
||||
* if for some reason a specific file contains binary tags that someone would like to
|
||||
* extract and set as-is, encoding conversion would get in the way.
|
||||
*/
|
||||
bool raw = false;
|
||||
/**
|
||||
* In text mode (default), tags are separated by a line feed. However, when combining
|
||||
* opustags with grep or other line-based tools, this proves to be a bad separator because
|
||||
* tag values may contain newlines. Changing the delimiter to '\0' with -z eases the
|
||||
* processing of multi-line tags with other tools that support null-terminated lines.
|
||||
*/
|
||||
char tag_delimiter = '\n';
|
||||
};
|
||||
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
options parse_options(int argc, char** argv, 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,
|
||||
* unless raw is true.
|
||||
*
|
||||
* The output generated is meant to be parseable by #ot::read_comments.
|
||||
*/
|
||||
void print_comments(const std::list<std::u8string>& comments, FILE* output, const options& opt);
|
||||
|
||||
/**
|
||||
* Parse the comments outputted by #ot::print_comments. Unless raw is true, the comments are
|
||||
* converted from the system encoding to UTF-8, and returned as UTF-8.
|
||||
*/
|
||||
std::list<std::u8string> read_comments(FILE* input, const options& opt);
|
||||
|
||||
/**
|
||||
* Remove all comments matching the specified selector, which may either be a field name or a
|
||||
* NAME=VALUE pair. The field name is case-insensitive.
|
||||
*/
|
||||
void delete_comments(std::list<std::u8string>& comments, const std::u8string& selector);
|
||||
|
||||
/**
|
||||
* Main entry point to the opustags program, and pretty much the same as calling opustags from the
|
||||
* command-line.
|
||||
*/
|
||||
void run(const options& opt);
|
||||
|
||||
/** \} */
|
||||
|
||||
}
|
||||
|
||||
/** Handy literal suffix for building byte strings. */
|
||||
ot::byte_string operator""_bs(const char* data, size_t size);
|
||||
ot::byte_string_view operator""_bsv(const char* data, size_t size);
|
275
src/system.cc
275
src/system.cc
@ -1,275 +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 <fstream>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
#include <sys/stat.h>
|
||||
#include <sys/wait.h>
|
||||
#include <unistd.h>
|
||||
|
||||
ot::byte_string operator""_bs(const char* data, size_t size)
|
||||
{
|
||||
return ot::byte_string(reinterpret_cast<const uint8_t*>(data), size);
|
||||
}
|
||||
|
||||
ot::byte_string_view operator""_bsv(const char* data, size_t size)
|
||||
{
|
||||
return ot::byte_string_view(reinterpret_cast<const uint8_t*>(data), size);
|
||||
}
|
||||
|
||||
void ot::partial_file::open(const char* destination)
|
||||
{
|
||||
final_name = destination;
|
||||
temporary_name = final_name + ".XXXXXX.part";
|
||||
int fd = mkstemps(const_cast<char*>(temporary_name.data()), 5);
|
||||
if (fd == -1)
|
||||
throw status {st::standard_error,
|
||||
"Could not create a partial file for '" + final_name + "': " +
|
||||
strerror(errno)};
|
||||
file = fdopen(fd, "w");
|
||||
if (file == nullptr)
|
||||
throw status {st::standard_error,
|
||||
"Could not get the partial file handle to '" + temporary_name + "': " +
|
||||
strerror(errno)};
|
||||
}
|
||||
|
||||
static mode_t get_umask()
|
||||
{
|
||||
// libc doesn’t 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 doesn’t 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));
|
||||
}
|
||||
|
||||
void ot::partial_file::commit()
|
||||
{
|
||||
if (file == nullptr)
|
||||
return;
|
||||
file.reset();
|
||||
copy_permissions(final_name.c_str(), temporary_name.c_str());
|
||||
if (rename(temporary_name.c_str(), final_name.c_str()) == -1)
|
||||
throw status {st::standard_error,
|
||||
"Could not move the result file '" + temporary_name + "' to '" +
|
||||
final_name + "': " + strerror(errno) + "."};
|
||||
}
|
||||
|
||||
void ot::partial_file::abort()
|
||||
{
|
||||
if (file == nullptr)
|
||||
return;
|
||||
file.reset();
|
||||
remove(temporary_name.c_str());
|
||||
}
|
||||
|
||||
/**
|
||||
* Determine the file size, in bytes, of the given file. Return -1 on for streams.
|
||||
*/
|
||||
static long get_file_size(FILE* f)
|
||||
{
|
||||
if (fseek(f, 0L, SEEK_END) != 0) {
|
||||
clearerr(f); // Recover.
|
||||
return -1;
|
||||
}
|
||||
long file_size = ftell(f);
|
||||
rewind(f);
|
||||
return file_size;
|
||||
}
|
||||
|
||||
ot::byte_string ot::slurp_binary_file(const char* filename)
|
||||
{
|
||||
file f = strcmp(filename, "-") == 0 ? freopen(nullptr, "rb", stdin)
|
||||
: fopen(filename, "rb");
|
||||
if (f == nullptr)
|
||||
throw status { st::standard_error,
|
||||
"Could not open '"s + filename + "': " + strerror(errno) + "." };
|
||||
|
||||
byte_string content;
|
||||
long file_size = get_file_size(f.get());
|
||||
if (file_size == -1) {
|
||||
// Read the input stream block by block and resize the output byte string as needed.
|
||||
uint8_t buffer[4096];
|
||||
while (!feof(f.get())) {
|
||||
size_t read_len = fread(buffer, 1, sizeof(buffer), f.get());
|
||||
content.append(buffer, read_len);
|
||||
if (ferror(f.get()))
|
||||
throw status { st::standard_error,
|
||||
"Could not read '"s + filename + "': " + strerror(errno) + "." };
|
||||
}
|
||||
} else {
|
||||
// Lucky! We know the file size, so let’s slurp it at once.
|
||||
content.resize(file_size);
|
||||
if (fread(content.data(), 1, file_size, f.get()) < file_size)
|
||||
throw status { st::standard_error,
|
||||
"Could not read '"s + filename + "': " + strerror(errno) + "." };
|
||||
}
|
||||
|
||||
return content;
|
||||
}
|
||||
|
||||
/** C++ wrapper for iconv. */
|
||||
class encoding_converter {
|
||||
public:
|
||||
/**
|
||||
* Allocate the iconv conversion state, initializing the given source and destination
|
||||
* character encodings. If it's okay to have some information lost, make sure `to` ends with
|
||||
* "//TRANSLIT", otherwise the conversion will fail when a character cannot be represented
|
||||
* in the target encoding. See the documentation of iconv_open for details.
|
||||
*/
|
||||
encoding_converter(const char* from, const char* to);
|
||||
~encoding_converter();
|
||||
/**
|
||||
* Convert text using iconv. If the input sequence is invalid, return #st::badly_encoded and
|
||||
* abort the processing, leaving out in an undefined state.
|
||||
*/
|
||||
template<class InChar, class OutChar>
|
||||
std::basic_string<OutChar> convert(std::basic_string_view<InChar>);
|
||||
private:
|
||||
iconv_t cd; /**< conversion descriptor */
|
||||
};
|
||||
|
||||
encoding_converter::encoding_converter(const char* from, const char* to)
|
||||
{
|
||||
cd = iconv_open(to, from);
|
||||
if (cd == (iconv_t) -1)
|
||||
throw std::bad_alloc();
|
||||
}
|
||||
|
||||
encoding_converter::~encoding_converter()
|
||||
{
|
||||
iconv_close(cd);
|
||||
}
|
||||
|
||||
template<class InChar, class OutChar>
|
||||
std::basic_string<OutChar> encoding_converter::convert(std::basic_string_view<InChar> in)
|
||||
{
|
||||
iconv(cd, nullptr, nullptr, nullptr, nullptr);
|
||||
std::basic_string<OutChar> out;
|
||||
out.reserve(in.size());
|
||||
const char* in_data = reinterpret_cast<const char*>(in.data());
|
||||
char* in_cursor = const_cast<char*>(in_data);
|
||||
size_t in_left = in.size();
|
||||
constexpr size_t chunk_size = 1024;
|
||||
char chunk[chunk_size];
|
||||
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) {
|
||||
// Loop normally.
|
||||
} else if (rc == (size_t) -1) {
|
||||
throw ot::status {ot::st::badly_encoded, strerror(errno) + "."s};
|
||||
} else if (rc != 0) {
|
||||
throw ot::status {ot::st::badly_encoded,
|
||||
"Some characters could not be converted into the target encoding."};
|
||||
}
|
||||
|
||||
out.append(reinterpret_cast<OutChar*>(chunk), out_cursor - chunk);
|
||||
if (in_cursor == nullptr)
|
||||
break;
|
||||
else if (in_left == 0)
|
||||
in_cursor = nullptr;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
std::u8string ot::encode_utf8(std::string_view in)
|
||||
{
|
||||
static encoding_converter to_utf8_cvt("", "UTF-8");
|
||||
return to_utf8_cvt.convert<char, char8_t>(in);
|
||||
}
|
||||
|
||||
std::string ot::decode_utf8(std::u8string_view in)
|
||||
{
|
||||
static encoding_converter from_utf8_cvt("UTF-8", "");
|
||||
return from_utf8_cvt.convert<char8_t, char>(in);
|
||||
}
|
||||
|
||||
std::string ot::shell_escape(std::string_view word)
|
||||
{
|
||||
std::string escaped_word;
|
||||
// Pre-allocate the result, assuming most of the time enclosing it in single quotes is enough.
|
||||
escaped_word.reserve(2 + word.size());
|
||||
|
||||
escaped_word += '\'';
|
||||
for (char c : word) {
|
||||
if (c == '\'')
|
||||
escaped_word += "'\\''";
|
||||
else if (c == '!')
|
||||
escaped_word += "'\\!'";
|
||||
else
|
||||
escaped_word += c;
|
||||
}
|
||||
escaped_word += '\'';
|
||||
|
||||
return escaped_word;
|
||||
}
|
||||
|
||||
void ot::run_editor(std::string_view editor, std::string_view path)
|
||||
{
|
||||
std::string command = std::string(editor) + " " + shell_escape(path);
|
||||
int status = system(command.c_str());
|
||||
|
||||
if (status == -1)
|
||||
throw ot::status {st::standard_error, "waitpid error: "s + strerror(errno)};
|
||||
else if (!WIFEXITED(status))
|
||||
throw ot::status {st::child_process_failed,
|
||||
"Child process did not terminate normally: "s + strerror(errno)};
|
||||
else if (WEXITSTATUS(status) != 0)
|
||||
throw ot::status {st::child_process_failed,
|
||||
"Child process exited with " + std::to_string(WEXITSTATUS(status))};
|
||||
}
|
||||
|
||||
timespec ot::get_file_timestamp(const char* path)
|
||||
{
|
||||
timespec mtime;
|
||||
struct stat st;
|
||||
if (stat(path, &st) == -1)
|
||||
throw status {st::standard_error, path + ": stat error: "s + strerror(errno)};
|
||||
#if defined(HAVE_STAT_ST_MTIM)
|
||||
mtime = st.st_mtim;
|
||||
#elif defined(HAVE_STAT_ST_MTIMESPEC)
|
||||
mtime = st.st_mtimespec;
|
||||
#else
|
||||
mtime.tv_sec = st.st_mtime;
|
||||
mtime.tv_nsec = st.st_mtimensec;
|
||||
#endif
|
||||
return mtime;
|
||||
}
|
103
src/tags.cc
Normal file
103
src/tags.cc
Normal 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
49
src/tags.h
Normal 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
50
src/tags_handler.h
Normal 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() {}
|
||||
};
|
||||
|
||||
}
|
44
src/tags_handlers/composite_tags_handler.cc
Normal file
44
src/tags_handlers/composite_tags_handler.cc
Normal 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;
|
||||
}
|
25
src/tags_handlers/composite_tags_handler.h
Normal file
25
src/tags_handlers/composite_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
31
src/tags_handlers/export_tags_handler.cc
Normal file
31
src/tags_handlers/export_tags_handler.cc
Normal 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;
|
||||
}
|
22
src/tags_handlers/export_tags_handler.h
Normal file
22
src/tags_handlers/export_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
22
src/tags_handlers/external_edit_tags_handler.cc
Normal file
22
src/tags_handlers/external_edit_tags_handler.cc
Normal 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;
|
||||
}
|
17
src/tags_handlers/external_edit_tags_handler.h
Normal file
17
src/tags_handlers/external_edit_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
67
src/tags_handlers/import_tags_handler.cc
Normal file
67
src/tags_handlers/import_tags_handler.cc
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
27
src/tags_handlers/import_tags_handler.h
Normal file
27
src/tags_handlers/import_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
28
src/tags_handlers/insertion_tags_handler.cc
Normal file
28
src/tags_handlers/insertion_tags_handler.cc
Normal 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;
|
||||
}
|
26
src/tags_handlers/insertion_tags_handler.h
Normal file
26
src/tags_handlers/insertion_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
16
src/tags_handlers/listing_tags_handler.cc
Normal file
16
src/tags_handlers/listing_tags_handler.cc
Normal 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";
|
||||
}
|
20
src/tags_handlers/listing_tags_handler.h
Normal file
20
src/tags_handlers/listing_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
33
src/tags_handlers/modification_tags_handler.cc
Normal file
33
src/tags_handlers/modification_tags_handler.cc
Normal 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;
|
||||
}
|
26
src/tags_handlers/modification_tags_handler.h
Normal file
26
src/tags_handlers/modification_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
35
src/tags_handlers/removal_tags_handler.cc
Normal file
35
src/tags_handlers/removal_tags_handler.cc
Normal 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;
|
||||
}
|
||||
}
|
22
src/tags_handlers/removal_tags_handler.h
Normal file
22
src/tags_handlers/removal_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
59
src/tags_handlers/stream_tags_handler.cc
Normal file
59
src/tags_handlers/stream_tags_handler.cc
Normal 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;
|
||||
}
|
33
src/tags_handlers/stream_tags_handler.h
Normal file
33
src/tags_handlers/stream_tags_handler.h
Normal 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;
|
||||
};
|
||||
|
||||
}
|
13
src/tags_handlers_errors.cc
Normal file
13
src/tags_handlers_errors.cc
Normal 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)
|
||||
{
|
||||
}
|
17
src/tags_handlers_errors.h
Normal file
17
src/tags_handlers_errors.h
Normal 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
10
src/version.h.in
Normal 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@";
|
||||
|
||||
}
|
@ -1,26 +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(base64.t EXCLUDE_FROM_ALL base64.cc)
|
||||
target_link_libraries(base64.t ot)
|
||||
|
||||
add_executable(oggdump EXCLUDE_FROM_ALL oggdump.cc)
|
||||
target_link_libraries(oggdump ot)
|
||||
|
||||
configure_file(gobble.opus . COPYONLY)
|
||||
configure_file(pixel.png . COPYONLY)
|
||||
|
||||
add_custom_target(
|
||||
check
|
||||
COMMAND prove "${CMAKE_CURRENT_BINARY_DIR}" "${CMAKE_CURRENT_SOURCE_DIR}"
|
||||
DEPENDS opustags gobble.opus system.t opus.t ogg.t cli.t base64.t
|
||||
)
|
46
t/base64.cc
46
t/base64.cc
@ -1,46 +0,0 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
static void check_encode_base64()
|
||||
{
|
||||
opaque_is(ot::encode_base64(""_bsv), u8"", "empty");
|
||||
opaque_is(ot::encode_base64("a"_bsv), u8"YQ==", "1 character");
|
||||
opaque_is(ot::encode_base64("aa"_bsv), u8"YWE=", "2 characters");
|
||||
opaque_is(ot::encode_base64("aaa"_bsv), u8"YWFh", "3 characters");
|
||||
opaque_is(ot::encode_base64("aaaa"_bsv), u8"YWFhYQ==", "4 characters");
|
||||
opaque_is(ot::encode_base64("\xFF\xFF\xFE"_bsv), u8"///+", "RFC alphabet");
|
||||
opaque_is(ot::encode_base64("\0x"_bsv), u8"AHg=", "embedded null bytes");
|
||||
}
|
||||
|
||||
static void check_decode_base64()
|
||||
{
|
||||
opaque_is(ot::decode_base64(u8""), ""_bsv, "empty");
|
||||
opaque_is(ot::decode_base64(u8"YQ=="), "a"_bsv, "1 character");
|
||||
opaque_is(ot::decode_base64(u8"YWE="), "aa"_bsv, "2 characters");
|
||||
opaque_is(ot::decode_base64(u8"YQ"), "a"_bsv, "padless 1 character");
|
||||
opaque_is(ot::decode_base64(u8"YWE"), "aa"_bsv, "padless 2 characters");
|
||||
opaque_is(ot::decode_base64(u8"YWFh"), "aaa"_bsv, "3 characters");
|
||||
opaque_is(ot::decode_base64(u8"YWFhYQ=="), "aaaa"_bsv, "4 characters");
|
||||
opaque_is(ot::decode_base64(u8"///+"), "\xFF\xFF\xFE"_bsv, "RFC alphabet");
|
||||
opaque_is(ot::decode_base64(u8"AHg="), "\0x"_bsv, "embedded null bytes");
|
||||
|
||||
try {
|
||||
ot::decode_base64(u8"Y===");
|
||||
throw failure("accepted a bad block size");
|
||||
} catch (const ot::status& e) {
|
||||
}
|
||||
|
||||
try {
|
||||
ot::decode_base64(u8"\xFF bad message!");
|
||||
throw failure("accepted an invalid character");
|
||||
} catch (const ot::status& e) {
|
||||
}
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
std::cout << "1..2\n";
|
||||
run(check_encode_base64, "base64 encoding");
|
||||
run(check_decode_base64, "base64 decoding");
|
||||
return 0;
|
||||
}
|
234
t/cli.cc
234
t/cli.cc
@ -1,234 +0,0 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
static ot::status read_comments(FILE* input, std::list<std::u8string>& comments, bool raw)
|
||||
{
|
||||
ot::options opt;
|
||||
opt.raw = raw;
|
||||
try {
|
||||
comments = ot::read_comments(input, opt);
|
||||
} catch (const ot::status& rc) {
|
||||
return rc;
|
||||
}
|
||||
return ot::st::ok;
|
||||
}
|
||||
|
||||
void check_read_comments()
|
||||
{
|
||||
std::list<std::u8string> 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 = read_comments(input.get(), comments, false);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read comments");
|
||||
auto&& expected = {u8"TITLE=a b c", u8"ARTIST=X", u8"Artist=Y"};
|
||||
if (!std::equal(comments.begin(), comments.end(), expected.begin(), expected.end()))
|
||||
throw failure("parsed user comments did not match expectations");
|
||||
}
|
||||
{
|
||||
std::string txt = "CORRUPTED=\xFF\xFF\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = read_comments(input.get(), comments, false);
|
||||
if (rc != ot::st::badly_encoded)
|
||||
throw failure("did not get the expected error reading corrupted data");
|
||||
}
|
||||
{
|
||||
std::string txt = "RAW=\xFF\xFF\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = read_comments(input.get(), comments, true);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read comments");
|
||||
if (comments.front() != (char8_t*) "RAW=\xFF\xFF")
|
||||
throw failure("parsed user comments did not match expectations");
|
||||
}
|
||||
{
|
||||
std::string txt = "MULTILINE=First\n\tSecond\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = read_comments(input.get(), comments, true);
|
||||
if (rc != ot::st::ok)
|
||||
throw failure("could not read comments");
|
||||
if (comments.front() != u8"MULTILINE=First\nSecond")
|
||||
throw failure("parsed user comments did not match expectations");
|
||||
}
|
||||
{
|
||||
std::string txt = "MALFORMED\n"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = read_comments(input.get(), comments, false);
|
||||
if (rc != ot::st::error)
|
||||
throw failure("did not get the expected error reading malformed comments");
|
||||
}
|
||||
{
|
||||
std::string txt = "\tBad"s;
|
||||
ot::file input = fmemopen((char*) txt.data(), txt.size(), "r");
|
||||
rc = read_comments(input.get(), comments, true);
|
||||
if (rc != ot::st::error)
|
||||
throw failure("did not get the expected error reading bad continuation line");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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::st::ok;
|
||||
try {
|
||||
opt = ot::parse_options(argc, argv, comments);
|
||||
} catch (const ot::status& e) {
|
||||
rc = e;
|
||||
}
|
||||
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.front() != u8"X" || *std::next(opt.to_delete.begin()) != u8"a=b" ||
|
||||
opt.to_add != std::list<std::u8string>{ u8"X=Y Z" })
|
||||
throw failure("unexpected option parsing result for case #1");
|
||||
|
||||
opt = parse({"opustags", "-S", "x", "-S", "-a", "x=y z", "-i"});
|
||||
if (opt.paths_in.size() != 1 || opt.paths_in.front() != "x" || opt.path_out ||
|
||||
!opt.overwrite || opt.to_delete.size() != 0 ||
|
||||
opt.to_add != std::list<std::u8string>{ u8"N=1", u8"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");
|
||||
|
||||
opt = parse({"opustags", "-ie", "x"});
|
||||
if (opt.paths_in.size() != 1 || opt.paths_in[0] != "x" ||
|
||||
!opt.edit_interactively || !opt.overwrite || !opt.in_place)
|
||||
throw failure("unexpected option parsing result for case #4");
|
||||
|
||||
opt = parse({"opustags", "-a", "X=\xFF", "--raw", "x"});
|
||||
if (!opt.raw || opt.to_add.front() != u8"X=\xFF")
|
||||
throw failure("--raw did not disable transcoding");
|
||||
}
|
||||
|
||||
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.starts_with(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", "-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 more than once.", "set all and read opus from stdin");
|
||||
error_case({"opustags", "-i", "-"}, "Cannot modify standard input in place.", "write stdin in-place");
|
||||
error_case({"opustags", "-o", "x", "--output", "y", "z"},
|
||||
"Cannot specify --output more than once.", "double output");
|
||||
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");
|
||||
error_case({"opustags", "-e", "-i", "x", "y"},
|
||||
"Exactly one input file must be specified.", "editing interactively two files at once");
|
||||
error_case({"opustags", "--edit", "-", "-o", "x"},
|
||||
"Cannot edit interactively when standard input or standard output are already used.",
|
||||
"editing interactively from stdandard intput");
|
||||
error_case({"opustags", "--edit", "x", "-o", "-"},
|
||||
"Cannot edit interactively when standard input or standard output are already used.",
|
||||
"editing interactively to stdandard output");
|
||||
error_case({"opustags", "--edit", "x"}, "Cannot edit interactively when no output is specified.", "editing without output");
|
||||
error_case({"opustags", "--edit", "x", "-i", "-a", "X=Y"}, "Cannot mix --edit with -adDsS.", "mixing -e and -a");
|
||||
error_case({"opustags", "--edit", "x", "-i", "-d", "X"}, "Cannot mix --edit with -adDsS.", "mixing -e and -d");
|
||||
error_case({"opustags", "--edit", "x", "-i", "-D"}, "Cannot mix --edit with -adDsS.", "mixing -e and -D");
|
||||
error_case({"opustags", "--edit", "x", "-i", "-S"}, "Cannot mix --edit with -adDsS.", "mixing -e and -S");
|
||||
error_case({"opustags", "--output-cover", "x", "--output-cover", "y"},
|
||||
"Cannot specify --output-cover more than once.", "multiple --output-cover");
|
||||
error_case({"opustags", "x", "-o", "-", "--output-cover", "-"},
|
||||
"Cannot specify standard output for both --output and --output-cover.", "-o and --output-cover conflict");
|
||||
error_case({"opustags", "-i", "x", "y", "--output-cover", "z"},
|
||||
"Cannot use --output-cover with multiple input files.", "--output-cover with multiple input");
|
||||
error_case({"opustags", "-i", "--vendor", "x"},
|
||||
"--vendor is only supported in read-only mode.", "--vendor when editing");
|
||||
error_case({"opustags", "-d", "\xFF", "x"},
|
||||
"Could not encode argument into UTF-8:",
|
||||
"-d with binary data");
|
||||
error_case({"opustags", "-a", "X=\xFF", "x"},
|
||||
"Could not encode argument into UTF-8:",
|
||||
"-a with binary data");
|
||||
error_case({"opustags", "-s", "X=\xFF", "x"},
|
||||
"Could not encode argument into UTF-8:",
|
||||
"-s with binary data");
|
||||
}
|
||||
|
||||
static void check_delete_comments()
|
||||
{
|
||||
using C = std::list<std::u8string>;
|
||||
C original = {u8"TITLE=X", u8"Title=Y", u8"Title=Z", u8"ARTIST=A", u8"artIst=B"};
|
||||
|
||||
C edited = original;
|
||||
ot::delete_comments(edited, u8"derp");
|
||||
if (!std::equal(edited.begin(), edited.end(), original.begin(), original.end()))
|
||||
throw failure("should not have deleted anything");
|
||||
|
||||
ot::delete_comments(edited, u8"Title");
|
||||
C expected = {u8"ARTIST=A", u8"artIst=B"};
|
||||
if (!std::equal(edited.begin(), edited.end(), expected.begin(), expected.end()))
|
||||
throw failure("did not delete all titles correctly");
|
||||
|
||||
edited = original;
|
||||
ot::delete_comments(edited, u8"titlE=Y");
|
||||
ot::delete_comments(edited, u8"Title=z");
|
||||
expected = {u8"TITLE=X", u8"Title=Z", u8"ARTIST=A", u8"artIst=B"};
|
||||
if (!std::equal(edited.begin(), edited.end(), expected.begin(), expected.end()))
|
||||
throw failure("did not delete a specific title correctly");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
std::cout << "1..4\n";
|
||||
run(check_read_comments, "check tags parsing");
|
||||
run(check_good_arguments, "check options parsing");
|
||||
run(check_bad_arguments, "check options parsing errors");
|
||||
run(check_delete_comments, "delete comments");
|
||||
return 0;
|
||||
}
|
BIN
t/gobble.opus
BIN
t/gobble.opus
Binary file not shown.
167
t/ogg.cc
167
t/ogg.cc
@ -1,167 +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());
|
||||
|
||||
if (reader.next_page() != true)
|
||||
throw failure("could not read the first page");
|
||||
if (!ot::is_opus_stream(reader.page))
|
||||
throw failure("failed to identify the stream as opus");
|
||||
reader.process_header_packet([](ogg_packet& p) {
|
||||
if (p.bytes != 19)
|
||||
throw failure("unexpected length for the first packet");
|
||||
});
|
||||
|
||||
if (reader.next_page() != true)
|
||||
throw failure("could not read the second page");
|
||||
reader.process_header_packet([](ogg_packet& p) {
|
||||
if (p.bytes != 62)
|
||||
throw failure("unexpected length for the second packet");
|
||||
});
|
||||
|
||||
while (!ogg_page_eos(&reader.page)) {
|
||||
if (reader.next_page() != true)
|
||||
throw failure("failure reading a page");
|
||||
}
|
||||
if (reader.next_page() != false)
|
||||
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::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);
|
||||
writer.write_header_packet(1234, 1, 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());
|
||||
if (reader.next_page() != true)
|
||||
throw failure("could not read the first page");
|
||||
reader.process_header_packet([&first_packet](ogg_packet &p) {
|
||||
if (!same_packet(p, first_packet))
|
||||
throw failure("unexpected content in the first packet");
|
||||
});
|
||||
if (reader.next_page() != true)
|
||||
throw failure("could not read the second page");
|
||||
reader.process_header_packet([&second_packet](ogg_packet &p) {
|
||||
if (!same_packet(p, second_packet))
|
||||
throw failure("unexpected content in the second packet");
|
||||
});
|
||||
if (reader.next_page() != false)
|
||||
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());
|
||||
try {
|
||||
reader.next_page();
|
||||
throw failure("did not raise an error");
|
||||
} catch (const ot::status& rc) {
|
||||
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");
|
||||
}
|
||||
|
||||
void check_renumber_page()
|
||||
{
|
||||
ot::file input = fopen("gobble.opus", "r");
|
||||
if (input == nullptr)
|
||||
throw failure("could not open gobble.opus");
|
||||
|
||||
ot::ogg_reader reader(input.get());
|
||||
if (reader.next_page() != true)
|
||||
throw failure("could not read the first page");
|
||||
|
||||
long new_pageno = 1234;
|
||||
ot::renumber_page(reader.page, new_pageno);
|
||||
if (ogg_page_pageno(&reader.page) != new_pageno)
|
||||
throw failure("renumbering failed");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
std::cout << "1..5\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");
|
||||
run(check_renumber_page, "page renumbering");
|
||||
return 0;
|
||||
}
|
42
t/oggdump.cc
42
t/oggdump.cc
@ -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;
|
||||
}
|
194
t/opus.cc
194
t/opus.cc
@ -1,194 +0,0 @@
|
||||
#include <opustags.h>
|
||||
#include "tap.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
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()
|
||||
{
|
||||
ogg_packet op;
|
||||
op.bytes = sizeof(standard_OpusTags) - 1;
|
||||
op.packet = (unsigned char*) standard_OpusTags;
|
||||
ot::opus_tags tags = ot::parse_tags(op);
|
||||
if (tags.vendor != u8"opustags test packet")
|
||||
throw failure("bad vendor string");
|
||||
if (tags.comments.size() != 2)
|
||||
throw failure("bad number of comments");
|
||||
auto it = tags.comments.begin();
|
||||
if (*it != u8"TITLE=Foo")
|
||||
throw failure("bad title");
|
||||
++it;
|
||||
if (*it != u8"ARTIST=Bar")
|
||||
throw failure("bad artist");
|
||||
if (tags.extra_data.size() != 0)
|
||||
throw failure("found mysterious padding data");
|
||||
}
|
||||
|
||||
static ot::status try_parse_tags(const ogg_packet& packet)
|
||||
{
|
||||
try {
|
||||
ot::parse_tags(packet);
|
||||
return ot::st::ok;
|
||||
} catch (const ot::status& rc) {
|
||||
return rc;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 (try_parse_tags(op) != ot::st::cut_magic_number)
|
||||
throw failure("did not detect the overflowing magic number");
|
||||
op.bytes = 11;
|
||||
if (try_parse_tags(op) != ot::st::cut_vendor_length)
|
||||
throw failure("did not detect the overflowing vendor string length");
|
||||
op.bytes = size;
|
||||
|
||||
header_data[0] = 'o';
|
||||
if (try_parse_tags(op) != 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 (try_parse_tags(op) != ot::st::cut_vendor_data)
|
||||
throw failure("did not detect the overflowing vendor string");
|
||||
*vendor_length = end - vendor_string - 3;
|
||||
if (try_parse_tags(op) != ot::st::cut_comment_count)
|
||||
throw failure("did not detect the overflowing comment count");
|
||||
*vendor_length = comment_count - vendor_string;
|
||||
|
||||
++*comment_count;
|
||||
if (try_parse_tags(op) != ot::st::cut_comment_length)
|
||||
throw failure("did not detect the overflowing comment length");
|
||||
*first_comment_length = end - first_comment_data + 1;
|
||||
if (try_parse_tags(op) != ot::st::cut_comment_data)
|
||||
throw failure("did not detect the overflowing comment data");
|
||||
}
|
||||
|
||||
static void recode_standard()
|
||||
{
|
||||
ogg_packet op;
|
||||
op.bytes = sizeof(standard_OpusTags) - 1;
|
||||
op.packet = (unsigned char*) standard_OpusTags;
|
||||
ot::opus_tags tags = ot::parse_tags(op);
|
||||
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()
|
||||
{
|
||||
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();
|
||||
|
||||
ot::opus_tags tags = ot::parse_tags(op);
|
||||
if (tags.extra_data != "\0hello"_bsv)
|
||||
throw failure("corrupted extra data");
|
||||
// recode the packet and ensure it's exactly the same
|
||||
auto packet = ot::render_tags(tags);
|
||||
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");
|
||||
}
|
||||
|
||||
static void extract_cover()
|
||||
{
|
||||
ot::byte_string_view picture_data = ""_bsv
|
||||
"\x00\x00\x00\x03" // Picture type 3.
|
||||
"\x00\x00\x00\x09" "image/foo" // MIME type.
|
||||
"\x00\x00\x00\x00" "" // Description.
|
||||
"\x00\x00\x00\x00" // Width.
|
||||
"\x00\x00\x00\x00" // Height.
|
||||
"\x00\x00\x00\x00" // Color depth.
|
||||
"\x00\x00\x00\x00" // Palette size.
|
||||
"\x00\x00\x00\x0C" "Picture data";
|
||||
|
||||
ot::opus_tags tags;
|
||||
tags.comments = { u8"METADATA_BLOCK_PICTURE=" + ot::encode_base64(picture_data) };
|
||||
std::optional<ot::picture> cover = ot::extract_cover(tags);
|
||||
if (!cover)
|
||||
throw failure("could not extract the cover");
|
||||
if (cover->mime_type != "image/foo"_bsv)
|
||||
throw failure("bad extracted MIME type");
|
||||
if (cover->picture_data != "Picture data"_bsv)
|
||||
throw failure("bad extracted picture data");
|
||||
|
||||
ot::byte_string_view truncated_data = picture_data.substr(0, picture_data.size() - 1);
|
||||
tags.comments = { u8"METADATA_BLOCK_PICTURE=" + ot::encode_base64(truncated_data) };
|
||||
try {
|
||||
ot::extract_cover(tags);
|
||||
throw failure("accepted a bad picture block");
|
||||
} catch (const ot::status& rc) {}
|
||||
}
|
||||
|
||||
static void make_cover()
|
||||
{
|
||||
ot::byte_string_view picture_block = ""_bsv
|
||||
"\x00\x00\x00\x03" // Picture type 3.
|
||||
"\x00\x00\x00\x09" "image/png" // MIME type.
|
||||
"\x00\x00\x00\x00" "" // Description.
|
||||
"\x00\x00\x00\x00" // Width.
|
||||
"\x00\x00\x00\x00" // Height.
|
||||
"\x00\x00\x00\x00" // Color depth.
|
||||
"\x00\x00\x00\x00" // Palette size.
|
||||
"\x00\x00\x00\x11" "\x89PNG Picture data";
|
||||
|
||||
std::u8string expected = u8"METADATA_BLOCK_PICTURE=" + ot::encode_base64(picture_block);
|
||||
opaque_is(ot::make_cover("\x89PNG Picture data"_bsv), expected, "build the picture tag");
|
||||
}
|
||||
|
||||
int main()
|
||||
{
|
||||
std::cout << "1..6\n";
|
||||
run(parse_standard, "parse a standard OpusTags packet");
|
||||
run(parse_corrupted, "correctly reject invalid packets");
|
||||
run(recode_standard, "recode a standard OpusTags packet");
|
||||
run(recode_padding, "recode a OpusTags packet with padding");
|
||||
run(extract_cover, "extract the cover art");
|
||||
run(make_cover, "encode the cover art");
|
||||
return 0;
|
||||
}
|
344
t/opustags.t
344
t/opustags.t
@ -1,344 +0,0 @@
|
||||
#!/usr/bin/env perl
|
||||
|
||||
use strict;
|
||||
use warnings;
|
||||
use utf8;
|
||||
|
||||
use Test::More tests => 66;
|
||||
use Test::Deep qw(cmp_deeply re);
|
||||
|
||||
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, 512], '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 = qr{opustags version .*\n\nUsage: opustags --help\n};
|
||||
cmp_deeply(opustags('--help'), [re($expected_help), '', 0], '--help displays the help message');
|
||||
cmp_deeply(opustags('-h'), [re($expected_help), '', 0], '-h displays the help message too');
|
||||
|
||||
is_deeply(opustags('--derp'), ['', <<"EOF", 512], 'unrecognized option shows an error');
|
||||
error: Unrecognized option '--derp'.
|
||||
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', 512], '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=foobar\tquux"), [<<'END_OUT', <<'END_ERR', 0], 'control characters');
|
||||
X=foobar quux
|
||||
END_OUT
|
||||
warning: Some tags contain control characters.
|
||||
END_ERR
|
||||
|
||||
is_deeply(opustags('out.opus', '-D', '-a', "X=foo\n\nbar"), [<<'END_OUT', '', 0], 'newline characters');
|
||||
X=foo
|
||||
|
||||
bar
|
||||
END_OUT
|
||||
|
||||
is_deeply(opustags(qw(-i out.opus -s fatal=yes -s FOO -s BAR)), ['', <<'EOF', 512], '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
|
||||
#IGNORE=COMMENTS
|
||||
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');
|
||||
|
||||
####################################################################################################
|
||||
# Interactive edition
|
||||
|
||||
$ENV{EDITOR} = 'sed -i -e y/aeiou/AEIOU/ `sleep 0.1`';
|
||||
is_deeply(opustags('gobble.opus', '-eo', "'screaming !'.opus"), ['', '', 0], 'edit a file with EDITOR');
|
||||
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were modified');
|
||||
|
||||
$ENV{EDITOR} = 'true';
|
||||
is_deeply(opustags('-ie', "'screaming !'.opus"), ['', "Cancelling edition because the tags file was not modified.\n", 256], 'close -e without saving');
|
||||
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were not modified');
|
||||
|
||||
$ENV{EDITOR} = 'false';
|
||||
is_deeply(opustags('-ie', "'screaming !'.opus"), ['', "'screaming !'.opus: error: Child process exited with 1\n", 256], 'editor exiting with an error');
|
||||
is(md5("'screaming !'.opus"), '56e85ccaa83a13c15576d75bbd6d835f', 'the tags were not modified');
|
||||
|
||||
unlink("'screaming !'.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 = 'en_US.iso88591';
|
||||
my @all_locales = split(' ', `locale -a`);
|
||||
|
||||
SKIP: {
|
||||
skip "locale $locale is not present", 5 unless (any { $_ eq $locale } @all_locales);
|
||||
|
||||
opustags(qw(gobble.opus -a TITLE=七面鳥 -a ARTIST=éàç -o out.opus -y));
|
||||
|
||||
local $ENV{LC_ALL} = $locale;
|
||||
local $ENV{LANGUAGE} = '';
|
||||
|
||||
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", 256], 'read tags in ISO-8859-1 with incompatible characters');
|
||||
encoder=Lavc58.18.100 libopus
|
||||
END_OUT
|
||||
out.opus: error: Invalid or incomplete multibyte or wide character. See --raw.
|
||||
END_ERR
|
||||
|
||||
is_deeply(opustags(qw(out.opus -d TITLE -d ARTIST), {mode => ':raw'}), [<<"END_OUT", '', 0], 'read tags in ISO-8859-1');
|
||||
encoder=Lavc58.18.100 libopus
|
||||
I=\xf9\xce
|
||||
END_OUT
|
||||
|
||||
$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
|
||||
|
||||
unlink('out.opus');
|
||||
}
|
||||
|
||||
####################################################################################################
|
||||
# Raw edition
|
||||
|
||||
is_deeply(opustags(qw(-S gobble.opus -o out.opus --raw -a), "U=\xFE", {in => <<"END_IN", mode => ':raw'}), ['', '', 0], 'raw set-all with binary data');
|
||||
T=\xFF
|
||||
END_IN
|
||||
|
||||
is_deeply(opustags(qw(out.opus --raw), { mode => ':raw' }), [<<"END_OUT", '', 0], 'raw read');
|
||||
T=\xFF
|
||||
U=\xFE
|
||||
END_OUT
|
||||
|
||||
unlink('out.opus');
|
||||
|
||||
####################################################################################################
|
||||
# Multiple-page tags
|
||||
|
||||
my $big_tags = "DATA=x\n" x 15000; # > 90K, which is over the max page size of 64KiB.
|
||||
is_deeply(opustags(qw(-S gobble.opus -o out.opus), {in => $big_tags}), ['', '', 0], 'write multi-page header');
|
||||
is_deeply(opustags('out.opus'), [$big_tags, '', 0], 'read multi-page header');
|
||||
is_deeply(opustags(qw(out.opus -i -D -a), 'encoder=Lavc58.18.100 libopus'), ['', '', 0], 'shrink the header');
|
||||
is(md5('out.opus'), '111a483596ac32352fbce4d14d16abd2', 'the result is identical to the original file');
|
||||
unlink('out.opus');
|
||||
|
||||
####################################################################################################
|
||||
# Cover arts
|
||||
|
||||
is_deeply(opustags(qw(-D --set-cover pixel.png gobble.opus -o out.opus)), ['', '', 0], 'set the cover');
|
||||
is_deeply(opustags(qw(--output-cover out.png out.opus)), [<<'END_OUT', '', 0], 'extract the cover');
|
||||
METADATA_BLOCK_PICTURE=AAAAAwAAAAlpbWFnZS9wbmcAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAEWJUE5HDQoaCgAAAA1JSERSAAAAAQAAAAEIAgAAAJB3U94AAAAMSURBVAjXY/j//z8ABf4C/tzMWecAAAAASUVORK5CYII=
|
||||
END_OUT
|
||||
is(md5('out.png'), md5('pixel.png'), 'the extracted cover is identical to the one set');
|
||||
unlink('out.opus');
|
||||
unlink('out.png');
|
||||
|
||||
is_deeply(opustags(qw(-D --set-cover - gobble.opus), { in => "GIF8 x" }), [<<'END_OUT', '', 0], 'read the cover from stdin');
|
||||
METADATA_BLOCK_PICTURE=AAAAAwAAAAlpbWFnZS9naWYAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAZHSUY4IHg=
|
||||
END_OUT
|
||||
|
||||
####################################################################################################
|
||||
# Vendor string
|
||||
|
||||
is_deeply(opustags(qw(--vendor gobble.opus)), ["Lavf58.12.100\n", '', 0], 'print the vendor string');
|
||||
|
||||
is_deeply(opustags(qw(--set-vendor opustags gobble.opus -o out.opus)), ['', '', 0], 'set the vendor string');
|
||||
is_deeply(opustags(qw(--vendor out.opus)), ["opustags\n", '', 0], 'the vendor string was updated');
|
||||
unlink('out.opus');
|
||||
|
||||
####################################################################################################
|
||||
# Multi-line tags
|
||||
|
||||
is_deeply(opustags(qw(--set-all gobble.opus -o out.opus), { in => "MULTILINE=one\n\ttwo\nSIMPLE=three\n" }), ['', '', 0], 'parses continuation lines');
|
||||
is_deeply(opustags(qw(out.opus -z)), ["MULTILINE=one\ntwo\0SIMPLE=three\0", '', 0], 'delimits output with NUL on -z');
|
||||
unlink('out.opus');
|
||||
|
||||
is_deeply(opustags(qw(--set-all gobble.opus -o out.opus -z), { in => "MULTILINE=one\ntwo\0SIMPLE=three\0" }), ['', '', 0], 'delimits input with NUL on -z');
|
||||
is_deeply(opustags(qw(out.opus)), [<<'END', '', 0], 'indents continuation lines');
|
||||
MULTILINE=one
|
||||
two
|
||||
SIMPLE=three
|
||||
END
|
||||
unlink('out.opus');
|
BIN
t/pixel.png
BIN
t/pixel.png
Binary file not shown.
Before Width: | Height: | Size: 69 B |
78
t/system.cc
78
t/system.cc
@ -1,78 +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;
|
||||
try {
|
||||
bad_tmp.open("/dev/null");
|
||||
throw failure("opening a device as a partial file should fail");
|
||||
} catch (const ot::status& rc) {
|
||||
is(rc, ot::st::standard_error, "opening a device as a partial file fails");
|
||||
}
|
||||
|
||||
bad_tmp.open(result);
|
||||
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;
|
||||
good_tmp.open(result);
|
||||
name = good_tmp.name();
|
||||
good_tmp.commit();
|
||||
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_slurp()
|
||||
{
|
||||
static const ot::byte_string_view pixel = ""_bsv
|
||||
"\x89\x50\x4e\x47\x0d\x0a\x1a\x0a\x00\x00\x00\x0d"
|
||||
"\x49\x48\x44\x52\x00\x00\x00\x01\x00\x00\x00\x01"
|
||||
"\x08\x02\x00\x00\x00\x90\x77\x53\xde\x00\x00\x00"
|
||||
"\x0c\x49\x44\x41\x54\x08\xd7\x63\xf8\xff\xff\x3f"
|
||||
"\x00\x05\xfe\x02\xfe\xdc\xcc\x59\xe7\x00\x00\x00"
|
||||
"\x00\x49\x45\x4e\x44\xae\x42\x60\x82";
|
||||
opaque_is(ot::slurp_binary_file("pixel.png"), pixel, "loads a whole file");
|
||||
}
|
||||
|
||||
void check_converter()
|
||||
{
|
||||
setlocale(LC_ALL, "");
|
||||
is(ot::decode_utf8(ot::encode_utf8("Éphémère")), "Éphémère", "decode_utf8 reverts encode_utf8");
|
||||
opaque_is(ot::encode_utf8(ot::decode_utf8(u8"Éphémère")), u8"Éphémère",
|
||||
"encode_utf8 reverts decode_utf8");
|
||||
|
||||
try {
|
||||
ot::decode_utf8((char8_t*) "\xFF\xFF");
|
||||
throw failure("conversion from bad UTF-8 did not fail");
|
||||
} catch (const ot::status&) {}
|
||||
}
|
||||
|
||||
void check_shell_esape()
|
||||
{
|
||||
is(ot::shell_escape("foo"), "'foo'", "simple string");
|
||||
is(ot::shell_escape("a'b"), "'a'\\''b'", "string with a simple quote");
|
||||
is(ot::shell_escape("a!b"), "'a'\\!'b'", "string with a bang");
|
||||
is(ot::shell_escape("a!b'c!d'e"), "'a'\\!'b'\\''c'\\!'d'\\''e'", "string with a bang");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
plan(4);
|
||||
run(check_partial_files, "test partial files");
|
||||
run(check_slurp, "file slurping");
|
||||
run(check_converter, "test encoding converter");
|
||||
run(check_shell_esape, "test shell escaping");
|
||||
return 0;
|
||||
}
|
73
t/tap.h
73
t/tap.h
@ -1,73 +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";
|
||||
} catch (const ot::status &rc) {
|
||||
std::cerr << "# unexpected error: " << rc.message << "\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 <typename T, typename U>
|
||||
void opaque_is(const T& got, const U& expected, const char* name)
|
||||
{
|
||||
if (got != expected)
|
||||
throw failure(name);
|
||||
}
|
||||
|
||||
template <>
|
||||
void is(const ot::status& got, const ot::st& expected, const char* name)
|
||||
{
|
||||
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
169
tests/actions_test.cc
Normal 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
2
tests/main.cc
Normal file
@ -0,0 +1,2 @@
|
||||
#define CATCH_CONFIG_MAIN
|
||||
#include "catch.h"
|
148
tests/ogg_test.cc
Normal file
148
tests/ogg_test.cc
Normal 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
205
tests/options_test.cc
Normal 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
BIN
tests/samples/beep.ogg
Normal file
Binary file not shown.
BIN
tests/samples/mystery-beep.ogg
Normal file
BIN
tests/samples/mystery-beep.ogg
Normal file
Binary file not shown.
BIN
tests/samples/mystery.ogg
Normal file
BIN
tests/samples/mystery.ogg
Normal file
Binary file not shown.
96
tests/tags_handlers/composite_tags_handler_test.cc
Normal file
96
tests/tags_handlers/composite_tags_handler_test.cc
Normal 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());
|
||||
}
|
||||
}
|
23
tests/tags_handlers/export_tags_handler_test.cc
Normal file
23
tests/tags_handlers/export_tags_handler_test.cc
Normal 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
|
||||
|
||||
)");
|
||||
}
|
142
tests/tags_handlers/import_tags_handler_test.cc
Normal file
142
tests/tags_handlers/import_tags_handler_test.cc
Normal 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);
|
||||
});
|
||||
}
|
||||
}
|
22
tests/tags_handlers/insertion_tags_handler_test.cc
Normal file
22
tests/tags_handlers/insertion_tags_handler_test.cc
Normal 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);
|
||||
}
|
22
tests/tags_handlers/listing_tags_handler_test.cc
Normal file
22
tests/tags_handlers/listing_tags_handler_test.cc
Normal 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");
|
||||
}
|
33
tests/tags_handlers/modification_tags_handler_test.cc
Normal file
33
tests/tags_handlers/modification_tags_handler_test.cc
Normal 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));
|
||||
}
|
43
tests/tags_handlers/removal_tags_handler_test.cc
Normal file
43
tests/tags_handlers/removal_tags_handler_test.cc
Normal 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));
|
||||
}
|
||||
}
|
92
tests/tags_handlers/stream_tags_handler_test.cc
Normal file
92
tests/tags_handlers/stream_tags_handler_test.cc
Normal 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
85
tests/tags_test.cc
Normal 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());
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user