Improve CLI

- Implement tests (not everything covered yet)
- Improve documentation in README.md
- Fix issues when editing tags of multiple files at once
This commit is contained in:
Martchus 2016-07-30 23:17:49 +02:00
parent dc70d92b0f
commit 6d8833fe9d
5 changed files with 453 additions and 104 deletions

View File

@ -127,6 +127,13 @@ set(WIDGETS_UI_FILES
# resources/icons.qrc # resources/icons.qrc
#) #)
set(TEST_HEADER_FILES
)
set(TEST_SRC_FILES
tests/cppunit.cpp
tests/cli.cpp
)
set(TS_FILES set(TS_FILES
translations/${META_PROJECT_NAME}_de_DE.ts translations/${META_PROJECT_NAME}_de_DE.ts
translations/${META_PROJECT_NAME}_en_US.ts translations/${META_PROJECT_NAME}_en_US.ts
@ -207,6 +214,7 @@ if(WIDGETS_GUI OR QUICK_GUI)
include(QtConfig) include(QtConfig)
endif() endif()
include(WindowsResources) include(WindowsResources)
include(TestTarget)
include(AppTarget) include(AppTarget)
include(ShellCompletion) include(ShellCompletion)
include(ConfigHeader) include(ConfigHeader)

View File

@ -80,33 +80,59 @@ Checkout the available operations and options with --help.
#### Examples #### Examples
Here are some Bash examples which illustrate getting and setting tag information: Here are some Bash examples which illustrate getting and setting tag information:
##### Reading tags
* *Displays* title, album and artist of all *.m4a files in the specified directory: * *Displays* title, album and artist of all *.m4a files in the specified directory:
``` ```
tageditor get title album artist --files /some/dir/*.m4a tageditor get title album artist --files /some/dir/*.m4a
``` ```
**Note**: All values are printed in UTF-8 encoding, no matter which encoding is actually used within the tag. * *Displays* all supported fields of all *.mkv files in the specified directory:
```
tageditor get --files /some/dir/*.mkv
```
* *Displays* technical information about all *.m4a files in the specified directory:
```
tageditor info --files /some/dir/*.m4a
```
* *Displays* technical information about all *.m4a files in the specified directory: * *Displays* technical information about all *.m4a files in the specified directory:
``` ```
tageditor info --files /some/dir/*.m4a tageditor info --files /some/dir/*.m4a
``` ```
**Note**: All values are printed in UTF-8 encoding, no matter which encoding is actually used within the tag.
##### Writing tags
* *Sets* title, album, artist, cover and track number of all *.m4a files in the specified directory: * *Sets* title, album, artist, cover and track number of all *.m4a files in the specified directory:
``` ```
tageditor set "title=Title of "{1st,2nd,3rd}" file" "title=Title of "{4..16}"th file" \ tageditor set title="Title of "{1st,2nd,3rd}" file" title="Title of "{4..16}"th file" \
"album=The Album" "artist=The Artist" \ album="The Album" artist="The Artist" \
cover=/path/to/image track={1..16}/16 --files /some/dir/*.m4a cover=/path/to/image track={1..16}/16 --files /some/dir/*.m4a
``` ```
The first file will get the name *Title of 1st file*, the second file will get the name *Title of 2nd file* and so on. - The first file will get the title *Title of 1st file*, the second file will get the name *Title of 2nd file* and so on.
The 16th and following files will all get the name *Title of the 16th file*. The same scheme is used for the track numbers. - The 16th and following files will all get the title *Title of the 16th file*.
All files will get the album name *The Album*, the artist *The Artist* and the cover image from the file */path/to/image*. - The same scheme is used for the track numbers.
- All files will get the album name *The Album*, the artist *The Artist* and the cover image from the file */path/to/image*.
**Note**: All specified values are assumed to be UTF-8 encoded, no matter which encoding has been specified as preferred encoding via ``--encoding`` option. (This mentioned option only affects the encoding to be used *within* the tag.) * *Sets* title of both specified files and the album of the second specified file:
```
tageditor set title0="Title for both files" album1="Album for 2nd file" \
--files file1.ogg file2.mp3
```
The number after the field name specifies the index of the first file to use the value for. The first index is 0.
* *Sets* the title specificly for the track with the ID ``3134325680`` and removes
the tags targeting the song/track and the album/movie/episode in general:
```
tageditor set target-level=30 target-tracks=3134325680 title="Title for track 3134325680" \
--remove-targets target-level=50 , target-level=30 \
--files file.mka
```
For more information checkout the [Matroska specification](https://matroska.org/technical/specs/tagging/index.html).
* Here is another example, demonstrating the use of arrays and the syntax to auto-increase numeric fields such as the track number: * Here is another example, demonstrating the use of arrays and the syntax to auto-increase numeric fields such as the track number:
@ -122,12 +148,14 @@ Here are some Bash examples which illustrate getting and setting tag information
titles+=("title=${title%.*}"); \ titles+=("title=${title%.*}"); \
done done
# now set the titles and other tag information # now set the titles and other tag information
tageditor set "${titles[@]}" "album=Some Album" track+=1/25 disk=1/1 -f *.m4a tageditor set "${titles[@]}" album="Some Album" track+=1/25 disk=1/1 -f *.m4a
``` ```
Note the *+* sign after the field name *track* which indicates that the field value should be increased after **Note**: The *+* sign after the field name *track* which indicates that the field value should be increased after
a file has been processed. a file has been processed.
**Note**: All specified values are assumed to be UTF-8 encoded, no matter which encoding has been specified as preferred encoding via ``--encoding`` option. (This mentioned option only affects the encoding to be used *within* the tag.)
## Build instructions ## Build instructions
The application depends on [c++utilities](https://github.com/Martchus/cpp-utilities) and [tagparser](https://github.com/Martchus/tagparser) and is built the same way as these libaries. For basic instructions checkout the README file of [c++utilities](https://github.com/Martchus/cpp-utilities). The application depends on [c++utilities](https://github.com/Martchus/cpp-utilities) and [tagparser](https://github.com/Martchus/tagparser) and is built the same way as these libaries. For basic instructions checkout the README file of [c++utilities](https://github.com/Martchus/cpp-utilities).

View File

@ -27,6 +27,7 @@
#include <iostream> #include <iostream>
#include <cstring> #include <cstring>
#include <algorithm> #include <algorithm>
#include <unordered_map>
using namespace std; using namespace std;
using namespace ApplicationUtilities; using namespace ApplicationUtilities;
@ -41,6 +42,8 @@ using namespace Utility;
namespace Cli { namespace Cli {
// define enums, operators and structs to handle specified field denotations
enum class DenotationType enum class DenotationType
{ {
Normal, Normal,
@ -53,28 +56,99 @@ inline TagType operator| (TagType lhs, TagType rhs)
return static_cast<TagType>(static_cast<unsigned int>(lhs) | static_cast<unsigned int>(rhs)); return static_cast<TagType>(static_cast<unsigned int>(lhs) | static_cast<unsigned int>(rhs));
} }
inline TagType operator& (TagType lhs, TagType rhs)
{
return static_cast<TagType>(static_cast<unsigned int>(lhs) & static_cast<unsigned int>(rhs));
}
inline TagType &operator|= (TagType &lhs, TagType rhs) inline TagType &operator|= (TagType &lhs, TagType rhs)
{ {
return lhs = static_cast<TagType>(static_cast<unsigned int>(lhs) | static_cast<unsigned int>(rhs)); return lhs = static_cast<TagType>(static_cast<unsigned int>(lhs) | static_cast<unsigned int>(rhs));
} }
struct FieldDenotation struct FieldScope
{ {
FieldDenotation(KnownField field); FieldScope(KnownField field = KnownField::Invalid, TagType tagType = TagType::Unspecified, TagTarget tagTarget = TagTarget());
bool operator ==(const FieldScope &other) const;
KnownField field; KnownField field;
DenotationType type;
TagType tagType; TagType tagType;
TagTarget tagTarget; TagTarget tagTarget;
vector<pair<unsigned int, string> > values;
}; };
FieldDenotation::FieldDenotation(KnownField field) : FieldScope::FieldScope(KnownField field, TagType tagType, TagTarget tagTarget) :
field(field), field(field),
type(DenotationType::Normal), tagType(tagType),
tagType(TagType::Unspecified) tagTarget(tagTarget)
{} {}
inline bool isDigit(char c) bool FieldScope::operator ==(const FieldScope &other) const
{
return field == other.field && tagType == other.tagType && tagTarget == other.tagTarget;
}
struct FieldValue
{
FieldValue(DenotationType type, unsigned int fileIndex, const char *value);
DenotationType type;
unsigned int fileIndex;
string value;
};
inline FieldValue::FieldValue(DenotationType type, unsigned int fileIndex, const char *value) :
type(type),
fileIndex(fileIndex),
value(value)
{}
}
namespace std {
using namespace Cli;
template <> struct hash<TagTarget::IdContainerType>
{
std::size_t operator()(const TagTarget::IdContainerType &ids) const
{
using std::hash;
auto seed = ids.size();
for(auto id : ids) {
seed ^= id + 0x9e3779b9 + (seed << 6) + (seed >> 2);
}
return seed;
}
};
template <> struct hash<TagTarget>
{
std::size_t operator()(const TagTarget& target) const
{
using std::hash;
return ((hash<uint64>()(target.level())
^ (hash<TagTarget::IdContainerType>()(target.tracks()) << 1)) >> 1)
^ (hash<TagTarget::IdContainerType>()(target.attachments()) << 1);
}
};
template <> struct hash<FieldScope>
{
std::size_t operator()(const FieldScope& scope) const
{
using std::hash;
return ((hash<KnownField>()(scope.field)
^ (hash<TagType>()(scope.tagType) << 1)) >> 1)
^ (hash<TagTarget>()(scope.tagTarget) << 1);
}
};
}
namespace Cli {
typedef vector<FieldValue> FieldValues;
typedef unordered_map<FieldScope, FieldValues> FieldDenotations;
constexpr bool isDigit(char c)
{ {
return c >= '0' && c <= '9'; return c >= '0' && c <= '9';
} }
@ -98,6 +172,9 @@ string incremented(const string &str, unsigned int toIncrement = 1)
res += c; res += c;
} }
} }
if(hasValue) {
res += numberToString(value + 1);
}
return res; return res;
} }
@ -116,7 +193,7 @@ void printNotifications(NotificationList &notifications, const char *head = null
return; return;
} }
if(!notifications.empty()) { if(!notifications.empty()) {
printNotifications: printNotifications:
if(head) { if(head) {
cout << head << endl; cout << head << endl;
} }
@ -168,7 +245,7 @@ void printFieldNames(const ArgumentOccurance &occurance)
{ {
CMD_UTILS_START_CONSOLE; CMD_UTILS_START_CONSOLE;
VAR_UNUSED(occurance) VAR_UNUSED(occurance)
cout << fieldNames << endl; cout << fieldNames << endl;
} }
TagUsage parseUsageDenotation(const Argument &usageArg, TagUsage defaultUsage) TagUsage parseUsageDenotation(const Argument &usageArg, TagUsage defaultUsage)
@ -286,18 +363,16 @@ bool applyTargetConfiguration(TagTarget &target, const std::string &configStr)
} }
} }
vector<FieldDenotation> parseFieldDenotations(const Argument &fieldsArg, bool readOnly) FieldDenotations parseFieldDenotations(const Argument &fieldsArg, bool readOnly)
{ {
vector<FieldDenotation> fields; FieldDenotations fields;
if(fieldsArg.isPresent()) { if(fieldsArg.isPresent()) {
const vector<const char *> &fieldDenotations = fieldsArg.values(); const vector<const char *> &fieldDenotations = fieldsArg.values();
fields.reserve(fieldDenotations.size()); FieldScope scope;
TagType currentTagType = TagType::Unspecified;
TagTarget currentTagTarget;
for(const char *fieldDenotationString : fieldDenotations) { for(const char *fieldDenotationString : fieldDenotations) {
// check for tag or target specifier // check for tag or target specifier
const auto fieldDenotationLen = strlen(fieldDenotationString); const auto fieldDenotationLen = strlen(fieldDenotationString);
if(!strncmp(fieldDenotationString, "tag:", 4)) { if(!strncmp(fieldDenotationString, "tag=", 4)) {
if(fieldDenotationLen == 4) { if(fieldDenotationLen == 4) {
cerr << "Warning: The \"tag\"-specifier has been used with no value(s) and hence is ignored. Possible values are: id3,id3v1,id3v2,itunes,vorbis,matroska,all" << endl; cerr << "Warning: The \"tag\"-specifier has been used with no value(s) and hence is ignored. Possible values are: id3,id3v1,id3v2,itunes,vorbis,matroska,all" << endl;
} else { } else {
@ -320,14 +395,14 @@ vector<FieldDenotation> parseFieldDenotations(const Argument &fieldsArg, bool re
break; break;
} else { } else {
cerr << "Warning: The value provided with the \"tag\"-specifier is invalid and will be ignored. Possible values are: id3,id3v1,id3v2,itunes,vorbis,matroska,all" << endl; cerr << "Warning: The value provided with the \"tag\"-specifier is invalid and will be ignored. Possible values are: id3,id3v1,id3v2,itunes,vorbis,matroska,all" << endl;
tagType = currentTagType; tagType = scope.tagType;
break; break;
} }
} }
currentTagType = tagType; scope.tagType = tagType;
break; break;
} }
} else if(applyTargetConfiguration(currentTagTarget, fieldDenotationString)) { } else if(applyTargetConfiguration(scope.tagTarget, fieldDenotationString)) {
continue; continue;
} }
// read field name // read field name
@ -359,78 +434,77 @@ vector<FieldDenotation> parseFieldDenotations(const Argument &fieldsArg, bool re
continue; continue;
} }
// parse the denoted filed // parse the denoted filed
KnownField field;
if(!strncmp(fieldDenotationString, "title", fieldNameLen)) { if(!strncmp(fieldDenotationString, "title", fieldNameLen)) {
field = KnownField::Title; scope.field = KnownField::Title;
} else if(!strncmp(fieldDenotationString, "album", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "album", fieldNameLen)) {
field = KnownField::Album; scope.field = KnownField::Album;
} else if(!strncmp(fieldDenotationString, "artist", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "artist", fieldNameLen)) {
field = KnownField::Artist; scope.field = KnownField::Artist;
} else if(!strncmp(fieldDenotationString, "genre", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "genre", fieldNameLen)) {
field = KnownField::Genre; scope.field = KnownField::Genre;
} else if(!strncmp(fieldDenotationString, "year", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "year", fieldNameLen)) {
field = KnownField::Year; scope.field = KnownField::Year;
} else if(!strncmp(fieldDenotationString, "comment", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "comment", fieldNameLen)) {
field = KnownField::Comment; scope.field = KnownField::Comment;
} else if(!strncmp(fieldDenotationString, "bpm", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "bpm", fieldNameLen)) {
field = KnownField::Bpm; scope.field = KnownField::Bpm;
} else if(!strncmp(fieldDenotationString, "bps", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "bps", fieldNameLen)) {
field = KnownField::Bps; scope.field = KnownField::Bps;
} else if(!strncmp(fieldDenotationString, "lyricist", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "lyricist", fieldNameLen)) {
field = KnownField::Lyricist; scope.field = KnownField::Lyricist;
} else if(!strncmp(fieldDenotationString, "track", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "track", fieldNameLen)) {
field = KnownField::TrackPosition; scope.field = KnownField::TrackPosition;
} else if(!strncmp(fieldDenotationString, "disk", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "disk", fieldNameLen)) {
field = KnownField::DiskPosition; scope.field = KnownField::DiskPosition;
} else if(!strncmp(fieldDenotationString, "part", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "part", fieldNameLen)) {
field = KnownField::PartNumber; scope.field = KnownField::PartNumber;
} else if(!strncmp(fieldDenotationString, "totalparts", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "totalparts", fieldNameLen)) {
field = KnownField::TotalParts; scope.field = KnownField::TotalParts;
} else if(!strncmp(fieldDenotationString, "encoder", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "encoder", fieldNameLen)) {
field = KnownField::Encoder; scope.field = KnownField::Encoder;
} else if(!strncmp(fieldDenotationString, "recorddate", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "recorddate", fieldNameLen)) {
field = KnownField::RecordDate; scope.field = KnownField::RecordDate;
} else if(!strncmp(fieldDenotationString, "performers", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "performers", fieldNameLen)) {
field = KnownField::Performers; scope.field = KnownField::Performers;
} else if(!strncmp(fieldDenotationString, "duration", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "duration", fieldNameLen)) {
field = KnownField::Length; scope.field = KnownField::Length;
} else if(!strncmp(fieldDenotationString, "language", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "language", fieldNameLen)) {
field = KnownField::Language; scope.field = KnownField::Language;
} else if(!strncmp(fieldDenotationString, "encodersettings", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "encodersettings", fieldNameLen)) {
field = KnownField::EncoderSettings; scope.field = KnownField::EncoderSettings;
} else if(!strncmp(fieldDenotationString, "lyrics", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "lyrics", fieldNameLen)) {
field = KnownField::Lyrics; scope.field = KnownField::Lyrics;
} else if(!strncmp(fieldDenotationString, "synchronizedlyrics", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "synchronizedlyrics", fieldNameLen)) {
field = KnownField::SynchronizedLyrics; scope.field = KnownField::SynchronizedLyrics;
} else if(!strncmp(fieldDenotationString, "grouping", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "grouping", fieldNameLen)) {
field = KnownField::Grouping; scope.field = KnownField::Grouping;
} else if(!strncmp(fieldDenotationString, "recordlabel", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "recordlabel", fieldNameLen)) {
field = KnownField::RecordLabel; scope.field = KnownField::RecordLabel;
} else if(!strncmp(fieldDenotationString, "cover", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "cover", fieldNameLen)) {
field = KnownField::Cover; scope.field = KnownField::Cover;
type = DenotationType::File; // read cover always from file type = DenotationType::File; // read cover always from file
} else if(!strncmp(fieldDenotationString, "composer", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "composer", fieldNameLen)) {
field = KnownField::Composer; scope.field = KnownField::Composer;
} else if(!strncmp(fieldDenotationString, "rating", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "rating", fieldNameLen)) {
field = KnownField::Rating; scope.field = KnownField::Rating;
} else if(!strncmp(fieldDenotationString, "description", fieldNameLen)) { } else if(!strncmp(fieldDenotationString, "description", fieldNameLen)) {
field = KnownField::Description; scope.field = KnownField::Description;
} else { } else {
// no "KnownField" value matching -> discard the field denotation // no "KnownField" value matching -> discard the field denotation
cerr << "Warning: The field name \"" << string(fieldDenotationString, fieldNameLen) << "\" is unknown and will be ingored." << endl; cerr << "Warning: The field name \"" << string(fieldDenotationString, fieldNameLen) << "\" is unknown and will be ingored." << endl;
continue; continue;
} }
// add field denotation with parsed values // add field denotation scope
fields.emplace_back(field); auto &fieldValues = fields[scope];
FieldDenotation &fieldDenotation = fields.back(); // add value to the scope (if present)
fieldDenotation.type = type;
fieldDenotation.tagType = currentTagType;
fieldDenotation.tagTarget = currentTagTarget;
if(equationPos) { if(equationPos) {
if(readOnly) { if(readOnly) {
cerr << "Warning: Specified value for \"" << string(fieldDenotationString, fieldNameLen) << "\" will be ignored." << endl; cerr << "Warning: Specified value for \"" << string(fieldDenotationString, fieldNameLen) << "\" will be ignored." << endl;
} else { } else {
fieldDenotation.values.emplace_back(make_pair(mult == 1 ? fieldDenotation.values.size() : fileIndex, (equationPos + 1))); // file index might have been specified explicitely
// if not (mult == 1) use the index of the last value and increase it by one
// if there are no previous values, just use the index 0
fieldValues.emplace_back(FieldValue(type, mult == 1 ? (fieldValues.empty() ? 0 : fieldValues.back().fileIndex + 1) : fileIndex, (equationPos + 1)));
} }
} }
} }
@ -653,7 +727,7 @@ void displayFileInfo(const ArgumentOccurance &, const Argument &filesArg, const
{ {
CMD_UTILS_START_CONSOLE; CMD_UTILS_START_CONSOLE;
if(!filesArg.isPresent() || filesArg.values().empty()) { if(!filesArg.isPresent() || filesArg.values().empty()) {
cout << "Error: No files have been specified." << endl; cerr << "Error: No files have been specified." << endl;
return; return;
} }
MediaFileInfo fileInfo; MediaFileInfo fileInfo;
@ -781,7 +855,7 @@ void displayTagInfo(const Argument &fieldsArg, const Argument &filesArg, const A
{ {
CMD_UTILS_START_CONSOLE; CMD_UTILS_START_CONSOLE;
if(!filesArg.isPresent() || filesArg.values().empty()) { if(!filesArg.isPresent() || filesArg.values().empty()) {
cout << "Error: No files have been specified." << endl; cerr << "Error: No files have been specified." << endl;
return; return;
} }
const auto fields = parseFieldDenotations(fieldsArg, true); const auto fields = parseFieldDenotations(fieldsArg, true);
@ -801,7 +875,7 @@ void displayTagInfo(const Argument &fieldsArg, const Argument &filesArg, const A
TagType tagType = tag->type(); TagType tagType = tag->type();
// write tag name and target, eg. MP4/iTunes tag // write tag name and target, eg. MP4/iTunes tag
cout << tag->typeName(); cout << tag->typeName();
if(!tag->target().isEmpty()) { if(tagType == TagType::MatroskaTag || !tag->target().isEmpty()) {
cout << " targeting \"" << tag->targetString() << "\""; cout << " targeting \"" << tag->targetString() << "\"";
} }
cout << endl; cout << endl;
@ -832,11 +906,12 @@ void displayTagInfo(const Argument &fieldsArg, const Argument &filesArg, const A
} }
} }
} else { } else {
for(const FieldDenotation &fieldDenotation : fields) { for(const auto &fieldDenotation : fields) {
const auto &value = tag->value(fieldDenotation.field); const FieldScope &denotedScope = fieldDenotation.first;
if(fieldDenotation.tagType == TagType::Unspecified || (fieldDenotation.tagType | tagType) != TagType::Unspecified) { const TagValue &value = tag->value(denotedScope.field);
if(denotedScope.tagType == TagType::Unspecified || (denotedScope.tagType | tagType) != TagType::Unspecified) {
// write field name // write field name
const char *fieldName = KnownFieldModel::fieldName(fieldDenotation.field); const char *fieldName = KnownFieldModel::fieldName(denotedScope.field);
cout << ' ' << fieldName; cout << ' ' << fieldName;
// write padding // write padding
for(auto i = strlen(fieldName); i < 18; ++i) { for(auto i = strlen(fieldName); i < 18; ++i) {
@ -884,15 +959,16 @@ void setTagInfo(const SetTagInfoArgs &args)
return; return;
} }
auto fields = parseFieldDenotations(args.valuesArg, false); auto fields = parseFieldDenotations(args.valuesArg, false);
if(fields.empty() && args.attachmentsArg.values().empty() && args.docTitleArg.values().empty()) { if(fields.empty() && (!args.removeTargetsArg.isPresent() || args.removeTargetsArg.values().empty()) && (!args.attachmentsArg.isPresent() || args.attachmentsArg.values().empty()) && (!args.docTitleArg.isPresent() || args.docTitleArg.values().empty())) {
cerr << "Error: No fields/attachments have been specified." << endl; cerr << "Error: No fields/attachments have been specified." << endl;
return; return;
} }
// determine required targets // determine required targets
vector<TagTarget> requiredTargets; vector<TagTarget> requiredTargets;
for(const FieldDenotation &fieldDenotation : fields) { for(const auto &fieldDenotation : fields) {
if(find(requiredTargets.cbegin(), requiredTargets.cend(), fieldDenotation.tagTarget) == requiredTargets.cend()) { const FieldScope &scope = fieldDenotation.first;
requiredTargets.push_back(fieldDenotation.tagTarget); if(find(requiredTargets.cbegin(), requiredTargets.cend(), scope.tagTarget) == requiredTargets.cend()) {
requiredTargets.push_back(scope.tagTarget);
} }
} }
// determine targets to remove // determine targets to remove
@ -984,31 +1060,38 @@ void setTagInfo(const SetTagInfoArgs &args)
if(!tags.empty()) { if(!tags.empty()) {
// iterate through all tags // iterate through all tags
for(auto *tag : tags) { for(auto *tag : tags) {
// clear current values if option is present
if(args.removeOtherFieldsArg.isPresent()) { if(args.removeOtherFieldsArg.isPresent()) {
tag->removeAllFields(); tag->removeAllFields();
} }
auto tagType = tag->type(); // determine required information for deciding whether specified values match the scope of the current tag
bool targetSupported = tag->supportsTarget(); const auto tagType = tag->type();
auto tagTarget = tag->target(); const bool targetSupported = tag->supportsTarget();
for(FieldDenotation &fieldDenotation : fields) { const auto tagTarget = tag->target();
if((fieldDenotation.tagType == TagType::Unspecified // iterate through all denoted field values
|| (fieldDenotation.tagType | tagType) != TagType::Unspecified) for(auto &fieldDenotation : fields) {
&& (!targetSupported || fieldDenotation.tagTarget == tagTarget)) { const FieldScope &denotedScope = fieldDenotation.first;
pair<unsigned int, string> *selectedDenotatedValue = nullptr; FieldValues &denotedValues = fieldDenotation.second;
for(auto &someDenotatedValue : fieldDenotation.values) { // decide whether the scope of the denotation matches of the current tag
if(someDenotatedValue.first <= fileIndex) { if((denotedScope.tagType == TagType::Unspecified
if(!selectedDenotatedValue || (someDenotatedValue.first > selectedDenotatedValue->first)) { || (denotedScope.tagType & tagType) != TagType::Unspecified)
selectedDenotatedValue = &someDenotatedValue; && (!targetSupported || denotedScope.tagTarget == tagTarget)) {
// select the value for the current file index
FieldValue *selectedDenotedValue = nullptr;
for(FieldValue &denotatedValue : denotedValues) {
if(denotatedValue.fileIndex <= fileIndex) {
if(!selectedDenotedValue || (denotatedValue.fileIndex > selectedDenotedValue->fileIndex)) {
selectedDenotedValue = &denotatedValue;
} }
} }
} }
if(selectedDenotatedValue) { if(selectedDenotedValue) {
if(fieldDenotation.type == DenotationType::File) { // one of the denoted values
if(selectedDenotatedValue->second.empty()) { if(!selectedDenotedValue->value.empty()) {
tag->setValue(fieldDenotation.field, TagValue()); if(selectedDenotedValue->type == DenotationType::File) {
} else {
try { try {
MediaFileInfo fileInfo(selectedDenotatedValue->second); // assume the file refers to a picture
MediaFileInfo fileInfo(selectedDenotedValue->value);
fileInfo.open(true); fileInfo.open(true);
fileInfo.parseContainerFormat(); fileInfo.parseContainerFormat();
auto buff = make_unique<char []>(fileInfo.size()); auto buff = make_unique<char []>(fileInfo.size());
@ -1016,23 +1099,26 @@ void setTagInfo(const SetTagInfoArgs &args)
fileInfo.stream().read(buff.get(), fileInfo.size()); fileInfo.stream().read(buff.get(), fileInfo.size());
TagValue value(move(buff), fileInfo.size(), TagDataType::Picture); TagValue value(move(buff), fileInfo.size(), TagDataType::Picture);
value.setMimeType(fileInfo.mimeType()); value.setMimeType(fileInfo.mimeType());
tag->setValue(fieldDenotation.field, move(value)); tag->setValue(denotedScope.field, move(value));
} catch(const Media::Failure &) { } catch(const Media::Failure &) {
fileInfo.addNotification(NotificationType::Critical, "Unable to parse specified cover file.", context); fileInfo.addNotification(NotificationType::Critical, "Unable to parse specified cover file.", context);
} catch(...) { } catch(...) {
::IoUtilities::catchIoFailure(); ::IoUtilities::catchIoFailure();
fileInfo.addNotification(NotificationType::Critical, "An IO error occured when parsing the specified cover file.", context); fileInfo.addNotification(NotificationType::Critical, "An IO error occured when parsing the specified cover file.", context);
} }
} else {
TagTextEncoding usedEncoding = denotedEncoding;
if(!tag->canEncodingBeUsed(denotedEncoding)) {
usedEncoding = tag->proposedTextEncoding();
}
tag->setValue(denotedScope.field, TagValue(selectedDenotedValue->value, TagTextEncoding::Utf8, usedEncoding));
if(selectedDenotedValue->type == DenotationType::Increment && tag == tags.back()) {
selectedDenotedValue->value = incremented(selectedDenotedValue->value);
}
} }
} else { } else {
TagTextEncoding usedEncoding = denotedEncoding; // if the denoted value is empty, just assign an empty TagValue to remove the field
if(!tag->canEncodingBeUsed(denotedEncoding)) { tag->setValue(denotedScope.field, TagValue());
usedEncoding = tag->proposedTextEncoding();
}
tag->setValue(fieldDenotation.field, TagValue(selectedDenotatedValue->second, TagTextEncoding::Utf8, usedEncoding));
if(fieldDenotation.type == DenotationType::Increment && tag == tags.back()) {
selectedDenotatedValue->second = incremented(selectedDenotatedValue->second);
}
} }
} }
} }
@ -1140,7 +1226,7 @@ void extractField(const Argument &fieldsArg, const Argument &inputFileArg, const
// iterate through all tags // iterate through all tags
for(const Tag *tag : tags) { for(const Tag *tag : tags) {
for(const auto &fieldDenotation : fields) { for(const auto &fieldDenotation : fields) {
const auto &value = tag->value(fieldDenotation.field); const auto &value = tag->value(fieldDenotation.first.field);
if(!value.isEmpty()) { if(!value.isEmpty()) {
values.emplace_back(&value, joinStrings({tag->typeName(), numberToString(values.size())}, "-")); values.emplace_back(&value, joinStrings({tag->typeName(), numberToString(values.size())}, "-"));
} }

226
tests/cli.cpp Normal file
View File

@ -0,0 +1,226 @@
#include <c++utilities/conversion/stringconversion.h>
#include <c++utilities/tests/testutils.h>
#include <cppunit/extensions/HelperMacros.h>
#include <cppunit/TestFixture.h>
#include <iostream>
using namespace std;
using namespace TestUtilities;
using namespace CPPUNIT_NS;
enum class TagStatus
{
Original,
TestMetaDataPresent,
Removed
};
/*!
* \brief The CliTests class tests the command line interface.
*/
class CliTests : public TestFixture
{
CPPUNIT_TEST_SUITE(CliTests);
#ifdef PLATFORM_UNIX
CPPUNIT_TEST(testBasicReadingAndWriting);
CPPUNIT_TEST(testHandlingOfTargets);
CPPUNIT_TEST(testHandlingOfId3Tags);
CPPUNIT_TEST(testMultipleFiles);
CPPUNIT_TEST(testMultipleValuesPerField);
CPPUNIT_TEST(testHandlingAttachments);
CPPUNIT_TEST(testDisplayingTechnicalInfo);
#endif
CPPUNIT_TEST_SUITE_END();
public:
void setUp();
void tearDown();
#ifdef PLATFORM_UNIX
void testBasicReadingAndWriting();
void testHandlingOfTargets();
void testHandlingOfId3Tags();
void testMultipleFiles();
void testMultipleValuesPerField();
void testHandlingAttachments();
void testDisplayingTechnicalInfo();
#endif
private:
};
CPPUNIT_TEST_SUITE_REGISTRATION(CliTests);
void CliTests::setUp()
{}
void CliTests::tearDown()
{}
#ifdef PLATFORM_UNIX
/*!
* \brief Tests basic reading and writing of tags.
*/
void CliTests::testBasicReadingAndWriting()
{
string stdout, stderr;
// get specific field
string mkvFile(workingCopyPath("matroska_wave1/test2.mkv"));
const char *const args1[] = {"tageditor", "get", "title", "-f", mkvFile.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args1, stdout, stderr));
CPPUNIT_ASSERT(stderr.empty());
// context of the following fields is the album (so "Title" means the title of the album)
CPPUNIT_ASSERT(stdout.find("album") != string::npos);
CPPUNIT_ASSERT(stdout.find("Title Elephant Dream - test 2") != string::npos);
CPPUNIT_ASSERT(stdout.find("Year 2010") == string::npos);
// get all fields
const char *const args2[] = {"tageditor", "get", "-f", mkvFile.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args2, stdout, stderr));
CPPUNIT_ASSERT(stderr.empty());
CPPUNIT_ASSERT(stdout.find("Title Elephant Dream - test 2") != string::npos);
CPPUNIT_ASSERT(stdout.find("Year 2010") != string::npos);
CPPUNIT_ASSERT(stdout.find("Comment Matroska Validation File 2, 100,000 timecode scale, odd aspect ratio, and CRC-32. Codecs are AVC and AAC") != string::npos);
// set some fields, keep other field
const char *const args3[] = {"tageditor", "set", "title=A new title", "genre=Testfile", "-f", mkvFile.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args3, stdout, stderr));
CPPUNIT_ASSERT(stdout.find("Changes have been applied") != string::npos);
CPPUNIT_ASSERT_EQUAL(0, execApp(args2, stdout, stderr));
CPPUNIT_ASSERT(stderr.empty());
CPPUNIT_ASSERT(stdout.find("Title A new title") != string::npos);
CPPUNIT_ASSERT(stdout.find("Year 2010") != string::npos);
CPPUNIT_ASSERT(stdout.find("Comment Matroska Validation File 2, 100,000 timecode scale, odd aspect ratio, and CRC-32. Codecs are AVC and AAC") != string::npos);
CPPUNIT_ASSERT(stdout.find("Genre Testfile") != string::npos);
// set some fields, discard other
const char *const args4[] = {"tageditor", "set", "title=Foo", "artist=Bar", "--remove-other-fields", "-f", mkvFile.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args4, stdout, stderr));
CPPUNIT_ASSERT_EQUAL(0, execApp(args2, stdout, stderr));
CPPUNIT_ASSERT(stderr.empty());
CPPUNIT_ASSERT(stdout.find("Title Foo") != string::npos);
CPPUNIT_ASSERT(stdout.find("Artist Bar") != string::npos);
CPPUNIT_ASSERT(stdout.find("Year") == string::npos);
CPPUNIT_ASSERT(stdout.find("Comment") == string::npos);
CPPUNIT_ASSERT(stdout.find("Genre") == string::npos);
}
/*!
* \brief Tests adding and removing of targets.
*/
void CliTests::testHandlingOfTargets()
{
string stdout, stderr;
string mkvFile(workingCopyPath("matroska_wave1/test2.mkv"));
const char *const args1[] = {"tageditor", "get", "-f", mkvFile.data(), nullptr};
// add song title (title field for tag with level 30)
const char *const args2[] = {"tageditor", "set", "target-level=30", "title=The song title", "genre=The song genre", "target-level=50", "genre=The album genre", "-f", mkvFile.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args2, stdout, stderr));
CPPUNIT_ASSERT_EQUAL(0, execApp(args1, stdout, stderr));
size_t songPos, albumPos;
CPPUNIT_ASSERT((songPos = stdout.find("song")) != string::npos);
CPPUNIT_ASSERT((albumPos = stdout.find("album")) != string::npos);
CPPUNIT_ASSERT(stdout.find("Title The song title") > songPos);
CPPUNIT_ASSERT(stdout.find("Genre The song genre") > songPos);
CPPUNIT_ASSERT(stdout.find("Title Elephant Dream - test 2") > albumPos);
CPPUNIT_ASSERT(stdout.find("Genre The album genre") > albumPos);
// remove tags targeting level 30 and 50 and add new tag targeting level 30 and the audio track
const char *const args3[] = {"tageditor", "set", "target-level=30", "target-tracks=3134325680", "title=The audio track", "encoder=likely some AAC encoder", "--remove-targets", "target-level=30", ",", "target-level=50", "-f", mkvFile.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args3, stdout, stderr));
CPPUNIT_ASSERT_EQUAL(0, execApp(args1, stdout, stderr));
CPPUNIT_ASSERT((songPos = stdout.find("song")) != string::npos);
CPPUNIT_ASSERT(stdout.find("song", songPos + 1) == string::npos);
CPPUNIT_ASSERT(stdout.find("3134325680") != string::npos);
CPPUNIT_ASSERT((albumPos = stdout.find("album")) == string::npos);
CPPUNIT_ASSERT(stdout.find("Title The audio track") != string::npos);
CPPUNIT_ASSERT(stdout.find("Encoder likely some AAC encoder") != string::npos);
}
/*!
* \brief Tests handling of ID3v1 and ID3v2 tags.
*/
void CliTests::testHandlingOfId3Tags()
{
// TODO
}
/*!
* \brief Tests reading and writing multiple files at once.
*/
void CliTests::testMultipleFiles()
{
string stdout, stderr;
string mkvFile1(workingCopyPath("matroska_wave1/test1.mkv"));
string mkvFile2(workingCopyPath("matroska_wave1/test2.mkv"));
string mkvFile3(workingCopyPath("matroska_wave1/test3.mkv"));
// get tags of 3 files at once
const char *const args1[] = {"tageditor", "get", "-f", mkvFile1.data(), mkvFile2.data(), mkvFile3.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args1, stdout, stderr));
size_t pos1 = stdout.find("Title Big Buck Bunny - test 1");
size_t pos2 = stdout.find("Title Elephant Dream - test 2");
size_t pos3 = stdout.find("Title Elephant Dream - test 3");
CPPUNIT_ASSERT(pos1 != string::npos);
CPPUNIT_ASSERT(pos2 > pos1);
CPPUNIT_ASSERT(pos3 > pos2);
// set title and part number of 3 files at once
const char *const args2[] = {"tageditor", "set", "target-level=30", "title=test1", "title=test2", "title=test3", "part+=1", "target-level=50", "title=MKV testfiles", "totalparts=3", "-f", mkvFile1.data(), mkvFile2.data(), mkvFile3.data(), nullptr};
CPPUNIT_ASSERT_EQUAL(0, execApp(args2, stdout, stderr));
CPPUNIT_ASSERT_EQUAL(0, execApp(args1, stdout, stderr));
CPPUNIT_ASSERT((pos1 = stdout.find("Matroska tag targeting \"level 50 'album, opera, concert, movie, episode'\"\n"
" Title MKV testfiles\n"
" Year 2010\n"
" Comment Matroska Validation File1, basic MPEG4.2 and MP3 with only SimpleBlock\n"
" Total parts 3\n"
"Matroska tag targeting \"level 30 'track, song, chapter'\"\n"
" Title test1\n"
" Part 1")) != string::npos);
CPPUNIT_ASSERT((pos2 = stdout.find("Matroska tag targeting \"level 50 'album, opera, concert, movie, episode'\"\n"
" Title MKV testfiles\n"
" Year 2010\n"
" Comment Matroska Validation File 2, 100,000 timecode scale, odd aspect ratio, and CRC-32. Codecs are AVC and AAC\n"
" Total parts 3\n"
"Matroska tag targeting \"level 30 'track, song, chapter'\"\n"
" Title test2\n"
" Part 2")) > pos1);
CPPUNIT_ASSERT((stdout.find("Matroska tag targeting \"level 50 'album, opera, concert, movie, episode'\"\n"
" Title MKV testfiles\n"
" Year 2010\n"
" Comment Matroska Validation File 3, header stripping on the video track and no SimpleBlock\n"
" Total parts 3\n"
"Matroska tag targeting \"level 30 'track, song, chapter'\"\n"
" Title test3\n"
" Part 3")) > pos2);
}
/*!
* \brief Tests tagging multiple values per field.
*/
void CliTests::testMultipleValuesPerField()
{
// TODO
}
/*!
* \brief Tests handling attachments.
*/
void CliTests::testHandlingAttachments()
{
// TODO
}
/*!
* \brief Tests displaying technical info.
*/
void CliTests::testDisplayingTechnicalInfo()
{
// TODO
}
#endif

1
tests/cppunit.cpp Normal file
View File

@ -0,0 +1 @@
#include <c++utilities/tests/cppunit.h>