From 1e7bdae284ae1c72c6b168293c40863caac7096f Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Sun, 26 Apr 2026 09:46:19 -0700 Subject: [PATCH] [FLAC] Add iXML and BEXT support via APPLICATION blocks MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- taglib/flac/flacfile.cpp | 121 ++++++++++++++++++++++ taglib/flac/flacfile.h | 62 ++++++++++++ tests/test_flac.cpp | 211 +++++++++++++++++++++++++++++++++++++++ 3 files changed, 394 insertions(+) diff --git a/taglib/flac/flacfile.cpp b/taglib/flac/flacfile.cpp index a282c95a..017df40f 100644 --- a/taglib/flac/flacfile.cpp +++ b/taglib/flac/flacfile.cpp @@ -70,6 +70,8 @@ public: std::unique_ptr properties; ByteVector xiphCommentData; + String iXMLData; + ByteVector bextData; List 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>. + 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) + // 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>. + // 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); } diff --git a/taglib/flac/flacfile.h b/taglib/flac/flacfile.h index 4186f222..4aeace7f 100644 --- a/taglib/flac/flacfile.h +++ b/taglib/flac/flacfile.h @@ -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. diff --git a/tests/test_flac.cpp b/tests/test_flac.cpp index d80b9537..3541b3f5 100644 --- a/tests/test_flac.cpp +++ b/tests/test_flac.cpp @@ -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(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("1.0"); + 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("1"); + 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("1.6"); + 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(""); + 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(""); + 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(""), f2.iXMLData()); + } + }; CPPUNIT_TEST_SUITE_REGISTRATION(TestFLAC);