40 Commits

Author SHA1 Message Date
Urs Fleisch
1b94b93762 Version 2.3 2026-05-10 15:25:51 +02:00
HomerJau
8511827fa1 Skip Matroska Cues with AudioProperties::Fast and read-only mode
When the file is opened in read-only mode, it will not be written and
the Cues do not have to be updated. Skipping the Cues will make the
reading of large Matroska files over network filesystems (SMB/NFS)
faster.
2026-05-09 09:25:14 +02:00
Urs Fleisch
b02ff63916 Fix -Wconversion size_t to unsigned int warning 2026-05-09 08:28:54 +02:00
HomerJau
f1e8dac084 [Matroska: Follow chained SeekHead entries when parsing segment metadata
Some muxers — notably MakeMKV, and mkvmerge in certain configurations — write a small primary seekHead at the start of the segment that contains a single entry referencing a secondary seekHead near the end of the file. The secondary seekHead carries the actual entries for info, tracks, tags, chapters, and attachments.
2026-05-08 05:17:23 +02:00
Luc Schrijvers
d1460b6fbf Build fix for Haiku's fcntl.h which can't be found in sys/fcntl.h 2026-05-07 18:32:19 +02:00
Urs Fleisch
43190d30ed Prepare 2.3 release 2026-05-04 13:02:17 +02:00
Urs Fleisch
4c43f1c577 Matroska: Provide different WriteStyle to trade-off size/speed
A new Matroska::File::save(WriteStyle style) overload is provided to
control how tags, attachments and chapters are written to the file.

- Compact: Write tags, attachments and chapters as compact as possible.
  This is the default mode.
- DoNotShrink: Do not shrink elements; add void padding when content
  gets smaller. Allow inserts when content gets larger.
- AvoidInsert: Like 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.
  For very large files and/or slow (network) filesystems, using this
  mode will reduce write time significantly.

Co-authored-by: Copilot <copilot@github.com>
2026-05-04 12:55:17 +02:00
Ryan Francesconi
59ed19d12f [WAV] Decode iXML as UTF-8
The iXML chunk in BWF/WAV files is specified as UTF-8 (per the EBU
Tech 3285 supplement and the iXML spec). The reader was constructing
the String without an encoding hint, which falls back to Latin-1 and
mangles any non-ASCII bytes (e.g. Unicode in <NOTE>, <PROJECT>, or
<TRACK_LIST> entries written by Sound Devices, Zaxcom, etc.).
2026-05-01 06:32:09 +02:00
Ryan Francesconi
1e7bdae284 [FLAC] Add iXML and BEXT support via APPLICATION blocks
Adds 6 public methods on FLAC::File mirroring RIFF::WAV::File's existing
iXML/BEXT API: iXMLData/setiXMLData/hasiXMLData and the BEXT equivalents.

Reads APPLICATION blocks (RFC 9639 § 8.4) carrying either the IANA-
registered "riff" foreign-metadata wrapper or the direct "iXML" / "bext"
application IDs used by some third-party tools (e.g. Sequoia). Writes
the spec-blessed "riff"-wrapped form. Unrecognized application IDs and
"riff"-wrapped chunks other than iXML/bext (e.g. "fmt ", "JUNK") flow
through unmodified, so existing files round-trip without churn.

Test coverage: read direct + riff-wrapped for both iXML and BEXT,
write+reread round-trip, empty-clears-block, and an unknown-application-
block preservation guard.
2026-05-01 06:31:50 +02:00
HomerJau
e07b956fda [Matroska] Allow Orphaned Chapter Reading (when Chapter has no EditionID)
Fix: Handle orphan ChapterAtom elements not wrapped in EditionEntry

The Matroska specification requires every ChapterAtom to be inside an
EditionEntry. However, some muxers (older FFmpeg versions, some streaming
tools) produce files with ChapterAtom elements directly under Chapters,
without an EditionEntry wrapper.
MKVToolNix and FFmpeg both handle this case gracefully by treating orphan
atoms as belonging to an implicit default edition. Previously, TagLib
silently ignored these chapters, returning an empty ChapterEditionList.

This change:
- Collects orphan ChapterAtom elements encountered directly under Chapters
- Wraps them in an implicit default edition (UID = 0, isDefault = true,
  isOrdered = false) so they are exposed through the existing
  chapterEditionList() API
- Extracts the atom-parsing logic into a private parseChapterAtom() helper
  to avoid code duplication between the two call sites

No existing behavior is changed - files that already conform to the spec
(chapters inside an EditionEntry) parse identically.
2026-04-26 08:24:19 +02:00
Urs Fleisch
5e1cb4081d Limit MP4 atom sibling count at top level (#1344) 2026-04-26 07:16:53 +02:00
Urs Fleisch
e7e4f0958c Merge pull requests #1325 #1343 from ryanfrancesconi MP4 chapterlist
ryanfrancesconi/feature/mp4-chapterlist
ryanfrancesconi/fix/qt-chapter-orphaned-mdat
2026-04-26 07:15:40 +02:00
Urs Fleisch
497c040f04 Set MP4 chapters only if modified
An equality operator is added for the chapters. The chapters are
only written to the file if they were really modified, so just
reading the chapters without modifying them will not affect
the save operation.
2026-04-25 11:46:51 +02:00
Ryan Francesconi
05c2c8671e MP4: Add test coverage for chapter unicode, empty titles, and format independence
Six new tests exercise corners of the chapter implementation that the
orphaned-mdat fix did not reach:

testQTChapterListUnicodeTitles / testChapterListUnicodeTitles --
Round-trip Japanese, German (umlaut), and Russian titles through the
QT text-sample serialisation and the Nero length-prefixed UTF-8 path
respectively.  These are separate paths in the code and benefit from
separate coverage.

testQTChapterListEmptyTitleStripped --
A multi-chapter list whose first entry is empty at t=0 matches the QT
dummy-marker pattern; read() must drop it.  Test documents the rule so
a regression is immediately detectable.

testQTChapterListSingleEmptyTitleNotStripped --
The stripping rule only applies when size > 1.  A single empty-title
chapter at t=0 is valid and must be preserved.

testNeroAndQTChaptersAreIndependent --
Both formats can coexist; removing one leaves the other intact.
Validates the lazy saveChaptersIfModified contract in mp4file.cpp.

testNeroChaptersAloneWhenNoQT --
Writing one format must not create atoms for the other.

All 47 MP4 tests pass.
2026-04-23 12:19:27 -07:00
Ryan Francesconi
85b6a9eb93 MP4: Guard against deleting shared mdat on QT chapter remove
The previous fix for orphaned chapter mdats assumed the chapter text
mdat was dedicated and derived its location from stco[0] - 8.  In
audiobooks that co-locate chapter text at the start of the primary
audio mdat (stco[0] == audioMdat.offset + 8), that arithmetic lands
on the audio mdat header, the "mdat" signature check passes, and the
full audio payload gets removed -- shrinking a 484 MB audiobook to
5.4 MB.

Fix: resolve the chapter mdat by finding the top-level mdat whose
data range contains stco[0], then re-parse after the trak/tref
removals and confirm no other track's stco/co64 points into that
mdat before deleting it.  Shared mdats are left intact; the dead
chapter text bytes remain as harmless padding.

Add a regression test that writes a chapter track, patches its
stco[0] to point into the primary audio mdat (simulating the
audiobook layout), removes the chapter track, and verifies the
audio mdat is byte-identical afterwards.
2026-04-23 12:14:00 -07:00
Ryan Francesconi
5c70f0071f MP4: Add regression test for orphaned mdat on QT chapter remove
Adds testQTChapterListNoOrphanedMdat which performs three add/remove
cycles and asserts that the top-level mdat count is identical before and
after.  Without the fix, each cycle leaves an orphaned mdat at EOF, so
three cycles produce originalCount + 3 atoms.

Uses TagLib's own MP4::Atoms parser as the primary check, with
AtomicParsley as an optional cross-validation when installed.
2026-04-23 11:03:23 -07:00
Ryan Francesconi
ae171ee237 MP4: Remove orphaned mdat when removing QT chapter track
write() appends a new mdat at EOF to hold chapter text samples but the
removal code (both remove() and the replace-existing path in write())
only deleted the chapter trak and tref atoms from inside moov.  Each
add/remove cycle left the previous chapter mdat behind, causing orphaned
mdat atoms to accumulate.

Fix: extract a removeQTChapterTrack() helper that performs all three
removals atomically.  Before deleting the chapter trak, the helper reads
the first stco chunk offset (which points 8 bytes past the chapter mdat
header) to locate the mdat.  After removing the trak and tref (both
inside moov, which precedes the mdat at EOF), it adjusts the mdat offset
by -(chapterLen + trefLen) and removes the atom, leaving no orphaned data.
2026-04-23 11:03:23 -07:00
Urs Fleisch
78c7208bc9 Integrate MP4 chapters into MP4::File 2026-04-23 11:03:23 -07:00
Urs Fleisch
0df52e3993 Apply stco/co64 bounds fix from PR #1333 to MP4 chapter code
The updateChunkOffsets() function in mp4qtchapterlist.cpp and
mp4chapterlist.cpp is duplicated code from mp4tag.cpp and needs
the patch from mp4tag.cpp too.
2026-04-23 11:03:23 -07:00
Ryan Francesconi
ba2441b378 corrected nanosecond unit change -> milliseconds
taglib/mp4/mp4chapterlist.h
• start​Time doc comment: 100​-nanosecond units → milliseconds

taglib/mp4/mp4chapterlist.cpp
• render​Chpl​Data: from​Long​Long(ch​.start​Time) → from​Long​Long(ch​.start​Time * 10000​LL)
• parse​Chpl​Data: ch​.start​Time = start​Time → ch​.start​Time = start​Time100ns / 10000​LL

taglib/mp4/mp4qtchapterlist.cpp
• read: current​Time * 10000000​.0 / timescale → current​Time * 1000​.0 / timescale
• build​Stts lambda: time100ns * timescale / 10000000​.0 → time​Ms * timescale / 1000​.0

tests/test_mp4.cpp
• All start​Time assignments and assertions divided by 10,000 (e.g. 300000000​LL → 30000​LL)
2026-04-23 11:03:23 -07:00
Ryan Francesconi
c5ea13bb34 overloads for read, write, remove
Changes made

mp4chapterlist.h
• Added (​MP4::​File*) overloads for read, write, remove
• Replaced broken class ​File; forward declaration with #include "mp4file​.h" (fixed a subtle C++ name-resolution linker bug where Atoms(​File*) resolved to MP4::​File* instead of Tag​Lib::​File*)

mp4chapterlist.cpp
• Refactored: path-based overloads are now thin wrappers that delegate to file-based overloads
• File-based overloads construct Atoms locally — no Atoms* in the public API
• Removed chpl​Header​Size = 9 constant; replaced the minimum-size guard in parse​Chpl​Data with a correct 5-byte check (the old constant was version-1 specific and would reject valid version-0 atoms)

mp4qtchapterlist.h
• Added (​MP4::​File*) overloads for read, write, remove
• Removed Atoms* parameters entirely from the public API

mp4qtchapterlist.cpp
• Same refactor: path-based overloads delegate; file-based overloads construct Atoms locally
• Added empty-chapter guard: write(​MP4::​File*, {}) delegates to remove(file) instead of writing a 0-sample chapter track

tests/test_mp4.cpp
• Added test​Chapter​List​File​API and test​QTChapter​List​File​API — exercise the full write/read/remove cycle via the file-based API
• Updated test bodies to use the simplified (​MP4::​File*) API (no MP4::​Atoms construction in test code)
2026-04-23 11:03:23 -07:00
Ryan Francesconi
4a73d73b20 MP4: Add QuickTime-style chapter track support
QuickTime-style chapter tracks are the native chapter format for
Apple's ecosystem. They use a disabled text track (hdlr type "text")
referenced by a chap track-reference in the audio track's tref box.
This format is recognized by QuickTime, iTunes/Music, Final Cut Pro,
Logic Pro, DaVinci Resolve, VLC, and most other MP4/M4A players. It
is also the format that AVFoundation reads natively via
AVAssetChapterMetadataGroup.

The implementation produces output that matches ffmpeg's chapter track
structure byte-for-byte: per-sample stts entries (required by
AVFoundation), encd atoms for UTF-8 text encoding, edts/elst edit
lists, gmhd with gmin+text media information, and disabled tkhd flags
(track_in_movie only).

Key behaviors:
- write() inserts tref + chapter trak as a single contiguous block,
  then appends text samples in an mdat atom at EOF
- Handles non-zero first chapter times by prepending a dummy chapter
  at time 0 (stripped on read)
- Overwrite support: removes existing chapter track before writing
- Preserves existing metadata tags and audio data integrity
- Uses timescale=1000 (milliseconds) for chapter track timing

7 new tests covering write/read round-trip, remove, overwrite, tag
preservation, empty file read, timestamp precision, and non-zero
first chapter handling.
2026-04-23 11:03:23 -07:00
Ryan Francesconi
9c56f191e5 MP4: Add Nero-style chapter marker support
Implement read/write/remove of Nero-style chapter markers (chpl atom)
in MP4 files. The chpl atom lives at moov/udta/chpl, storing up to 255
chapter entries with 100-nanosecond timestamps and UTF-8 titles.

Includes CppUnit tests covering round-trip read/write, remove, tag
preservation, and reading from files with no chapters.
2026-04-23 11:03:23 -07:00
Urs Fleisch
77f6b9add5 Drop zero size ID3v2 frames but accept tag (#437) 2026-04-20 15:11:33 +02:00
Urs Fleisch
a64e7543f8 Fix DSD/DSF signed integer issues (#1332) 2026-04-20 15:08:37 +02:00
Felipe
d466b72eea docs: Some improvements to the documentation (#1337)
Make MP4 AtomDataType descriptions visible in the generated documentation.
Convert the ID3v2 text frame listing into a table.
Convert the shorten `fileType()` documentation into a table.
Fix some typos.
Add link to specification in `EventType` for consistency with other headers.
2026-04-13 20:05:53 +02:00
Urs Fleisch
c3a0e1d0a2 Matroska: Use seek head for faster element lookup (#1321)
Limit scan for Matroska seek head to 512 KB in ReadStyle::Fast

---------

Co-authored-by: tolriq <git@leetzone.org>
2026-04-13 19:58:52 +02:00
Ryan Francesconi
13751f5a6b Fix/shorten rice golomb k bounds (#1335)
* Shorten: Reject out-of-range k in getRiceGolombCode

k values outside [0, 31] cause undefined behavior: a left shift by 32
on int32_t (UB in C++) when bitsAvailable reaches 32 after a buffer
refill. Guard against this at the top of getRiceGolombCode and return
false (invalid file) for any k outside the valid range.

* Shorten: Reject out-of-range k in getRiceGolombCode

k values outside [0, 31] cause undefined behavior: a left shift by 32
on int32_t (UB in C++) when bitsAvailable reaches 32 after a buffer
refill. Guard against this at the top of getRiceGolombCode and return
false (invalid file) for any k outside the valid range.
2026-04-09 14:03:36 -06:00
Urs Fleisch
4da5ac2de4 Fix writing too many offsets when updating MP4 stco/co64 atoms (#1332)
This will fix a DoS with a crafted MP4 file causing too many offsets
to be written when updating the stco or co64 tables in MP4 files.

Credits for the discovery of this bug go to Yuen Ying Ng (Ruth)
(Cyber Security Researcher at PwC Hong Kong).
2026-04-08 20:53:59 +02:00
Urs Fleisch
193091fe2e Fix unbounded recursion in EBML/Matroska MasterElement and MP4 atoms (#1326)
Credits for fix and reporting go to https://github.com/ericliu-12.
2026-04-08 20:52:58 +02:00
Ryan Francesconi
5d63187a8b MP4: Fix data race in ItemFactory lazy map initialization (#1331)
Concurrent calls to propertyKeyForName() and handlerTypeForName() (e.g.
via batchMap during import) could race on the isEmpty() guard used for
first-call lazy initialization.

Replace isEmpty() guards with std::call_once / std::once_flag so that
each map is initialized exactly once in a thread-safe manner. Using
call_once (rather than eager construction in the base class constructor)
preserves virtual dispatch, allowing ItemFactory subclasses to override
nameHandlerMap() and namePropertyMap() correctly.

Both property maps are initialized together in a single once_flag since
nameForPropertyKey is derived from namePropertyMap.
2026-04-04 17:52:54 +02:00
Ryan Francesconi
f32b503f56 Fix bitrate calculation unit errors in ADTS and MP4 ESDS parsers (#1330)
mpegheader.cpp: ADTS bitrate divided by 1024 (binary kilo) instead of
1000 (decimal kilo), causing ~2.4% underreporting for all AAC streams.

mp4properties.cpp: ESDS averageBitrate double-rounded via both +500 and
+0.5 before int cast, causing standard bitrates (128000, 192000, etc.)
to read 1 kbps too high.
2026-04-04 16:34:37 +02:00
Ryan Francesconi
d6a2134cf3 Clamp oversized RIFF chunk to available bytes instead of rejecting it (#1329)
Some encoders write a valid data chunk but with a slightly too-large
declared chunkSize, or place the data chunk beyond the declared RIFF
boundary. The previous behaviour called break, abandoning all remaining
chunks and making the file appear empty to taglib.

Lenient parsers (ffmpeg, QuickTime) handle this case by clamping the
chunk size to the bytes that actually remain in the file. Adopt the
same strategy: when chunkSize would exceed the file length, clamp it
and continue parsing rather than stopping early.
2026-04-04 12:47:49 +02:00
Ryan Francesconi
abadbb6768 Add BEXT and iXML chunk support to WAV files (#1323)
Read, write, and remove Broadcast Audio Extension (BEXT, EBU Tech 3285)
and iXML metadata chunks in WAV files. BEXT is widely used in broadcast
and professional audio for originator, description, time reference, and
loudness metadata. iXML is used by field recorders and DAWs for scene,
take, and track metadata.
2026-04-04 12:14:34 +02:00
Daniel
49510e7d5a Move MPEG check to end of content-based detection (#1319)
MPEG::File::isSupported() scans for frame sync bytes that can appear
in other files, causing them to be misidentified as MP3.

This also includes a test with such a file.
2026-04-04 08:01:41 +02:00
Daniel
7f2f2ddcaf Add tests for FileRef content-based detection via ByteVectorStream (#1318)
This covers all 18 formats supported by the content-based detection.
2026-04-04 07:43:56 +02:00
Urs Fleisch
0368c0239a Pin submodule utfcpp to tag v4.0.9 (#1315)
git submodule init
git submodule update --remote
(cd 3rdparty/utfcpp && git checkout v4.0.9)
git add 3rdparty/utfcpp
git commit -m 'Pin submodule utfcpp to tag v4.0.9'
2026-03-31 20:04:11 +02:00
Felipe
9411bb161f Opus: Read output gain (#1320) 2026-03-31 11:14:44 -05:00
Urs Fleisch
78298769de Version 2.2.1 2026-03-07 06:41:13 +01:00
Urs Fleisch
c43d2b3fc1 Avoid duplicates in StringList Matroska::Tag::complexPropertyKeys()
When using for example

examples/tagwriter -C GENRE \
"name=GENRE,targetTypeValue=50,value=Soft Rock;name=GENRE,targetTypeValue=50,value=Classic Rock" \
path/to/file.mka

the GENRE key was included twice and tagreader displayed the two genre
tags twice.
2026-02-28 07:51:04 +01:00
68 changed files with 5605 additions and 235 deletions

View File

@@ -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)
=========================

View File

@@ -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)

View File

@@ -117,4 +117,3 @@ int main(int argc, char *argv[])
}
return 0;
}

View File

@@ -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()

View File

@@ -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;

View File

@@ -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');

View File

@@ -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);

View File

@@ -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.

View File

@@ -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);
}

View File

@@ -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.

View 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;

View File

@@ -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();

View File

@@ -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;

View File

@@ -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)

View File

@@ -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;
}

View File

@@ -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;
}

View File

@@ -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;

View File

@@ -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

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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)

View File

@@ -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.
*

View 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();

View File

@@ -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;

View File

@@ -69,3 +69,8 @@ offset_t Matroska::Segment::dataOffset() const
{
return offset() + sizeLength;
}
offset_t Matroska::Segment::endOffset() const
{
return dataOffset() + dataSize;
}

View File

@@ -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;

View File

@@ -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());
}
}

View 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

View File

@@ -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;
}
}
}

View File

@@ -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
View 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
View 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

View 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

View File

@@ -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

View File

@@ -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.
*

View 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;

View 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;
}

View 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

View File

@@ -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>(

File diff suppressed because it is too large Load Diff

View 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

View File

@@ -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;

View File

@@ -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;

View File

@@ -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,

View File

@@ -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

View File

@@ -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;

View File

@@ -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);

View File

@@ -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 {

View File

@@ -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)

View File

@@ -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);

View 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;

View File

@@ -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])

View File

@@ -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.

View 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,

View File

@@ -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;

View File

@@ -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)

View File

@@ -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

Binary file not shown.

View 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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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);

View File

@@ -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()

View File

@@ -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);

View File

@@ -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>