mirror of
https://github.com/taglib/taglib.git
synced 2026-06-13 17:59:24 -04:00
Compare commits
40 Commits
release-2.
...
v2.3
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1b94b93762 | ||
|
|
8511827fa1 | ||
|
|
b02ff63916 | ||
|
|
f1e8dac084 | ||
|
|
d1460b6fbf | ||
|
|
43190d30ed | ||
|
|
4c43f1c577 | ||
|
|
59ed19d12f | ||
|
|
1e7bdae284 | ||
|
|
e07b956fda | ||
|
|
5e1cb4081d | ||
|
|
e7e4f0958c | ||
|
|
497c040f04 | ||
|
|
05c2c8671e | ||
|
|
85b6a9eb93 | ||
|
|
5c70f0071f | ||
|
|
ae171ee237 | ||
|
|
78c7208bc9 | ||
|
|
0df52e3993 | ||
|
|
ba2441b378 | ||
|
|
c5ea13bb34 | ||
|
|
4a73d73b20 | ||
|
|
9c56f191e5 | ||
|
|
77f6b9add5 | ||
|
|
a64e7543f8 | ||
|
|
d466b72eea | ||
|
|
c3a0e1d0a2 | ||
|
|
13751f5a6b | ||
|
|
4da5ac2de4 | ||
|
|
193091fe2e | ||
|
|
5d63187a8b | ||
|
|
f32b503f56 | ||
|
|
d6a2134cf3 | ||
|
|
abadbb6768 | ||
|
|
49510e7d5a | ||
|
|
7f2f2ddcaf | ||
|
|
0368c0239a | ||
|
|
9411bb161f | ||
|
|
78298769de | ||
|
|
c43d2b3fc1 |
2
3rdparty/utfcpp
vendored
2
3rdparty/utfcpp
vendored
Submodule 3rdparty/utfcpp updated: df857efc5b...63d64de49f
20
CHANGELOG.md
20
CHANGELOG.md
@@ -1,7 +1,27 @@
|
||||
TagLib 2.3 (May 10, 2026)
|
||||
=========================
|
||||
|
||||
* MP4: Support for chapters (Nero and QuickTime).
|
||||
* WAV: Support for BEXT and iXML chunks.
|
||||
* FLAC: Support for BEXT and iXML application blocks.
|
||||
* Opus: New audio property `outputGain()`.
|
||||
* Speed up Matroska reading by using seek head for element lookup.
|
||||
* Speed up Matroska writing by offering multiple write style modes.
|
||||
* More tolerant handling of files with oversized RIFF chunks, zero size ID3v2
|
||||
frames and Matroska chapters without edition.
|
||||
* Avoid wrong content-based detection as MPEG files.
|
||||
* Fix bitrate calculations for MPEG ADTS and MP4 ESDS.
|
||||
* Fix data race with multi-threaded use of `MP4::ItemFactory`.
|
||||
* Fix unbounded recursion in EBML/Matroska `MasterElement` and MP4 atoms.
|
||||
* Limit number of MP4 atoms at top level.
|
||||
* Fix writing too many offsets when updating MP4 stco/co64 atoms.
|
||||
* Fix k bounds in Shorten Rice-Golomb coding.
|
||||
|
||||
TagLib 2.2.1 (Mar 7, 2026)
|
||||
==========================
|
||||
|
||||
* Support edition, chapter and attachment UIDs in Matroska simple tags.
|
||||
* Avoid duplicates in Matroska complex property keys.
|
||||
|
||||
TagLib 2.2 (Feb 18, 2026)
|
||||
=========================
|
||||
|
||||
@@ -92,8 +92,8 @@ endif()
|
||||
# Minor version: increase it if you add ABI compatible features.
|
||||
# Patch version: increase it for bug fix releases.
|
||||
set(TAGLIB_SOVERSION_MAJOR 2)
|
||||
set(TAGLIB_SOVERSION_MINOR 2)
|
||||
set(TAGLIB_SOVERSION_PATCH 1)
|
||||
set(TAGLIB_SOVERSION_MINOR 3)
|
||||
set(TAGLIB_SOVERSION_PATCH 0)
|
||||
|
||||
include(ConfigureChecks.cmake)
|
||||
|
||||
|
||||
@@ -117,4 +117,3 @@ int main(int argc, char *argv[])
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
|
||||
@@ -196,6 +196,10 @@ if(WITH_MP4)
|
||||
mp4/mp4coverart.h
|
||||
mp4/mp4stem.h
|
||||
mp4/mp4itemfactory.h
|
||||
mp4/mp4chapter.h
|
||||
mp4/mp4chapterholder.h
|
||||
mp4/mp4nerochapterlist.h
|
||||
mp4/mp4qtchapterlist.h
|
||||
)
|
||||
endif()
|
||||
if(WITH_MOD)
|
||||
@@ -240,6 +244,7 @@ if(WITH_MATROSKA)
|
||||
matroska/matroskaproperties.h
|
||||
matroska/matroskasimpletag.h
|
||||
matroska/matroskatag.h
|
||||
matroska/matroskawritestyle.h
|
||||
)
|
||||
set(tag_PRIVATE_HDRS ${tag_PRIVATE_HDRS}
|
||||
matroska/ebml/ebmlbinaryelement.h
|
||||
@@ -372,6 +377,9 @@ if(WITH_MP4)
|
||||
mp4/mp4coverart.cpp
|
||||
mp4/mp4stem.cpp
|
||||
mp4/mp4itemfactory.cpp
|
||||
mp4/mp4chapter.cpp
|
||||
mp4/mp4nerochapterlist.cpp
|
||||
mp4/mp4qtchapterlist.cpp
|
||||
)
|
||||
endif()
|
||||
|
||||
|
||||
@@ -172,7 +172,7 @@ namespace TagLib {
|
||||
void setReadOnly(bool readOnly);
|
||||
|
||||
/*!
|
||||
* Return \c true if the item is read-only.
|
||||
* Returns \c true if the item is read-only.
|
||||
*/
|
||||
bool isReadOnly() const;
|
||||
|
||||
|
||||
@@ -312,7 +312,7 @@ void DSDIFF::File::removeRootChunk(unsigned int i)
|
||||
unsigned long long chunkSize = d->chunks[i].size + d->chunks[i].padding + 12;
|
||||
|
||||
d->size -= chunkSize;
|
||||
insert(ByteVector::fromLongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
insert(ByteVector::fromULongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
|
||||
removeBlock(d->chunks[i].offset - 12, chunkSize);
|
||||
|
||||
@@ -346,7 +346,7 @@ void DSDIFF::File::setRootChunkData(unsigned int i, const ByteVector &data)
|
||||
// First we update the global size
|
||||
|
||||
d->size += ((data.size() + 1) & ~1) - (d->chunks[i].size + d->chunks[i].padding);
|
||||
insert(ByteVector::fromLongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
insert(ByteVector::fromULongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
|
||||
// Now update the specific chunk
|
||||
|
||||
@@ -383,7 +383,7 @@ void DSDIFF::File::setRootChunkData(const ByteVector &name, const ByteVector &da
|
||||
|
||||
// First we update the global size
|
||||
d->size += (offset & 1) + ((data.size() + 1) & ~1) + 12;
|
||||
insert(ByteVector::fromLongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
insert(ByteVector::fromULongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
|
||||
// Now add the chunk to the file
|
||||
const unsigned long long fileLength = length();
|
||||
@@ -410,12 +410,12 @@ void DSDIFF::File::removeChildChunk(unsigned int i, unsigned int childChunkNum)
|
||||
|
||||
unsigned long long removedChunkTotalSize = childChunks[i].size + childChunks[i].padding + 12;
|
||||
d->size -= removedChunkTotalSize;
|
||||
insert(ByteVector::fromLongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
insert(ByteVector::fromULongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
|
||||
// Update child chunk size
|
||||
|
||||
d->chunks[d->childChunkIndex[childChunkNum]].size -= removedChunkTotalSize;
|
||||
insert(ByteVector::fromLongLong(d->chunks[d->childChunkIndex[childChunkNum]].size,
|
||||
insert(ByteVector::fromULongLong(d->chunks[d->childChunkIndex[childChunkNum]].size,
|
||||
d->endianness == BigEndian),
|
||||
d->chunks[d->childChunkIndex[childChunkNum]].offset - 8, 8);
|
||||
// Remove the chunk
|
||||
@@ -462,13 +462,13 @@ void DSDIFF::File::setChildChunkData(unsigned int i,
|
||||
|
||||
d->size += ((data.size() + 1) & ~1) - (childChunks[i].size + childChunks[i].padding);
|
||||
|
||||
insert(ByteVector::fromLongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
insert(ByteVector::fromULongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
|
||||
// And the PROP chunk size
|
||||
|
||||
d->chunks[d->childChunkIndex[childChunkNum]].size +=
|
||||
((data.size() + 1) & ~1) - (childChunks[i].size + childChunks[i].padding);
|
||||
insert(ByteVector::fromLongLong(d->chunks[d->childChunkIndex[childChunkNum]].size,
|
||||
insert(ByteVector::fromULongLong(d->chunks[d->childChunkIndex[childChunkNum]].size,
|
||||
d->endianness == BigEndian),
|
||||
d->chunks[d->childChunkIndex[childChunkNum]].offset - 8, 8);
|
||||
|
||||
@@ -538,13 +538,13 @@ void DSDIFF::File::setChildChunkData(const ByteVector &name,
|
||||
// First we update the global size
|
||||
|
||||
d->size += (offset & 1) + ((data.size() + 1) & ~1) + 12;
|
||||
insert(ByteVector::fromLongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
insert(ByteVector::fromULongLong(d->size, d->endianness == BigEndian), 4, 8);
|
||||
|
||||
// And the child chunk size
|
||||
|
||||
d->chunks[d->childChunkIndex[childChunkNum]].size += (offset & 1)
|
||||
+ ((data.size() + 1) & ~1) + 12;
|
||||
insert(ByteVector::fromLongLong(d->chunks[d->childChunkIndex[childChunkNum]].size,
|
||||
insert(ByteVector::fromULongLong(d->chunks[d->childChunkIndex[childChunkNum]].size,
|
||||
d->endianness == BigEndian),
|
||||
d->chunks[d->childChunkIndex[childChunkNum]].offset - 8, 8);
|
||||
|
||||
@@ -606,14 +606,14 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
bool bigEndian = d->endianness == BigEndian;
|
||||
|
||||
d->type = readBlock(4);
|
||||
d->size = readBlock(8).toLongLong(bigEndian);
|
||||
d->size = readBlock(8).toULongLong(bigEndian);
|
||||
d->format = readBlock(4);
|
||||
|
||||
// + 12: chunk header at least, fix for additional junk bytes
|
||||
|
||||
while(tell() + 12 <= length()) {
|
||||
ByteVector chunkName = readBlock(4);
|
||||
unsigned long long chunkSize = readBlock(8).toLongLong(bigEndian);
|
||||
unsigned long long chunkSize = readBlock(8).toULongLong(bigEndian);
|
||||
|
||||
if(!isValidChunkID(chunkName)) {
|
||||
debug("DSDIFF::File::read() -- Chunk '" + chunkName + "' has invalid ID");
|
||||
@@ -666,14 +666,14 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
}
|
||||
else if(d->chunks[i].name == "DST ") {
|
||||
// Now decode the chunks inside the DST chunk to read the DST Frame Information one
|
||||
long long dstChunkEnd = d->chunks[i].offset + d->chunks[i].size;
|
||||
unsigned long long dstChunkEnd = d->chunks[i].offset + d->chunks[i].size;
|
||||
seek(d->chunks[i].offset);
|
||||
|
||||
audioDataSizeinBytes = d->chunks[i].size;
|
||||
|
||||
while(tell() + 12 <= dstChunkEnd) {
|
||||
while(static_cast<unsigned long long>(tell()) + 12 <= dstChunkEnd) {
|
||||
ByteVector dstChunkName = readBlock(4);
|
||||
long long dstChunkSize = readBlock(8).toLongLong(bigEndian);
|
||||
unsigned long long dstChunkSize = readBlock(8).toULongLong(bigEndian);
|
||||
|
||||
if(!isValidChunkID(dstChunkName)) {
|
||||
debug("DSDIFF::File::read() -- DST Chunk '" + dstChunkName + "' has invalid ID");
|
||||
@@ -681,7 +681,7 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
break;
|
||||
}
|
||||
|
||||
if(static_cast<long long>(tell()) + dstChunkSize > dstChunkEnd) {
|
||||
if(tell() + dstChunkSize > dstChunkEnd) {
|
||||
debug("DSDIFF::File::read() -- DST Chunk '" + dstChunkName
|
||||
+ "' has invalid size (larger than the DST chunk)");
|
||||
setValid(false);
|
||||
@@ -708,14 +708,14 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
}
|
||||
}
|
||||
else if(d->chunks[i].name == "PROP") {
|
||||
d->childChunkIndex[PROPChunk] = i;
|
||||
d->childChunkIndex[PROPChunk] = static_cast<int>(i);
|
||||
// Now decodes the chunks inside the PROP chunk
|
||||
long long propChunkEnd = d->chunks[i].offset + d->chunks[i].size;
|
||||
unsigned long long propChunkEnd = d->chunks[i].offset + d->chunks[i].size;
|
||||
// +4 to remove the 'SND ' marker at beginning of 'PROP' chunk
|
||||
seek(d->chunks[i].offset + 4);
|
||||
while(tell() + 12 <= propChunkEnd) {
|
||||
while(static_cast<unsigned long long>(tell()) + 12 <= propChunkEnd) {
|
||||
ByteVector propChunkName = readBlock(4);
|
||||
long long propChunkSize = readBlock(8).toLongLong(bigEndian);
|
||||
unsigned long long propChunkSize = readBlock(8).toULongLong(bigEndian);
|
||||
|
||||
if(!isValidChunkID(propChunkName)) {
|
||||
debug("DSDIFF::File::read() -- PROP Chunk '" + propChunkName + "' has invalid ID");
|
||||
@@ -723,7 +723,7 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
break;
|
||||
}
|
||||
|
||||
if(static_cast<long long>(tell()) + propChunkSize > propChunkEnd) {
|
||||
if(tell() + propChunkSize > propChunkEnd) {
|
||||
debug("DSDIFF::File::read() -- PROP Chunk '" + propChunkName
|
||||
+ "' has invalid size (larger than the PROP chunk)");
|
||||
setValid(false);
|
||||
@@ -751,17 +751,17 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
}
|
||||
}
|
||||
else if(d->chunks[i].name == "DIIN") {
|
||||
d->childChunkIndex[DIINChunk] = i;
|
||||
d->childChunkIndex[DIINChunk] = static_cast<int>(i);
|
||||
d->hasDiin = true;
|
||||
|
||||
// Now decode the chunks inside the DIIN chunk
|
||||
|
||||
long long diinChunkEnd = d->chunks[i].offset + d->chunks[i].size;
|
||||
unsigned long long diinChunkEnd = d->chunks[i].offset + d->chunks[i].size;
|
||||
seek(d->chunks[i].offset);
|
||||
|
||||
while(tell() + 12 <= diinChunkEnd) {
|
||||
while(static_cast<unsigned long long>(tell()) + 12 <= diinChunkEnd) {
|
||||
ByteVector diinChunkName = readBlock(4);
|
||||
long long diinChunkSize = readBlock(8).toLongLong(bigEndian);
|
||||
unsigned long long diinChunkSize = readBlock(8).toULongLong(bigEndian);
|
||||
|
||||
if(!isValidChunkID(diinChunkName)) {
|
||||
debug("DSDIFF::File::read() -- DIIN Chunk '" + diinChunkName + "' has invalid ID");
|
||||
@@ -769,7 +769,7 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
break;
|
||||
}
|
||||
|
||||
if(static_cast<long long>(tell()) + diinChunkSize > diinChunkEnd) {
|
||||
if(tell() + diinChunkSize > diinChunkEnd) {
|
||||
debug("DSDIFF::File::read() -- DIIN Chunk '" + diinChunkName
|
||||
+ "' has invalid size (larger than the DIIN chunk)");
|
||||
setValid(false);
|
||||
@@ -825,7 +825,7 @@ void DSDIFF::File::read(bool readProperties, Properties::ReadStyle propertiesSty
|
||||
if(d->childChunks[PROPChunk][i].name == "ID3 " ||
|
||||
d->childChunks[PROPChunk][i].name == "id3 ") {
|
||||
if(d->hasID3v2) {
|
||||
d->duplicateID3V2chunkIndex = i;
|
||||
d->duplicateID3V2chunkIndex = static_cast<int>(i);
|
||||
// ID3V2 tag has already been found at root level
|
||||
continue;
|
||||
}
|
||||
@@ -913,7 +913,7 @@ void DSDIFF::File::writeChunk(const ByteVector &name, const ByteVector &data,
|
||||
combined.append(ByteVector(leadingPadding, '\x00'));
|
||||
|
||||
combined.append(name);
|
||||
combined.append(ByteVector::fromLongLong(data.size(), d->endianness == BigEndian));
|
||||
combined.append(ByteVector::fromULongLong(data.size(), d->endianness == BigEndian));
|
||||
combined.append(data);
|
||||
if((data.size() & 0x01) != 0)
|
||||
combined.append('\x00');
|
||||
|
||||
@@ -46,8 +46,8 @@ public:
|
||||
FilePrivate &operator=(const FilePrivate &) = delete;
|
||||
|
||||
const ID3v2::FrameFactory *ID3v2FrameFactory;
|
||||
long long fileSize = 0;
|
||||
long long metadataOffset = 0;
|
||||
unsigned long long fileSize = 0;
|
||||
unsigned long long metadataOffset = 0;
|
||||
std::unique_ptr<Properties> properties;
|
||||
std::unique_ptr<ID3v2::Tag> tag;
|
||||
};
|
||||
@@ -116,17 +116,17 @@ bool DSF::File::save(ID3v2::Version version)
|
||||
// Three things must be updated: the file size, the tag data, and the metadata offset
|
||||
|
||||
if(d->tag->isEmpty()) {
|
||||
long long newFileSize = d->metadataOffset ? d->metadataOffset : d->fileSize;
|
||||
unsigned long long newFileSize = d->metadataOffset ? d->metadataOffset : d->fileSize;
|
||||
|
||||
// Update the file size
|
||||
if(d->fileSize != newFileSize) {
|
||||
insert(ByteVector::fromLongLong(newFileSize, false), 12, 8);
|
||||
insert(ByteVector::fromULongLong(newFileSize, false), 12, 8);
|
||||
d->fileSize = newFileSize;
|
||||
}
|
||||
|
||||
// Update the metadata offset to 0 since there is no longer a tag
|
||||
if(d->metadataOffset) {
|
||||
insert(ByteVector::fromLongLong(0ULL, false), 20, 8);
|
||||
insert(ByteVector::fromULongLong(0ULL, false), 20, 8);
|
||||
d->metadataOffset = 0;
|
||||
}
|
||||
|
||||
@@ -136,19 +136,19 @@ bool DSF::File::save(ID3v2::Version version)
|
||||
else {
|
||||
ByteVector tagData = d->tag->render(version);
|
||||
|
||||
long long newMetadataOffset = d->metadataOffset ? d->metadataOffset : d->fileSize;
|
||||
long long newFileSize = newMetadataOffset + tagData.size();
|
||||
long long oldTagSize = d->fileSize - newMetadataOffset;
|
||||
unsigned long long newMetadataOffset = d->metadataOffset ? d->metadataOffset : d->fileSize;
|
||||
unsigned long long newFileSize = newMetadataOffset + tagData.size();
|
||||
unsigned long long oldTagSize = d->fileSize - newMetadataOffset;
|
||||
|
||||
// Update the file size
|
||||
if(d->fileSize != newFileSize) {
|
||||
insert(ByteVector::fromLongLong(newFileSize, false), 12, 8);
|
||||
insert(ByteVector::fromULongLong(newFileSize, false), 12, 8);
|
||||
d->fileSize = newFileSize;
|
||||
}
|
||||
|
||||
// Update the metadata offset
|
||||
if(d->metadataOffset != newMetadataOffset) {
|
||||
insert(ByteVector::fromLongLong(newMetadataOffset, false), 20, 8);
|
||||
insert(ByteVector::fromULongLong(newMetadataOffset, false), 20, 8);
|
||||
d->metadataOffset = newMetadataOffset;
|
||||
}
|
||||
|
||||
@@ -175,7 +175,7 @@ void DSF::File::read(AudioProperties::ReadStyle propertiesStyle)
|
||||
return;
|
||||
}
|
||||
|
||||
long long dsdHeaderSize = readBlock(8).toLongLong(false);
|
||||
unsigned long long dsdHeaderSize = readBlock(8).toULongLong(false);
|
||||
|
||||
// Integrity check
|
||||
if(dsdHeaderSize != 28) {
|
||||
@@ -184,16 +184,16 @@ void DSF::File::read(AudioProperties::ReadStyle propertiesStyle)
|
||||
return;
|
||||
}
|
||||
|
||||
d->fileSize = readBlock(8).toLongLong(false);
|
||||
d->fileSize = readBlock(8).toULongLong(false);
|
||||
|
||||
// File is malformed or corrupted, allow trailing garbage
|
||||
if(d->fileSize > length()) {
|
||||
if(d->fileSize > static_cast<unsigned long long>(length())) {
|
||||
debug("DSF::File::read() -- File is corrupted wrong length");
|
||||
setValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
d->metadataOffset = readBlock(8).toLongLong(false);
|
||||
d->metadataOffset = readBlock(8).toULongLong(false);
|
||||
|
||||
// File is malformed or corrupted
|
||||
if(d->metadataOffset > d->fileSize) {
|
||||
@@ -210,7 +210,7 @@ void DSF::File::read(AudioProperties::ReadStyle propertiesStyle)
|
||||
return;
|
||||
}
|
||||
|
||||
long long fmtHeaderSize = readBlock(8).toLongLong(false);
|
||||
unsigned long long fmtHeaderSize = readBlock(8).toULongLong(false);
|
||||
if(fmtHeaderSize != 52) {
|
||||
debug("DSF::File::read() -- File is corrupted, wrong FMT header size");
|
||||
setValid(false);
|
||||
|
||||
@@ -225,7 +225,7 @@ namespace
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_MATROSKA
|
||||
else if(ext == "MKA" || ext == "MKV" || ext == "WEBM")
|
||||
file = new Matroska::File(stream, readAudioProperties);
|
||||
file = new Matroska::File(stream, readAudioProperties, audioPropertiesStyle);
|
||||
#endif
|
||||
|
||||
// if file is not valid, leave it to content-based detection.
|
||||
@@ -246,8 +246,7 @@ namespace
|
||||
{
|
||||
File *file = nullptr;
|
||||
|
||||
if(MPEG::File::isSupported(stream))
|
||||
file = new MPEG::File(stream, readAudioProperties, audioPropertiesStyle);
|
||||
if(false);
|
||||
#ifdef TAGLIB_WITH_VORBIS
|
||||
else if(Ogg::Vorbis::File::isSupported(stream))
|
||||
file = new Ogg::Vorbis::File(stream, readAudioProperties, audioPropertiesStyle);
|
||||
@@ -300,6 +299,8 @@ namespace
|
||||
else if(Matroska::File::isSupported(stream))
|
||||
file = new Matroska::File(stream, readAudioProperties, audioPropertiesStyle);
|
||||
#endif
|
||||
else if(MPEG::File::isSupported(stream))
|
||||
file = new MPEG::File(stream, readAudioProperties, audioPropertiesStyle);
|
||||
|
||||
// isSupported() only does a quick check, so double check the file here.
|
||||
|
||||
|
||||
@@ -70,6 +70,8 @@ public:
|
||||
|
||||
std::unique_ptr<Properties> properties;
|
||||
ByteVector xiphCommentData;
|
||||
String iXMLData;
|
||||
ByteVector bextData;
|
||||
List<FLAC::MetadataBlock *> blocks;
|
||||
|
||||
offset_t flacStart { 0 };
|
||||
@@ -241,6 +243,52 @@ bool FLAC::File::save()
|
||||
|
||||
d->xiphCommentData = xiphComment()->render(false);
|
||||
|
||||
// Drop any APPLICATION blocks we recognize as iXML or bext from the block
|
||||
// list. Recognized blocks were normally extracted to d->iXMLData /
|
||||
// d->bextData during scan() and never added here, but this also catches
|
||||
// entries inserted after scan() (defensive).
|
||||
for(auto it = d->blocks.begin(); it != d->blocks.end();) {
|
||||
if((*it)->code() == MetadataBlock::Application) {
|
||||
const ByteVector blockData = (*it)->render();
|
||||
if(blockData.size() >= 4) {
|
||||
const ByteVector appId = blockData.mid(0, 4);
|
||||
ByteVector innerId;
|
||||
if(appId == "riff" && blockData.size() >= 12)
|
||||
innerId = blockData.mid(4, 4);
|
||||
else if(appId == "iXML" || appId == "bext")
|
||||
innerId = appId;
|
||||
|
||||
if(innerId == "iXML" || innerId == "bext") {
|
||||
delete *it;
|
||||
it = d->blocks.erase(it);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
++it;
|
||||
}
|
||||
|
||||
// Append fresh APPLICATION/"riff" blocks for iXML and bext if non-empty.
|
||||
// Per FLAC foreign-metadata convention the payload is a RIFF chunk:
|
||||
// <4 byte FOURCC><4 byte LE size><data>.
|
||||
if(!d->iXMLData.isEmpty()) {
|
||||
const ByteVector xml = d->iXMLData.data(String::UTF8);
|
||||
ByteVector payload;
|
||||
payload.append("riff");
|
||||
payload.append("iXML");
|
||||
payload.append(ByteVector::fromUInt(xml.size(), false));
|
||||
payload.append(xml);
|
||||
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
|
||||
}
|
||||
if(!d->bextData.isEmpty()) {
|
||||
ByteVector payload;
|
||||
payload.append("riff");
|
||||
payload.append("bext");
|
||||
payload.append(ByteVector::fromUInt(d->bextData.size(), false));
|
||||
payload.append(d->bextData);
|
||||
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
|
||||
}
|
||||
|
||||
// Replace metadata blocks
|
||||
|
||||
MetadataBlock *commentBlock =
|
||||
@@ -433,6 +481,26 @@ void FLAC::File::removePictures()
|
||||
}
|
||||
}
|
||||
|
||||
String FLAC::File::iXMLData() const
|
||||
{
|
||||
return d->iXMLData;
|
||||
}
|
||||
|
||||
void FLAC::File::setiXMLData(const String &data)
|
||||
{
|
||||
d->iXMLData = data;
|
||||
}
|
||||
|
||||
ByteVector FLAC::File::BEXTData() const
|
||||
{
|
||||
return d->bextData;
|
||||
}
|
||||
|
||||
void FLAC::File::setBEXTData(const ByteVector &data)
|
||||
{
|
||||
d->bextData = data;
|
||||
}
|
||||
|
||||
void FLAC::File::strip(int tags)
|
||||
{
|
||||
if(tags & ID3v1)
|
||||
@@ -462,6 +530,16 @@ bool FLAC::File::hasID3v2Tag() const
|
||||
return d->ID3v2Location >= 0;
|
||||
}
|
||||
|
||||
bool FLAC::File::hasiXMLData() const
|
||||
{
|
||||
return !d->iXMLData.isEmpty();
|
||||
}
|
||||
|
||||
bool FLAC::File::hasBEXTData() const
|
||||
{
|
||||
return !d->bextData.isEmpty();
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// private members
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -613,6 +691,49 @@ void FLAC::File::scan()
|
||||
else if(blockType == MetadataBlock::Padding) {
|
||||
// Skip all padding blocks.
|
||||
}
|
||||
else if(blockType == MetadataBlock::Application && data.size() >= 4) {
|
||||
// APPLICATION block (RFC 9639 § 8.4):
|
||||
// <4 bytes> big-endian application ID (ASCII FOURCC in practice)
|
||||
// <n bytes> application-defined data
|
||||
//
|
||||
// We recognize two conventions for carrying RIFF iXML / bext metadata:
|
||||
// 1. App ID "riff" — IANA-registered FLAC foreign-metadata wrapper.
|
||||
// Payload is a RIFF chunk: <4 byte FOURCC><4 byte LE size><data>.
|
||||
// 2. App ID "iXML" or "bext" — direct, used by some third-party tools
|
||||
// (e.g. Sequoia). Payload is the chunk data verbatim.
|
||||
//
|
||||
// Other application IDs (and "riff" wrapping FOURCCs we don't recognize)
|
||||
// fall through to UnknownMetadataBlock so they round-trip unchanged.
|
||||
const ByteVector appId = data.mid(0, 4);
|
||||
ByteVector innerId;
|
||||
ByteVector innerData;
|
||||
|
||||
if(appId == "riff" && data.size() >= 12) {
|
||||
innerId = data.mid(4, 4);
|
||||
const unsigned int innerSize = data.toUInt(8U, false);
|
||||
innerData = data.mid(12, innerSize);
|
||||
}
|
||||
else if(appId == "iXML" || appId == "bext") {
|
||||
innerId = appId;
|
||||
innerData = data.mid(4);
|
||||
}
|
||||
|
||||
if(innerId == "iXML") {
|
||||
if(d->iXMLData.isEmpty())
|
||||
d->iXMLData = String(innerData, String::UTF8);
|
||||
else
|
||||
debug("FLAC::File::scan() -- multiple iXML blocks found, discarding");
|
||||
}
|
||||
else if(innerId == "bext") {
|
||||
if(d->bextData.isEmpty())
|
||||
d->bextData = innerData;
|
||||
else
|
||||
debug("FLAC::File::scan() -- multiple BEXT blocks found, discarding");
|
||||
}
|
||||
else {
|
||||
block = new UnknownMetadataBlock(blockType, data);
|
||||
}
|
||||
}
|
||||
else {
|
||||
block = new UnknownMetadataBlock(blockType, data);
|
||||
}
|
||||
|
||||
@@ -296,6 +296,52 @@ namespace TagLib {
|
||||
*/
|
||||
void addPicture(Picture *picture);
|
||||
|
||||
/*!
|
||||
* Returns the raw iXML data as a String. Empty if no iXML metadata
|
||||
* is present. Read from an APPLICATION metadata block (RFC 9639 § 8.4)
|
||||
* carrying either the FLAC foreign-metadata application ID "riff"
|
||||
* (with an iXML RIFF chunk as payload) or the direct application ID
|
||||
* "iXML" used by some third-party tools.
|
||||
*
|
||||
* \see setiXMLData()
|
||||
* \see hasiXMLData()
|
||||
*/
|
||||
String iXMLData() const;
|
||||
|
||||
/*!
|
||||
* Sets the iXML data. Pass an empty string to remove the iXML
|
||||
* APPLICATION block on save. On save, the data is written using the
|
||||
* FLAC foreign-metadata convention: an APPLICATION block with
|
||||
* application ID "riff" wrapping an iXML RIFF chunk.
|
||||
*
|
||||
* \see iXMLData()
|
||||
* \see hasiXMLData()
|
||||
*/
|
||||
void setiXMLData(const String &data);
|
||||
|
||||
/*!
|
||||
* Returns the raw BEXT (Broadcast Audio Extension) data as a
|
||||
* ByteVector. Empty if no BEXT metadata is present. Read from an
|
||||
* APPLICATION metadata block (RFC 9639 § 8.4) carrying either the FLAC
|
||||
* foreign-metadata application ID "riff" (with a bext RIFF chunk as
|
||||
* payload) or the direct application ID "bext".
|
||||
*
|
||||
* \see setBEXTData()
|
||||
* \see hasBEXTData()
|
||||
*/
|
||||
ByteVector BEXTData() const;
|
||||
|
||||
/*!
|
||||
* Sets the BEXT data. Pass an empty ByteVector to remove the BEXT
|
||||
* APPLICATION block on save. On save, the data is written using the
|
||||
* FLAC foreign-metadata convention: an APPLICATION block with
|
||||
* application ID "riff" wrapping a bext RIFF chunk.
|
||||
*
|
||||
* \see BEXTData()
|
||||
* \see hasBEXTData()
|
||||
*/
|
||||
void setBEXTData(const ByteVector &data);
|
||||
|
||||
/*!
|
||||
* This will remove the tags that match the OR-ed together TagTypes from
|
||||
* the file. By default it removes all tags.
|
||||
@@ -332,6 +378,22 @@ namespace TagLib {
|
||||
*/
|
||||
bool hasID3v2Tag() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the file on disk actually has iXML data
|
||||
* stored in an APPLICATION metadata block.
|
||||
*
|
||||
* \see iXMLData()
|
||||
*/
|
||||
bool hasiXMLData() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the file on disk actually has BEXT data
|
||||
* stored in an APPLICATION metadata block.
|
||||
*
|
||||
* \see BEXTData()
|
||||
*/
|
||||
bool hasBEXTData() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the given \a stream can be opened as a FLAC
|
||||
* file.
|
||||
|
||||
@@ -87,7 +87,7 @@ namespace TagLib {
|
||||
int bitsPerSample() const;
|
||||
|
||||
/*!
|
||||
* Return the number of sample frames.
|
||||
* Returns the number of sample frames.
|
||||
*/
|
||||
unsigned long long sampleFrames() const;
|
||||
|
||||
|
||||
@@ -21,6 +21,7 @@
|
||||
#include "ebmlmasterelement.h"
|
||||
#include "ebmlvoidelement.h"
|
||||
#include "ebmlutils.h"
|
||||
#include "tdebug.h"
|
||||
#include "tfile.h"
|
||||
|
||||
using namespace TagLib;
|
||||
@@ -97,18 +98,34 @@ void EBML::MasterElement::setMinRenderSize(offset_t minimumSize)
|
||||
minRenderSize = minimumSize;
|
||||
}
|
||||
|
||||
bool EBML::MasterElement::read(File &file)
|
||||
bool EBML::MasterElement::read(File &file, int depth)
|
||||
{
|
||||
static constexpr int MAX_EBML_DEPTH = 64;
|
||||
if(depth > MAX_EBML_DEPTH) {
|
||||
debug("EBML: Maximum nesting depth exceeded");
|
||||
return false;
|
||||
}
|
||||
const offset_t maxOffset = file.tell() + dataSize;
|
||||
std::unique_ptr<Element> element;
|
||||
while((element = findNextElement(file, maxOffset))) {
|
||||
if(!element->read(file))
|
||||
return false;
|
||||
if(auto master = dynamic_cast<MasterElement *>(element.get())) {
|
||||
if(!master->read(file, depth + 1))
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
if(!element->read(file))
|
||||
return false;
|
||||
}
|
||||
elements.push_back(std::move(element));
|
||||
}
|
||||
return file.tell() == maxOffset;
|
||||
}
|
||||
|
||||
bool EBML::MasterElement::read(File &file)
|
||||
{
|
||||
return read(file, 0);
|
||||
}
|
||||
|
||||
ByteVector EBML::MasterElement::render()
|
||||
{
|
||||
ByteVector buffer = renderId();
|
||||
|
||||
@@ -55,6 +55,8 @@ namespace TagLib
|
||||
void setMinRenderSize(offset_t minimumSize);
|
||||
|
||||
protected:
|
||||
bool read(File &file, int depth);
|
||||
|
||||
offset_t offset;
|
||||
offset_t padding = 0;
|
||||
offset_t minRenderSize = 0;
|
||||
|
||||
@@ -46,7 +46,7 @@ std::unique_ptr<Matroska::Attachments> EBML::MkAttachments::parse() const
|
||||
{
|
||||
auto attachments = std::make_unique<Matroska::Attachments>();
|
||||
attachments->setOffset(offset);
|
||||
attachments->setSize(getSize());
|
||||
attachments->setSize(getSize() + padding);
|
||||
|
||||
for(const auto &element : elements) {
|
||||
if(element->getId() != Id::MkAttachedFile)
|
||||
|
||||
@@ -31,6 +31,48 @@
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
namespace {
|
||||
|
||||
Matroska::Chapter parseChapterAtom(
|
||||
const std::unique_ptr<EBML::Element> &atomElement)
|
||||
{
|
||||
Matroska::Chapter::UID chapterUid = 0;
|
||||
Matroska::Chapter::Time chapterTimeStart = 0;
|
||||
Matroska::Chapter::Time chapterTimeEnd = 0;
|
||||
List<Matroska::Chapter::Display> chapterDisplays;
|
||||
bool chapterHidden = false;
|
||||
|
||||
const auto chapterAtom = EBML::element_cast<EBML::Element::Id::MkChapterAtom>(atomElement);
|
||||
for(const auto &chapterChild : *chapterAtom) {
|
||||
if(const EBML::Element::Id cid = chapterChild->getId(); cid == EBML::Element::Id::MkChapterUID)
|
||||
chapterUid = EBML::element_cast<EBML::Element::Id::MkChapterUID>(chapterChild)->getValue();
|
||||
else if(cid == EBML::Element::Id::MkChapterTimeStart)
|
||||
chapterTimeStart = EBML::element_cast<EBML::Element::Id::MkChapterTimeStart>(chapterChild)->getValue();
|
||||
else if(cid == EBML::Element::Id::MkChapterTimeEnd)
|
||||
chapterTimeEnd = EBML::element_cast<EBML::Element::Id::MkChapterTimeEnd>(chapterChild)->getValue();
|
||||
else if(cid == EBML::Element::Id::MkChapterFlagHidden)
|
||||
chapterHidden = EBML::element_cast<EBML::Element::Id::MkChapterFlagHidden>(chapterChild)->getValue() != 0;
|
||||
else if (cid == EBML::Element::Id::MkChapterDisplay) {
|
||||
const auto display = EBML::element_cast<EBML::Element::Id::MkChapterDisplay>(chapterChild);
|
||||
String displayString;
|
||||
String displayLanguage;
|
||||
for(const auto &displayChild : *display) {
|
||||
if (const EBML::Element::Id did = displayChild->getId(); did == EBML::Element::Id::MkChapString)
|
||||
displayString = EBML::element_cast<EBML::Element::Id::MkChapString>(displayChild)->getValue();
|
||||
else if(did == EBML::Element::Id::MkChapLanguage)
|
||||
displayLanguage = EBML::element_cast<EBML::Element::Id::MkChapLanguage>(displayChild)->getValue();
|
||||
}
|
||||
if(!displayString.isEmpty()) {
|
||||
chapterDisplays.append(Matroska::Chapter::Display(displayString, displayLanguage));
|
||||
}
|
||||
}
|
||||
}
|
||||
return Matroska::Chapter(chapterTimeStart, chapterTimeEnd, chapterDisplays,
|
||||
chapterUid, chapterHidden);
|
||||
}
|
||||
|
||||
} // namespae
|
||||
|
||||
EBML::MkChapters::MkChapters(int sizeLength, offset_t dataSize, offset_t offset):
|
||||
MasterElement(Id::MkChapters, sizeLength, dataSize, offset)
|
||||
{
|
||||
@@ -41,7 +83,7 @@ EBML::MkChapters::MkChapters(Id, int sizeLength, offset_t dataSize, offset_t off
|
||||
{
|
||||
}
|
||||
|
||||
EBML::MkChapters::MkChapters():
|
||||
EBML::MkChapters::MkChapters() :
|
||||
MasterElement(Id::MkChapters, 0, 0, 0)
|
||||
{
|
||||
}
|
||||
@@ -50,9 +92,22 @@ std::unique_ptr<Matroska::Chapters> EBML::MkChapters::parse() const
|
||||
{
|
||||
auto chapters = std::make_unique<Matroska::Chapters>();
|
||||
chapters->setOffset(offset);
|
||||
chapters->setSize(getSize());
|
||||
chapters->setSize(getSize() + padding);
|
||||
|
||||
// Collect any orphan ChapterAtom elements not wrapped in an EditionEntry.
|
||||
// The Matroska spec requires ChapterAtom to be inside an EditionEntry, but
|
||||
// some muxers produce files with ChapterAtom directly under Chapters.
|
||||
// MKVToolNix and FFmpeg handle this case by treating the orphan atoms as
|
||||
// belonging to an implicit default edition.
|
||||
List<Matroska::Chapter> orphanChapters;
|
||||
|
||||
for(const auto &element : elements) {
|
||||
if(element->getId() == Id::MkChapterAtom) {
|
||||
if(auto chapter = parseChapterAtom(element); chapter.uid()) {
|
||||
orphanChapters.append(chapter);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if(element->getId() != Id::MkEditionEntry)
|
||||
continue;
|
||||
|
||||
@@ -69,39 +124,8 @@ std::unique_ptr<Matroska::Chapters> EBML::MkChapters::parse() const
|
||||
else if(id == Id::MkEditionFlagOrdered)
|
||||
editionIsOrdered = element_cast<Id::MkEditionFlagOrdered>(editionChild)->getValue() != 0;
|
||||
else if(id == Id::MkChapterAtom) {
|
||||
Matroska::Chapter::UID chapterUid = 0;
|
||||
Matroska::Chapter::Time chapterTimeStart = 0;
|
||||
Matroska::Chapter::Time chapterTimeEnd = 0;
|
||||
List<Matroska::Chapter::Display> chapterDisplays;
|
||||
bool chapterHidden = false;
|
||||
const auto chapterAtom = element_cast<Id::MkChapterAtom>(editionChild);
|
||||
for(const auto &chapterChild : *chapterAtom) {
|
||||
if(const Id cid = chapterChild->getId(); cid == Id::MkChapterUID)
|
||||
chapterUid = element_cast<Id::MkChapterUID>(chapterChild)->getValue();
|
||||
else if(cid == Id::MkChapterTimeStart)
|
||||
chapterTimeStart = element_cast<Id::MkChapterTimeStart>(chapterChild)->getValue();
|
||||
else if(cid == Id::MkChapterTimeEnd)
|
||||
chapterTimeEnd = element_cast<Id::MkChapterTimeEnd>(chapterChild)->getValue();
|
||||
else if(cid == Id::MkChapterFlagHidden)
|
||||
chapterHidden = element_cast<Id::MkChapterFlagHidden>(chapterChild)->getValue() != 0;
|
||||
else if(cid == Id::MkChapterDisplay) {
|
||||
const auto display = element_cast<Id::MkChapterDisplay>(chapterChild);
|
||||
String displayString;
|
||||
String displayLanguage;
|
||||
for(const auto &displayChild : *display) {
|
||||
if(const Id did = displayChild->getId(); did == Id::MkChapString)
|
||||
displayString = element_cast<Id::MkChapString>(displayChild)->getValue();
|
||||
else if(did == Id::MkChapLanguage)
|
||||
displayLanguage = element_cast<Id::MkChapLanguage>(displayChild)->getValue();
|
||||
}
|
||||
if(!displayString.isEmpty()) {
|
||||
chapterDisplays.append(Matroska::Chapter::Display(displayString, displayLanguage));
|
||||
}
|
||||
}
|
||||
}
|
||||
if(chapterUid) {
|
||||
editionChapters.append(Matroska::Chapter(
|
||||
chapterTimeStart, chapterTimeEnd, chapterDisplays, chapterUid, chapterHidden));
|
||||
if(auto chapter = parseChapterAtom(editionChild); chapter.uid()) {
|
||||
editionChapters.append(chapter);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -110,5 +134,13 @@ std::unique_ptr<Matroska::Chapters> EBML::MkChapters::parse() const
|
||||
editionChapters, editionIsDefault, editionIsOrdered, editionUid));
|
||||
}
|
||||
}
|
||||
|
||||
// If orphan chapters were found, wrap them in an implicit default edition
|
||||
// so they are not silently lost.
|
||||
if (!orphanChapters.isEmpty()) {
|
||||
chapters->addChapterEdition(Matroska::ChapterEdition(
|
||||
orphanChapters, true, false, 0));
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
@@ -30,6 +30,32 @@
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
namespace {
|
||||
|
||||
template <EBML::Element::Id Id, typename ElementType>
|
||||
std::unique_ptr<ElementType> readElementAt(File &file,
|
||||
offset_t offset,
|
||||
offset_t maxOffset)
|
||||
{
|
||||
if(offset < 0 || offset >= maxOffset) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
file.seek(offset);
|
||||
auto element = EBML::Element::factory(file);
|
||||
if(!element || element->getId() != Id) {
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
auto typed = EBML::element_cast<Id>(std::move(element));
|
||||
if(!typed || !typed->read(file)) {
|
||||
return nullptr;
|
||||
}
|
||||
return typed;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
EBML::MkSegment::MkSegment(int sizeLength, offset_t dataSize, offset_t offset):
|
||||
MasterElement(Id::MkSegment, sizeLength, dataSize, offset)
|
||||
{
|
||||
@@ -49,56 +75,178 @@ offset_t EBML::MkSegment::segmentDataOffset() const
|
||||
|
||||
bool EBML::MkSegment::read(File &file)
|
||||
{
|
||||
const offset_t maxOffset = file.tell() + dataSize;
|
||||
return readLimited(file, dataSize);
|
||||
}
|
||||
|
||||
bool EBML::MkSegment::readLimited(File &file, offset_t scanLimit)
|
||||
{
|
||||
const offset_t filePos = file.tell();
|
||||
const offset_t maxOffset = filePos + dataSize;
|
||||
const offset_t maxScanOffset = filePos + std::min(scanLimit, dataSize);
|
||||
// When scanLimit is less than dataSize, the caller has requested a
|
||||
// fast/limited scan (e.g. AudioProperties::Fast). In that case and if the
|
||||
// file has been opened in read-only mode, we skip parsing the Cues element,
|
||||
// which can be tens of MB on large files, causing severe slowdowns over
|
||||
// network filesystems, and do not have to be updated in read-only mode.
|
||||
const bool skipCues = file.readOnly() && scanLimit < dataSize;
|
||||
MasterElement *pendingPaddingTarget = nullptr;
|
||||
offset_t accumulatedPadding = 0;
|
||||
std::unique_ptr<Element> element;
|
||||
int i = 0;
|
||||
int seekHeadIndex = -1;
|
||||
while((element = findNextElement(file, maxOffset))) {
|
||||
while((element = findNextElement(file, maxScanOffset))) {
|
||||
if(const Id id = element->getId(); id == Id::MkSeekHead) {
|
||||
seekHeadIndex = i;
|
||||
seekHead = element_cast<Id::MkSeekHead>(std::move(element));
|
||||
if(!seekHead->read(file))
|
||||
return false;
|
||||
// We have a seek head, let's use it for faster access to the other elements
|
||||
if(const auto elementAfterSeekHead = findNextElement(file, maxScanOffset);
|
||||
elementAfterSeekHead && elementAfterSeekHead->getId() == Id::VoidElement)
|
||||
seekHead->setPadding(elementAfterSeekHead->getSize());
|
||||
const offset_t segDataOffset = segmentDataOffset();
|
||||
const auto matroskaSeekHead = parseSeekHead();
|
||||
const auto accumulateVoidPadding = [&](MasterElement *target) {
|
||||
offset_t accPadding = 0;
|
||||
while(const auto next = findNextElement(file, maxOffset)) {
|
||||
if(next->getId() != Id::VoidElement)
|
||||
break;
|
||||
accPadding += next->getSize();
|
||||
next->skipData(file);
|
||||
}
|
||||
if(accPadding > 0)
|
||||
target->setPadding(accPadding);
|
||||
};
|
||||
|
||||
// Build a work list of seek entries. Some muxers (e.g. MakeMKV,
|
||||
// mkvmerge) write a small primary SeekHead at the start of the segment
|
||||
// that only references a secondary SeekHead at the end of the file,
|
||||
// which in turn lists Info / Tracks / Tags / Chapters / Attachments.
|
||||
// Follow such MkSeekHead -> MkSeekHead chains so the real entries are
|
||||
// not silently dropped.
|
||||
List<std::pair<unsigned int, offset_t>> entries =
|
||||
matroskaSeekHead->entryList();
|
||||
// Guard against pathological / circular chains.
|
||||
int chainedSeekHeadsFollowed = 0;
|
||||
constexpr int MAX_CHAINED_SEEKHEADS = 8;
|
||||
|
||||
for(unsigned int i = 0; i < entries.size(); ++i) {
|
||||
const auto &[idValue, relativeOffset] = entries[i];
|
||||
const offset_t absoluteOffset = segDataOffset + relativeOffset;
|
||||
switch(static_cast<Id>(idValue)) {
|
||||
case Id::MkSeekHead: {
|
||||
if(chainedSeekHeadsFollowed++ >= MAX_CHAINED_SEEKHEADS)
|
||||
break;
|
||||
auto chained = readElementAt<Id::MkSeekHead, MkSeekHead>(
|
||||
file, absoluteOffset, maxOffset);
|
||||
if(!chained)
|
||||
break;
|
||||
if(const auto parsed = chained->parse(segDataOffset)) {
|
||||
for(const auto &entry : parsed->entryList())
|
||||
entries.append(entry);
|
||||
}
|
||||
break;
|
||||
}
|
||||
case Id::MkCues:
|
||||
if(!skipCues) {
|
||||
if(!((cues = readElementAt<Id::MkCues, MkCues>(
|
||||
file, absoluteOffset, maxOffset))))
|
||||
return false;
|
||||
}
|
||||
break;
|
||||
case Id::MkInfo:
|
||||
if(!((info = readElementAt<Id::MkInfo, MkInfo>(
|
||||
file, absoluteOffset, maxOffset))))
|
||||
return false;
|
||||
break;
|
||||
case Id::MkTracks:
|
||||
if(!((tracks = readElementAt<Id::MkTracks, MkTracks>(
|
||||
file, absoluteOffset, maxOffset))))
|
||||
return false;
|
||||
break;
|
||||
case Id::MkTags:
|
||||
if(!((tags = readElementAt<Id::MkTags, MkTags>(
|
||||
file, absoluteOffset, maxOffset))))
|
||||
return false;
|
||||
accumulateVoidPadding(tags.get());
|
||||
break;
|
||||
case Id::MkAttachments:
|
||||
if(!((attachments = readElementAt<Id::MkAttachments, MkAttachments>(
|
||||
file, absoluteOffset, maxOffset))))
|
||||
return false;
|
||||
accumulateVoidPadding(attachments.get());
|
||||
break;
|
||||
case Id::MkChapters:
|
||||
if(!((chapters = readElementAt<Id::MkChapters, MkChapters>(
|
||||
file, absoluteOffset, maxOffset))))
|
||||
return false;
|
||||
accumulateVoidPadding(chapters.get());
|
||||
break;
|
||||
default:
|
||||
break;
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
else if(id == Id::VoidElement) {
|
||||
if(pendingPaddingTarget) {
|
||||
accumulatedPadding += element->getSize();
|
||||
pendingPaddingTarget->setPadding(accumulatedPadding);
|
||||
}
|
||||
element->skipData(file);
|
||||
}
|
||||
else if(id == Id::MkCues) {
|
||||
cues = element_cast<Id::MkCues>(std::move(element));
|
||||
if(!cues->read(file))
|
||||
return false;
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
if(!skipCues) {
|
||||
cues = element_cast<Id::MkCues>(std::move(element));
|
||||
if(!cues->read(file))
|
||||
return false;
|
||||
}
|
||||
else {
|
||||
element->skipData(file);
|
||||
}
|
||||
}
|
||||
else if(id == Id::MkInfo) {
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
info = element_cast<Id::MkInfo>(std::move(element));
|
||||
if(!info->read(file))
|
||||
return false;
|
||||
}
|
||||
else if(id == Id::MkTracks) {
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
tracks = element_cast<Id::MkTracks>(std::move(element));
|
||||
if(!tracks->read(file))
|
||||
return false;
|
||||
}
|
||||
else if(id == Id::MkTags) {
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
tags = element_cast<Id::MkTags>(std::move(element));
|
||||
if(!tags->read(file))
|
||||
return false;
|
||||
pendingPaddingTarget = tags.get();
|
||||
}
|
||||
else if(id == Id::MkAttachments) {
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
attachments = element_cast<Id::MkAttachments>(std::move(element));
|
||||
if(!attachments->read(file))
|
||||
return false;
|
||||
pendingPaddingTarget = attachments.get();
|
||||
}
|
||||
else if(id == Id::MkChapters) {
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
chapters = element_cast<Id::MkChapters>(std::move(element));
|
||||
if(!chapters->read(file))
|
||||
return false;
|
||||
pendingPaddingTarget = chapters.get();
|
||||
}
|
||||
else {
|
||||
if(id == Id::VoidElement
|
||||
&& seekHead
|
||||
&& seekHeadIndex == i - 1)
|
||||
seekHead->setPadding(element->getSize());
|
||||
|
||||
pendingPaddingTarget = nullptr;
|
||||
accumulatedPadding = 0;
|
||||
element->skipData(file);
|
||||
}
|
||||
i++;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -51,6 +51,7 @@ namespace TagLib {
|
||||
|
||||
offset_t segmentDataOffset() const;
|
||||
bool read(File &file) override;
|
||||
bool readLimited(File &file, offset_t scanLimit);
|
||||
std::unique_ptr<Matroska::Tag> parseTag() const;
|
||||
std::unique_ptr<Matroska::Attachments> parseAttachments() const;
|
||||
std::unique_ptr<Matroska::Chapters> parseChapters() const;
|
||||
|
||||
@@ -47,7 +47,7 @@ std::unique_ptr<Matroska::Tag> EBML::MkTags::parse() const
|
||||
{
|
||||
auto mTag = std::make_unique<Matroska::Tag>();
|
||||
mTag->setOffset(offset);
|
||||
mTag->setSize(getSize());
|
||||
mTag->setSize(getSize() + padding);
|
||||
mTag->setID(static_cast<Matroska::Element::ID>(id));
|
||||
|
||||
// Loop through each <Tag> element
|
||||
|
||||
@@ -137,5 +137,14 @@ ByteVector Matroska::Attachments::renderInternal()
|
||||
|
||||
attachments.appendElement(std::move(attachedFileElement));
|
||||
}
|
||||
// Pad to the previous size so the element keeps its slot in the file,
|
||||
// unless this element is the trailing element of the segment in
|
||||
// AvoidInsert mode -- shrinking from the end never inserts anything.
|
||||
if(writeStyle() != WriteStyle::Compact &&
|
||||
!(writeStyle() == WriteStyle::AvoidInsert && isTrailingInSegment())) {
|
||||
const auto beforeSize = sizeRenderedOrWritten();
|
||||
if(beforeSize > 0)
|
||||
attachments.setMinRenderSize(beforeSize);
|
||||
}
|
||||
return attachments.render();
|
||||
}
|
||||
|
||||
@@ -147,5 +147,14 @@ ByteVector Matroska::Chapters::renderInternal()
|
||||
|
||||
chapters.appendElement(std::move(chapterEditionElement));
|
||||
}
|
||||
// Pad to the previous size so the element keeps its slot in the file,
|
||||
// unless this element is the trailing element of the segment in
|
||||
// AvoidInsert mode -- shrinking from the end never inserts anything.
|
||||
if(writeStyle() != WriteStyle::Compact &&
|
||||
!(writeStyle() == WriteStyle::AvoidInsert && isTrailingInSegment())) {
|
||||
const auto beforeSize = sizeRenderedOrWritten();
|
||||
if(beforeSize > 0)
|
||||
chapters.setMinRenderSize(beforeSize);
|
||||
}
|
||||
return chapters.render();
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
#include "tlist.h"
|
||||
#include "tfile.h"
|
||||
#include "tbytevector.h"
|
||||
#include "ebmlvoidelement.h"
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
@@ -42,6 +43,14 @@ public:
|
||||
// therefore rendering is required by default and needs to be explicitly set
|
||||
// using setNeedsRender(false) together with overriding the write() method.
|
||||
bool needsRender = true;
|
||||
WriteStyle writeStyle = WriteStyle::Compact;
|
||||
bool isLastElement = true;
|
||||
bool isTrailingInSegment = false;
|
||||
offset_t appendOffset = 0;
|
||||
// Populated during render() for AvoidInsert+grow+non-last: the offset and
|
||||
// original size of the slot that should be overwritten with a Void element.
|
||||
offset_t voidAtOffset = 0;
|
||||
offset_t voidAtSize = 0;
|
||||
};
|
||||
|
||||
Matroska::Element::Element(ID id) :
|
||||
@@ -116,8 +125,24 @@ bool Matroska::Element::render()
|
||||
const auto data = renderInternal();
|
||||
setNeedsRender(false);
|
||||
if(const auto afterSize = data.size(); afterSize != beforeSize) {
|
||||
if(!emitSizeChanged(afterSize - beforeSize)) {
|
||||
return false;
|
||||
if(e->writeStyle == WriteStyle::AvoidInsert && !e->isLastElement
|
||||
&& afterSize > beforeSize && beforeSize > 0) {
|
||||
// Record old slot for void-overwrite, move element to end of segment.
|
||||
e->voidAtOffset = e->offset;
|
||||
e->voidAtSize = beforeSize;
|
||||
e->offset = e->appendOffset;
|
||||
// Notify listeners that a new element of afterSize bytes appeared at
|
||||
// appendOffset (which is past all other elements, so no offset shifts).
|
||||
if(!emitSizeChanged(static_cast<offset_t>(afterSize))) {
|
||||
return false;
|
||||
}
|
||||
// Update appendOffset for any subsequent AvoidInsert-grow in this round.
|
||||
e->appendOffset += static_cast<offset_t>(afterSize);
|
||||
}
|
||||
else {
|
||||
if(!emitSizeChanged(afterSize - beforeSize)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,8 +186,55 @@ offset_t Matroska::Element::sizeRenderedOrWritten() const
|
||||
return dataSize != 0 ? dataSize : e->size;
|
||||
}
|
||||
|
||||
void Matroska::Element::setWriteStyle(WriteStyle style)
|
||||
{
|
||||
e->writeStyle = style;
|
||||
}
|
||||
|
||||
Matroska::WriteStyle Matroska::Element::writeStyle() const
|
||||
{
|
||||
return e->writeStyle;
|
||||
}
|
||||
|
||||
void Matroska::Element::setIsLastElement(bool isLast)
|
||||
{
|
||||
e->isLastElement = isLast;
|
||||
}
|
||||
|
||||
void Matroska::Element::setAppendOffset(offset_t appendOffset)
|
||||
{
|
||||
e->appendOffset = appendOffset;
|
||||
}
|
||||
|
||||
void Matroska::Element::setIsTrailingInSegment(bool isTrailing)
|
||||
{
|
||||
e->isTrailingInSegment = isTrailing;
|
||||
}
|
||||
|
||||
bool Matroska::Element::isTrailingInSegment() const
|
||||
{
|
||||
return e->isTrailingInSegment;
|
||||
}
|
||||
|
||||
bool Matroska::Element::wasMoved() const
|
||||
{
|
||||
// voidAtSize is set when the element was moved during render().
|
||||
// After write() it is cleared, but the caller checks before write().
|
||||
return e->voidAtOffset != 0 || e->voidAtSize != 0;
|
||||
}
|
||||
|
||||
void Matroska::Element::write(File &file)
|
||||
{
|
||||
if(e->voidAtSize > 0) {
|
||||
// AvoidInsert: overwrite the old slot with a Void element.
|
||||
const auto voidData = EBML::VoidElement::renderSize(e->voidAtSize);
|
||||
file.insert(voidData, e->voidAtOffset, e->voidAtSize);
|
||||
e->voidAtOffset = 0;
|
||||
// The element was moved to a new position (end of segment),
|
||||
// so there are no existing bytes to replace at the new offset.
|
||||
e->size = 0;
|
||||
e->voidAtSize = 0;
|
||||
}
|
||||
file.insert(e->data, e->offset, e->size);
|
||||
e->size = e->data.size();
|
||||
}
|
||||
|
||||
@@ -26,6 +26,7 @@
|
||||
#include "taglib_export.h"
|
||||
#include "taglib.h"
|
||||
#include "tlist.h"
|
||||
#include "matroskawritestyle.h"
|
||||
|
||||
namespace TagLib {
|
||||
class File;
|
||||
@@ -57,6 +58,17 @@ namespace TagLib {
|
||||
bool emitSizeChanged(offset_t delta);
|
||||
virtual bool sizeChanged(Element &caller, offset_t delta);
|
||||
|
||||
void setWriteStyle(WriteStyle style);
|
||||
WriteStyle writeStyle() const;
|
||||
void setIsLastElement(bool isLast);
|
||||
void setAppendOffset(offset_t appendOffset);
|
||||
bool wasMoved() const;
|
||||
//! Mark this element as the trailing element of the segment (no other
|
||||
//! element follows it in the file). Trailing elements may shrink even
|
||||
//! in non-Compact write styles because no offsets need to be preserved.
|
||||
void setIsTrailingInSegment(bool isTrailing);
|
||||
bool isTrailingInSegment() const;
|
||||
|
||||
protected:
|
||||
offset_t sizeRenderedOrWritten() const;
|
||||
|
||||
|
||||
@@ -144,6 +144,8 @@ PropertyMap Matroska::File::setProperties(const PropertyMap &properties)
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr offset_t FAST_SCAN_LIMIT = static_cast<offset_t>(512 * 1024);
|
||||
|
||||
String keyForAttachedFile(const Matroska::AttachedFile &attachedFile)
|
||||
{
|
||||
if(attachedFile.mediaType().startsWith("image/")) {
|
||||
@@ -376,10 +378,15 @@ void Matroska::File::read(bool readProperties, Properties::ReadStyle readStyle)
|
||||
head->skipData(*this);
|
||||
}
|
||||
|
||||
offset_t maxOffset = fileLength - tell();
|
||||
if (readStyle == Properties::ReadStyle::Fast && maxOffset > FAST_SCAN_LIMIT) {
|
||||
maxOffset = FAST_SCAN_LIMIT;
|
||||
}
|
||||
|
||||
// Find the Matroska segment in the file
|
||||
const std::unique_ptr<EBML::MkSegment> segment(
|
||||
EBML::element_cast<EBML::Element::Id::MkSegment>(
|
||||
EBML::findElement(*this, EBML::Element::Id::MkSegment, fileLength - tell())
|
||||
EBML::findElement(*this, EBML::Element::Id::MkSegment, maxOffset)
|
||||
)
|
||||
);
|
||||
if(!segment) {
|
||||
@@ -389,14 +396,18 @@ void Matroska::File::read(bool readProperties, Properties::ReadStyle readStyle)
|
||||
}
|
||||
|
||||
// Read the segment into memory from file
|
||||
if(!segment->read(*this)) {
|
||||
d->segment = segment->parseSegment();
|
||||
maxOffset = segment->getDataSize();
|
||||
if (readStyle == Properties::ReadStyle::Fast && maxOffset > FAST_SCAN_LIMIT) {
|
||||
maxOffset = FAST_SCAN_LIMIT;
|
||||
}
|
||||
if(!segment->readLimited(*this, maxOffset)) {
|
||||
debug("Failed to read segment");
|
||||
setValid(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Parse the elements
|
||||
d->segment = segment->parseSegment();
|
||||
d->seekHead = segment->parseSeekHead();
|
||||
d->cues = segment->parseCues();
|
||||
d->tag = segment->parseTag();
|
||||
@@ -434,6 +445,11 @@ void Matroska::File::read(bool readProperties, Properties::ReadStyle readStyle)
|
||||
}
|
||||
|
||||
bool Matroska::File::save()
|
||||
{
|
||||
return save(WriteStyle::Compact);
|
||||
}
|
||||
|
||||
bool Matroska::File::save(WriteStyle writeStyle)
|
||||
{
|
||||
if(readOnly()) {
|
||||
debug("Matroska::File::save() -- File is read only.");
|
||||
@@ -497,6 +513,75 @@ bool Matroska::File::save()
|
||||
renderList.sort(sortAscending);
|
||||
renderList.append(newElements);
|
||||
|
||||
// Configure write style on each data element. Determines whether elements
|
||||
// may be padded (DoNotShrink/AvoidInsert) or moved to the end (AvoidInsert).
|
||||
// New elements (no prior size) are always written compactly.
|
||||
if(writeStyle != WriteStyle::Compact) {
|
||||
// Determine which existing data element has the highest file offset
|
||||
// (i.e., is "last" among the data elements, before cues/seekHead/segment).
|
||||
// New elements always go after existing ones and are treated as compact.
|
||||
const Element *lastDataElement = nullptr;
|
||||
for(const auto element : renderList) {
|
||||
if(element->size() > 0)
|
||||
lastDataElement = element;
|
||||
}
|
||||
|
||||
// For AvoidInsert: an existing data element (Tags, Chapters, Attachments)
|
||||
// located before the LAST Cluster must not be grown in-place. Doing so
|
||||
// would shift later clusters and invalidate their cue positions. Such
|
||||
// elements are voided at their original position and appended at the
|
||||
// end of the segment instead. The boundary is the maximum cluster offset
|
||||
// (derived from cue-point cluster positions). If no cue points are
|
||||
// available, the Cues element offset is used as a safe upper bound
|
||||
// (Cues are always after the last Cluster). A value of 0 means
|
||||
// "no boundary" – any offset compares >= 0, so the boundary check is
|
||||
// a no-op in non-AvoidInsert modes.
|
||||
offset_t audioBoundary = 0;
|
||||
if(writeStyle == WriteStyle::AvoidInsert && d->cues) {
|
||||
const offset_t segDataOffset = d->segment->dataOffset();
|
||||
for(const auto &cp : d->cues->cuePointList()) {
|
||||
for(const auto &ct : cp->cueTrackList()) {
|
||||
audioBoundary = std::max(audioBoundary,
|
||||
segDataOffset + ct->getClusterPosition());
|
||||
}
|
||||
}
|
||||
if(audioBoundary == 0)
|
||||
audioBoundary = d->cues->offset();
|
||||
}
|
||||
|
||||
for(const auto element : renderList) {
|
||||
if(element->size() > 0) {
|
||||
element->setWriteStyle(writeStyle);
|
||||
// An element is "last" only if it has the highest data-element
|
||||
// offset AND sits past the last cluster. The latter is always true
|
||||
// when audioBoundary == 0 (DoNotShrink, or AvoidInsert without cues).
|
||||
element->setIsLastElement(element == lastDataElement
|
||||
&& element->offset() >= audioBoundary);
|
||||
}
|
||||
}
|
||||
|
||||
// For AvoidInsert: identify the segment-trailing element (highest offset
|
||||
// among data elements, Cues, SeekHead). The trailing element may shrink
|
||||
// without padding -- there is nothing after it whose offset would shift,
|
||||
// so a trailing void would be wasted space.
|
||||
if(writeStyle == WriteStyle::AvoidInsert) {
|
||||
Element *trailing = nullptr;
|
||||
offset_t maxOffset = 0;
|
||||
const auto consider = [&](Element *e) {
|
||||
if(e && e->size() > 0 && e->offset() > maxOffset) {
|
||||
maxOffset = e->offset();
|
||||
trailing = e;
|
||||
}
|
||||
};
|
||||
for(const auto element : renderList)
|
||||
consider(element);
|
||||
consider(d->cues.get());
|
||||
consider(d->seekHead.get());
|
||||
if(trailing)
|
||||
trailing->setIsTrailingInSegment(true);
|
||||
}
|
||||
}
|
||||
|
||||
// Add our new elements to the Seek Head (if the file has one)
|
||||
if(d->seekHead) {
|
||||
const auto segmentDataOffset = d->segment->dataOffset();
|
||||
@@ -539,6 +624,12 @@ bool Matroska::File::save()
|
||||
bool rendering = true;
|
||||
while(rendering && renderRound < 5) {
|
||||
rendering = false;
|
||||
// Initialize appendOffset for AvoidInsert elements at the start of each round.
|
||||
if(writeStyle == WriteStyle::AvoidInsert) {
|
||||
const offset_t appendOffset = d->segment->endOffset();
|
||||
for(const auto element : renderList)
|
||||
element->setAppendOffset(appendOffset);
|
||||
}
|
||||
for(const auto element : renderList) {
|
||||
if(element->needsRender()) {
|
||||
rendering = true;
|
||||
@@ -550,6 +641,51 @@ bool Matroska::File::save()
|
||||
++renderRound;
|
||||
}
|
||||
|
||||
// For AvoidInsert: elements that were moved during rendering may have
|
||||
// stale offsets if in-place elements grew after the move was computed.
|
||||
// Re-assign their offsets sequentially from the correct position.
|
||||
if(writeStyle == WriteStyle::AvoidInsert) {
|
||||
// Collect moved elements in render order (= ascending original-offset order
|
||||
// = order they appear in renderList before any re-sort).
|
||||
List<Element *> movedElements;
|
||||
offset_t totalMovedSize = 0;
|
||||
for(const auto element : renderList) {
|
||||
if(element->wasMoved()) {
|
||||
movedElements.append(element);
|
||||
totalMovedSize += static_cast<offset_t>(element->data().size());
|
||||
}
|
||||
}
|
||||
if(!movedElements.isEmpty()) {
|
||||
// The segment end includes in-place growths AND all moved element sizes.
|
||||
// The moved elements start right after all in-place content.
|
||||
offset_t appendAt = d->segment->endOffset() - totalMovedSize;
|
||||
for(const auto element : movedElements) {
|
||||
element->setOffset(appendAt);
|
||||
appendAt += static_cast<offset_t>(element->data().size());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// For elements that were moved to the end by AvoidInsert, update their
|
||||
// seek head entry to reflect the new file position.
|
||||
if(writeStyle == WriteStyle::AvoidInsert && d->seekHead) {
|
||||
const offset_t segDataOffset = d->segment->dataOffset();
|
||||
for(const auto element : renderList) {
|
||||
if(element->wasMoved()) {
|
||||
d->seekHead->updateEntry(element->id(), element->offset() - segDataOffset);
|
||||
}
|
||||
}
|
||||
// Re-render the seekHead (and anything it affects) after updating entries.
|
||||
// The seekHead slot was pre-padded, so this should not cause size changes.
|
||||
d->seekHead->setNeedsRender(true);
|
||||
for(const auto element : renderList) {
|
||||
if(element->needsRender()) {
|
||||
if(!element->render())
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Write out to file
|
||||
renderList.sort(sortAscending);
|
||||
for(const auto element : renderList)
|
||||
|
||||
@@ -24,6 +24,7 @@
|
||||
#include "taglib_export.h"
|
||||
#include "tfile.h"
|
||||
#include "matroskaproperties.h"
|
||||
#include "matroskawritestyle.h"
|
||||
|
||||
//! An implementation of Matroska metadata
|
||||
namespace TagLib::Matroska {
|
||||
@@ -145,6 +146,13 @@ namespace TagLib::Matroska {
|
||||
*/
|
||||
bool save() override;
|
||||
|
||||
/*!
|
||||
* Save the file with the specified write style.
|
||||
*
|
||||
* This returns \c true if the save was successful.
|
||||
*/
|
||||
bool save(WriteStyle style);
|
||||
|
||||
/*!
|
||||
* Returns a pointer to the attachments of the file.
|
||||
*
|
||||
|
||||
@@ -54,7 +54,6 @@ bool Matroska::SeekHead::isValid(TagLib::File &file) const
|
||||
void Matroska::SeekHead::addEntry(const Element &element)
|
||||
{
|
||||
entries.append({element.id(), element.offset()});
|
||||
debug("adding to seekhead");
|
||||
setNeedsRender(true);
|
||||
}
|
||||
|
||||
@@ -64,6 +63,22 @@ void Matroska::SeekHead::addEntry(ID id, offset_t offset)
|
||||
setNeedsRender(true);
|
||||
}
|
||||
|
||||
void Matroska::SeekHead::updateEntry(ID id, offset_t newOffset)
|
||||
{
|
||||
for(auto &entry : entries) {
|
||||
if(entry.first == id) {
|
||||
entry.second = newOffset;
|
||||
setNeedsRender(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const List<std::pair<unsigned int, offset_t>> &Matroska::SeekHead::entryList() const
|
||||
{
|
||||
return entries;
|
||||
}
|
||||
|
||||
ByteVector Matroska::SeekHead::renderInternal()
|
||||
{
|
||||
const auto beforeSize = sizeRenderedOrWritten();
|
||||
|
||||
@@ -39,6 +39,8 @@ namespace TagLib {
|
||||
bool isValid(TagLib::File &file) const;
|
||||
void addEntry(const Element &element);
|
||||
void addEntry(ID id, offset_t offset);
|
||||
void updateEntry(ID id, offset_t offset);
|
||||
const List<std::pair<unsigned int, offset_t>> &entryList() const;
|
||||
void write(TagLib::File &file) override;
|
||||
void sort();
|
||||
bool sizeChanged(Element &caller, offset_t delta) override;
|
||||
|
||||
@@ -69,3 +69,8 @@ offset_t Matroska::Segment::dataOffset() const
|
||||
{
|
||||
return offset() + sizeLength;
|
||||
}
|
||||
|
||||
offset_t Matroska::Segment::endOffset() const
|
||||
{
|
||||
return dataOffset() + dataSize;
|
||||
}
|
||||
|
||||
@@ -33,6 +33,7 @@ namespace TagLib::Matroska {
|
||||
bool render() override;
|
||||
bool sizeChanged(Element &caller, offset_t delta) override;
|
||||
offset_t dataOffset() const;
|
||||
offset_t endOffset() const;
|
||||
|
||||
private:
|
||||
ByteVector renderInternal() override;
|
||||
|
||||
@@ -364,6 +364,16 @@ ByteVector Matroska::Tag::renderInternal()
|
||||
}
|
||||
tags.appendElement(std::move(tag));
|
||||
}
|
||||
// Pad to the previous size so the element keeps its slot in the file,
|
||||
// unless this element is the trailing element of the segment in
|
||||
// AvoidInsert mode -- shrinking from the end never inserts anything,
|
||||
// so the trailing void would be wasted space.
|
||||
if(writeStyle() != WriteStyle::Compact &&
|
||||
!(writeStyle() == WriteStyle::AvoidInsert && isTrailingInSegment())) {
|
||||
const auto beforeSize = sizeRenderedOrWritten();
|
||||
if(beforeSize > 0)
|
||||
tags.setMinRenderSize(beforeSize);
|
||||
}
|
||||
return tags.render();
|
||||
}
|
||||
|
||||
@@ -551,10 +561,11 @@ StringList Matroska::Tag::complexPropertyKeys() const
|
||||
{
|
||||
StringList keys;
|
||||
for(const SimpleTag &t : std::as_const(d->tags)) {
|
||||
if(t.type() != SimpleTag::StringType ||
|
||||
t.trackUid() != 0 || t.editionUid() != 0 ||
|
||||
t.chapterUid() != 0 || t.attachmentUid() != 0 ||
|
||||
translateTag(t.name(), t.targetTypeValue()).isEmpty()) {
|
||||
if((t.type() != SimpleTag::StringType ||
|
||||
t.trackUid() != 0 || t.editionUid() != 0 ||
|
||||
t.chapterUid() != 0 || t.attachmentUid() != 0 ||
|
||||
translateTag(t.name(), t.targetTypeValue()).isEmpty()) &&
|
||||
!keys.contains(t.name())) {
|
||||
keys.append(t.name());
|
||||
}
|
||||
}
|
||||
|
||||
49
taglib/matroska/matroskawritestyle.h
Normal file
49
taglib/matroska/matroskawritestyle.h
Normal file
@@ -0,0 +1,49 @@
|
||||
/***************************************************************************
|
||||
copyright : (C) 2026 by Urs Fleisch
|
||||
email : ufleisch@users.sourceforge.net
|
||||
***************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef TAGLIB_MATROSKAWRITESTYLE_H
|
||||
#define TAGLIB_MATROSKAWRITESTYLE_H
|
||||
|
||||
namespace TagLib::Matroska {
|
||||
/*!
|
||||
* Controls the trade-off between file size and write speed when saving.
|
||||
* Mode of writing tags, attachments and chapters to the file.
|
||||
* For very large files and/or slow (network) filesystems, using
|
||||
* \c AvoidInsert will reduce write time significantly.
|
||||
*/
|
||||
enum class WriteStyle {
|
||||
//! Write tags, attachments and chapters as compact as possible (default).
|
||||
Compact,
|
||||
//! Do not shrink elements; add void padding when content gets smaller.
|
||||
//! Allow inserts when content gets larger.
|
||||
DoNotShrink,
|
||||
//! Like \c DoNotShrink but also avoid inserts for non-last elements:
|
||||
//! replace a growing non-last element with a void of the old size and
|
||||
//! append the new element at the end of the segment.
|
||||
AvoidInsert
|
||||
};
|
||||
}
|
||||
|
||||
#endif //TAGLIB_MATROSKAWRITESTYLE_H
|
||||
@@ -51,7 +51,7 @@ public:
|
||||
AtomList children;
|
||||
};
|
||||
|
||||
MP4::Atom::Atom(File *file)
|
||||
MP4::Atom::Atom(File *file, int depth)
|
||||
: d(std::make_unique<AtomPrivate>(file->tell()))
|
||||
{
|
||||
d->children.setAutoDelete(true);
|
||||
@@ -109,8 +109,13 @@ MP4::Atom::Atom(File *file)
|
||||
else if(d->name == "stsd") {
|
||||
file->seek(8, File::Current);
|
||||
}
|
||||
static constexpr int MAX_MP4_ATOM_DEPTH = 64;
|
||||
if(depth > MAX_MP4_ATOM_DEPTH) {
|
||||
debug("MP4: Maximum nesting depth exceeded");
|
||||
return;
|
||||
}
|
||||
while(file->tell() < d->offset + d->length) {
|
||||
auto child = new MP4::Atom(file);
|
||||
auto child = new MP4::Atom(file, depth + 1);
|
||||
d->children.append(child);
|
||||
if(child->d->length == 0)
|
||||
return;
|
||||
@@ -122,6 +127,11 @@ MP4::Atom::Atom(File *file)
|
||||
file->seek(d->offset + d->length);
|
||||
}
|
||||
|
||||
MP4::Atom::Atom(File *file)
|
||||
: Atom(file, 0)
|
||||
{
|
||||
}
|
||||
|
||||
MP4::Atom::~Atom() = default;
|
||||
|
||||
MP4::Atom *
|
||||
@@ -212,6 +222,8 @@ public:
|
||||
MP4::Atoms::Atoms(File *file) :
|
||||
d(std::make_unique<AtomsPrivate>())
|
||||
{
|
||||
static constexpr int MAX_MP4_ATOM_COUNT_PER_LEVEL = 5000;
|
||||
|
||||
d->atoms.setAutoDelete(true);
|
||||
|
||||
file->seek(0, File::End);
|
||||
@@ -222,6 +234,13 @@ MP4::Atoms::Atoms(File *file) :
|
||||
d->atoms.append(atom);
|
||||
if (atom->length() == 0)
|
||||
break;
|
||||
|
||||
if(d->atoms.size() > MAX_MP4_ATOM_COUNT_PER_LEVEL) {
|
||||
debug("MP4: Maximum atom count exceeded");
|
||||
// Make sure the file is detected as invalid.
|
||||
d->atoms.clear();
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -35,27 +35,48 @@ namespace TagLib {
|
||||
namespace MP4 {
|
||||
|
||||
enum AtomDataType {
|
||||
TypeImplicit = 0, // for use with tags for which no type needs to be indicated because only one type is allowed
|
||||
TypeUTF8 = 1, // without any count or null terminator
|
||||
TypeUTF16 = 2, // also known as UTF-16BE
|
||||
TypeSJIS = 3, // deprecated unless it is needed for special Japanese characters
|
||||
TypeHTML = 6, // the HTML file header specifies which HTML version
|
||||
TypeXML = 7, // the XML header must identify the DTD or schemas
|
||||
TypeUUID = 8, // also known as GUID; stored as 16 bytes in binary (valid as an ID)
|
||||
TypeISRC = 9, // stored as UTF-8 text (valid as an ID)
|
||||
TypeMI3P = 10, // stored as UTF-8 text (valid as an ID)
|
||||
TypeGIF = 12, // (deprecated) a GIF image
|
||||
TypeJPEG = 13, // a JPEG image
|
||||
TypePNG = 14, // a PNG image
|
||||
TypeURL = 15, // absolute, in UTF-8 characters
|
||||
TypeDuration = 16, // in milliseconds, 32-bit integer
|
||||
TypeDateTime = 17, // in UTC, counting seconds since midnight, January 1, 1904; 32 or 64-bits
|
||||
TypeGenred = 18, // a list of enumerated values
|
||||
TypeInteger = 21, // a signed big-endian integer with length one of { 1,2,3,4,8 } bytes
|
||||
TypeRIAAPA = 24, // RIAA parental advisory; { -1=no, 1=yes, 0=unspecified }, 8-bit integer
|
||||
TypeUPC = 25, // Universal Product Code, in text UTF-8 format (valid as an ID)
|
||||
TypeBMP = 27, // Windows bitmap image
|
||||
TypeUndefined = 255 // undefined
|
||||
//! For use with tags for which no type needs to be indicated because only one type is allowed
|
||||
TypeImplicit = 0,
|
||||
//! Without any count or null terminator
|
||||
TypeUTF8 = 1,
|
||||
//! Also known as UTF-16BE
|
||||
TypeUTF16 = 2,
|
||||
//! Deprecated unless it is needed for special Japanese characters
|
||||
TypeSJIS = 3,
|
||||
//! The HTML file header specifies which HTML version
|
||||
TypeHTML = 6,
|
||||
//! The XML header must identify the DTD or schemas
|
||||
TypeXML = 7,
|
||||
//! Also known as GUID; stored as 16 bytes in binary (valid as an ID)
|
||||
TypeUUID = 8,
|
||||
//! Stored as UTF-8 text (valid as an ID)
|
||||
TypeISRC = 9,
|
||||
//! Stored as UTF-8 text (valid as an ID)
|
||||
TypeMI3P = 10,
|
||||
//! (Deprecated) A GIF image
|
||||
TypeGIF = 12,
|
||||
//! A JPEG image
|
||||
TypeJPEG = 13,
|
||||
//! A PNG image
|
||||
TypePNG = 14,
|
||||
//! Absolute, in UTF-8 characters
|
||||
TypeURL = 15,
|
||||
//! In milliseconds, 32-bit integer
|
||||
TypeDuration = 16,
|
||||
//! In UTC, counting seconds since midnight, January 1, 1904; 32 or 64-bits
|
||||
TypeDateTime = 17,
|
||||
//! A list of enumerated values
|
||||
TypeGenred = 18,
|
||||
//! A signed big-endian integer with length one of { 1,2,3,4,8 } bytes
|
||||
TypeInteger = 21,
|
||||
//! RIAA parental advisory; { -1=no, 1=yes, 0=unspecified }, 8-bit integer
|
||||
TypeRIAAPA = 24,
|
||||
//! Universal Product Code, in text UTF-8 format (valid as an ID)
|
||||
TypeUPC = 25,
|
||||
//! Windows bitmap image
|
||||
TypeBMP = 27,
|
||||
//! Undefined
|
||||
TypeUndefined = 255
|
||||
};
|
||||
|
||||
#ifndef DO_NOT_DOCUMENT
|
||||
@@ -89,6 +110,9 @@ namespace TagLib {
|
||||
const ByteVector &name() const;
|
||||
const AtomList &children() const;
|
||||
|
||||
protected:
|
||||
Atom(File *file, int depth);
|
||||
|
||||
private:
|
||||
class AtomPrivate;
|
||||
TAGLIB_MSVC_SUPPRESS_WARNING_NEEDS_TO_HAVE_DLL_INTERFACE
|
||||
|
||||
89
taglib/mp4/mp4chapter.cpp
Normal file
89
taglib/mp4/mp4chapter.cpp
Normal file
@@ -0,0 +1,89 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#include "mp4chapter.h"
|
||||
#include "tstring.h"
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
class MP4::Chapter::ChapterPrivate
|
||||
{
|
||||
public:
|
||||
ChapterPrivate() = default;
|
||||
~ChapterPrivate() = default;
|
||||
String title;
|
||||
long long startTime {0};
|
||||
};
|
||||
|
||||
MP4::Chapter::Chapter(const String &title, long long startTime) :
|
||||
d(std::make_unique<ChapterPrivate>())
|
||||
{
|
||||
d->title = title;
|
||||
d->startTime = startTime;
|
||||
}
|
||||
|
||||
MP4::Chapter::Chapter(const Chapter &other) :
|
||||
d(std::make_unique<ChapterPrivate>(*other.d))
|
||||
{
|
||||
}
|
||||
|
||||
MP4::Chapter::Chapter(Chapter &&other) noexcept = default;
|
||||
|
||||
MP4::Chapter::Chapter::~Chapter() = default;
|
||||
|
||||
MP4::Chapter &MP4::Chapter::Chapter::operator=(const Chapter &other)
|
||||
{
|
||||
Chapter(other).swap(*this);
|
||||
return *this;
|
||||
}
|
||||
|
||||
MP4::Chapter &MP4::Chapter::Chapter::operator=(
|
||||
Chapter &&other) noexcept = default;
|
||||
|
||||
bool MP4::Chapter::operator==(const Chapter &other) const
|
||||
{
|
||||
return title() == other.title() && startTime() == other.startTime();
|
||||
}
|
||||
|
||||
bool MP4::Chapter::operator!=(const Chapter &other) const
|
||||
{
|
||||
return !(*this == other);
|
||||
}
|
||||
|
||||
void MP4::Chapter::swap(Chapter &other) noexcept
|
||||
{
|
||||
using std::swap;
|
||||
|
||||
swap(d, other.d);
|
||||
}
|
||||
|
||||
const String &MP4::Chapter::title() const
|
||||
{
|
||||
return d->title;
|
||||
}
|
||||
|
||||
long long MP4::Chapter::startTime() const
|
||||
{
|
||||
return d->startTime;
|
||||
}
|
||||
108
taglib/mp4/mp4chapter.h
Normal file
108
taglib/mp4/mp4chapter.h
Normal file
@@ -0,0 +1,108 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef TAGLIB_MP4CHAPTER_H
|
||||
#define TAGLIB_MP4CHAPTER_H
|
||||
|
||||
#include <memory>
|
||||
#include "taglib_export.h"
|
||||
#include "tlist.h"
|
||||
|
||||
namespace TagLib {
|
||||
class String;
|
||||
namespace MP4 {
|
||||
|
||||
/*!
|
||||
* A single Nero-style chapter marker.
|
||||
*/
|
||||
class TAGLIB_EXPORT Chapter {
|
||||
public:
|
||||
/*!
|
||||
* Construct a chapter.
|
||||
*/
|
||||
Chapter(const String &title, long long startTime);
|
||||
|
||||
/*!
|
||||
* Construct a chapter as a copy of \a other.
|
||||
*/
|
||||
Chapter(const Chapter &other);
|
||||
|
||||
/*!
|
||||
* Construct a chapter moving from \a other.
|
||||
*/
|
||||
Chapter(Chapter &&other) noexcept;
|
||||
|
||||
/*!
|
||||
* Destroys this chapter.
|
||||
*/
|
||||
~Chapter();
|
||||
|
||||
/*!
|
||||
* Copies the contents of \a other into this object.
|
||||
*/
|
||||
Chapter &operator=(const Chapter &other);
|
||||
|
||||
/*!
|
||||
* Moves the contents of \a other into this object.
|
||||
*/
|
||||
Chapter &operator=(Chapter &&other) noexcept;
|
||||
|
||||
/*!
|
||||
* Returns \c true if the chapter and \a other contain the same data.
|
||||
*/
|
||||
bool operator==(const Chapter &other) const;
|
||||
|
||||
/*!
|
||||
* Returns \c true if the chapter and \a other differ in data.
|
||||
*/
|
||||
bool operator!=(const Chapter &other) const;
|
||||
|
||||
/*!
|
||||
* Exchanges the content of the object with the content of \a other.
|
||||
*/
|
||||
void swap(Chapter &other) noexcept;
|
||||
|
||||
/*!
|
||||
* Returns the title representing the chapter.
|
||||
*/
|
||||
const String &title() const;
|
||||
|
||||
/*!
|
||||
* Returns the start time in milliseconds.
|
||||
*/
|
||||
long long startTime() const;
|
||||
|
||||
private:
|
||||
class ChapterPrivate;
|
||||
TAGLIB_MSVC_SUPPRESS_WARNING_NEEDS_TO_HAVE_DLL_INTERFACE
|
||||
std::unique_ptr<ChapterPrivate> d;
|
||||
};
|
||||
|
||||
//! List of chapters.
|
||||
using ChapterList = List<Chapter>;
|
||||
|
||||
} // namespace MP4
|
||||
} // namespace TagLib
|
||||
|
||||
#endif
|
||||
126
taglib/mp4/mp4chapterholder.h
Normal file
126
taglib/mp4/mp4chapterholder.h
Normal file
@@ -0,0 +1,126 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2006 by Urs Fleisch
|
||||
email : ufleisch@users.sourceforge.net
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef TAGLIB_MP4CHAPTERHOLDER_H
|
||||
#define TAGLIB_MP4CHAPTERHOLDER_H
|
||||
|
||||
#include "mp4chapter.h"
|
||||
|
||||
namespace TagLib {
|
||||
class File;
|
||||
namespace MP4 {
|
||||
/*!
|
||||
* Base class to hold chapters and store modified state.
|
||||
*/
|
||||
class ChapterHolder {
|
||||
public:
|
||||
/*!
|
||||
* Get list of chapters.
|
||||
*/
|
||||
ChapterList chapters() const { return chapterList; }
|
||||
|
||||
/*!
|
||||
* Set list of chapters.
|
||||
*/
|
||||
void setChapters(const ChapterList &chapters) { chapterList = chapters; }
|
||||
|
||||
/*!
|
||||
* Returns \c true if the list of chapters has been modified.
|
||||
*/
|
||||
bool isModified() const { return modified; }
|
||||
|
||||
/*!
|
||||
* Set if the contained chapters are modified.
|
||||
*/
|
||||
void setModified(bool chaptersModified) { modified = chaptersModified; }
|
||||
|
||||
protected:
|
||||
ChapterList chapterList;
|
||||
bool modified = false;
|
||||
};
|
||||
|
||||
/*!
|
||||
* Lazily fetch list of chapters.
|
||||
* @tparam T class derived from ChapterHolder and implementing read(File *)
|
||||
* @param holder unique pointer to holder, initially null
|
||||
* @param file file with chapters
|
||||
* @return list of chapters, empty if no chapters found.
|
||||
*/
|
||||
template <typename T>
|
||||
ChapterList getChaptersLazy(std::unique_ptr<T> &holder, TagLib::File *file)
|
||||
{
|
||||
if (!holder) {
|
||||
holder = std::make_unique<T>();
|
||||
holder->read(file);
|
||||
}
|
||||
return holder->chapters();
|
||||
}
|
||||
|
||||
/*!
|
||||
* Lazily set a list of chapters.
|
||||
* @tparam T class derived from ChapterHolder
|
||||
* @param holder unique pointer to holder, initially null
|
||||
* @param chapters list of chapters to set
|
||||
*/
|
||||
template <typename T>
|
||||
void setChaptersLazy(std::unique_ptr<T> &holder, const ChapterList& chapters)
|
||||
{
|
||||
if (!holder) {
|
||||
holder = std::make_unique<T>();
|
||||
// The chapters have not been read before, so we do not know their
|
||||
// current state and mark them as modified. Otherwise, the check below
|
||||
// would not set the chapters if they are empty.
|
||||
holder->setModified(true);
|
||||
}
|
||||
if(holder->isModified() || holder->chapters() != chapters) {
|
||||
holder->setChapters(chapters);
|
||||
holder->setModified(true);
|
||||
}
|
||||
}
|
||||
|
||||
/*!
|
||||
* Save a list of chapters if it has been modified.
|
||||
* @tparam T class derived from ChapterHolder and implementing write(File *)
|
||||
* @param holder unique pointer to holder, initially null
|
||||
* @param file file with chapters
|
||||
* @return true if write successful or not modified.
|
||||
*/
|
||||
template <typename T>
|
||||
bool saveChaptersIfModified(std::unique_ptr<T> &holder, TagLib::File *file)
|
||||
{
|
||||
if(holder && holder->isModified()) {
|
||||
if(holder->write(file)) {
|
||||
holder->setModified(false);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
} // namespace MP4
|
||||
} // namespace TagLib
|
||||
|
||||
#endif
|
||||
@@ -30,6 +30,8 @@
|
||||
#include "tagutils.h"
|
||||
|
||||
#include "mp4itemfactory.h"
|
||||
#include "mp4nerochapterlist.h"
|
||||
#include "mp4qtchapterlist.h"
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
@@ -48,6 +50,8 @@ public:
|
||||
std::unique_ptr<MP4::Tag> tag;
|
||||
std::unique_ptr<MP4::Atoms> atoms;
|
||||
std::unique_ptr<MP4::Properties> properties;
|
||||
std::unique_ptr<MP4::NeroChapterList> neroChapterList;
|
||||
std::unique_ptr<MP4::QtChapterList> qtChapterList;
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -111,6 +115,26 @@ MP4::Properties *MP4::File::audioProperties() const
|
||||
return d->properties.get();
|
||||
}
|
||||
|
||||
MP4::ChapterList MP4::File::neroChapters()
|
||||
{
|
||||
return getChaptersLazy(d->neroChapterList, this);
|
||||
}
|
||||
|
||||
void MP4::File::setNeroChapters(const ChapterList& chapters)
|
||||
{
|
||||
setChaptersLazy(d->neroChapterList, chapters);
|
||||
}
|
||||
|
||||
MP4::ChapterList MP4::File::qtChapters()
|
||||
{
|
||||
return getChaptersLazy(d->qtChapterList, this);
|
||||
}
|
||||
|
||||
void MP4::File::setQtChapters(const ChapterList& chapters)
|
||||
{
|
||||
setChaptersLazy(d->qtChapterList, chapters);
|
||||
}
|
||||
|
||||
void
|
||||
MP4::File::read(bool readProperties)
|
||||
{
|
||||
@@ -148,7 +172,9 @@ MP4::File::save()
|
||||
return false;
|
||||
}
|
||||
|
||||
return d->tag->save();
|
||||
return d->tag->save() &&
|
||||
saveChaptersIfModified(d->neroChapterList, this) &&
|
||||
saveChaptersIfModified(d->qtChapterList, this);
|
||||
}
|
||||
|
||||
bool
|
||||
|
||||
@@ -31,6 +31,7 @@
|
||||
#include "mp4tag.h"
|
||||
#include "tag.h"
|
||||
#include "mp4properties.h"
|
||||
#include "mp4chapter.h"
|
||||
|
||||
namespace TagLib {
|
||||
//! An implementation of MP4 (AAC, ALAC, ...) metadata
|
||||
@@ -130,6 +131,26 @@ namespace TagLib {
|
||||
*/
|
||||
Properties *audioProperties() const override;
|
||||
|
||||
/*!
|
||||
* Returns the Nero style chapters for this file.
|
||||
*/
|
||||
ChapterList neroChapters();
|
||||
|
||||
/*!
|
||||
* Sets the Nero style chapters for this file.
|
||||
*/
|
||||
void setNeroChapters(const ChapterList &chapters);
|
||||
|
||||
/*!
|
||||
* Returns the QuickTime chapters for this file.
|
||||
*/
|
||||
ChapterList qtChapters();
|
||||
|
||||
/*!
|
||||
* Sets the QuickTime style chapters for this file.
|
||||
*/
|
||||
void setQtChapters(const ChapterList &chapters);
|
||||
|
||||
/*!
|
||||
* Save the file.
|
||||
*
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
|
||||
#include "mp4itemfactory.h"
|
||||
|
||||
#include <mutex>
|
||||
#include <utility>
|
||||
|
||||
#include "tbytevector.h"
|
||||
@@ -47,6 +48,8 @@ public:
|
||||
NameHandlerMap handlerTypeForName;
|
||||
Map<ByteVector, String> propertyKeyForName;
|
||||
Map<String, ByteVector> nameForPropertyKey;
|
||||
mutable std::once_flag handlerMapOnce;
|
||||
mutable std::once_flag propertyMapsOnce;
|
||||
};
|
||||
|
||||
ItemFactory ItemFactory::factory;
|
||||
@@ -239,9 +242,11 @@ std::pair<String, StringList> ItemFactory::itemToProperty(
|
||||
|
||||
String ItemFactory::propertyKeyForName(const ByteVector &name) const
|
||||
{
|
||||
if(d->propertyKeyForName.isEmpty()) {
|
||||
std::call_once(d->propertyMapsOnce, [this] {
|
||||
d->propertyKeyForName = namePropertyMap();
|
||||
}
|
||||
for(const auto &[k, t] : std::as_const(d->propertyKeyForName))
|
||||
d->nameForPropertyKey[t] = k;
|
||||
});
|
||||
String key = d->propertyKeyForName.value(name);
|
||||
if(key.isEmpty() && name.startsWith(freeFormPrefix)) {
|
||||
key = name.mid(std::size(freeFormPrefix) - 1);
|
||||
@@ -251,14 +256,11 @@ String ItemFactory::propertyKeyForName(const ByteVector &name) const
|
||||
|
||||
ByteVector ItemFactory::nameForPropertyKey(const String &key) const
|
||||
{
|
||||
if(d->nameForPropertyKey.isEmpty()) {
|
||||
if(d->propertyKeyForName.isEmpty()) {
|
||||
d->propertyKeyForName = namePropertyMap();
|
||||
}
|
||||
for(const auto &[k, t] : std::as_const(d->propertyKeyForName)) {
|
||||
std::call_once(d->propertyMapsOnce, [this] {
|
||||
d->propertyKeyForName = namePropertyMap();
|
||||
for(const auto &[k, t] : std::as_const(d->propertyKeyForName))
|
||||
d->nameForPropertyKey[t] = k;
|
||||
}
|
||||
}
|
||||
});
|
||||
ByteVector name = d->nameForPropertyKey.value(key);
|
||||
if(name.isEmpty() && !key.isEmpty()) {
|
||||
const auto &firstChar = key[0];
|
||||
@@ -317,9 +319,9 @@ ItemFactory::NameHandlerMap ItemFactory::nameHandlerMap() const
|
||||
ItemFactory::ItemHandlerType ItemFactory::handlerTypeForName(
|
||||
const ByteVector &name) const
|
||||
{
|
||||
if(d->handlerTypeForName.isEmpty()) {
|
||||
std::call_once(d->handlerMapOnce, [this] {
|
||||
d->handlerTypeForName = nameHandlerMap();
|
||||
}
|
||||
});
|
||||
auto type = d->handlerTypeForName.value(name, ItemHandlerType::Unknown);
|
||||
if (type == ItemHandlerType::Unknown && name.size() == 4) {
|
||||
type = ItemHandlerType::Text;
|
||||
|
||||
320
taglib/mp4/mp4nerochapterlist.cpp
Normal file
320
taglib/mp4/mp4nerochapterlist.cpp
Normal file
@@ -0,0 +1,320 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#include "mp4nerochapterlist.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "tdebug.h"
|
||||
#include "mp4file.h"
|
||||
#include "mp4atom.h"
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
namespace
|
||||
{
|
||||
ByteVector renderAtom(const ByteVector &name, const ByteVector &data)
|
||||
{
|
||||
return ByteVector::fromUInt(data.size() + 8) + name + data;
|
||||
}
|
||||
|
||||
// Update parent atom sizes along a path when child size changes by delta.
|
||||
// Mirrors MP4::Tag::updateParents().
|
||||
void updateParentSizes(TagLib::File *file, const MP4::AtomList &path,
|
||||
offset_t delta, int ignore = 0)
|
||||
{
|
||||
if(static_cast<int>(path.size()) <= ignore)
|
||||
return;
|
||||
|
||||
auto itEnd = path.end();
|
||||
std::advance(itEnd, 0 - ignore);
|
||||
|
||||
for(auto it = path.begin(); it != itEnd; ++it) {
|
||||
file->seek((*it)->offset());
|
||||
if(const long size = file->readBlock(4).toUInt(); size == 1) {
|
||||
// 64-bit size
|
||||
file->seek(4, TagLib::File::Current);
|
||||
const long long longSize = file->readBlock(8).toLongLong();
|
||||
file->seek((*it)->offset() + 8);
|
||||
file->writeBlock(ByteVector::fromLongLong(longSize + delta));
|
||||
}
|
||||
else {
|
||||
// 32-bit size
|
||||
file->seek((*it)->offset());
|
||||
file->writeBlock(ByteVector::fromUInt(static_cast<unsigned int>(size + delta)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update stco/co64/tfhd chunk offsets when file content shifts.
|
||||
// Mirrors MP4::Tag::updateOffsets().
|
||||
void updateChunkOffsets(TagLib::File *file, const MP4::Atoms *atoms,
|
||||
offset_t delta, offset_t offset)
|
||||
{
|
||||
if(const MP4::Atom *moov = atoms->find("moov")) {
|
||||
const MP4::AtomList stco = moov->findall("stco", true);
|
||||
for(const auto &atom : stco) {
|
||||
if(atom->offset() > offset)
|
||||
atom->addToOffset(delta);
|
||||
file->seek(atom->offset() + 12);
|
||||
ByteVector data = file->readBlock(atom->length() - 12);
|
||||
unsigned int count = data.toUInt();
|
||||
file->seek(atom->offset() + 16);
|
||||
unsigned int pos = 4;
|
||||
const unsigned int maxPos = data.size() - 4;
|
||||
while(count-- && pos <= maxPos) {
|
||||
auto o = static_cast<offset_t>(data.toUInt(pos));
|
||||
if(o > offset)
|
||||
o += delta;
|
||||
file->writeBlock(ByteVector::fromUInt(static_cast<unsigned int>(o)));
|
||||
pos += 4;
|
||||
}
|
||||
}
|
||||
|
||||
const MP4::AtomList co64 = moov->findall("co64", true);
|
||||
for(const auto &atom : co64) {
|
||||
if(atom->offset() > offset)
|
||||
atom->addToOffset(delta);
|
||||
file->seek(atom->offset() + 12);
|
||||
ByteVector data = file->readBlock(atom->length() - 12);
|
||||
unsigned int count = data.toUInt();
|
||||
file->seek(atom->offset() + 16);
|
||||
unsigned int pos = 4;
|
||||
const unsigned int maxPos = data.size() - 8;
|
||||
while(count-- && pos <= maxPos) {
|
||||
long long o = data.toLongLong(pos);
|
||||
if(o > offset)
|
||||
o += delta;
|
||||
file->writeBlock(ByteVector::fromLongLong(o));
|
||||
pos += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(const MP4::Atom *moof = atoms->find("moof")) {
|
||||
const MP4::AtomList tfhd = moof->findall("tfhd", true);
|
||||
for(const auto &atom : tfhd) {
|
||||
if(atom->offset() > offset)
|
||||
atom->addToOffset(delta);
|
||||
file->seek(atom->offset() + 9);
|
||||
ByteVector data = file->readBlock(atom->length() - 9);
|
||||
if(const unsigned int flags = data.toUInt(0, 3, true);
|
||||
flags & 1) {
|
||||
long long o = data.toLongLong(7U);
|
||||
if(o > offset)
|
||||
o += delta;
|
||||
file->seek(atom->offset() + 16);
|
||||
file->writeBlock(ByteVector::fromLongLong(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the binary payload for a chpl atom (version 1).
|
||||
ByteVector renderChplData(const MP4::ChapterList &chapters)
|
||||
{
|
||||
const unsigned int count = std::min(chapters.size(), 255U);
|
||||
|
||||
ByteVector data;
|
||||
// Version (1 byte) + flags (3 bytes) + reserved (4 bytes)
|
||||
data.append(static_cast<char>(0x01)); // version 1
|
||||
data.append(ByteVector(3, '\0')); // flags
|
||||
data.append(ByteVector(4, '\0')); // reserved
|
||||
|
||||
// Chapter count
|
||||
data.append(static_cast<char>(count & 0xFF));
|
||||
|
||||
unsigned int i = 0;
|
||||
for(const auto &ch : chapters) {
|
||||
if(i++ >= count)
|
||||
break;
|
||||
|
||||
// Start time: 8 bytes big-endian, on-disk format is 100-nanosecond units
|
||||
data.append(ByteVector::fromLongLong(ch.startTime() * 10000LL));
|
||||
|
||||
// Title: 1-byte length + UTF-8 bytes (max 255 bytes)
|
||||
ByteVector titleBytes = ch.title().data(String::UTF8);
|
||||
const unsigned int titleLen = std::min(titleBytes.size(), 255U);
|
||||
data.append(static_cast<char>(titleLen & 0xFF));
|
||||
if(titleLen > 0)
|
||||
data.append(titleBytes.mid(0, titleLen));
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
// Parse the binary content of a chpl atom into a ChapterList.
|
||||
MP4::ChapterList parseChplData(const ByteVector &data)
|
||||
{
|
||||
MP4::ChapterList chapters;
|
||||
|
||||
// Minimum: version(1) + flags(3) + count(1) = 5 bytes (version 0 layout)
|
||||
if(data.size() < 5)
|
||||
return chapters;
|
||||
|
||||
unsigned int pos = 0;
|
||||
const auto version = static_cast<unsigned char>(data[pos++]);
|
||||
|
||||
// Skip flags (3 bytes)
|
||||
pos += 3;
|
||||
|
||||
// Version 1 has 4 reserved bytes
|
||||
if(version >= 1)
|
||||
pos += 4;
|
||||
|
||||
if(pos >= data.size())
|
||||
return chapters;
|
||||
|
||||
const unsigned int count = static_cast<unsigned char>(data[pos++]);
|
||||
|
||||
for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) {
|
||||
const long long startTime100ns = data.toLongLong(pos);
|
||||
pos += 8;
|
||||
|
||||
const unsigned int titleLen = static_cast<unsigned char>(data[pos++]);
|
||||
|
||||
String title;
|
||||
if(titleLen > 0 && pos + titleLen <= data.size()) {
|
||||
title = String(data.mid(pos, titleLen), String::UTF8);
|
||||
pos += titleLen;
|
||||
}
|
||||
|
||||
chapters.append(MP4::Chapter(title, startTime100ns / 10000LL));
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// public members
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
bool MP4::NeroChapterList::read(TagLib::File *file)
|
||||
{
|
||||
const Atoms atoms(file);
|
||||
|
||||
const Atom *chpl = atoms.find("moov", "udta", "chpl");
|
||||
modified = false;
|
||||
chapterList.clear();
|
||||
if(chpl) {
|
||||
// Read the atom content (skip 8-byte atom header)
|
||||
file->seek(chpl->offset() + 8);
|
||||
const ByteVector data = file->readBlock(chpl->length() - 8);
|
||||
|
||||
chapterList = parseChplData(data);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
bool MP4::NeroChapterList::write(TagLib::File *file)
|
||||
{
|
||||
// Writing an empty list is equivalent to removing the chapters.
|
||||
if(chapterList.isEmpty())
|
||||
return remove(file);
|
||||
|
||||
const Atoms atoms(file);
|
||||
|
||||
if(!atoms.find("moov")) {
|
||||
debug("MP4ChapterList::write() -- No moov atom found");
|
||||
return false;
|
||||
}
|
||||
|
||||
const ByteVector chplPayload = renderChplData(chapterList);
|
||||
const ByteVector chplAtom = renderAtom("chpl", chplPayload);
|
||||
|
||||
if(const Atom *existingChpl = atoms.find("moov", "udta", "chpl")) {
|
||||
// Replace existing chpl atom
|
||||
const offset_t offset = existingChpl->offset();
|
||||
const offset_t oldLength = existingChpl->length();
|
||||
const offset_t delta = static_cast<offset_t>(chplAtom.size()) - oldLength;
|
||||
|
||||
file->insert(chplAtom, offset, oldLength);
|
||||
|
||||
if(delta != 0) {
|
||||
// Update parent sizes: moov and udta
|
||||
const AtomList parentPath = atoms.path("moov", "udta", "chpl");
|
||||
updateParentSizes(file, parentPath, delta, 1); // ignore chpl itself
|
||||
updateChunkOffsets(file, &atoms, delta, offset);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Need to insert a new chpl atom
|
||||
|
||||
if(AtomList udtaPath = atoms.path("moov", "udta"); udtaPath.size() == 2) {
|
||||
// udta exists -- insert chpl at the beginning of udta's content
|
||||
const offset_t insertOffset = udtaPath.back()->offset() + 8;
|
||||
file->insert(chplAtom, insertOffset, 0);
|
||||
|
||||
updateParentSizes(file, udtaPath, chplAtom.size());
|
||||
updateChunkOffsets(file, &atoms, chplAtom.size(), insertOffset);
|
||||
}
|
||||
else {
|
||||
// No udta -- insert udta + chpl at the beginning of moov's content
|
||||
const ByteVector udtaAtom = renderAtom("udta", chplAtom);
|
||||
|
||||
AtomList moovPath = atoms.path("moov");
|
||||
if(moovPath.isEmpty()) {
|
||||
debug("MP4ChapterList::write() -- No moov atom in path");
|
||||
return false;
|
||||
}
|
||||
|
||||
const offset_t insertOffset = moovPath.back()->offset() + 8;
|
||||
file->insert(udtaAtom, insertOffset, 0);
|
||||
|
||||
updateParentSizes(file, moovPath, udtaAtom.size());
|
||||
updateChunkOffsets(file, &atoms, udtaAtom.size(), insertOffset);
|
||||
}
|
||||
}
|
||||
|
||||
modified = false;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool MP4::NeroChapterList::remove(TagLib::File *file)
|
||||
{
|
||||
const Atoms atoms(file);
|
||||
chapterList.clear();
|
||||
modified = false;
|
||||
|
||||
const Atom *chpl = atoms.find("moov", "udta", "chpl");
|
||||
if(!chpl) {
|
||||
// No chpl atom -- nothing to remove
|
||||
return true;
|
||||
}
|
||||
|
||||
const offset_t offset = chpl->offset();
|
||||
const offset_t length = chpl->length();
|
||||
|
||||
file->removeBlock(offset, length);
|
||||
|
||||
// Update parent sizes with negative delta
|
||||
const AtomList parentPath = atoms.path("moov", "udta", "chpl");
|
||||
updateParentSizes(file, parentPath, -length, 1); // ignore chpl itself
|
||||
updateChunkOffsets(file, &atoms, -length, offset);
|
||||
|
||||
return true;
|
||||
}
|
||||
66
taglib/mp4/mp4nerochapterlist.h
Normal file
66
taglib/mp4/mp4nerochapterlist.h
Normal file
@@ -0,0 +1,66 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef TAGLIB_MP4CHAPTERLIST_H
|
||||
#define TAGLIB_MP4CHAPTERLIST_H
|
||||
|
||||
#include "mp4chapterholder.h"
|
||||
|
||||
namespace TagLib {
|
||||
class File;
|
||||
namespace MP4 {
|
||||
|
||||
/*!
|
||||
* Reads, writes, and removes Nero-style chapter markers (chpl atom)
|
||||
* from MP4 files. Operates independently of MP4::Tag -- the chpl atom
|
||||
* lives at moov/udta/chpl, a sibling of the metadata ilst path.
|
||||
*/
|
||||
class NeroChapterList : public ChapterHolder
|
||||
{
|
||||
public:
|
||||
/*!
|
||||
* Reads chapter markers from the already-opened \a file.
|
||||
* Returns \c false if the file has no chpl atom.
|
||||
*/
|
||||
bool read(TagLib::File *file);
|
||||
|
||||
/*!
|
||||
* Writes chapter markers to the already-opened \a file,
|
||||
* replacing any existing chpl atom.
|
||||
* The chapter count is capped at 255 (Nero format limit).
|
||||
* Returns \c true on success.
|
||||
*/
|
||||
bool write(TagLib::File *file);
|
||||
|
||||
/*!
|
||||
* Removes the chpl atom from the already-opened \a file.
|
||||
* Returns \c true on success, or if no chpl atom exists.
|
||||
*/
|
||||
bool remove(TagLib::File *file);
|
||||
};
|
||||
|
||||
} // namespace MP4
|
||||
} // namespace TagLib
|
||||
|
||||
#endif
|
||||
@@ -224,7 +224,7 @@ MP4::Properties::read(File *file, const Atoms *atoms)
|
||||
pos += 10;
|
||||
if(const unsigned int bitrateValue = data.toUInt(pos);
|
||||
bitrateValue != 0 || d->length <= 0) {
|
||||
d->bitrate = static_cast<int>((bitrateValue + 500) / 1000.0 + 0.5);
|
||||
d->bitrate = static_cast<int>(bitrateValue / 1000.0 + 0.5);
|
||||
}
|
||||
else {
|
||||
d->bitrate = static_cast<int>(
|
||||
|
||||
1307
taglib/mp4/mp4qtchapterlist.cpp
Normal file
1307
taglib/mp4/mp4qtchapterlist.cpp
Normal file
File diff suppressed because it is too large
Load Diff
78
taglib/mp4/mp4qtchapterlist.h
Normal file
78
taglib/mp4/mp4qtchapterlist.h
Normal file
@@ -0,0 +1,78 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef TAGLIB_MP4QTCHAPTERLIST_H
|
||||
#define TAGLIB_MP4QTCHAPTERLIST_H
|
||||
|
||||
#include "mp4chapterholder.h"
|
||||
|
||||
namespace TagLib {
|
||||
class File;
|
||||
namespace MP4 {
|
||||
|
||||
/*!
|
||||
* Reads, writes, and removes QuickTime-style chapter tracks from MP4
|
||||
* files. A QT chapter track is a disabled text track (\c hdlr type
|
||||
* \c "text") referenced by a \c chap track-reference in the audio
|
||||
* track's \c tref box. This format is understood by QuickTime,
|
||||
* iTunes, Final Cut, Logic, DaVinci Resolve, Twisted Wave, and most
|
||||
* other Apple/macOS software.
|
||||
*
|
||||
* The existing \c MP4ChapterList class handles Nero-style \c chpl
|
||||
* atoms, which are a different (and less widely supported) chapter
|
||||
* format.
|
||||
*
|
||||
* Chapter times use the same 100-nanosecond unit convention as
|
||||
* \c MP4ChapterList so that existing \c Chapter / \c ChapterList
|
||||
* types can be shared.
|
||||
*/
|
||||
class QtChapterList : public ChapterHolder
|
||||
{
|
||||
public:
|
||||
/*!
|
||||
* Reads chapter markers from the QuickTime chapter track in the
|
||||
* already-opened \a file.
|
||||
* Returns \c false if the file has no chapter track.
|
||||
*/
|
||||
bool read(TagLib::File *file);
|
||||
|
||||
/*!
|
||||
* Writes chapter markers as a QuickTime chapter track to the
|
||||
* already-opened \a file, replacing any existing chapter track.
|
||||
* Returns \c true on success.
|
||||
*/
|
||||
bool write(TagLib::File *file);
|
||||
|
||||
/*!
|
||||
* Removes the QuickTime chapter track and its \c tref/chap
|
||||
* reference from the already-opened \a file.
|
||||
* Returns \c true on success, or if no chapter track exists.
|
||||
*/
|
||||
bool remove(TagLib::File *file);
|
||||
};
|
||||
|
||||
} // namespace MP4
|
||||
} // namespace TagLib
|
||||
|
||||
#endif
|
||||
@@ -200,7 +200,8 @@ MP4::Tag::updateOffsets(offset_t delta, offset_t offset)
|
||||
unsigned int count = data.toUInt();
|
||||
d->file->seek(atom->offset() + 16);
|
||||
unsigned int pos = 4;
|
||||
while(count--) {
|
||||
const unsigned int maxPos = data.size() - 4;
|
||||
while(count-- && pos <= maxPos) {
|
||||
auto o = static_cast<offset_t>(data.toUInt(pos));
|
||||
if(o > offset) {
|
||||
o += delta;
|
||||
@@ -220,7 +221,8 @@ MP4::Tag::updateOffsets(offset_t delta, offset_t offset)
|
||||
unsigned int count = data.toUInt();
|
||||
d->file->seek(atom->offset() + 16);
|
||||
unsigned int pos = 4;
|
||||
while(count--) {
|
||||
const unsigned int maxPos = data.size() - 8;
|
||||
while(count-- && pos <= maxPos) {
|
||||
long long o = data.toLongLong(pos);
|
||||
if(o > offset) {
|
||||
o += delta;
|
||||
|
||||
@@ -93,28 +93,32 @@ namespace TagLib {
|
||||
unsigned long sampleFrames() const;
|
||||
|
||||
/*!
|
||||
* Returns the track gain as an integer value,
|
||||
* to convert to dB: trackGain in dB = 64.82 - (trackGain / 256)
|
||||
* Returns the track gain as an integer value.
|
||||
*
|
||||
* To convert to dB: trackGain in dB = 64.82 - (trackGain / 256)
|
||||
*/
|
||||
int trackGain() const;
|
||||
|
||||
/*!
|
||||
* Returns the track peak as an integer value,
|
||||
* to convert to dB: trackPeak in dB = trackPeak / 256
|
||||
* to convert to floating [-1..1]: trackPeak = 10^(trackPeak / 256 / 20)/32768
|
||||
* Returns the track peak as an integer value.
|
||||
*
|
||||
* To convert to dB: trackPeak in dB = trackPeak / 256 \n
|
||||
* To convert to floating [-1..1]: trackPeak = 10^(trackPeak / 256 / 20)/32768
|
||||
*/
|
||||
int trackPeak() const;
|
||||
|
||||
/*!
|
||||
* Returns the album gain as an integer value,
|
||||
* to convert to dB: albumGain in dB = 64.82 - (albumGain / 256)
|
||||
* Returns the album gain as an integer value.
|
||||
*
|
||||
* To convert to dB: albumGain in dB = 64.82 - (albumGain / 256)
|
||||
*/
|
||||
int albumGain() const;
|
||||
|
||||
/*!
|
||||
* Returns the album peak as an integer value,
|
||||
* to convert to dB: albumPeak in dB = albumPeak / 256
|
||||
* to convert to floating [-1..1]: albumPeak = 10^(albumPeak / 256 / 20)/32768
|
||||
* Returns the album peak as an integer value.
|
||||
*
|
||||
* To convert to dB: albumPeak in dB = albumPeak / 256 \n
|
||||
* To convert to floating [-1..1]: albumPeak = 10^(albumPeak / 256 / 20)/32768
|
||||
*/
|
||||
int albumPeak() const;
|
||||
|
||||
|
||||
@@ -58,7 +58,9 @@ namespace TagLib {
|
||||
};
|
||||
|
||||
/*!
|
||||
* Event types defined in id3v2.4.0-frames.txt 4.5. Event timing codes.
|
||||
* Event types defined in
|
||||
* <a href="https://github.com/taglib/taglib/blob/master/taglib/mpeg/id3v2/id3v2.4.0-frames.txt">
|
||||
* id3v2.4.0-frames.txt</a> 4.5. Event timing codes.
|
||||
*/
|
||||
enum EventType {
|
||||
Padding = 0x00,
|
||||
|
||||
@@ -45,52 +45,53 @@ namespace TagLib {
|
||||
* identification frames. There are a number of variations on this. Those
|
||||
* enumerated in the ID3v2.4 standard are:
|
||||
*
|
||||
* <ul>
|
||||
* <li><b>TALB</b> Album/Movie/Show title</li>
|
||||
* <li><b>TBPM</b> BPM (beats per minute)</li>
|
||||
* <li><b>TCOM</b> Composer</li>
|
||||
* <li><b>TCON</b> Content type</li>
|
||||
* <li><b>TCOP</b> Copyright message</li>
|
||||
* <li><b>TDEN</b> Encoding time</li>
|
||||
* <li><b>TDLY</b> Playlist delay</li>
|
||||
* <li><b>TDOR</b> Original release time</li>
|
||||
* <li><b>TDRC</b> Recording time</li>
|
||||
* <li><b>TDRL</b> Release time</li>
|
||||
* <li><b>TDTG</b> Tagging time</li>
|
||||
* <li><b>TENC</b> Encoded by</li>
|
||||
* <li><b>TEXT</b> Lyricist/Text writer</li>
|
||||
* <li><b>TFLT</b> %File type</li>
|
||||
* <li><b>TIPL</b> Involved people list</li>
|
||||
* <li><b>TIT1</b> Content group description</li>
|
||||
* <li><b>TIT2</b> Title/songname/content description</li>
|
||||
* <li><b>TIT3</b> Subtitle/Description refinement</li>
|
||||
* <li><b>TKEY</b> Initial key</li>
|
||||
* <li><b>TLAN</b> Language(s)</li>
|
||||
* <li><b>TLEN</b> Length</li>
|
||||
* <li><b>TMCL</b> Musician credits list</li>
|
||||
* <li><b>TMED</b> Media type</li>
|
||||
* <li><b>TMOO</b> Mood</li>
|
||||
* <li><b>TOAL</b> Original album/movie/show title</li>
|
||||
* <li><b>TOFN</b> Original filename</li>
|
||||
* <li><b>TOLY</b> Original lyricist(s)/text writer(s)</li>
|
||||
* <li><b>TOPE</b> Original artist(s)/performer(s)</li>
|
||||
* <li><b>TOWN</b> %File owner/licensee</li>
|
||||
* <li><b>TPE1</b> Lead performer(s)/Soloist(s)</li>
|
||||
* <li><b>TPE2</b> Band/orchestra/accompaniment</li>
|
||||
* <li><b>TPE3</b> Conductor/performer refinement</li>
|
||||
* <li><b>TPE4</b> Interpreted, remixed, or otherwise modified by</li>
|
||||
* <li><b>TPOS</b> Part of a set</li>
|
||||
* <li><b>TPRO</b> Produced notice</li>
|
||||
* <li><b>TPUB</b> Publisher</li>
|
||||
* <li><b>TRCK</b> Track number/Position in set</li>
|
||||
* <li><b>TRSN</b> Internet radio station name</li>
|
||||
* <li><b>TRSO</b> Internet radio station owner</li>
|
||||
* <li><b>TSOA</b> Album sort order</li>
|
||||
* <li><b>TSOP</b> Performer sort order</li>
|
||||
* <li><b>TSOT</b> Title sort order</li>
|
||||
* <li><b>TSRC</b> ISRC (international standard recording code)</li>
|
||||
* <li><b>TSSE</b> Software/Hardware and settings used for encoding</li>
|
||||
* <li><b>TSST</b> Set subtitle</li>
|
||||
* %Frame | Description
|
||||
* :----: | :-----------------------------------------------
|
||||
* TALB | Album/Movie/Show title
|
||||
* TBPM | BPM (beats per minute)
|
||||
* TCOM | Composer
|
||||
* TCON | Content type
|
||||
* TCOP | Copyright message
|
||||
* TDEN | Encoding time
|
||||
* TDLY | Playlist delay
|
||||
* TDOR | Original release time
|
||||
* TDRC | Recording time
|
||||
* TDRL | Release time
|
||||
* TDTG | Tagging time
|
||||
* TENC | Encoded by
|
||||
* TEXT | Lyricist/Text writer
|
||||
* TFLT | %File type
|
||||
* TIPL | Involved people list
|
||||
* TIT1 | Content group description
|
||||
* TIT2 | Title/songname/content description
|
||||
* TIT3 | Subtitle/Description refinement
|
||||
* TKEY | Initial key
|
||||
* TLAN | Language(s)
|
||||
* TLEN | Length
|
||||
* TMCL | Musician credits list
|
||||
* TMED | Media type
|
||||
* TMOO | Mood
|
||||
* TOAL | Original album/movie/show title
|
||||
* TOFN | Original filename
|
||||
* TOLY | Original lyricist(s)/text writer(s)
|
||||
* TOPE | Original artist(s)/performer(s)
|
||||
* TOWN | %File owner/licensee
|
||||
* TPE1 | Lead performer(s)/Soloist(s)
|
||||
* TPE2 | Band/orchestra/accompaniment
|
||||
* TPE3 | Conductor/performer refinement
|
||||
* TPE4 | Interpreted, remixed, or otherwise modified by
|
||||
* TPOS | Part of a set
|
||||
* TPRO | Produced notice
|
||||
* TPUB | Publisher
|
||||
* TRCK | Track number/Position in set
|
||||
* TRSN | Internet radio station name
|
||||
* TRSO | Internet radio station owner
|
||||
* TSOA | Album sort order
|
||||
* TSOP | Performer sort order
|
||||
* TSOT | Title sort order
|
||||
* TSRC | ISRC (international standard recording code)
|
||||
* TSSE | Software/Hardware and settings used for encoding
|
||||
* TSST | Set subtitle
|
||||
* </ul>
|
||||
*
|
||||
* The ID3v2 Frames document gives a description of each of these formats
|
||||
|
||||
@@ -121,9 +121,11 @@ std::pair<Frame::Header *, bool> FrameFactory::prepareFrameHeader(
|
||||
|
||||
// A quick sanity check -- make sure that the frameID is 4 uppercase Latin1
|
||||
// characters. Also make sure that there is data in the frame.
|
||||
// A frame size of zero is invalid, but tolerated here to later only drop the
|
||||
// frame but not the whole tag.
|
||||
|
||||
if(frameID.size() != (version < 3U ? 3U : 4U) ||
|
||||
header->frameSize() <= static_cast<unsigned int>(header->dataLengthIndicator() ? 4 : 0) ||
|
||||
header->frameSize() < static_cast<unsigned int>(header->dataLengthIndicator() ? 4 : 0) ||
|
||||
header->frameSize() > data.size())
|
||||
{
|
||||
delete header;
|
||||
|
||||
@@ -879,13 +879,6 @@ void ID3v2::Tag::parse(const ByteVector &origData)
|
||||
if(!frame)
|
||||
return;
|
||||
|
||||
// Checks to make sure that frame parsed correctly.
|
||||
|
||||
if(frame->size() <= 0) {
|
||||
delete frame;
|
||||
return;
|
||||
}
|
||||
|
||||
if(frame->header()->version() == headerVersion) {
|
||||
frameDataPosition += frame->size() + frame->headerSize();
|
||||
} else {
|
||||
@@ -895,7 +888,14 @@ void ID3v2::Tag::parse(const ByteVector &origData)
|
||||
Frame::Header origHeader(origData, headerVersion);
|
||||
frameDataPosition += origHeader.frameSize() + origHeader.size();
|
||||
}
|
||||
addFrame(frame);
|
||||
|
||||
if(frame->size() > 0) {
|
||||
addFrame(frame);
|
||||
} else {
|
||||
// A frame with size 0 is invalid, drop it. "A frame must be at least 1
|
||||
// byte big" (id3v2.4.0-structure.txt - 4, id3v2.3.0.txt - 3.3).
|
||||
delete frame;
|
||||
}
|
||||
}
|
||||
|
||||
d->factory->rebuildAggregateFrames(this);
|
||||
|
||||
@@ -239,7 +239,7 @@ void MPEG::Header::parse(File *file, offset_t offset, bool checkLength)
|
||||
(static_cast<unsigned char>(frameLengthData[0]) << 3) |
|
||||
(static_cast<unsigned char>(frameLengthData[1]) >> 5);
|
||||
|
||||
d->bitrate = static_cast<int>(d->frameLength * d->sampleRate / 1024.0 + 0.5) * 8 / 1024;
|
||||
d->bitrate = static_cast<int>(d->frameLength * d->sampleRate / 1024.0 + 0.5) * 8 / 1000;
|
||||
}
|
||||
}
|
||||
else {
|
||||
|
||||
@@ -45,6 +45,7 @@ public:
|
||||
int inputSampleRate { 0 };
|
||||
int channels { 0 };
|
||||
int opusVersion { 0 };
|
||||
int outputGain { 0 };
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -93,6 +94,11 @@ int Opus::Properties::opusVersion() const
|
||||
return d->opusVersion;
|
||||
}
|
||||
|
||||
int Opus::Properties::outputGain() const
|
||||
{
|
||||
return d->outputGain;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// private members
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -122,9 +128,10 @@ void Opus::Properties::read(File *file)
|
||||
|
||||
// *Input Sample Rate* (32 bits, unsigned, little endian)
|
||||
d->inputSampleRate = data.toUInt(pos, false);
|
||||
// pos += 4;
|
||||
pos += 4;
|
||||
|
||||
// *Output Gain* (16 bits, signed, little endian)
|
||||
d->outputGain = data.toShort(pos, false);
|
||||
// pos += 2;
|
||||
|
||||
// *Channel Mapping Family* (8 bits, unsigned)
|
||||
|
||||
@@ -80,7 +80,7 @@ namespace TagLib {
|
||||
* Returns the sample rate in Hz.
|
||||
*
|
||||
* \note Always returns 48000, because Opus can decode any stream at a
|
||||
* sample rate of 8, 12, 16, 24, or 48 kHz,
|
||||
* sample rate of 8, 12, 16, 24, or 48 kHz.
|
||||
*/
|
||||
int sampleRate() const override;
|
||||
|
||||
@@ -101,6 +101,13 @@ namespace TagLib {
|
||||
*/
|
||||
int opusVersion() const;
|
||||
|
||||
/*!
|
||||
* Returns the output gain in signed Q7.8 fixed-point format.
|
||||
*
|
||||
* To convert the value to dB, divide it by 256.0.
|
||||
*/
|
||||
int outputGain() const;
|
||||
|
||||
private:
|
||||
void read(File *file);
|
||||
|
||||
|
||||
@@ -298,7 +298,7 @@ void RIFF::File::read()
|
||||
|
||||
seek(offset);
|
||||
const ByteVector chnkName = readBlock(4);
|
||||
const unsigned int chunkSize = readBlock(4).toUInt(bigEndian);
|
||||
unsigned int chunkSize = readBlock(4).toUInt(bigEndian);
|
||||
|
||||
if(!isValidChunkName(chnkName)) {
|
||||
debug("RIFF::File::read() -- Chunk '" + chnkName + "' has invalid ID");
|
||||
@@ -306,8 +306,12 @@ void RIFF::File::read()
|
||||
}
|
||||
|
||||
if(static_cast<long long>(offset) + 8 + chunkSize > length()) {
|
||||
debug("RIFF::File::read() -- Chunk '" + chnkName + "' has invalid size (larger than the file size)");
|
||||
break;
|
||||
// Clamp to available bytes rather than rejecting the chunk outright.
|
||||
// Some encoders write a correct data chunk but with a slightly too-large
|
||||
// declared size, or place the data chunk outside the declared RIFF boundary.
|
||||
// Lenient parsers (ffmpeg, QuickTime) handle this by clamping; we do the same.
|
||||
debug("RIFF::File::read() -- Chunk '" + chnkName + "' is truncated; clamping size to available bytes.");
|
||||
chunkSize = static_cast<unsigned int>(length() - offset - 8);
|
||||
}
|
||||
|
||||
Chunk chunk;
|
||||
|
||||
@@ -55,6 +55,11 @@ public:
|
||||
|
||||
bool hasID3v2 { false };
|
||||
bool hasInfo { false };
|
||||
bool hasiXML { false };
|
||||
bool hasBEXT { false };
|
||||
|
||||
String iXMLData;
|
||||
ByteVector bextData;
|
||||
};
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -108,6 +113,26 @@ RIFF::Info::Tag *RIFF::WAV::File::InfoTag() const
|
||||
return d->tag.access<RIFF::Info::Tag>(InfoIndex, false);
|
||||
}
|
||||
|
||||
String RIFF::WAV::File::iXMLData() const
|
||||
{
|
||||
return d->iXMLData;
|
||||
}
|
||||
|
||||
void RIFF::WAV::File::setiXMLData(const String &data)
|
||||
{
|
||||
d->iXMLData = data;
|
||||
}
|
||||
|
||||
ByteVector RIFF::WAV::File::BEXTData() const
|
||||
{
|
||||
return d->bextData;
|
||||
}
|
||||
|
||||
void RIFF::WAV::File::setBEXTData(const ByteVector &data)
|
||||
{
|
||||
d->bextData = data;
|
||||
}
|
||||
|
||||
void RIFF::WAV::File::strip(TagTypes tags)
|
||||
{
|
||||
removeTagChunks(tags);
|
||||
@@ -160,6 +185,26 @@ bool RIFF::WAV::File::save(TagTypes tags, StripTags strip, ID3v2::Version versio
|
||||
if(strip == StripOthers)
|
||||
File::strip(static_cast<TagTypes>(AllTags & ~tags));
|
||||
|
||||
if(!d->bextData.isEmpty()) {
|
||||
removeChunk("bext");
|
||||
setChunkData("bext", d->bextData);
|
||||
d->hasBEXT = true;
|
||||
}
|
||||
else if(d->hasBEXT) {
|
||||
removeChunk("bext");
|
||||
d->hasBEXT = false;
|
||||
}
|
||||
|
||||
if(!d->iXMLData.isEmpty()) {
|
||||
removeChunk("iXML");
|
||||
setChunkData("iXML", d->iXMLData.data(String::UTF8));
|
||||
d->hasiXML = true;
|
||||
}
|
||||
else if(d->hasiXML) {
|
||||
removeChunk("iXML");
|
||||
d->hasiXML = false;
|
||||
}
|
||||
|
||||
if(tags & ID3v2) {
|
||||
removeTagChunks(ID3v2);
|
||||
|
||||
@@ -191,6 +236,16 @@ bool RIFF::WAV::File::hasInfoTag() const
|
||||
return d->hasInfo;
|
||||
}
|
||||
|
||||
bool RIFF::WAV::File::hasiXMLData() const
|
||||
{
|
||||
return d->hasiXML;
|
||||
}
|
||||
|
||||
bool RIFF::WAV::File::hasBEXTData() const
|
||||
{
|
||||
return d->hasBEXT;
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// private members
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -219,6 +274,14 @@ void RIFF::WAV::File::read(bool readProperties)
|
||||
}
|
||||
}
|
||||
}
|
||||
else if(name == "iXML") {
|
||||
d->hasiXML = true;
|
||||
d->iXMLData = String(chunkData(i), String::UTF8);
|
||||
}
|
||||
else if(name == "bext") {
|
||||
d->hasBEXT = true;
|
||||
d->bextData = chunkData(i);
|
||||
}
|
||||
}
|
||||
|
||||
if(!d->tag[ID3v2Index])
|
||||
|
||||
@@ -134,6 +134,42 @@ namespace TagLib {
|
||||
*/
|
||||
Info::Tag *InfoTag() const;
|
||||
|
||||
/*!
|
||||
* Returns the raw iXML chunk data as a String.
|
||||
* Empty if no iXML chunk is present.
|
||||
*
|
||||
* \see setiXMLData()
|
||||
* \see hasiXMLData()
|
||||
*/
|
||||
String iXMLData() const;
|
||||
|
||||
/*!
|
||||
* Sets the iXML chunk data. Pass an empty string to remove the
|
||||
* iXML chunk on save.
|
||||
*
|
||||
* \see iXMLData()
|
||||
* \see hasiXMLData()
|
||||
*/
|
||||
void setiXMLData(const String &data);
|
||||
|
||||
/*!
|
||||
* Returns the raw BEXT (Broadcast Audio Extension) chunk data
|
||||
* as a ByteVector. Empty if no BEXT chunk is present.
|
||||
*
|
||||
* \see setBEXTData()
|
||||
* \see hasBEXTData()
|
||||
*/
|
||||
ByteVector BEXTData() const;
|
||||
|
||||
/*!
|
||||
* Sets the BEXT chunk data. Pass an empty ByteVector to remove
|
||||
* the BEXT chunk on save.
|
||||
*
|
||||
* \see BEXTData()
|
||||
* \see hasBEXTData()
|
||||
*/
|
||||
void setBEXTData(const ByteVector &data);
|
||||
|
||||
/*!
|
||||
* This will strip the tags that match the OR-ed together TagTypes from the
|
||||
* file. By default it strips all tags. It returns \c true if the tags are
|
||||
@@ -191,6 +227,20 @@ namespace TagLib {
|
||||
*/
|
||||
bool hasInfoTag() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the file on disk actually has an iXML chunk.
|
||||
*
|
||||
* \see iXMLTag
|
||||
*/
|
||||
bool hasiXMLData() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the file on disk actually has a BEXT chunk.
|
||||
*
|
||||
* \see bextTag
|
||||
*/
|
||||
bool hasBEXTData() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the given \a stream can be opened as a WAV
|
||||
* file.
|
||||
|
||||
@@ -104,6 +104,11 @@ namespace {
|
||||
|
||||
bool VariableLengthInput::getRiceGolombCode(int32_t &i32, int32_t k)
|
||||
{
|
||||
// k must be in [0, 31]: values outside this range would cause shift-by-32
|
||||
// (UB for int32_t) or negative shifts, and are invalid for this format.
|
||||
if(k < 0 || k > 31)
|
||||
return false;
|
||||
|
||||
static constexpr uint32_t sMaskTable[] = {
|
||||
0x0,
|
||||
0x1, 0x3, 0x7, 0xf,
|
||||
|
||||
@@ -52,13 +52,20 @@ namespace TagLib {
|
||||
|
||||
//! Returns the Shorten file version (1-3).
|
||||
int shortenVersion() const;
|
||||
//! Returns the file type (0-9).
|
||||
//! 0 = 8-bit µ-law,
|
||||
//! 1 = signed 8-bit PCM, 2 = unsigned 8-bit PCM,
|
||||
//! 3 = signed big-endian 16-bit PCM, 4 = unsigned big-endian 16-bit PCM,
|
||||
//! 5 = signed little-endian 16-bit PCM, 6 = unsigned little-endian 16-bit PCM,
|
||||
//! 7 = 8-bit ITU-T G.711 µ-law, 8 = 8-bit µ-law,
|
||||
//! 9 = 8-bit A-law, 10 = 8-bit ITU-T G.711 A-law
|
||||
//! Returns the file type (0-10).
|
||||
//! Value | %File type
|
||||
//! :---: | :--------------------------------
|
||||
//! 0 | 8-bit µ-law
|
||||
//! 1 | signed 8-bit PCM
|
||||
//! 2 | unsigned 8-bit PCM
|
||||
//! 3 | signed big-endian 16-bit PCM
|
||||
//! 4 | unsigned big-endian 16-bit PCM
|
||||
//! 5 | signed little-endian 16-bit PCM
|
||||
//! 6 | unsigned little-endian 16-bit PCM
|
||||
//! 7 | 8-bit ITU-T G.711 µ-law
|
||||
//! 8 | 8-bit µ-law
|
||||
//! 9 | 8-bit A-law
|
||||
//! 10 | 8-bit ITU-T G.711 A-law
|
||||
int fileType() const;
|
||||
int bitsPerSample() const;
|
||||
unsigned long sampleFrames() const;
|
||||
|
||||
@@ -27,8 +27,8 @@
|
||||
#define TAGLIB_H
|
||||
|
||||
#define TAGLIB_MAJOR_VERSION 2
|
||||
#define TAGLIB_MINOR_VERSION 2
|
||||
#define TAGLIB_PATCH_VERSION 1
|
||||
#define TAGLIB_MINOR_VERSION 3
|
||||
#define TAGLIB_PATCH_VERSION 0
|
||||
|
||||
#if (defined(_MSC_VER) && _MSC_VER >= 1600)
|
||||
#define TAGLIB_CONSTRUCT_BITSET(x) static_cast<unsigned long long>(x)
|
||||
|
||||
@@ -87,6 +87,7 @@ SET(test_runner_SRCS
|
||||
test_complexproperties.cpp
|
||||
test_file.cpp
|
||||
test_fileref.cpp
|
||||
test_fileref_detect.cpp
|
||||
test_id3v1.cpp
|
||||
test_id3v2.cpp
|
||||
test_id3v2framefactory.cpp
|
||||
|
||||
BIN
tests/data/mpeg-sync-flac.flac
Normal file
BIN
tests/data/mpeg-sync-flac.flac
Normal file
Binary file not shown.
484
tests/test_fileref_detect.cpp
Normal file
484
tests/test_fileref_detect.cpp
Normal file
@@ -0,0 +1,484 @@
|
||||
/***************************************************************************
|
||||
copyright : (C) 2026 by TagLib developers
|
||||
***************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#include "taglib_config.h"
|
||||
#include "tfilestream.h"
|
||||
#include "tbytevectorstream.h"
|
||||
#include "fileref.h"
|
||||
#include "mpegfile.h"
|
||||
#ifdef TAGLIB_WITH_VORBIS
|
||||
#include "flacfile.h"
|
||||
#include "oggflacfile.h"
|
||||
#include "opusfile.h"
|
||||
#include "speexfile.h"
|
||||
#include "vorbisfile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_APE
|
||||
#include "apefile.h"
|
||||
#include "mpcfile.h"
|
||||
#include "wavpackfile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_ASF
|
||||
#include "asffile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_TRUEAUDIO
|
||||
#include "trueaudiofile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_MP4
|
||||
#include "mp4file.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_RIFF
|
||||
#include "aifffile.h"
|
||||
#include "wavfile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_DSF
|
||||
#include "dsdifffile.h"
|
||||
#include "dsffile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_SHORTEN
|
||||
#include "shortenfile.h"
|
||||
#endif
|
||||
#ifdef TAGLIB_WITH_MATROSKA
|
||||
#include "matroskafile.h"
|
||||
#endif
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
#include "utils.h"
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
// Files not covered by detection tests and the reason why:
|
||||
// (All of these return null because no format's isSupported() matches them)
|
||||
//
|
||||
// MOD/S3M/IT/XM formats have no isSupported() implementation at all,
|
||||
// so content-based detection is impossible for these files:
|
||||
// changed.mod, test.mod, changed.s3m, test.s3m, test.it,
|
||||
// changed.xm, test.xm, stripped.xm
|
||||
//
|
||||
// bare ID3 tag data without any surrounding audio stream:
|
||||
// 005411.id3, broken-tenc.id3, unsynch.id3
|
||||
//
|
||||
// null bytes / truly unsupported binary format:
|
||||
// no-extension, unsupported-extension.xx
|
||||
//
|
||||
// .mp3-named files where MPEG::File::isSupported() returns false because the
|
||||
// MPEG frame scanner cannot find any valid frames in the content:
|
||||
// garbage.mp3 (random binary data with no MPEG sync bytes),
|
||||
// compressed_id3_frame.mp3 (zlib-compressed ID3 frame inflates to garbage
|
||||
// that the frame scanner cannot parse past),
|
||||
// duplicate_id3v2.mp3 (two consecutive ID3v2 headers confuse the size
|
||||
// calculation, shifting the scan past any real frames),
|
||||
// excessive_alloc.mp3 (APIC frame carries a crafted huge size field that
|
||||
// the ID3v2 skip overshoots the actual frames),
|
||||
// extended-header.mp3 (ID3v2.4 extended header flag causes incorrect size
|
||||
// skip so the scanner starts inside the header),
|
||||
// w000.mp3 (malformed file with no discoverable MPEG sync bytes)
|
||||
//
|
||||
// MPC SV4/SV5: MPC::File::isSupported() only recognises "MPCK" (SV8) and
|
||||
// "MP+" (SV7); older SV4/SV5 streams have no standardised magic bytes:
|
||||
// sv4_header.mpc, sv5_header.mpc
|
||||
//
|
||||
// MP4 with 64-bit atom sizes: first box is "moov" rather than the required
|
||||
// "ftyp", so MP4::File::isSupported() returns false:
|
||||
// 64bit.mp4
|
||||
//
|
||||
// corrupt AIFF: the FORM header is present but the sub-type bytes at offset 8
|
||||
// are garbled (0x80 0x46 instead of 'AIFF'/'AIFC'), so
|
||||
// RIFF::AIFF::File::isSupported() returns false:
|
||||
// excessive_alloc.aif
|
||||
|
||||
namespace {
|
||||
|
||||
template <typename T> void detectByContent(const char *testFile) {
|
||||
FileStream fs(TEST_FILE_PATH_C(testFile));
|
||||
CPPUNIT_ASSERT(fs.isOpen());
|
||||
const ByteVector data = fs.readBlock(fs.length());
|
||||
ByteVectorStream bvs(data);
|
||||
const FileRef f(&bvs);
|
||||
CPPUNIT_ASSERT(!f.isNull());
|
||||
CPPUNIT_ASSERT(dynamic_cast<T *>(f.file()) != nullptr);
|
||||
}
|
||||
|
||||
void detectNullByContent(const char *testFile) {
|
||||
FileStream fs(TEST_FILE_PATH_C(testFile));
|
||||
CPPUNIT_ASSERT(fs.isOpen());
|
||||
const ByteVector data = fs.readBlock(fs.length());
|
||||
ByteVectorStream bvs(data);
|
||||
const FileRef f(&bvs);
|
||||
CPPUNIT_ASSERT(f.isNull());
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestFileRefDetectByContent : public CppUnit::TestFixture
|
||||
{
|
||||
CPPUNIT_TEST_SUITE(TestFileRefDetectByContent);
|
||||
|
||||
// MPEG (always available)
|
||||
CPPUNIT_TEST(testApeId3v1Mp3);
|
||||
CPPUNIT_TEST(testApeId3v2Mp3);
|
||||
CPPUNIT_TEST(testApeMp3);
|
||||
CPPUNIT_TEST(testBladeencMp3);
|
||||
CPPUNIT_TEST(testEmpty1sAac);
|
||||
CPPUNIT_TEST(testId3v22TdaMp3);
|
||||
CPPUNIT_TEST(testInvalidFrames1Mp3);
|
||||
CPPUNIT_TEST(testInvalidFrames2Mp3);
|
||||
CPPUNIT_TEST(testInvalidFrames3Mp3);
|
||||
CPPUNIT_TEST(testItunes10Mp3);
|
||||
CPPUNIT_TEST(testLameCbrMp3);
|
||||
CPPUNIT_TEST(testLameVbrMp3);
|
||||
CPPUNIT_TEST(testMpeg2Mp3);
|
||||
CPPUNIT_TEST(testRareFramesMp3);
|
||||
CPPUNIT_TEST(testTocManyChildrenMp3);
|
||||
CPPUNIT_TEST(testXingMp3);
|
||||
|
||||
#ifdef TAGLIB_WITH_VORBIS
|
||||
// Ogg::Vorbis::File
|
||||
CPPUNIT_TEST(testEmptyOgg);
|
||||
CPPUNIT_TEST(testEmptyVorbisOga);
|
||||
CPPUNIT_TEST(testLowercaseFieldsOgg);
|
||||
CPPUNIT_TEST(testTestOgg);
|
||||
// Ogg::FLAC::File
|
||||
CPPUNIT_TEST(testEmptyFlacOga);
|
||||
// FLAC::File
|
||||
CPPUNIT_TEST(testEmptySeektableFlac);
|
||||
CPPUNIT_TEST(testMultipleVcFlac);
|
||||
CPPUNIT_TEST(testNoTagsFlac);
|
||||
CPPUNIT_TEST(testSilence44SFlac);
|
||||
CPPUNIT_TEST(testSinewaveFlac);
|
||||
CPPUNIT_TEST(testZeroSizedPaddingFlac);
|
||||
CPPUNIT_TEST(testFLACWithMPEGSyncBytes);
|
||||
// Ogg::Speex::File
|
||||
CPPUNIT_TEST(testEmptySpx);
|
||||
// Ogg::Opus::File
|
||||
CPPUNIT_TEST(testCorrectnessGainSilentOutputOpus);
|
||||
// Corrupt files: isSupported() returns true but isValid() returns false
|
||||
CPPUNIT_TEST(testNullSegfaultOga);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_APE
|
||||
// MPC::File
|
||||
CPPUNIT_TEST(testClickMpc);
|
||||
CPPUNIT_TEST(testInfloopMpc);
|
||||
CPPUNIT_TEST(testSegfaultMpc);
|
||||
CPPUNIT_TEST(testSegfault2Mpc);
|
||||
CPPUNIT_TEST(testSv8HeaderMpc);
|
||||
CPPUNIT_TEST(testZerodivMpc);
|
||||
// WavPack::File
|
||||
CPPUNIT_TEST(testClickWv);
|
||||
CPPUNIT_TEST(testDsdStereoWv);
|
||||
CPPUNIT_TEST(testFourChannelsWv);
|
||||
CPPUNIT_TEST(testInfloopWv);
|
||||
CPPUNIT_TEST(testNoLengthWv);
|
||||
CPPUNIT_TEST(testNonStandardRateWv);
|
||||
CPPUNIT_TEST(testTaggedWv);
|
||||
// APE::File
|
||||
CPPUNIT_TEST(testLongloopApe);
|
||||
CPPUNIT_TEST(testMac390HdrApe);
|
||||
CPPUNIT_TEST(testMac396Ape);
|
||||
CPPUNIT_TEST(testMac399Id3v2Ape);
|
||||
CPPUNIT_TEST(testMac399TaggedApe);
|
||||
CPPUNIT_TEST(testMac399Ape);
|
||||
CPPUNIT_TEST(testZerodivApe);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_TRUEAUDIO
|
||||
CPPUNIT_TEST(testEmptyTta);
|
||||
CPPUNIT_TEST(testTaggedTta);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_MP4
|
||||
CPPUNIT_TEST(testBlankVideoM4v);
|
||||
CPPUNIT_TEST(testCovrJunkM4a);
|
||||
CPPUNIT_TEST(testEmptyAlacM4a);
|
||||
CPPUNIT_TEST(testGnreM4a);
|
||||
CPPUNIT_TEST(testHasTagsM4a);
|
||||
CPPUNIT_TEST(testIlstIsLastM4a);
|
||||
CPPUNIT_TEST(testInfloopM4a);
|
||||
CPPUNIT_TEST(testNoTags3g2);
|
||||
CPPUNIT_TEST(testNoTagsM4a);
|
||||
CPPUNIT_TEST(testNonFullMetaM4a);
|
||||
CPPUNIT_TEST(testNonprintableAtomTypeM4a);
|
||||
CPPUNIT_TEST(testZeroLengthMdatM4a);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_ASF
|
||||
CPPUNIT_TEST(testLosslessWma);
|
||||
CPPUNIT_TEST(testSilence1Wma);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_RIFF
|
||||
// RIFF::AIFF::File
|
||||
CPPUNIT_TEST(testAlawAifc);
|
||||
CPPUNIT_TEST(testDuplicateId3v2Aiff);
|
||||
CPPUNIT_TEST(testEmptyAiff);
|
||||
CPPUNIT_TEST(testNoiseAif);
|
||||
CPPUNIT_TEST(testNoiseOddAif);
|
||||
CPPUNIT_TEST(testSegfaultAif);
|
||||
// RIFF::WAV::File
|
||||
CPPUNIT_TEST(testAlawWav);
|
||||
CPPUNIT_TEST(testDuplicateTagsWav);
|
||||
CPPUNIT_TEST(testEmptyWav);
|
||||
CPPUNIT_TEST(testFloat64Wav);
|
||||
CPPUNIT_TEST(testInfloopWav);
|
||||
CPPUNIT_TEST(testInvalidChunkWav);
|
||||
CPPUNIT_TEST(testPcmWithFactChunkWav);
|
||||
CPPUNIT_TEST(testSegfaultWav);
|
||||
CPPUNIT_TEST(testUint8weWav);
|
||||
CPPUNIT_TEST(testZeroSizeChunkWav);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_DSF
|
||||
CPPUNIT_TEST(testEmpty10msDsf);
|
||||
CPPUNIT_TEST(testEmpty10msDff);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_SHORTEN
|
||||
CPPUNIT_TEST(test2SecSilenceShn);
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_MATROSKA
|
||||
CPPUNIT_TEST(testNoTagsMka);
|
||||
CPPUNIT_TEST(testNoTagsWebm);
|
||||
CPPUNIT_TEST(testOptimizedMkv);
|
||||
CPPUNIT_TEST(testTagsBeforeCuesMkv);
|
||||
#endif
|
||||
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
// -- MPEG::File (always available) --
|
||||
|
||||
void testApeId3v1Mp3() { detectByContent<MPEG::File>("ape-id3v1.mp3"); }
|
||||
void testApeId3v2Mp3() { detectByContent<MPEG::File>("ape-id3v2.mp3"); }
|
||||
void testApeMp3() { detectByContent<MPEG::File>("ape.mp3"); }
|
||||
void testBladeencMp3() { detectByContent<MPEG::File>("bladeenc.mp3"); }
|
||||
void testEmpty1sAac() { detectByContent<MPEG::File>("empty1s.aac"); }
|
||||
void testId3v22TdaMp3() { detectByContent<MPEG::File>("id3v22-tda.mp3"); }
|
||||
void testInvalidFrames1Mp3() {
|
||||
detectByContent<MPEG::File>("invalid-frames1.mp3");
|
||||
}
|
||||
void testInvalidFrames2Mp3() {
|
||||
detectByContent<MPEG::File>("invalid-frames2.mp3");
|
||||
}
|
||||
void testInvalidFrames3Mp3() {
|
||||
detectByContent<MPEG::File>("invalid-frames3.mp3");
|
||||
}
|
||||
void testItunes10Mp3() { detectByContent<MPEG::File>("itunes10.mp3"); }
|
||||
void testLameCbrMp3() { detectByContent<MPEG::File>("lame_cbr.mp3"); }
|
||||
void testLameVbrMp3() { detectByContent<MPEG::File>("lame_vbr.mp3"); }
|
||||
void testMpeg2Mp3() { detectByContent<MPEG::File>("mpeg2.mp3"); }
|
||||
void testRareFramesMp3() {
|
||||
detectByContent<MPEG::File>("rare_frames.mp3");
|
||||
}
|
||||
void testTocManyChildrenMp3() {
|
||||
detectByContent<MPEG::File>("toc_many_children.mp3");
|
||||
}
|
||||
void testXingMp3() { detectByContent<MPEG::File>("xing.mp3"); }
|
||||
|
||||
#ifdef TAGLIB_WITH_VORBIS
|
||||
// -- Ogg::Vorbis::File --
|
||||
void testEmptyOgg() { detectByContent<Ogg::Vorbis::File>("empty.ogg"); }
|
||||
void testEmptyVorbisOga() {
|
||||
detectByContent<Ogg::Vorbis::File>("empty_vorbis.oga");
|
||||
}
|
||||
void testLowercaseFieldsOgg() {
|
||||
detectByContent<Ogg::Vorbis::File>("lowercase-fields.ogg");
|
||||
}
|
||||
void testTestOgg() { detectByContent<Ogg::Vorbis::File>("test.ogg"); }
|
||||
|
||||
// -- Ogg::FLAC::File --
|
||||
void testEmptyFlacOga() {
|
||||
detectByContent<Ogg::FLAC::File>("empty_flac.oga");
|
||||
}
|
||||
|
||||
// -- FLAC::File --
|
||||
void testEmptySeektableFlac() {
|
||||
detectByContent<FLAC::File>("empty-seektable.flac");
|
||||
}
|
||||
void testMultipleVcFlac() {
|
||||
detectByContent<FLAC::File>("multiple-vc.flac");
|
||||
}
|
||||
void testNoTagsFlac() { detectByContent<FLAC::File>("no-tags.flac"); }
|
||||
void testSilence44SFlac() {
|
||||
detectByContent<FLAC::File>("silence-44-s.flac");
|
||||
}
|
||||
void testSinewaveFlac() { detectByContent<FLAC::File>("sinewave.flac"); }
|
||||
void testZeroSizedPaddingFlac() {
|
||||
detectByContent<FLAC::File>("zero-sized-padding.flac");
|
||||
}
|
||||
void testFLACWithMPEGSyncBytes() {
|
||||
detectByContent<FLAC::File>("mpeg-sync-flac.flac");
|
||||
}
|
||||
|
||||
// -- Ogg::Speex::File --
|
||||
void testEmptySpx() { detectByContent<Ogg::Speex::File>("empty.spx"); }
|
||||
|
||||
// -- Ogg::Opus::File --
|
||||
void testCorrectnessGainSilentOutputOpus() {
|
||||
detectByContent<Ogg::Opus::File>("correctness_gain_silent_output.opus");
|
||||
}
|
||||
|
||||
// segfault.oga: Ogg::FLAC::File::isSupported() returns true (valid Ogg
|
||||
// container with a fLaC marker), but the FLAC metadata header inside is
|
||||
// corrupt so Ogg::FLAC::File::isValid() returns false.
|
||||
void testNullSegfaultOga() { detectNullByContent("segfault.oga"); }
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_APE
|
||||
// -- MPC::File --
|
||||
void testClickMpc() { detectByContent<MPC::File>("click.mpc"); }
|
||||
void testInfloopMpc() { detectByContent<MPC::File>("infloop.mpc"); }
|
||||
void testSegfaultMpc() { detectByContent<MPC::File>("segfault.mpc"); }
|
||||
void testSegfault2Mpc() { detectByContent<MPC::File>("segfault2.mpc"); }
|
||||
void testSv8HeaderMpc() { detectByContent<MPC::File>("sv8_header.mpc"); }
|
||||
void testZerodivMpc() { detectByContent<MPC::File>("zerodiv.mpc"); }
|
||||
|
||||
// -- WavPack::File --
|
||||
void testClickWv() { detectByContent<WavPack::File>("click.wv"); }
|
||||
void testDsdStereoWv() { detectByContent<WavPack::File>("dsd_stereo.wv"); }
|
||||
void testFourChannelsWv() {
|
||||
detectByContent<WavPack::File>("four_channels.wv");
|
||||
}
|
||||
void testInfloopWv() { detectByContent<WavPack::File>("infloop.wv"); }
|
||||
void testNoLengthWv() { detectByContent<WavPack::File>("no_length.wv"); }
|
||||
void testNonStandardRateWv() {
|
||||
detectByContent<WavPack::File>("non_standard_rate.wv");
|
||||
}
|
||||
void testTaggedWv() { detectByContent<WavPack::File>("tagged.wv"); }
|
||||
|
||||
// -- APE::File --
|
||||
void testLongloopApe() { detectByContent<APE::File>("longloop.ape"); }
|
||||
void testMac390HdrApe() { detectByContent<APE::File>("mac-390-hdr.ape"); }
|
||||
void testMac396Ape() { detectByContent<APE::File>("mac-396.ape"); }
|
||||
void testMac399Id3v2Ape() {
|
||||
detectByContent<APE::File>("mac-399-id3v2.ape");
|
||||
}
|
||||
void testMac399TaggedApe() {
|
||||
detectByContent<APE::File>("mac-399-tagged.ape");
|
||||
}
|
||||
void testMac399Ape() { detectByContent<APE::File>("mac-399.ape"); }
|
||||
void testZerodivApe() { detectByContent<APE::File>("zerodiv.ape"); }
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_TRUEAUDIO
|
||||
// -- TrueAudio::File --
|
||||
void testEmptyTta() { detectByContent<TrueAudio::File>("empty.tta"); }
|
||||
void testTaggedTta() { detectByContent<TrueAudio::File>("tagged.tta"); }
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_MP4
|
||||
// -- MP4::File --
|
||||
void testBlankVideoM4v() { detectByContent<MP4::File>("blank_video.m4v"); }
|
||||
void testCovrJunkM4a() { detectByContent<MP4::File>("covr-junk.m4a"); }
|
||||
void testEmptyAlacM4a() { detectByContent<MP4::File>("empty_alac.m4a"); }
|
||||
void testGnreM4a() { detectByContent<MP4::File>("gnre.m4a"); }
|
||||
void testHasTagsM4a() { detectByContent<MP4::File>("has-tags.m4a"); }
|
||||
void testIlstIsLastM4a() {
|
||||
detectByContent<MP4::File>("ilst-is-last.m4a");
|
||||
}
|
||||
void testInfloopM4a() { detectByContent<MP4::File>("infloop.m4a"); }
|
||||
void testNoTags3g2() { detectByContent<MP4::File>("no-tags.3g2"); }
|
||||
void testNoTagsM4a() { detectByContent<MP4::File>("no-tags.m4a"); }
|
||||
void testNonFullMetaM4a() {
|
||||
detectByContent<MP4::File>("non-full-meta.m4a");
|
||||
}
|
||||
void testNonprintableAtomTypeM4a() {
|
||||
detectByContent<MP4::File>("nonprintable-atom-type.m4a");
|
||||
}
|
||||
void testZeroLengthMdatM4a() {
|
||||
detectByContent<MP4::File>("zero-length-mdat.m4a");
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_ASF
|
||||
// -- ASF::File --
|
||||
void testLosslessWma() { detectByContent<ASF::File>("lossless.wma"); }
|
||||
void testSilence1Wma() { detectByContent<ASF::File>("silence-1.wma"); }
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_RIFF
|
||||
// -- RIFF::AIFF::File --
|
||||
void testAlawAifc() { detectByContent<RIFF::AIFF::File>("alaw.aifc"); }
|
||||
void testDuplicateId3v2Aiff() {
|
||||
detectByContent<RIFF::AIFF::File>("duplicate_id3v2.aiff");
|
||||
}
|
||||
void testEmptyAiff() { detectByContent<RIFF::AIFF::File>("empty.aiff"); }
|
||||
void testNoiseAif() { detectByContent<RIFF::AIFF::File>("noise.aif"); }
|
||||
void testNoiseOddAif() {
|
||||
detectByContent<RIFF::AIFF::File>("noise_odd.aif");
|
||||
}
|
||||
void testSegfaultAif() {
|
||||
detectByContent<RIFF::AIFF::File>("segfault.aif");
|
||||
}
|
||||
|
||||
// -- RIFF::WAV::File --
|
||||
void testAlawWav() { detectByContent<RIFF::WAV::File>("alaw.wav"); }
|
||||
void testDuplicateTagsWav() {
|
||||
detectByContent<RIFF::WAV::File>("duplicate_tags.wav");
|
||||
}
|
||||
void testEmptyWav() { detectByContent<RIFF::WAV::File>("empty.wav"); }
|
||||
void testFloat64Wav() { detectByContent<RIFF::WAV::File>("float64.wav"); }
|
||||
void testInfloopWav() { detectByContent<RIFF::WAV::File>("infloop.wav"); }
|
||||
void testInvalidChunkWav() {
|
||||
detectByContent<RIFF::WAV::File>("invalid-chunk.wav");
|
||||
}
|
||||
void testPcmWithFactChunkWav() {
|
||||
detectByContent<RIFF::WAV::File>("pcm_with_fact_chunk.wav");
|
||||
}
|
||||
void testSegfaultWav() { detectByContent<RIFF::WAV::File>("segfault.wav"); }
|
||||
void testUint8weWav() { detectByContent<RIFF::WAV::File>("uint8we.wav"); }
|
||||
void testZeroSizeChunkWav() {
|
||||
detectByContent<RIFF::WAV::File>("zero-size-chunk.wav");
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_DSF
|
||||
// -- DSF::File --
|
||||
void testEmpty10msDsf() { detectByContent<DSF::File>("empty10ms.dsf"); }
|
||||
|
||||
// -- DSDIFF::File --
|
||||
void testEmpty10msDff() { detectByContent<DSDIFF::File>("empty10ms.dff"); }
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_SHORTEN
|
||||
// -- Shorten::File --
|
||||
void test2SecSilenceShn() {
|
||||
detectByContent<Shorten::File>("2sec-silence.shn");
|
||||
}
|
||||
#endif
|
||||
|
||||
#ifdef TAGLIB_WITH_MATROSKA
|
||||
// -- Matroska::File --
|
||||
void testNoTagsMka() { detectByContent<Matroska::File>("no-tags.mka"); }
|
||||
void testNoTagsWebm() { detectByContent<Matroska::File>("no-tags.webm"); }
|
||||
void testOptimizedMkv() {
|
||||
detectByContent<Matroska::File>("optimized.mkv");
|
||||
}
|
||||
void testTagsBeforeCuesMkv() {
|
||||
detectByContent<Matroska::File>("tags-before-cues.mkv");
|
||||
}
|
||||
#endif
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestFileRefDetectByContent);
|
||||
@@ -28,6 +28,7 @@
|
||||
|
||||
#include "tstringlist.h"
|
||||
#include "tpropertymap.h"
|
||||
#include "tbytevectorstream.h"
|
||||
#include "tag.h"
|
||||
#include "flacfile.h"
|
||||
#include "xiphcomment.h"
|
||||
@@ -67,6 +68,13 @@ class TestFLAC : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testRemoveXiphField);
|
||||
CPPUNIT_TEST(testEmptySeekTable);
|
||||
CPPUNIT_TEST(testPictureStoredAfterComment);
|
||||
CPPUNIT_TEST(testReadiXMLDirect);
|
||||
CPPUNIT_TEST(testReadiXMLRiffWrapped);
|
||||
CPPUNIT_TEST(testReadBEXTDirect);
|
||||
CPPUNIT_TEST(testReadBEXTRiffWrapped);
|
||||
CPPUNIT_TEST(testWriteiXMLAndBEXT);
|
||||
CPPUNIT_TEST(testWriteEmptyClearsiXMLAndBEXT);
|
||||
CPPUNIT_TEST(testRoundTripPreservesUnknownApplicationBlock);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -663,6 +671,209 @@ public:
|
||||
CPPUNIT_ASSERT(fileData.startsWith(expectedData));
|
||||
}
|
||||
|
||||
// Build a 4-byte FLAC metadata-block header:
|
||||
// <1 bit last><7 bit type><24 bit length, big-endian>.
|
||||
static ByteVector flacBlockHeader(unsigned int payloadSize, int blockType, bool isLast)
|
||||
{
|
||||
ByteVector h = ByteVector::fromUInt(payloadSize);
|
||||
h[0] = static_cast<char>(blockType | (isLast ? 0x80 : 0x00));
|
||||
return h;
|
||||
}
|
||||
|
||||
// Build the body of an APPLICATION/"riff"-wrapped RIFF chunk:
|
||||
// [appID="riff"][FOURCC][LE size][data].
|
||||
static ByteVector riffWrappedAppData(const ByteVector &fourcc, const ByteVector &data)
|
||||
{
|
||||
ByteVector body("riff", 4);
|
||||
body.append(fourcc);
|
||||
body.append(ByteVector::fromUInt(data.size(), false));
|
||||
body.append(data);
|
||||
return body;
|
||||
}
|
||||
|
||||
// Build a minimal synthetic FLAC stream: "fLaC" + zero-init STREAMINFO +
|
||||
// one APPLICATION block (which gets the last-block flag). Caller passes
|
||||
// the full APPLICATION block payload starting with the 4-byte appID.
|
||||
static ByteVector synthFlacWithApp(const ByteVector &appPayload)
|
||||
{
|
||||
ByteVector flac("fLaC", 4);
|
||||
flac.append(flacBlockHeader(34, 0, false)); // STREAMINFO header
|
||||
flac.append(ByteVector(34, '\0')); // STREAMINFO body
|
||||
flac.append(flacBlockHeader(appPayload.size(), 2, true));
|
||||
flac.append(appPayload);
|
||||
return flac;
|
||||
}
|
||||
|
||||
void testReadiXMLDirect()
|
||||
{
|
||||
const String xml("<BWFXML><IXML_VERSION>1.0</IXML_VERSION></BWFXML>");
|
||||
ByteVector appPayload("iXML", 4);
|
||||
appPayload.append(xml.data(String::UTF8));
|
||||
|
||||
ByteVector data = synthFlacWithApp(appPayload);
|
||||
ByteVectorStream stream(data);
|
||||
FLAC::File f(&stream, false);
|
||||
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
CPPUNIT_ASSERT_EQUAL(xml, f.iXMLData());
|
||||
}
|
||||
|
||||
void testReadiXMLRiffWrapped()
|
||||
{
|
||||
const String xml("<BWFXML><SCENE>1</SCENE></BWFXML>");
|
||||
const ByteVector appPayload =
|
||||
riffWrappedAppData("iXML", xml.data(String::UTF8));
|
||||
|
||||
ByteVector data = synthFlacWithApp(appPayload);
|
||||
ByteVectorStream stream(data);
|
||||
FLAC::File f(&stream, false);
|
||||
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
CPPUNIT_ASSERT_EQUAL(xml, f.iXMLData());
|
||||
}
|
||||
|
||||
void testReadBEXTDirect()
|
||||
{
|
||||
const ByteVector bext("test bext data");
|
||||
ByteVector appPayload("bext", 4);
|
||||
appPayload.append(bext);
|
||||
|
||||
ByteVector data = synthFlacWithApp(appPayload);
|
||||
ByteVectorStream stream(data);
|
||||
FLAC::File f(&stream, false);
|
||||
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
CPPUNIT_ASSERT_EQUAL(bext, f.BEXTData());
|
||||
}
|
||||
|
||||
void testReadBEXTRiffWrapped()
|
||||
{
|
||||
const ByteVector bext("test bext data");
|
||||
const ByteVector appPayload = riffWrappedAppData("bext", bext);
|
||||
|
||||
ByteVector data = synthFlacWithApp(appPayload);
|
||||
ByteVectorStream stream(data);
|
||||
FLAC::File f(&stream, false);
|
||||
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
CPPUNIT_ASSERT_EQUAL(bext, f.BEXTData());
|
||||
}
|
||||
|
||||
void testWriteiXMLAndBEXT()
|
||||
{
|
||||
ScopedFileCopy copy("silence-44-s", ".flac");
|
||||
const string newname = copy.fileName();
|
||||
|
||||
const String xml("<BWFXML><IXML_VERSION>1.6</IXML_VERSION></BWFXML>");
|
||||
const ByteVector bext("bext payload bytes");
|
||||
|
||||
{
|
||||
FLAC::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
f.setiXMLData(xml);
|
||||
f.setBEXTData(bext);
|
||||
f.save();
|
||||
}
|
||||
{
|
||||
FLAC::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
CPPUNIT_ASSERT_EQUAL(xml, f.iXMLData());
|
||||
CPPUNIT_ASSERT_EQUAL(bext, f.BEXTData());
|
||||
}
|
||||
|
||||
// On-disk format check: written blocks must use the IANA-registered
|
||||
// "riff" wrapper, not the direct "iXML"/"bext" application IDs.
|
||||
const ByteVector fileBytes = PlainFile(newname.c_str()).readAll();
|
||||
ByteVector expectediXMLApp("riff", 4);
|
||||
expectediXMLApp.append("iXML");
|
||||
expectediXMLApp.append(ByteVector::fromUInt(xml.data(String::UTF8).size(), false));
|
||||
expectediXMLApp.append(xml.data(String::UTF8));
|
||||
CPPUNIT_ASSERT(fileBytes.find(expectediXMLApp) >= 0);
|
||||
|
||||
ByteVector expectedBEXTApp("riff", 4);
|
||||
expectedBEXTApp.append("bext");
|
||||
expectedBEXTApp.append(ByteVector::fromUInt(bext.size(), false));
|
||||
expectedBEXTApp.append(bext);
|
||||
CPPUNIT_ASSERT(fileBytes.find(expectedBEXTApp) >= 0);
|
||||
}
|
||||
|
||||
void testWriteEmptyClearsiXMLAndBEXT()
|
||||
{
|
||||
ScopedFileCopy copy("silence-44-s", ".flac");
|
||||
const string newname = copy.fileName();
|
||||
|
||||
{
|
||||
FLAC::File f(newname.c_str());
|
||||
f.setiXMLData("<BWFXML/>");
|
||||
f.setBEXTData(ByteVector("bext"));
|
||||
f.save();
|
||||
}
|
||||
{
|
||||
FLAC::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
f.setiXMLData(String());
|
||||
f.setBEXTData(ByteVector());
|
||||
f.save();
|
||||
}
|
||||
{
|
||||
FLAC::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
CPPUNIT_ASSERT(f.iXMLData().isEmpty());
|
||||
CPPUNIT_ASSERT(f.BEXTData().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void testRoundTripPreservesUnknownApplicationBlock()
|
||||
{
|
||||
// Source: silence-44-s with an extra APPLICATION/"SMED" block injected
|
||||
// just before its existing VORBIS_COMMENT block. Goal: setting iXML and
|
||||
// saving must not disturb the SMED block (it's an unrecognized app ID).
|
||||
const ByteVector smedAppPayload("SMED", 4);
|
||||
ByteVector smedExtra("opaque sequoia metadata payload");
|
||||
ByteVector smedBlock = smedAppPayload;
|
||||
smedBlock.append(smedExtra);
|
||||
|
||||
// Splice a fresh APPLICATION/SMED block into a synthetic FLAC. Use the
|
||||
// file we just built as the input stream so we don't have to mutate a
|
||||
// real FLAC's seek table / picture offsets.
|
||||
ByteVector flac("fLaC", 4);
|
||||
flac.append(flacBlockHeader(34, 0, false));
|
||||
flac.append(ByteVector(34, '\0'));
|
||||
flac.append(flacBlockHeader(smedBlock.size(), 2, true));
|
||||
flac.append(smedBlock);
|
||||
|
||||
ByteVectorStream stream(flac);
|
||||
{
|
||||
FLAC::File f(&stream, false);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
f.setiXMLData("<BWFXML/>");
|
||||
f.save();
|
||||
}
|
||||
|
||||
// SMED block must still be present on disk after save.
|
||||
ByteVector saved = *stream.data();
|
||||
CPPUNIT_ASSERT(saved.find(smedAppPayload) >= 0);
|
||||
CPPUNIT_ASSERT(saved.find(smedExtra) >= 0);
|
||||
|
||||
// And the iXML data must round-trip.
|
||||
ByteVectorStream stream2(saved);
|
||||
FLAC::File f2(&stream2, false);
|
||||
CPPUNIT_ASSERT(f2.isValid());
|
||||
CPPUNIT_ASSERT(f2.hasiXMLData());
|
||||
CPPUNIT_ASSERT_EQUAL(String("<BWFXML/>"), f2.iXMLData());
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestFLAC);
|
||||
|
||||
@@ -156,6 +156,10 @@ class TestMatroska : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testOpenInvalid);
|
||||
CPPUNIT_TEST(testSegmentSizeChange);
|
||||
CPPUNIT_TEST(testChapters);
|
||||
CPPUNIT_TEST(testSaveTypes);
|
||||
CPPUNIT_TEST(testSaveTypesBeforeCues);
|
||||
CPPUNIT_TEST(testSaveTypesNoTrailingVoid);
|
||||
CPPUNIT_TEST(testSaveTypesReclaimVoid);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -1249,6 +1253,530 @@ public:
|
||||
CPPUNIT_ASSERT(origData == fileData);
|
||||
}
|
||||
|
||||
void testSaveTypesBeforeCues()
|
||||
{
|
||||
// tags-before-cues.mkv layout:
|
||||
// SeekHead | Void | SegInfo | Tracks | Tags | Cluster | Cues
|
||||
//
|
||||
// Verify all three WriteStyles correctly grow the Tags element which
|
||||
// sits *before* the Cluster:
|
||||
// - Compact / DoNotShrink: bytes are inserted before the Cluster, the
|
||||
// Cluster shifts, the seek head and cue cluster positions must be
|
||||
// updated accordingly; the file must remain valid and tag content
|
||||
// must round-trip.
|
||||
// - AvoidInsert: the Tags element is replaced with a Void at its
|
||||
// original position and appended at the end of the segment, so the
|
||||
// Cluster must NOT shift; tag content must round-trip.
|
||||
|
||||
const ByteVector origData =
|
||||
PlainFile(TEST_FILE_PATH_C("tags-before-cues.mkv")).readAll();
|
||||
|
||||
// Cluster ID 0x1F43B675 does not appear in the SeekHead of this file,
|
||||
// so find() returns the offset of the actual Cluster element.
|
||||
const ByteVector clusterId = ByteVector::fromUInt(0x1F43B675U, true);
|
||||
const ByteVector tagsId = ByteVector::fromUInt(0x1254C367U, true);
|
||||
const ByteVector cuesId = ByteVector::fromUInt(0x1C53BB6BU, true);
|
||||
const int origClusterPos = origData.find(clusterId);
|
||||
CPPUNIT_ASSERT(origClusterPos > 0);
|
||||
|
||||
const String longTitle =
|
||||
"An Extremely Long Title Value That Is Definitely Larger Than The Original "
|
||||
"Tags Element In The File Because It Contains Many Characters To Ensure "
|
||||
"That The AvoidInsert Move-To-End Behavior Triggers Here";
|
||||
const String longArtist =
|
||||
"An Extremely Long Artist Name Value That Is Also Larger Than The Original "
|
||||
"Tags Element And Together With The Title Tag Makes The Rendered Output "
|
||||
"Exceed The Original Tags Size So The AvoidInsert Triggers";
|
||||
|
||||
for(const auto writeStyle : {Matroska::WriteStyle::Compact,
|
||||
Matroska::WriteStyle::DoNotShrink,
|
||||
Matroska::WriteStyle::AvoidInsert}) {
|
||||
const auto wsLabel = String::number(static_cast<int>(writeStyle)).to8Bit();
|
||||
ScopedFileCopy copy("tags-before-cues", ".mkv");
|
||||
const string newname = copy.fileName();
|
||||
|
||||
// Save with Tags significantly larger than the original Tags element.
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT_MESSAGE("Open ws=" + wsLabel, f.isValid());
|
||||
auto tag = f.tag(true);
|
||||
tag->clearSimpleTags();
|
||||
tag->addSimpleTag(Matroska::SimpleTag(
|
||||
String("TITLE"), longTitle,
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
tag->addSimpleTag(Matroska::SimpleTag(
|
||||
String("ARTIST"), longArtist,
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
CPPUNIT_ASSERT_MESSAGE("Save ws=" + wsLabel, f.save(writeStyle));
|
||||
}
|
||||
|
||||
// File must be valid: Accurate mode verifies seek-head and cue positions.
|
||||
// Tag content must round-trip exactly.
|
||||
{
|
||||
Matroska::File f(newname.c_str(), true, AudioProperties::Accurate);
|
||||
CPPUNIT_ASSERT_MESSAGE("Reopen valid ws=" + wsLabel, f.isValid());
|
||||
auto tag = f.tag(false);
|
||||
CPPUNIT_ASSERT_MESSAGE("Tag exists ws=" + wsLabel, tag != nullptr);
|
||||
const auto &simpleTags = tag->simpleTagsList();
|
||||
bool foundTitle = false, foundArtist = false;
|
||||
for(const auto &st : simpleTags) {
|
||||
if(st.name() == "TITLE" && st.toString() == longTitle)
|
||||
foundTitle = true;
|
||||
else if(st.name() == "ARTIST" && st.toString() == longArtist)
|
||||
foundArtist = true;
|
||||
}
|
||||
CPPUNIT_ASSERT_MESSAGE("TITLE roundtrip ws=" + wsLabel, foundTitle);
|
||||
CPPUNIT_ASSERT_MESSAGE("ARTIST roundtrip ws=" + wsLabel, foundArtist);
|
||||
}
|
||||
|
||||
const ByteVector newData = PlainFile(newname.c_str()).readAll();
|
||||
const int newClusterPos = newData.find(clusterId);
|
||||
CPPUNIT_ASSERT_MESSAGE("Cluster present ws=" + wsLabel, newClusterPos > 0);
|
||||
|
||||
if(writeStyle == Matroska::WriteStyle::AvoidInsert) {
|
||||
// Cluster must not shift in AvoidInsert mode.
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE(
|
||||
"AvoidInsert must not shift Cluster",
|
||||
origClusterPos, newClusterPos);
|
||||
// Tags must be appended after Cues.
|
||||
const int cuesPos = newData.find(cuesId, newClusterPos);
|
||||
const int newTagsPos = newData.find(tagsId, cuesPos + 4);
|
||||
CPPUNIT_ASSERT_MESSAGE("Tags appended after Cues ws=" + wsLabel,
|
||||
newTagsPos > cuesPos);
|
||||
}
|
||||
else {
|
||||
// Compact / DoNotShrink: Tags grew in place, so Cluster must have
|
||||
// shifted to a higher offset.
|
||||
CPPUNIT_ASSERT_MESSAGE(
|
||||
"Cluster must shift when growing in place ws=" + wsLabel,
|
||||
newClusterPos > origClusterPos);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void testSaveTypesNoTrailingVoid()
|
||||
{
|
||||
// After AvoidInsert moved the Tags element to the end of the segment,
|
||||
// a subsequent save with smaller content must not leave a trailing
|
||||
// EBML void at the very end of the segment. The trailing element may
|
||||
// shrink freely because no element follows it.
|
||||
ScopedFileCopy copy("tags-before-cues", ".mkv");
|
||||
const string newname = copy.fileName();
|
||||
|
||||
// Round 1: enlarge Tags so they get moved to the end.
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
auto tag = f.tag(true);
|
||||
tag->clearSimpleTags();
|
||||
tag->addSimpleTag(Matroska::SimpleTag(
|
||||
String("TITLE"),
|
||||
String("An Extremely Long Title Value That Is Definitely Larger Than The Original "
|
||||
"Tags Element In The File Because It Contains Many Characters To Ensure "
|
||||
"That The AvoidInsert Move-To-End Behavior Triggers Here"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
tag->addSimpleTag(Matroska::SimpleTag(
|
||||
String("ARTIST"),
|
||||
String("An Extremely Long Artist Name Value That Is Also Larger Than The Original "
|
||||
"Tags Element And Together With The Title Tag Makes The Rendered Output "
|
||||
"Exceed The Original Tags Size So The AvoidInsert Triggers"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
CPPUNIT_ASSERT(f.save(Matroska::WriteStyle::AvoidInsert));
|
||||
}
|
||||
const size_t sizeAfterRound1 = PlainFile(newname.c_str()).readAll().size();
|
||||
|
||||
// Round 2: shrink Tags. The trailing element must shrink without
|
||||
// leaving a void at the end.
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
auto tag = f.tag(true);
|
||||
tag->clearSimpleTags();
|
||||
tag->addSimpleTag(Matroska::SimpleTag(
|
||||
String("TITLE"), String("X"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
CPPUNIT_ASSERT(f.save(Matroska::WriteStyle::AvoidInsert));
|
||||
}
|
||||
{
|
||||
Matroska::File f(newname.c_str(), true, AudioProperties::Accurate);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.tag(false) != nullptr);
|
||||
}
|
||||
const ByteVector newData = PlainFile(newname.c_str()).readAll();
|
||||
|
||||
// File must have shrunk because the trailing Tags element shrank.
|
||||
CPPUNIT_ASSERT(newData.size() < sizeAfterRound1);
|
||||
|
||||
// The last bytes of the file must be the (small) Tags element, not a
|
||||
// Void element. Find the Tags element after the Cues element and parse
|
||||
// its VINT size: the file must end exactly at Tags' end with nothing
|
||||
// (no Void) after it.
|
||||
const ByteVector clusterId = ByteVector::fromUInt(0x1F43B675U, true);
|
||||
const ByteVector cuesId = ByteVector::fromUInt(0x1C53BB6BU, true);
|
||||
const ByteVector tagsId = ByteVector::fromUInt(0x1254C367U, true);
|
||||
const int clusterPos = newData.find(clusterId);
|
||||
const int cuesPos = newData.find(cuesId, clusterPos);
|
||||
const int tagsPos = newData.find(tagsId, cuesPos + 4);
|
||||
CPPUNIT_ASSERT(tagsPos > cuesPos);
|
||||
|
||||
// Decode VINT data size of the Tags element. The first byte after the
|
||||
// 4-byte ID has a leading marker bit indicating the VINT length.
|
||||
const auto vintFirst = static_cast<unsigned char>(newData[tagsPos + 4]);
|
||||
int vintLen = 1;
|
||||
for(int b = 0; b < 8; ++b) {
|
||||
if(vintFirst & (0x80 >> b)) { vintLen = b + 1; break; }
|
||||
}
|
||||
unsigned long long dataSize = vintFirst & ((0x80 >> (vintLen - 1)) - 1);
|
||||
for(int i = 1; i < vintLen; ++i)
|
||||
dataSize = (dataSize << 8) | static_cast<unsigned char>(newData[tagsPos + 4 + i]);
|
||||
const unsigned long long tagsEnd =
|
||||
static_cast<unsigned long long>(tagsPos) + 4 + vintLen + dataSize;
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE(
|
||||
"No trailing EBML void must remain at the end of the segment",
|
||||
static_cast<unsigned long long>(newData.size()), tagsEnd);
|
||||
}
|
||||
|
||||
void testSaveTypesReclaimVoid()
|
||||
{
|
||||
// After AvoidInsert moves a Tags element to the end (leaving a Void at
|
||||
// its original position), a subsequent save with WriteStyle::Compact
|
||||
// must produce a tightly packed file: the void left by the move must
|
||||
// be reclaimed and the file must be at most as large as the original.
|
||||
ScopedFileCopy copy("tags-before-cues", ".mkv");
|
||||
const string newname = copy.fileName();
|
||||
|
||||
// Step 1: AvoidInsert with enlarged Tags -> Tags moved to end, Void in
|
||||
// original slot. File grows.
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
auto tag = f.tag(true);
|
||||
tag->clearSimpleTags();
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"),
|
||||
String("An Extremely Long Title Value That Is Definitely Larger Than The Original "
|
||||
"Tags Element In The File Because It Contains Many Characters To Ensure "
|
||||
"That The AvoidInsert Move-To-End Behavior Triggers Here"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("ARTIST"),
|
||||
String("An Extremely Long Artist Name Value That Is Also Larger Than The Original "
|
||||
"Tags Element And Together With The Title Tag Makes The Rendered Output "
|
||||
"Exceed The Original Tags Size So The AvoidInsert Triggers"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
CPPUNIT_ASSERT(f.save(Matroska::WriteStyle::AvoidInsert));
|
||||
}
|
||||
const size_t sizeAfterAvoidInsert =
|
||||
PlainFile(newname.c_str()).readAll().size();
|
||||
CPPUNIT_ASSERT(sizeAfterAvoidInsert >
|
||||
PlainFile(TEST_FILE_PATH_C("tags-before-cues.mkv")).readAll().size());
|
||||
|
||||
// Step 2: Save again with Compact and short tag values. Compact must
|
||||
// reclaim the void left by the prior move and produce a file no
|
||||
// larger than the original.
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
auto tag = f.tag(true);
|
||||
tag->clearSimpleTags();
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"), String("X"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
CPPUNIT_ASSERT(f.save(Matroska::WriteStyle::Compact));
|
||||
}
|
||||
const size_t sizeAfterCompact =
|
||||
PlainFile(newname.c_str()).readAll().size();
|
||||
CPPUNIT_ASSERT_MESSAGE(
|
||||
"Compact must reclaim space after AvoidInsert grew the file",
|
||||
sizeAfterCompact < sizeAfterAvoidInsert);
|
||||
|
||||
// Reference: applying Compact directly to the original file with the
|
||||
// same tiny tags. Note: an orphan Void left in the middle of the
|
||||
// segment by AvoidInsert is not currently reclaimed by Compact (it is
|
||||
// attached as padding to a neighbouring element), so the post-Compact
|
||||
// size is allowed to be slightly larger than the reference. The
|
||||
// result must, however, be no larger than the original input file.
|
||||
ScopedFileCopy reference("tags-before-cues", ".mkv");
|
||||
{
|
||||
Matroska::File f(reference.fileName().c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
auto tag = f.tag(true);
|
||||
tag->clearSimpleTags();
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"), String("X"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
CPPUNIT_ASSERT(f.save(Matroska::WriteStyle::Compact));
|
||||
}
|
||||
const size_t referenceCompactSize =
|
||||
PlainFile(reference.fileName().c_str()).readAll().size();
|
||||
CPPUNIT_ASSERT(referenceCompactSize <= sizeAfterCompact);
|
||||
|
||||
// File must round-trip correctly.
|
||||
{
|
||||
Matroska::File f(newname.c_str(), true, AudioProperties::Accurate);
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
auto tag = f.tag(false);
|
||||
CPPUNIT_ASSERT(tag != nullptr);
|
||||
bool foundTitle = false;
|
||||
for(const auto &st : tag->simpleTagsList()) {
|
||||
if(st.name() == "TITLE" && st.toString() == "X") {
|
||||
foundTitle = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CPPUNIT_ASSERT(foundTitle);
|
||||
}
|
||||
}
|
||||
|
||||
void testSaveTypes()
|
||||
{
|
||||
// Helper lambdas for adding data of different sizes
|
||||
// largeTags: 2 simple tags with long values
|
||||
const auto setLargeTags = [](Matroska::File &f) {
|
||||
auto tag = f.tag(true);
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"),
|
||||
String("A Very Long Title That Takes Up A Lot Of Space In The File 1234567890"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("ARTIST"),
|
||||
String("A Very Long Artist Name That Takes Up A Lot Of Space In The File 1234567890"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
};
|
||||
const auto setSmallTags = [](Matroska::File &f) {
|
||||
auto tag = f.tag(true);
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"), String("Short"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
};
|
||||
const auto setMediumTags = [](Matroska::File &f) {
|
||||
auto tag = f.tag(true);
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"), String("Medium Title 12345678901234"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("ARTIST"), String("Medium Artist"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
};
|
||||
const auto setExtraLargeTags = [](Matroska::File &f) {
|
||||
auto tag = f.tag(true);
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("TITLE"),
|
||||
String("An Extremely Long Title That Is Even Larger Than The Previous Large Title "
|
||||
"With Extra Content To Ensure Growth 0123456789ABCDEF"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
tag->addSimpleTag(Matroska::SimpleTag(String("ARTIST"),
|
||||
String("An Extremely Long Artist Name Exceeding The Prior Large Artist Value "
|
||||
"With Even More Content To Guarantee Growth 0123456789ABCDEF"),
|
||||
Matroska::SimpleTag::TargetTypeValue::Track));
|
||||
};
|
||||
|
||||
const auto setLargeAttachments = [](Matroska::File &f) {
|
||||
auto atts = f.attachments(true);
|
||||
atts->addAttachedFile(Matroska::AttachedFile(
|
||||
ByteVector(200, 'x'), "cover.jpg", "image/jpeg", 111ULL, "Cover"));
|
||||
};
|
||||
const auto setSmallAttachments = [](Matroska::File &f) {
|
||||
auto atts = f.attachments(true);
|
||||
atts->addAttachedFile(Matroska::AttachedFile(
|
||||
ByteVector(20, 'x'), "img.png", "image/png", 222ULL, "Img"));
|
||||
};
|
||||
const auto setMediumAttachments = [](Matroska::File &f) {
|
||||
auto atts = f.attachments(true);
|
||||
atts->addAttachedFile(Matroska::AttachedFile(
|
||||
ByteVector(80, 'x'), "cover.jpg", "image/jpeg", 333ULL, "Cover"));
|
||||
};
|
||||
const auto setExtraLargeAttachments = [](Matroska::File &f) {
|
||||
auto atts = f.attachments(true);
|
||||
atts->addAttachedFile(Matroska::AttachedFile(
|
||||
ByteVector(500, 'x'), "cover.jpg", "image/jpeg", 444ULL, "Cover"));
|
||||
};
|
||||
|
||||
const auto setLargeChapters = [](Matroska::File &f) {
|
||||
auto chs = f.chapters(true);
|
||||
chs->addChapterEdition(Matroska::ChapterEdition(
|
||||
List<Matroska::Chapter>{
|
||||
Matroska::Chapter(0, 40000,
|
||||
List{Matroska::Chapter::Display("Chapter One Long Name", "eng")},
|
||||
1, false),
|
||||
Matroska::Chapter(40000, 80000,
|
||||
List{Matroska::Chapter::Display("Chapter Two Long Name", "eng")},
|
||||
2, false),
|
||||
}, true, false));
|
||||
};
|
||||
const auto setSmallChapters = [](Matroska::File &f) {
|
||||
auto chs = f.chapters(true);
|
||||
chs->addChapterEdition(Matroska::ChapterEdition(
|
||||
List<Matroska::Chapter>{
|
||||
Matroska::Chapter(0, 1000,
|
||||
List{Matroska::Chapter::Display("A", "und")},
|
||||
1, false),
|
||||
}, false, false));
|
||||
};
|
||||
const auto setMediumChapters = [](Matroska::File &f) {
|
||||
auto chs = f.chapters(true);
|
||||
chs->addChapterEdition(Matroska::ChapterEdition(
|
||||
List<Matroska::Chapter>{
|
||||
Matroska::Chapter(0, 40000,
|
||||
List{Matroska::Chapter::Display("Chapter Medium", "eng")},
|
||||
1, false),
|
||||
}, true, false));
|
||||
};
|
||||
const auto setExtraLargeChapters = [](Matroska::File &f) {
|
||||
auto chs = f.chapters(true);
|
||||
chs->addChapterEdition(Matroska::ChapterEdition(
|
||||
List<Matroska::Chapter>{
|
||||
Matroska::Chapter(0, 40000,
|
||||
List{Matroska::Chapter::Display("Chapter One Extremely Long Name Here", "eng"),
|
||||
Matroska::Chapter::Display("Kapitel Eins Sehr Langer Name", "deu")},
|
||||
1, false),
|
||||
Matroska::Chapter(40000, 80000,
|
||||
List{Matroska::Chapter::Display("Chapter Two Extremely Long Name Here", "eng"),
|
||||
Matroska::Chapter::Display("Kapitel Zwei Sehr Langer Name", "deu")},
|
||||
2, false),
|
||||
Matroska::Chapter(80000, 120000,
|
||||
List{Matroska::Chapter::Display("Chapter Three Extra Large", "eng")},
|
||||
3, true),
|
||||
}, true, true));
|
||||
};
|
||||
|
||||
for(const auto writeStyle : {Matroska::WriteStyle::Compact,
|
||||
Matroska::WriteStyle::DoNotShrink,
|
||||
Matroska::WriteStyle::AvoidInsert}) {
|
||||
ScopedFileCopy copy("no-tags", ".mka");
|
||||
const string newname = copy.fileName();
|
||||
const int wsIdx = static_cast<int>(writeStyle);
|
||||
|
||||
// Verify tag/attachment/chapter content for a saved file. Each round
|
||||
// uses unique identifiers (specific TITLE value, attachment UID,
|
||||
// chapter timeStart) so any cross-round leakage or corruption is
|
||||
// caught here.
|
||||
const auto verifyRound = [&](const std::string &label,
|
||||
const String &expectedTitle,
|
||||
unsigned long long expectedAttachmentUid,
|
||||
unsigned int expectedChapterCount,
|
||||
unsigned long long expectedFirstChapterEnd) {
|
||||
Matroska::File f(newname.c_str(), true, AudioProperties::Accurate);
|
||||
CPPUNIT_ASSERT_MESSAGE(label + " valid", f.isValid());
|
||||
auto tag = f.tag(false);
|
||||
CPPUNIT_ASSERT_MESSAGE(label + " tag", tag != nullptr);
|
||||
bool foundTitle = false;
|
||||
for(const auto &st : tag->simpleTagsList()) {
|
||||
if(st.name() == "TITLE" && st.toString() == expectedTitle) {
|
||||
foundTitle = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CPPUNIT_ASSERT_MESSAGE(label + " TITLE roundtrip", foundTitle);
|
||||
auto atts = f.attachments(false);
|
||||
CPPUNIT_ASSERT_MESSAGE(label + " attachments", atts != nullptr);
|
||||
bool foundAtt = false;
|
||||
for(const auto &a : atts->attachedFileList()) {
|
||||
if(a.uid() == expectedAttachmentUid) {
|
||||
foundAtt = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CPPUNIT_ASSERT_MESSAGE(label + " attachment uid roundtrip", foundAtt);
|
||||
auto chs = f.chapters(false);
|
||||
CPPUNIT_ASSERT_MESSAGE(label + " chapters", chs != nullptr);
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE(label + " edition count", 1U,
|
||||
chs->chapterEditionList().size());
|
||||
const auto &edition = chs->chapterEditionList().front();
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE(label + " chapter count",
|
||||
expectedChapterCount, edition.chapterList().size());
|
||||
CPPUNIT_ASSERT_EQUAL_MESSAGE(label + " first chapter end",
|
||||
expectedFirstChapterEnd, edition.chapterList()[0].timeEnd());
|
||||
};
|
||||
|
||||
// --- Round 1: save large data ---
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
setLargeTags(f);
|
||||
setLargeAttachments(f);
|
||||
setLargeChapters(f);
|
||||
CPPUNIT_ASSERT_MESSAGE("Round1 save ws=" + String::number(wsIdx).to8Bit(), f.save(writeStyle));
|
||||
}
|
||||
const size_t sizeAfterRound1 = PlainFile(newname.c_str()).readAll().size();
|
||||
verifyRound("Round1 ws=" + std::to_string(wsIdx),
|
||||
String("A Very Long Title That Takes Up A Lot Of Space In The File 1234567890"),
|
||||
111ULL, 2U, 40000ULL);
|
||||
|
||||
// --- Round 2: save smaller data → slot must not shrink for DoNotShrink/AvoidInsert ---
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
f.tag(true)->clearSimpleTags();
|
||||
f.attachments(true)->clear();
|
||||
f.chapters(true)->clear();
|
||||
setSmallTags(f);
|
||||
setSmallAttachments(f);
|
||||
setSmallChapters(f);
|
||||
CPPUNIT_ASSERT_MESSAGE("Round2 save ws=" + String::number(wsIdx).to8Bit(), f.save(writeStyle));
|
||||
}
|
||||
const size_t sizeAfterRound2 = PlainFile(newname.c_str()).readAll().size();
|
||||
verifyRound("Round2 ws=" + std::to_string(wsIdx),
|
||||
String("Short"), 222ULL, 1U, 1000ULL);
|
||||
|
||||
if(writeStyle == Matroska::WriteStyle::Compact) {
|
||||
// Compact always shrinks, so file is smaller
|
||||
CPPUNIT_ASSERT(sizeAfterRound2 < sizeAfterRound1);
|
||||
} else if(writeStyle == Matroska::WriteStyle::AvoidInsert) {
|
||||
// AvoidInsert: existing slots are kept, but the segment-trailing
|
||||
// element may shrink (no element follows it -- shrinking only
|
||||
// truncates the file, no inserts are needed).
|
||||
CPPUNIT_ASSERT(sizeAfterRound2 <= sizeAfterRound1);
|
||||
} else {
|
||||
// DoNotShrink: elements keep their original slot size.
|
||||
// The file size must not be smaller than after round 1
|
||||
CPPUNIT_ASSERT_EQUAL(sizeAfterRound1, sizeAfterRound2);
|
||||
}
|
||||
|
||||
// --- Round 3: save medium data (fits in round2's slot if DoNotShrink/AvoidInsert) ---
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
f.tag(true)->clearSimpleTags();
|
||||
f.attachments(true)->clear();
|
||||
f.chapters(true)->clear();
|
||||
setMediumTags(f);
|
||||
setMediumAttachments(f);
|
||||
setMediumChapters(f);
|
||||
CPPUNIT_ASSERT_MESSAGE("Round3 save ws=" + String::number(wsIdx).to8Bit(), f.save(writeStyle));
|
||||
}
|
||||
const size_t sizeAfterRound3 = PlainFile(newname.c_str()).readAll().size();
|
||||
verifyRound("Round3 ws=" + std::to_string(wsIdx),
|
||||
String("Medium Title 12345678901234"), 333ULL, 1U, 40000ULL);
|
||||
|
||||
if(writeStyle == Matroska::WriteStyle::Compact) {
|
||||
// Compact: medium > small, but exact, so different from round2
|
||||
CPPUNIT_ASSERT(sizeAfterRound3 != sizeAfterRound2);
|
||||
CPPUNIT_ASSERT(sizeAfterRound3 < sizeAfterRound1);
|
||||
} else if(writeStyle == Matroska::WriteStyle::AvoidInsert) {
|
||||
// AvoidInsert: medium fits in round1's slot for non-trailing
|
||||
// elements, but the trailing element may take less space than in
|
||||
// round 1. File size therefore stays <= round 1.
|
||||
CPPUNIT_ASSERT(sizeAfterRound3 <= sizeAfterRound1);
|
||||
} else {
|
||||
// DoNotShrink: medium fits in round1's slot (with remaining void)
|
||||
// so file size stays the same as round1/round2
|
||||
CPPUNIT_ASSERT_EQUAL(sizeAfterRound1, sizeAfterRound3);
|
||||
}
|
||||
|
||||
// --- Round 4: save extra-large data (larger than round 1) ---
|
||||
{
|
||||
Matroska::File f(newname.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
f.tag(true)->clearSimpleTags();
|
||||
f.attachments(true)->clear();
|
||||
f.chapters(true)->clear();
|
||||
setExtraLargeTags(f);
|
||||
setExtraLargeAttachments(f);
|
||||
setExtraLargeChapters(f);
|
||||
CPPUNIT_ASSERT_MESSAGE("Round4 save ws=" + String::number(wsIdx).to8Bit(), f.save(writeStyle));
|
||||
}
|
||||
const size_t sizeAfterRound4 = PlainFile(newname.c_str()).readAll().size();
|
||||
verifyRound("Round4 ws=" + std::to_string(wsIdx),
|
||||
String("An Extremely Long Title That Is Even Larger Than The Previous Large Title "
|
||||
"With Extra Content To Ensure Growth 0123456789ABCDEF"),
|
||||
444ULL, 3U, 40000ULL);
|
||||
|
||||
// All styles must accommodate the larger data: file must be larger than round1
|
||||
CPPUNIT_ASSERT(sizeAfterRound4 > sizeAfterRound1);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestMatroska);
|
||||
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "mp4atom.h"
|
||||
#include "mp4file.h"
|
||||
#include "mp4itemfactory.h"
|
||||
#include "mp4chapterholder.h"
|
||||
#include "plainfile.h"
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
#include "utils.h"
|
||||
@@ -69,6 +70,56 @@ namespace
|
||||
};
|
||||
|
||||
CustomItemFactory CustomItemFactory::factory;
|
||||
|
||||
class MockChapterList : public MP4::ChapterHolder {
|
||||
public:
|
||||
static const MP4::ChapterList mockChapters;
|
||||
|
||||
bool read(TagLib::File *)
|
||||
{
|
||||
chapterList = mockChapters;
|
||||
++readCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
bool write(TagLib::File *)
|
||||
{
|
||||
++writeCount;
|
||||
return true;
|
||||
}
|
||||
|
||||
int readCount = 0;
|
||||
int writeCount = 0;
|
||||
};
|
||||
|
||||
const MP4::ChapterList MockChapterList::mockChapters = {
|
||||
MP4::Chapter("Mock", 123)
|
||||
};
|
||||
|
||||
class MockChapterFile : public PlainFile {
|
||||
public:
|
||||
explicit MockChapterFile(FileName name) : PlainFile(name)
|
||||
{
|
||||
}
|
||||
|
||||
MP4::ChapterList chapters()
|
||||
{
|
||||
return getChaptersLazy(chapterList, this);
|
||||
}
|
||||
|
||||
void setChapters(const MP4::ChapterList& chapters)
|
||||
{
|
||||
setChaptersLazy(chapterList, chapters);
|
||||
}
|
||||
|
||||
bool save() override
|
||||
{
|
||||
return MP4::saveChaptersIfModified(chapterList, this);
|
||||
}
|
||||
|
||||
std::unique_ptr<MockChapterList> chapterList;
|
||||
};
|
||||
|
||||
} // namespace
|
||||
|
||||
class TestMP4 : public CppUnit::TestFixture
|
||||
@@ -102,6 +153,26 @@ class TestMP4 : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testNonFullMetaAtom);
|
||||
CPPUNIT_TEST(testItemFactory);
|
||||
CPPUNIT_TEST(testNonPrintableAtom);
|
||||
CPPUNIT_TEST(testChapterListWrite);
|
||||
CPPUNIT_TEST(testChapterListRemove);
|
||||
CPPUNIT_TEST(testChapterListWithExistingTags);
|
||||
CPPUNIT_TEST(testChapterListReadEmpty);
|
||||
CPPUNIT_TEST(testQTChapterListWrite);
|
||||
CPPUNIT_TEST(testQTChapterListRemove);
|
||||
CPPUNIT_TEST(testQTChapterListWithExistingTags);
|
||||
CPPUNIT_TEST(testQTChapterListReadEmpty);
|
||||
CPPUNIT_TEST(testQTChapterListOverwrite);
|
||||
CPPUNIT_TEST(testQTChapterListTimestampPrecision);
|
||||
CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter);
|
||||
CPPUNIT_TEST(testQTChapterListNoOrphanedMdat);
|
||||
CPPUNIT_TEST(testQTChapterListSharedMdatPreservesAudio);
|
||||
CPPUNIT_TEST(testQTChapterListUnicodeTitles);
|
||||
CPPUNIT_TEST(testChapterListUnicodeTitles);
|
||||
CPPUNIT_TEST(testQTChapterListEmptyTitleStripped);
|
||||
CPPUNIT_TEST(testQTChapterListSingleEmptyTitleNotStripped);
|
||||
CPPUNIT_TEST(testNeroAndQTChaptersAreIndependent);
|
||||
CPPUNIT_TEST(testNeroChaptersAloneWhenNoQT);
|
||||
CPPUNIT_TEST(testLazyReadingAndWritingChapters);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -873,6 +944,847 @@ public:
|
||||
CPPUNIT_ASSERT_EQUAL(String("TITLE"), f.tag()->title());
|
||||
}
|
||||
}
|
||||
|
||||
void testChapterListWrite()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// File should have no chapters initially
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
}
|
||||
|
||||
// Write chapters
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Introduction", 0),
|
||||
MP4::Chapter("Main Content", 30000LL),
|
||||
MP4::Chapter("Conclusion", 60000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Introduction"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(30000LL, chapters[1].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Main Content"), chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(60000LL, chapters[2].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Conclusion"), chapters[2].title());
|
||||
|
||||
// Overwrite with different chapters
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Part One", 0)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify overwrite
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Part One"), chapters[0].title());
|
||||
}
|
||||
}
|
||||
|
||||
void testChapterListRemove()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write chapters
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Chapter 1", 0)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify written
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
|
||||
|
||||
// Remove chapters
|
||||
f.setNeroChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify removed
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
|
||||
// Remove from file with no chapters should also succeed
|
||||
f.setNeroChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
}
|
||||
|
||||
void testChapterListWithExistingTags()
|
||||
{
|
||||
ScopedFileCopy copy("has-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// File has existing tags -- verify they survive chapter operations
|
||||
String originalArtist;
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
originalArtist = f.tag()->artist();
|
||||
CPPUNIT_ASSERT(!originalArtist.isEmpty());
|
||||
|
||||
// Write chapters
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Intro", 0),
|
||||
MP4::Chapter("Verse", 10000LL)});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify chapters are written AND existing tags are preserved
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
|
||||
|
||||
// Remove chapters and verify tags still survive
|
||||
f.setNeroChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
|
||||
CPPUNIT_ASSERT(f.neroChapters().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void testChapterListReadEmpty()
|
||||
{
|
||||
// Reading from a file with no chpl atom should return empty list
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.neroChapters().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListWrite()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// File should have no QT chapters initially
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
}
|
||||
|
||||
// Write chapters (times in 100-nanosecond units)
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Intro", 0),
|
||||
MP4::Chapter("Verse", 15000LL),
|
||||
MP4::Chapter("Outro", 30000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(15000LL, chapters[1].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(30000LL, chapters[2].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Outro"), chapters[2].title());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListRemove()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write chapters first
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
MP4::Chapter("Chapter 2", 10000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify written
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
|
||||
// Remove chapters
|
||||
f.setQtChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify removed
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
|
||||
// Remove from file with no chapters should also succeed
|
||||
f.setQtChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListWithExistingTags()
|
||||
{
|
||||
ScopedFileCopy copy("has-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// File has existing tags -- verify they survive chapter operations
|
||||
String originalArtist;
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
originalArtist = f.tag()->artist();
|
||||
CPPUNIT_ASSERT(!originalArtist.isEmpty());
|
||||
|
||||
// Write chapters
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Intro", 0),
|
||||
MP4::Chapter("Verse", 10000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify chapters are written AND existing tags are preserved
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title());
|
||||
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
|
||||
|
||||
// Remove chapters and verify tags still survive
|
||||
f.setQtChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
|
||||
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListReadEmpty()
|
||||
{
|
||||
// Reading from a file with no chapter track should return empty list
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListOverwrite()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write initial chapters
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Old1", 0),
|
||||
MP4::Chapter("Old2", 5000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify initial
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
}
|
||||
|
||||
// Overwrite with different chapters
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("New1", 0),
|
||||
MP4::Chapter("New2", 10000LL),
|
||||
MP4::Chapter("New3", 20000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify overwrite
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("New1"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("New2"), chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("New3"), chapters[2].title());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListTimestampPrecision()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write chapters at precise times
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Start", 0),
|
||||
MP4::Chapter("Precise", 1500LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Read back and verify timestamps
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(1500LL, chapters[1].startTime());
|
||||
}
|
||||
}
|
||||
|
||||
void testQTChapterListNonZeroFirstChapter()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write chapters where first chapter is NOT at time 0
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("One", 10000LL),
|
||||
MP4::Chapter("Two", 20000LL),
|
||||
MP4::Chapter("Three", 30000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Read back -- dummy chapter at time 0 should be stripped
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(10000LL, chapters[0].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(20000LL, chapters[1].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(30000LL, chapters[2].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("One"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Two"), chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Three"), chapters[2].title());
|
||||
}
|
||||
}
|
||||
|
||||
// Regression test for the orphaned-mdat bug reported in PR #1325 by ufleisch.
|
||||
// Each add/remove cycle must leave the file's mdat count unchanged. Before
|
||||
// the fix, the chapter mdat appended by write() was never removed, so three
|
||||
// cycles produced originalCount + 3 mdat atoms.
|
||||
void testQTChapterListNoOrphanedMdat()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Count top-level mdat atoms using TagLib's own atom parser.
|
||||
auto countMdatTagLib = [&]() -> int {
|
||||
PlainFile pf(filename.c_str());
|
||||
MP4::Atoms atoms(&pf);
|
||||
int count = 0;
|
||||
for(const auto *atom : atoms.atoms())
|
||||
if(atom->name() == "mdat")
|
||||
++count;
|
||||
return count;
|
||||
};
|
||||
|
||||
const int baseMdatTagLib = countMdatTagLib();
|
||||
|
||||
// Three add/remove cycles (the scenario ufleisch demonstrated).
|
||||
for(int cycle = 0; cycle < 3; ++cycle) {
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
MP4::Chapter("Chapter 2", 10000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
}
|
||||
|
||||
// No orphaned mdat atoms should remain.
|
||||
CPPUNIT_ASSERT_EQUAL(baseMdatTagLib, countMdatTagLib());
|
||||
}
|
||||
|
||||
// Regression test for the data-loss bug reported in PR #1343 by ufleisch.
|
||||
// Audiobook-style files co-locate chapter text samples inside the main
|
||||
// audio mdat. In that case the chapter track's stco[0] does NOT mark a
|
||||
// dedicated chapter mdat -- it points into the shared audio mdat, and
|
||||
// naively deleting "the mdat at stco[0] - 8" destroys the audio payload.
|
||||
//
|
||||
// Simulate that layout by writing a chapter track, then rewriting its
|
||||
// stco[0] to point at the start of the primary audio mdat. Removing the
|
||||
// chapter track must leave the audio mdat fully intact.
|
||||
void testQTChapterListSharedMdatPreservesAudio()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
struct MdatInfo { offset_t offset; offset_t length; };
|
||||
auto findFirstMdat = [&]() -> MdatInfo {
|
||||
PlainFile pf(filename.c_str());
|
||||
MP4::Atoms atoms(&pf);
|
||||
for(const auto *atom : atoms.atoms())
|
||||
if(atom->name() == "mdat")
|
||||
return {atom->offset(), atom->length()};
|
||||
return {-1, 0};
|
||||
};
|
||||
|
||||
const MdatInfo audioMdat = findFirstMdat();
|
||||
CPPUNIT_ASSERT(audioMdat.offset >= 0);
|
||||
CPPUNIT_ASSERT(audioMdat.length > 16);
|
||||
|
||||
// Capture the audio mdat bytes so we can confirm byte-for-byte preservation.
|
||||
ByteVector originalAudioMdat;
|
||||
{
|
||||
PlainFile pf(filename.c_str());
|
||||
pf.seek(audioMdat.offset);
|
||||
originalAudioMdat = pf.readBlock(audioMdat.length);
|
||||
}
|
||||
|
||||
// Add a chapter track. write() appends its own mdat for the chapter text
|
||||
// at EOF; we'll relocate stco[0] below to simulate the shared-mdat case.
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
MP4::Chapter("Chapter 2", 1000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Rewrite the chapter track's stco[0] to point inside the audio mdat's
|
||||
// data, so findMdatContaining() will identify the audio mdat as the
|
||||
// candidate target. Choosing audioMdat.offset + 8 (the data start) is
|
||||
// the worst case: without the shared-mdat guard, the old code would
|
||||
// treat the audio mdat header as the chapter mdat header and wipe it.
|
||||
{
|
||||
PlainFile pf(filename.c_str());
|
||||
MP4::Atoms atoms(&pf);
|
||||
const MP4::Atom *moov = atoms.find("moov");
|
||||
CPPUNIT_ASSERT(moov);
|
||||
const MP4::AtomList traks = moov->findall("trak");
|
||||
CPPUNIT_ASSERT(traks.size() >= 2);
|
||||
// The chapter trak is the most recently added -- find the one whose
|
||||
// hdlr handler_type is "text".
|
||||
MP4::Atom *chapterTrak = nullptr;
|
||||
for(auto *t : traks) {
|
||||
MP4::Atom *hdlr = t->find("mdia", "hdlr");
|
||||
if(!hdlr) continue;
|
||||
pf.seek(hdlr->offset());
|
||||
if(ByteVector d = pf.readBlock(hdlr->length()); d.containsAt("text", 16)) {
|
||||
chapterTrak = t;
|
||||
break;
|
||||
}
|
||||
}
|
||||
CPPUNIT_ASSERT(chapterTrak);
|
||||
MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco");
|
||||
CPPUNIT_ASSERT(stco);
|
||||
// stco payload: full-box header(4) + entry_count(4) + offsets[]
|
||||
pf.seek(stco->offset() + 16);
|
||||
pf.writeBlock(ByteVector::fromUInt(
|
||||
static_cast<unsigned int>(audioMdat.offset + 8)));
|
||||
}
|
||||
|
||||
// Trigger the chapter-removal path with the crafted stco[0].
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// The audio mdat must survive with its contents byte-identical.
|
||||
const MdatInfo afterMdat = findFirstMdat();
|
||||
CPPUNIT_ASSERT(afterMdat.offset >= 0);
|
||||
CPPUNIT_ASSERT_EQUAL(audioMdat.length, afterMdat.length);
|
||||
{
|
||||
PlainFile pf(filename.c_str());
|
||||
pf.seek(afterMdat.offset);
|
||||
const ByteVector afterBytes = pf.readBlock(afterMdat.length);
|
||||
CPPUNIT_ASSERT(afterBytes == originalAudioMdat);
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode titles (CJK, Latin with diacritics, Cyrillic) survive the
|
||||
// write -> save -> open -> read round-trip through the QT chapter track.
|
||||
// This exercises the text-sample serialisation in mp4qtchapterlist.cpp.
|
||||
void testQTChapterListUnicodeTitles()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// UTF-8: 日本語, Über, Привет
|
||||
const String japanese("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e", String::UTF8);
|
||||
const String german("\xc3\x9c" "ber", String::UTF8);
|
||||
const String russian("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", String::UTF8);
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter(japanese, 0),
|
||||
MP4::Chapter(german, 15000LL),
|
||||
MP4::Chapter(russian, 30000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(japanese, chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(german, chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(russian, chapters[2].title());
|
||||
}
|
||||
}
|
||||
|
||||
// Unicode titles survive the write -> save -> open -> read round-trip
|
||||
// through the Nero chpl atom, which uses a different serialisation path
|
||||
// (length-prefixed UTF-8 inside udta/chpl).
|
||||
void testChapterListUnicodeTitles()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// UTF-8: 日本語, Über, Привет
|
||||
const String japanese("\xe6\x97\xa5\xe6\x9c\xac\xe8\xaa\x9e", String::UTF8);
|
||||
const String german("\xc3\x9c" "ber", String::UTF8);
|
||||
const String russian("\xd0\x9f\xd1\x80\xd0\xb8\xd0\xb2\xd0\xb5\xd1\x82", String::UTF8);
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter(japanese, 0),
|
||||
MP4::Chapter(german, 15000LL),
|
||||
MP4::Chapter(russian, 30000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.neroChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(japanese, chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(german, chapters[1].title());
|
||||
CPPUNIT_ASSERT_EQUAL(russian, chapters[2].title());
|
||||
}
|
||||
}
|
||||
|
||||
// When a multi-chapter list begins with an empty-titled chapter at time 0,
|
||||
// that entry matches the QT dummy-marker pattern and must be stripped on
|
||||
// read-back. This test documents the stripping behaviour so a regression
|
||||
// is immediately detectable.
|
||||
void testQTChapterListEmptyTitleStripped()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
// First entry has an empty title at t=0. write() sees the list already
|
||||
// starts at t=0 so no dummy is prepended; the empty entry is written
|
||||
// as-is. read() must strip it because size > 1 && startTime()==0 &&
|
||||
// title().isEmpty().
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("", 0),
|
||||
MP4::Chapter("Chapter 1", 5000LL),
|
||||
MP4::Chapter("Chapter 2", 10000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
// The empty t=0 entry is stripped; only the two real chapters remain.
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(5000LL, chapters[0].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Chapter 1"), chapters[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(10000LL, chapters[1].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Chapter 2"), chapters[1].title());
|
||||
}
|
||||
}
|
||||
|
||||
// A single chapter with an empty title at time 0 must NOT be stripped.
|
||||
// The stripping rule applies only when size > 1 -- a file with exactly one
|
||||
// chapter is valid and its t=0 marker is not a dummy.
|
||||
void testQTChapterListSingleEmptyTitleNotStripped()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("", 0)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
MP4::ChapterList chapters = f.qtChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime());
|
||||
CPPUNIT_ASSERT_EQUAL(String(""), chapters[0].title());
|
||||
}
|
||||
}
|
||||
|
||||
// Both Nero (chpl) and QT chapter tracks can coexist in the same file.
|
||||
// Writing one format must not disturb the other, and removing one must
|
||||
// leave the other intact -- this validates the saveChaptersIfModified lazy
|
||||
// save contract in mp4file.cpp.
|
||||
void testNeroAndQTChaptersAreIndependent()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write both formats in a single save.
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Nero 1", 0),
|
||||
MP4::Chapter("Nero 2", 10000LL)
|
||||
});
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("QT 1", 0),
|
||||
MP4::Chapter("QT 2", 20000LL)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// Verify both are present and distinct.
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
const MP4::ChapterList nero = f.neroChapters();
|
||||
const MP4::ChapterList qt = f.qtChapters();
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL(2U, nero.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Nero 1"), nero[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Nero 2"), nero[1].title());
|
||||
|
||||
CPPUNIT_ASSERT_EQUAL(2U, qt.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("QT 1"), qt[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("QT 2"), qt[1].title());
|
||||
|
||||
// Remove only the QT track.
|
||||
f.setQtChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
// QT removed; Nero chapters must be fully intact.
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
|
||||
|
||||
const MP4::ChapterList nero = f.neroChapters();
|
||||
CPPUNIT_ASSERT_EQUAL(2U, nero.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Nero 1"), nero[0].title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Nero 2"), nero[1].title());
|
||||
}
|
||||
}
|
||||
|
||||
// Writing only Nero chapters must not accidentally create a QT chapter track,
|
||||
// and writing only QT chapters must not accidentally create a Nero chpl atom.
|
||||
void testNeroChaptersAloneWhenNoQT()
|
||||
{
|
||||
// Nero only -- QT track must remain absent.
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setNeroChapters(MP4::ChapterList{
|
||||
MP4::Chapter("Nero Only", 0)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(1U, f.neroChapters().size());
|
||||
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
|
||||
}
|
||||
}
|
||||
|
||||
// QT only -- Nero chpl atom must remain absent.
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
f.setQtChapters(MP4::ChapterList{
|
||||
MP4::Chapter("QT Only", 0)
|
||||
});
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(1U, f.qtChapters().size());
|
||||
CPPUNIT_ASSERT(f.neroChapters().isEmpty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void testLazyReadingAndWritingChapters()
|
||||
{
|
||||
// No reads or writes if chapters are not used
|
||||
{
|
||||
MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a"));
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(!f.chapterList);
|
||||
}
|
||||
// Do not read if already read, do not write if not modified
|
||||
{
|
||||
MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a"));
|
||||
auto chapters = f.chapters();
|
||||
CPPUNIT_ASSERT(chapters == MockChapterList::mockChapters);
|
||||
CPPUNIT_ASSERT(f.chapterList);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
chapters = f.chapters();
|
||||
CPPUNIT_ASSERT(chapters == MockChapterList::mockChapters);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
CPPUNIT_ASSERT_EQUAL(0, f.chapterList->writeCount);
|
||||
}
|
||||
// Do not write if not modified
|
||||
{
|
||||
MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a"));
|
||||
auto chapters = f.chapters();
|
||||
CPPUNIT_ASSERT(chapters == MockChapterList::mockChapters);
|
||||
CPPUNIT_ASSERT(f.chapterList);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
f.setChapters(MockChapterList::mockChapters);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
CPPUNIT_ASSERT_EQUAL(0, f.chapterList->writeCount);
|
||||
}
|
||||
// Write if set without being read before
|
||||
{
|
||||
MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a"));
|
||||
f.setChapters(MP4::ChapterList());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.chapterList);
|
||||
CPPUNIT_ASSERT_EQUAL(0, f.chapterList->readCount);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->writeCount);
|
||||
}
|
||||
// Do write if modified
|
||||
{
|
||||
MockChapterFile f(TEST_FILE_PATH_C("no-tags.m4a"));
|
||||
CPPUNIT_ASSERT(f.chapters() == MockChapterList::mockChapters);
|
||||
CPPUNIT_ASSERT(f.chapterList);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
const auto chapters1 = MP4::ChapterList({
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
});
|
||||
f.setChapters(chapters1);
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters1);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters1);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->readCount);
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->writeCount);
|
||||
f.setChapters(chapters1);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.chapterList->writeCount);
|
||||
auto chapters2 = MP4::ChapterList({
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
MP4::Chapter("Chapter 2", 1),
|
||||
});
|
||||
f.setChapters(chapters2);
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters2);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters2);
|
||||
CPPUNIT_ASSERT_EQUAL(2, f.chapterList->writeCount);
|
||||
chapters2 = MP4::ChapterList({
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
MP4::Chapter("Chapter 2", 2),
|
||||
});
|
||||
f.setChapters(chapters2);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT_EQUAL(3, f.chapterList->writeCount);
|
||||
f.setChapters(chapters2);
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters2);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters2);
|
||||
CPPUNIT_ASSERT_EQUAL(3, f.chapterList->writeCount);
|
||||
const auto chapters3 = MP4::ChapterList({
|
||||
MP4::Chapter("Chapter 1", 0),
|
||||
MP4::Chapter("Chapter 3", 2),
|
||||
});
|
||||
f.setChapters(chapters3);
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters3);
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.chapters() == chapters3);
|
||||
CPPUNIT_ASSERT_EQUAL(4, f.chapterList->writeCount);
|
||||
f.setChapters(MP4::ChapterList());
|
||||
CPPUNIT_ASSERT(f.chapters().isEmpty());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.chapters().isEmpty());
|
||||
CPPUNIT_ASSERT_EQUAL(5, f.chapterList->writeCount);
|
||||
}
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4);
|
||||
|
||||
@@ -58,6 +58,7 @@ public:
|
||||
CPPUNIT_ASSERT_EQUAL(48000, f.audioProperties()->sampleRate());
|
||||
CPPUNIT_ASSERT_EQUAL(48000, f.audioProperties()->inputSampleRate());
|
||||
CPPUNIT_ASSERT_EQUAL(1, f.audioProperties()->opusVersion());
|
||||
CPPUNIT_ASSERT_EQUAL(-17920, f.audioProperties()->outputGain());
|
||||
}
|
||||
|
||||
void testReadComments()
|
||||
|
||||
@@ -61,6 +61,10 @@ class TestWAV : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testWaveFormatExtensible);
|
||||
CPPUNIT_TEST(testInvalidChunk);
|
||||
CPPUNIT_TEST(testRIFFInfoProperties);
|
||||
CPPUNIT_TEST(testBEXTTag);
|
||||
CPPUNIT_TEST(testBEXTTagWithOtherTags);
|
||||
CPPUNIT_TEST(testiXMLTag);
|
||||
CPPUNIT_TEST(testiXMLTagWithOtherTags);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -316,7 +320,7 @@ public:
|
||||
{
|
||||
FileStream stream(copy.fileName().c_str());
|
||||
stream.seek(0, IOStream::End);
|
||||
constexpr char garbage[] = "12345678";
|
||||
constexpr char garbage[] = "\r2345678";
|
||||
stream.writeBlock(ByteVector(garbage, sizeof(garbage) - 1));
|
||||
stream.seek(0);
|
||||
contentsBeforeModification = stream.readBlock(stream.length());
|
||||
@@ -482,6 +486,151 @@ public:
|
||||
}
|
||||
}
|
||||
|
||||
void testBEXTTag()
|
||||
{
|
||||
ScopedFileCopy copy("empty", ".wav");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
CPPUNIT_ASSERT(f.BEXTData().isEmpty());
|
||||
|
||||
f.setBEXTData(ByteVector("test bext data"));
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
CPPUNIT_ASSERT_EQUAL(ByteVector("test bext data"), f.BEXTData());
|
||||
|
||||
f.setBEXTData(ByteVector());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
CPPUNIT_ASSERT(f.BEXTData().isEmpty());
|
||||
}
|
||||
|
||||
// Check if file without BEXT is same as original empty file
|
||||
const ByteVector origData = PlainFile(TEST_FILE_PATH_C("empty.wav")).readAll();
|
||||
const ByteVector fileData = PlainFile(filename.c_str()).readAll();
|
||||
CPPUNIT_ASSERT(origData == fileData);
|
||||
}
|
||||
|
||||
void testBEXTTagWithOtherTags()
|
||||
{
|
||||
ScopedFileCopy copy("empty", ".wav");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
f.ID3v2Tag()->setTitle("ID3v2 Title");
|
||||
f.InfoTag()->setTitle("INFO Title");
|
||||
f.setBEXTData(ByteVector("bext payload"));
|
||||
f.save();
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.hasID3v2Tag());
|
||||
CPPUNIT_ASSERT(f.hasInfoTag());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
CPPUNIT_ASSERT_EQUAL(String("ID3v2 Title"), f.ID3v2Tag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(String("INFO Title"), f.InfoTag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(ByteVector("bext payload"), f.BEXTData());
|
||||
}
|
||||
}
|
||||
|
||||
void testiXMLTag()
|
||||
{
|
||||
ScopedFileCopy copy("empty", ".wav");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(f.iXMLData().isEmpty());
|
||||
|
||||
f.setiXMLData("<BWFXML><IXML_VERSION>1.0</IXML_VERSION></BWFXML>");
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
CPPUNIT_ASSERT_EQUAL(
|
||||
String("<BWFXML><IXML_VERSION>1.0</IXML_VERSION></BWFXML>"),
|
||||
f.iXMLData());
|
||||
|
||||
f.setiXMLData(String());
|
||||
f.save();
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(f.iXMLData().isEmpty());
|
||||
}
|
||||
|
||||
// Check if file without iXML is same as original empty file
|
||||
const ByteVector origData = PlainFile(TEST_FILE_PATH_C("empty.wav")).readAll();
|
||||
const ByteVector fileData = PlainFile(filename.c_str()).readAll();
|
||||
CPPUNIT_ASSERT(origData == fileData);
|
||||
}
|
||||
|
||||
void testiXMLTagWithOtherTags()
|
||||
{
|
||||
ScopedFileCopy copy("empty", ".wav");
|
||||
string filename = copy.fileName();
|
||||
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
f.ID3v2Tag()->setTitle("ID3v2 Title");
|
||||
f.setiXMLData("<BWFXML><SCENE>1</SCENE></BWFXML>");
|
||||
f.setBEXTData(ByteVector("bext data"));
|
||||
f.save();
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.hasID3v2Tag());
|
||||
CPPUNIT_ASSERT(f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(f.hasBEXTData());
|
||||
CPPUNIT_ASSERT_EQUAL(String("ID3v2 Title"), f.ID3v2Tag()->title());
|
||||
CPPUNIT_ASSERT_EQUAL(
|
||||
String("<BWFXML><SCENE>1</SCENE></BWFXML>"),
|
||||
f.iXMLData());
|
||||
CPPUNIT_ASSERT_EQUAL(ByteVector("bext data"), f.BEXTData());
|
||||
|
||||
f.setiXMLData(String());
|
||||
f.setBEXTData(ByteVector());
|
||||
f.strip();
|
||||
CPPUNIT_ASSERT(f.save());
|
||||
}
|
||||
{
|
||||
RIFF::WAV::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT(!f.hasID3v2Tag());
|
||||
CPPUNIT_ASSERT(!f.hasiXMLData());
|
||||
CPPUNIT_ASSERT(f.iXMLData().isEmpty());
|
||||
CPPUNIT_ASSERT(!f.hasBEXTData());
|
||||
CPPUNIT_ASSERT(f.BEXTData().isEmpty());
|
||||
}
|
||||
|
||||
// Check if file without tags is same as original empty file
|
||||
const ByteVector origData = PlainFile(TEST_FILE_PATH_C("empty.wav")).readAll();
|
||||
const ByteVector fileData = PlainFile(filename.c_str()).readAll();
|
||||
CPPUNIT_ASSERT(origData == fileData);
|
||||
}
|
||||
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestWAV);
|
||||
|
||||
@@ -32,7 +32,11 @@
|
||||
#else
|
||||
#include <unistd.h>
|
||||
#include <fcntl.h>
|
||||
#ifdef __HAIKU__
|
||||
#include <fcntl.h>
|
||||
#else
|
||||
#include <sys/fcntl.h>
|
||||
#endif
|
||||
#include <sys/stat.h>
|
||||
#endif
|
||||
#include <cstdio>
|
||||
|
||||
Reference in New Issue
Block a user