mirror of
https://github.com/taglib/taglib.git
synced 2026-05-25 13:08:55 -04:00
[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.
This commit is contained in:
committed by
Urs Fleisch
parent
e07b956fda
commit
1e7bdae284
@@ -70,6 +70,8 @@ public:
|
||||
|
||||
std::unique_ptr<Properties> properties;
|
||||
ByteVector xiphCommentData;
|
||||
String iXMLData;
|
||||
ByteVector bextData;
|
||||
List<FLAC::MetadataBlock *> blocks;
|
||||
|
||||
offset_t flacStart { 0 };
|
||||
@@ -241,6 +243,52 @@ bool FLAC::File::save()
|
||||
|
||||
d->xiphCommentData = xiphComment()->render(false);
|
||||
|
||||
// Drop any APPLICATION blocks we recognize as iXML or bext from the block
|
||||
// list. Recognized blocks were normally extracted to d->iXMLData /
|
||||
// d->bextData during scan() and never added here, but this also catches
|
||||
// entries inserted after scan() (defensive).
|
||||
for(auto it = d->blocks.begin(); it != d->blocks.end();) {
|
||||
if((*it)->code() == MetadataBlock::Application) {
|
||||
const ByteVector blockData = (*it)->render();
|
||||
if(blockData.size() >= 4) {
|
||||
const ByteVector appId = blockData.mid(0, 4);
|
||||
ByteVector innerId;
|
||||
if(appId == "riff" && blockData.size() >= 12)
|
||||
innerId = blockData.mid(4, 4);
|
||||
else if(appId == "iXML" || appId == "bext")
|
||||
innerId = appId;
|
||||
|
||||
if(innerId == "iXML" || innerId == "bext") {
|
||||
delete *it;
|
||||
it = d->blocks.erase(it);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
}
|
||||
++it;
|
||||
}
|
||||
|
||||
// Append fresh APPLICATION/"riff" blocks for iXML and bext if non-empty.
|
||||
// Per FLAC foreign-metadata convention the payload is a RIFF chunk:
|
||||
// <4 byte FOURCC><4 byte LE size><data>.
|
||||
if(!d->iXMLData.isEmpty()) {
|
||||
const ByteVector xml = d->iXMLData.data(String::UTF8);
|
||||
ByteVector payload;
|
||||
payload.append("riff");
|
||||
payload.append("iXML");
|
||||
payload.append(ByteVector::fromUInt(xml.size(), false));
|
||||
payload.append(xml);
|
||||
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
|
||||
}
|
||||
if(!d->bextData.isEmpty()) {
|
||||
ByteVector payload;
|
||||
payload.append("riff");
|
||||
payload.append("bext");
|
||||
payload.append(ByteVector::fromUInt(d->bextData.size(), false));
|
||||
payload.append(d->bextData);
|
||||
d->blocks.append(new UnknownMetadataBlock(MetadataBlock::Application, payload));
|
||||
}
|
||||
|
||||
// Replace metadata blocks
|
||||
|
||||
MetadataBlock *commentBlock =
|
||||
@@ -433,6 +481,26 @@ void FLAC::File::removePictures()
|
||||
}
|
||||
}
|
||||
|
||||
String FLAC::File::iXMLData() const
|
||||
{
|
||||
return d->iXMLData;
|
||||
}
|
||||
|
||||
void FLAC::File::setiXMLData(const String &data)
|
||||
{
|
||||
d->iXMLData = data;
|
||||
}
|
||||
|
||||
ByteVector FLAC::File::BEXTData() const
|
||||
{
|
||||
return d->bextData;
|
||||
}
|
||||
|
||||
void FLAC::File::setBEXTData(const ByteVector &data)
|
||||
{
|
||||
d->bextData = data;
|
||||
}
|
||||
|
||||
void FLAC::File::strip(int tags)
|
||||
{
|
||||
if(tags & ID3v1)
|
||||
@@ -462,6 +530,16 @@ bool FLAC::File::hasID3v2Tag() const
|
||||
return d->ID3v2Location >= 0;
|
||||
}
|
||||
|
||||
bool FLAC::File::hasiXMLData() const
|
||||
{
|
||||
return !d->iXMLData.isEmpty();
|
||||
}
|
||||
|
||||
bool FLAC::File::hasBEXTData() const
|
||||
{
|
||||
return !d->bextData.isEmpty();
|
||||
}
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// private members
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
@@ -613,6 +691,49 @@ void FLAC::File::scan()
|
||||
else if(blockType == MetadataBlock::Padding) {
|
||||
// Skip all padding blocks.
|
||||
}
|
||||
else if(blockType == MetadataBlock::Application && data.size() >= 4) {
|
||||
// APPLICATION block (RFC 9639 § 8.4):
|
||||
// <4 bytes> big-endian application ID (ASCII FOURCC in practice)
|
||||
// <n bytes> application-defined data
|
||||
//
|
||||
// We recognize two conventions for carrying RIFF iXML / bext metadata:
|
||||
// 1. App ID "riff" — IANA-registered FLAC foreign-metadata wrapper.
|
||||
// Payload is a RIFF chunk: <4 byte FOURCC><4 byte LE size><data>.
|
||||
// 2. App ID "iXML" or "bext" — direct, used by some third-party tools
|
||||
// (e.g. Sequoia). Payload is the chunk data verbatim.
|
||||
//
|
||||
// Other application IDs (and "riff" wrapping FOURCCs we don't recognize)
|
||||
// fall through to UnknownMetadataBlock so they round-trip unchanged.
|
||||
const ByteVector appId = data.mid(0, 4);
|
||||
ByteVector innerId;
|
||||
ByteVector innerData;
|
||||
|
||||
if(appId == "riff" && data.size() >= 12) {
|
||||
innerId = data.mid(4, 4);
|
||||
const unsigned int innerSize = data.toUInt(8U, false);
|
||||
innerData = data.mid(12, innerSize);
|
||||
}
|
||||
else if(appId == "iXML" || appId == "bext") {
|
||||
innerId = appId;
|
||||
innerData = data.mid(4);
|
||||
}
|
||||
|
||||
if(innerId == "iXML") {
|
||||
if(d->iXMLData.isEmpty())
|
||||
d->iXMLData = String(innerData, String::UTF8);
|
||||
else
|
||||
debug("FLAC::File::scan() -- multiple iXML blocks found, discarding");
|
||||
}
|
||||
else if(innerId == "bext") {
|
||||
if(d->bextData.isEmpty())
|
||||
d->bextData = innerData;
|
||||
else
|
||||
debug("FLAC::File::scan() -- multiple BEXT blocks found, discarding");
|
||||
}
|
||||
else {
|
||||
block = new UnknownMetadataBlock(blockType, data);
|
||||
}
|
||||
}
|
||||
else {
|
||||
block = new UnknownMetadataBlock(blockType, data);
|
||||
}
|
||||
|
||||
@@ -296,6 +296,52 @@ namespace TagLib {
|
||||
*/
|
||||
void addPicture(Picture *picture);
|
||||
|
||||
/*!
|
||||
* Returns the raw iXML data as a String. Empty if no iXML metadata
|
||||
* is present. Read from an APPLICATION metadata block (RFC 9639 § 8.4)
|
||||
* carrying either the FLAC foreign-metadata application ID "riff"
|
||||
* (with an iXML RIFF chunk as payload) or the direct application ID
|
||||
* "iXML" used by some third-party tools.
|
||||
*
|
||||
* \see setiXMLData()
|
||||
* \see hasiXMLData()
|
||||
*/
|
||||
String iXMLData() const;
|
||||
|
||||
/*!
|
||||
* Sets the iXML data. Pass an empty string to remove the iXML
|
||||
* APPLICATION block on save. On save, the data is written using the
|
||||
* FLAC foreign-metadata convention: an APPLICATION block with
|
||||
* application ID "riff" wrapping an iXML RIFF chunk.
|
||||
*
|
||||
* \see iXMLData()
|
||||
* \see hasiXMLData()
|
||||
*/
|
||||
void setiXMLData(const String &data);
|
||||
|
||||
/*!
|
||||
* Returns the raw BEXT (Broadcast Audio Extension) data as a
|
||||
* ByteVector. Empty if no BEXT metadata is present. Read from an
|
||||
* APPLICATION metadata block (RFC 9639 § 8.4) carrying either the FLAC
|
||||
* foreign-metadata application ID "riff" (with a bext RIFF chunk as
|
||||
* payload) or the direct application ID "bext".
|
||||
*
|
||||
* \see setBEXTData()
|
||||
* \see hasBEXTData()
|
||||
*/
|
||||
ByteVector BEXTData() const;
|
||||
|
||||
/*!
|
||||
* Sets the BEXT data. Pass an empty ByteVector to remove the BEXT
|
||||
* APPLICATION block on save. On save, the data is written using the
|
||||
* FLAC foreign-metadata convention: an APPLICATION block with
|
||||
* application ID "riff" wrapping a bext RIFF chunk.
|
||||
*
|
||||
* \see BEXTData()
|
||||
* \see hasBEXTData()
|
||||
*/
|
||||
void setBEXTData(const ByteVector &data);
|
||||
|
||||
/*!
|
||||
* This will remove the tags that match the OR-ed together TagTypes from
|
||||
* the file. By default it removes all tags.
|
||||
@@ -332,6 +378,22 @@ namespace TagLib {
|
||||
*/
|
||||
bool hasID3v2Tag() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the file on disk actually has iXML data
|
||||
* stored in an APPLICATION metadata block.
|
||||
*
|
||||
* \see iXMLData()
|
||||
*/
|
||||
bool hasiXMLData() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the file on disk actually has BEXT data
|
||||
* stored in an APPLICATION metadata block.
|
||||
*
|
||||
* \see BEXTData()
|
||||
*/
|
||||
bool hasBEXTData() const;
|
||||
|
||||
/*!
|
||||
* Returns whether or not the given \a stream can be opened as a FLAC
|
||||
* file.
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user