9 Commits
v2.3 ... master

Author SHA1 Message Date
HomerJau
a27dca557e [Matroska] Allow Chapters Without a ChapterUID
Fix: Don't silently drop ChapterAtom elements that omit MkChapterUID

parseChapterAtom() returned chapterUid = 0 when the source file omitted
the MkChapterUID element, and the existing call sites in
MkChapters::parse() (ebmlmkchapters.cpp lines 106 and 127) use a C++17
init-if `chapter.uid()` predicate that silently dropped such chapters.
The caller saw an empty ChapterEditionList for files that mkvinfo,
MediaInfo and FFmpeg all handle gracefully — produced by some audiobook
generators and older muxers that omit the per-chapter UID. The
companion orphan-EditionEntry fix in PR #1311 / commit e07b956f doesn't
cover this case because the ChapterAtoms there ARE wrapped in an
EditionEntry, just without a ChapterUID inside each atom.

This change:
- Synthesises a process-unique ChapterUID inside parseChapterAtom() when
  the source file lacks MkChapterUID. The synthetic value sets the high
  bit (1ULL << 63) and increments via a static std::atomic counter; real
  ChapterUIDs are random 64-bit values from muxers, so collision with a
  generated one is practically impossible while keeping the distinction
  local to TagLib.
- The existing chapter.uid() filters at the call sites then always
  evaluate truthy and the chapter is exposed through the public
  ChapterEditionList API as if it were spec-compliant.

No existing behavior is changed — files that already conform to the spec
(ChapterAtoms with a ChapterUID element) parse identically; only
previously-dropped chapters are now surfaced.

Reported and verified against real-world chaptered Matroska audiobook
files where mkvinfo / MediaInfo see all chapters but TagLib 2.3 returned
an empty ChapterEditionList.
2026-05-19 21:32:40 +02:00
Ryan Francesconi
8fcda2daa2 [FLAC] Pack hasiXML/hasBEXT with scanned to save padding
Per review feedback on #1364: moving the two new bool flags to the end
of FilePrivate, next to the existing `scanned` bool, lets the compiler
coalesce the three bytes into the same trailing padding slot.  Saves one
machine word per FLAC::File instance versus placing the flags mid-struct
between bextData and the List<MetadataBlock*>.

Pure layout change, no behaviour difference.  Test suite still green.
2026-05-17 16:38:07 +02:00
Ryan Francesconi
c32f7c7f86 [FLAC] Track iXML/BEXT block presence with explicit flags
hasiXMLData() / hasBEXTData() were implemented as !data.isEmpty()
checks, which conflated in-memory payload with on-disk block presence.
That caused two wrong answers:

* setiXMLData("foo") on a file with no iXML block made hasiXMLData()
  return true immediately, before save().
* A FLAC file carrying an iXML APPLICATION block with empty payload
  round-tripped fine, but hasiXMLData() reported false.

Switch to the same model RIFF::WAV::File already uses: explicit
hasiXML / hasBEXT bool flags on FilePrivate, set during scan() when
the APPLICATION block is recognised, updated during save() after the
block is (re)written or omitted, and returned verbatim by the
accessors. New regression test pins down the before/after-save and
empty-block cases.

Refs: https://github.com/taglib/taglib/issues/1362
2026-05-17 16:38:07 +02:00
Stephen Booth
e23d97c580 Add algorithm include for std::min and max 2026-05-17 08:18:58 +02:00
Stephen Booth
b8b91fd072 Add algorithm include for std::find_if 2026-05-17 08:18:58 +02:00
Stephen Booth
e83e02da2e Correct documentation comment for timeEnd 2026-05-17 08:16:07 +02:00
Stephen Booth
0b5296e20e Correct assignment operator qualification 2026-05-17 08:13:48 +02:00
Stephen Booth
83fdf27cd7 Correct assignment operator qualification 2026-05-17 08:13:48 +02:00
Stephen Booth
7010d112ba Correct destructor qualification 2026-05-17 08:12:12 +02:00
18 changed files with 113 additions and 8 deletions

View File

@@ -77,6 +77,8 @@ public:
offset_t flacStart { 0 };
offset_t streamStart { 0 };
bool scanned { false };
bool hasiXML { false };
bool hasBEXT { false };
};
////////////////////////////////////////////////////////////////////////////////
@@ -279,6 +281,10 @@ bool FLAC::File::save()
payload.append(ByteVector::fromUInt(xml.size(), false));
payload.append(xml);
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
d->hasiXML = true;
}
else {
d->hasiXML = false;
}
if(!d->bextData.isEmpty()) {
ByteVector payload;
@@ -287,6 +293,10 @@ bool FLAC::File::save()
payload.append(ByteVector::fromUInt(d->bextData.size(), false));
payload.append(d->bextData);
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
d->hasBEXT = true;
}
else {
d->hasBEXT = false;
}
// Replace metadata blocks
@@ -532,12 +542,12 @@ bool FLAC::File::hasID3v2Tag() const
bool FLAC::File::hasiXMLData() const
{
return !d->iXMLData.isEmpty();
return d->hasiXML;
}
bool FLAC::File::hasBEXTData() const
{
return !d->bextData.isEmpty();
return d->hasBEXT;
}
////////////////////////////////////////////////////////////////////////////////
@@ -719,14 +729,18 @@ void FLAC::File::scan()
}
if(innerId == "iXML") {
if(d->iXMLData.isEmpty())
if(!d->hasiXML) {
d->hasiXML = true;
d->iXMLData = String(innerData, String::UTF8);
}
else
debug("FLAC::File::scan() -- multiple iXML blocks found, discarding");
}
else if(innerId == "bext") {
if(d->bextData.isEmpty())
if(!d->hasBEXT) {
d->hasBEXT = true;
d->bextData = innerData;
}
else
debug("FLAC::File::scan() -- multiple BEXT blocks found, discarding");
}

View File

@@ -25,6 +25,7 @@
#include "ebmlmkchapters.h"
#include "ebmlstringelement.h"
#include <atomic>
#include "ebmluintelement.h"
#include "matroskachapters.h"
#include "matroskachapteredition.h"
@@ -33,6 +34,20 @@ using namespace TagLib;
namespace {
// Counter for synthesising a unique ChapterUID when the source file omits
// the MkChapterUID element (out-of-spec but produced by some muxers, e.g.
// older FFmpeg or audiobook generators). MKVToolNix / MediaInfo / FFmpeg
// all tolerate UID-less chapters; without this synth, the call sites in
// MkChapters::parse() filter such chapters out via the `chapter.uid()`
// check and they are silently lost.
//
// High bit set as a marker; counter starts at 1 and increments per atom
// so each parsed UID-less chapter gets a distinct value within the
// process lifetime. Real ChapterUIDs are random 64-bit values from
// muxers; the (1ULL << 63) | n encoding makes collision practically
// impossible while keeping the distinction local to TagLib.
static std::atomic<Matroska::Chapter::UID> synthesisedChapterUidCounter{1};
Matroska::Chapter parseChapterAtom(
const std::unique_ptr<EBML::Element> &atomElement)
{
@@ -67,6 +82,13 @@ Matroska::Chapter parseChapterAtom(
}
}
}
// Spec-noncompliant: ChapterAtom missing ChapterUID. Synthesise a
// process-unique UID so the chapter survives downstream `chapter.uid()`
// filters. See synthesisedChapterUidCounter comment above.
if(chapterUid == 0)
chapterUid = (1ULL << 63) |
synthesisedChapterUidCounter.fetch_add(1, std::memory_order_relaxed);
return Matroska::Chapter(chapterTimeStart, chapterTimeEnd, chapterDisplays,
chapterUid, chapterHidden);
}

View File

@@ -19,6 +19,9 @@
***************************************************************************/
#include "ebmlmksegment.h"
#include <algorithm>
#include "ebmlutils.h"
#include "matroskafile.h"
#include "matroskatag.h"

View File

@@ -19,7 +19,10 @@
***************************************************************************/
#include "ebmlutils.h"
#include <algorithm>
#include <random>
#include "tbytevector.h"
#include "matroskafile.h"
#include "tutils.h"

View File

@@ -19,7 +19,9 @@
***************************************************************************/
#include "ebmlvoidelement.h"
#include <algorithm>
#include "ebmlutils.h"
#include "tbytevector.h"

View File

@@ -19,6 +19,9 @@
***************************************************************************/
#include "matroskaattachments.h"
#include <algorithm>
#include "matroskaattachedfile.h"
#include "ebmlmkattachments.h"
#include "ebmlmasterelement.h"

View File

@@ -153,7 +153,7 @@ namespace TagLib {
Time timeStart() const;
/*!
* Returns the timestamp of the start of the chapter in nanoseconds.
* Returns the timestamp of the end of the chapter in nanoseconds.
*/
Time timeEnd() const;

View File

@@ -24,6 +24,9 @@
***************************************************************************/
#include "matroskachapters.h"
#include <algorithm>
#include "matroskachapteredition.h"
#include "ebmlstringelement.h"
#include "ebmlbinaryelement.h"

View File

@@ -19,7 +19,10 @@
***************************************************************************/
#include "matroskafile.h"
#include <algorithm>
#include <memory>
#include "matroskatag.h"
#include "matroskaattachments.h"
#include "matroskaattachedfile.h"

View File

@@ -19,6 +19,9 @@
***************************************************************************/
#include "matroskaseekhead.h"
#include <algorithm>
#include "ebmlmkseekhead.h"
#include "ebmlbinaryelement.h"
#include "ebmluintelement.h"

View File

@@ -19,9 +19,11 @@
***************************************************************************/
#include "matroskatag.h"
#include <algorithm>
#include <array>
#include <tuple>
#include "ebmlmasterelement.h"
#include "ebmlstringelement.h"
#include "ebmlbinaryelement.h"

View File

@@ -25,6 +25,8 @@
#include "modfile.h"
#include <algorithm>
#include "tstringlist.h"
#include "tdebug.h"
#include "tpropertymap.h"

View File

@@ -25,6 +25,7 @@
#include "mp4atom.h"
#include <algorithm>
#include <array>
#include <climits>
#include <utility>

View File

@@ -50,15 +50,15 @@ MP4::Chapter::Chapter(const Chapter &other) :
MP4::Chapter::Chapter(Chapter &&other) noexcept = default;
MP4::Chapter::Chapter::~Chapter() = default;
MP4::Chapter::~Chapter() = default;
MP4::Chapter &MP4::Chapter::Chapter::operator=(const Chapter &other)
MP4::Chapter &MP4::Chapter::operator=(const Chapter &other)
{
Chapter(other).swap(*this);
return *this;
}
MP4::Chapter &MP4::Chapter::Chapter::operator=(
MP4::Chapter &MP4::Chapter::operator=(
Chapter &&other) noexcept = default;
bool MP4::Chapter::operator==(const Chapter &other) const

View File

@@ -25,6 +25,7 @@
#include "textidentificationframe.h"
#include <algorithm>
#include <array>
#include <utility>

View File

@@ -25,6 +25,8 @@
#include "mpegfile.h"
#include <algorithm>
#include "taglib_config.h"
#include "id3v2framefactory.h"
#include "tdebug.h"

View File

@@ -25,6 +25,7 @@
#include "tagunion.h"
#include <algorithm>
#include <array>
#include "tstringlist.h"

View File

@@ -74,6 +74,7 @@ class TestFLAC : public CppUnit::TestFixture
CPPUNIT_TEST(testReadBEXTRiffWrapped);
CPPUNIT_TEST(testWriteiXMLAndBEXT);
CPPUNIT_TEST(testWriteEmptyClearsiXMLAndBEXT);
CPPUNIT_TEST(testHasiXMLAndBEXTReflectFileState);
CPPUNIT_TEST(testRoundTripPreservesUnknownApplicationBlock);
CPPUNIT_TEST_SUITE_END();
@@ -833,6 +834,45 @@ public:
}
}
void testHasiXMLAndBEXTReflectFileState()
{
// hasiXMLData() / hasBEXTData() must report whether the *file* carries an
// iXML / bext APPLICATION block, not whether in-memory payload happens to
// be non-empty. Regression test for an issue where the accessors were
// implemented as !data.isEmpty() and so flipped on as soon as set*Data()
// was called, before save(), and missed a real-but-empty block.
ScopedFileCopy copy("silence-44-s", ".flac");
const string newname = copy.fileName();
{
FLAC::File f(newname.c_str());
CPPUNIT_ASSERT(!f.hasiXMLData());
CPPUNIT_ASSERT(!f.hasBEXTData());
f.setiXMLData("<BWFXML/>");
f.setBEXTData(ByteVector("bext"));
// File hasn't been saved yet — file still has no blocks.
CPPUNIT_ASSERT(!f.hasiXMLData());
CPPUNIT_ASSERT(!f.hasBEXTData());
f.save();
// After save the blocks are on disk.
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT(f.hasBEXTData());
}
{
FLAC::File f(newname.c_str());
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT(f.hasBEXTData());
f.setiXMLData(String());
f.setBEXTData(ByteVector());
// In-memory payload now empty but the file still carries both blocks.
CPPUNIT_ASSERT(f.hasiXMLData());
CPPUNIT_ASSERT(f.hasBEXTData());
f.save();
CPPUNIT_ASSERT(!f.hasiXMLData());
CPPUNIT_ASSERT(!f.hasBEXTData());
}
}
void testRoundTripPreservesUnknownApplicationBlock()
{
// Source: silence-44-s with an extra APPLICATION/"SMED" block injected