From 11e3eb05bd8694976d065f37d62ee1a6811daef3 Mon Sep 17 00:00:00 2001 From: Antoine Colombier <7086688+acolombier@users.noreply.github.com> Date: Sun, 25 Jan 2026 12:33:54 +0000 Subject: [PATCH] MP4: Add support for NI STEM (#1299) --- taglib/CMakeLists.txt | 2 + taglib/mp4/mp4atom.cpp | 7 +- taglib/mp4/mp4item.cpp | 16 ++++ taglib/mp4/mp4item.h | 6 +- taglib/mp4/mp4itemfactory.cpp | 21 ++++- taglib/mp4/mp4itemfactory.h | 5 ++ taglib/mp4/mp4stem.cpp | 64 ++++++++++++++ taglib/mp4/mp4stem.h | 78 +++++++++++++++++ taglib/mp4/mp4tag.cpp | 80 +++++++++++++----- tests/CMakeLists.txt | 1 + tests/test_mp4stem.cpp | 153 ++++++++++++++++++++++++++++++++++ 11 files changed, 411 insertions(+), 22 deletions(-) create mode 100644 taglib/mp4/mp4stem.cpp create mode 100644 taglib/mp4/mp4stem.h create mode 100644 tests/test_mp4stem.cpp diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index ce6197aa..9e45b261 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -194,6 +194,7 @@ if(WITH_MP4) mp4/mp4item.h mp4/mp4properties.h mp4/mp4coverart.h + mp4/mp4stem.h mp4/mp4itemfactory.h ) endif() @@ -369,6 +370,7 @@ if(WITH_MP4) mp4/mp4item.cpp mp4/mp4properties.cpp mp4/mp4coverart.cpp + mp4/mp4stem.cpp mp4/mp4itemfactory.cpp ) endif() diff --git a/taglib/mp4/mp4atom.cpp b/taglib/mp4/mp4atom.cpp index 97d66eb9..78c96a93 100644 --- a/taglib/mp4/mp4atom.cpp +++ b/taglib/mp4/mp4atom.cpp @@ -37,7 +37,7 @@ namespace { constexpr std::array containers { "moov", "udta", "mdia", "meta", "ilst", "stbl", "minf", "moof", "traf", "trak", - "stsd" + "stsd", "stem" }; } // namespace @@ -86,6 +86,11 @@ MP4::Atom::Atom(File *file) d->name = header.mid(4, 4); + if(d->name == "stem") { + file->seek(d->length - 8, File::Current); + return; + } + for(auto c : containers) { if(d->name == c) { if(d->name == "meta") { diff --git a/taglib/mp4/mp4item.cpp b/taglib/mp4/mp4item.cpp index b1b14d03..50a4a511 100644 --- a/taglib/mp4/mp4item.cpp +++ b/taglib/mp4/mp4item.cpp @@ -44,6 +44,7 @@ public: StringList m_stringList; ByteVectorList m_byteVectorList; MP4::CoverArtList m_coverArtList; + MP4::Stem m_stem; }; MP4::Item::Item() : @@ -130,6 +131,13 @@ MP4::Item::Item(const MP4::CoverArtList &value) : d->m_coverArtList = value; } +MP4::Item::Item(const MP4::Stem &value) : + d(std::make_shared()) +{ + d->type = Type::Stem; + d->m_stem = value; +} + void MP4::Item::setAtomDataType(MP4::AtomDataType type) { d->atomDataType = type; @@ -194,6 +202,12 @@ MP4::Item::toCoverArtList() const return d->m_coverArtList; } +MP4::Stem +MP4::Item::toStem() const +{ + return d->m_stem; +} + bool MP4::Item::isValid() const { @@ -234,6 +248,8 @@ bool MP4::Item::operator==(const Item &other) const return toByteVectorList() == other.toByteVectorList(); case Type::CoverArtList: return toCoverArtList() == other.toCoverArtList(); + case Type::Stem: + return toStem() == other.toStem(); } } return false; diff --git a/taglib/mp4/mp4item.h b/taglib/mp4/mp4item.h index 8f6ae9a8..9b894584 100644 --- a/taglib/mp4/mp4item.h +++ b/taglib/mp4/mp4item.h @@ -29,6 +29,7 @@ #include "tstringlist.h" #include "taglib_export.h" #include "mp4coverart.h" +#include "mp4stem.h" namespace TagLib { namespace MP4 { @@ -49,7 +50,8 @@ namespace TagLib { LongLong, StringList, ByteVectorList, - CoverArtList + CoverArtList, + Stem, }; struct IntPair { @@ -80,6 +82,7 @@ namespace TagLib { Item(const StringList &value); Item(const ByteVectorList &value); Item(const CoverArtList &value); + Item(const Stem &value); void setAtomDataType(AtomDataType type); AtomDataType atomDataType() const; @@ -93,6 +96,7 @@ namespace TagLib { StringList toStringList() const; ByteVectorList toByteVectorList() const; CoverArtList toCoverArtList() const; + Stem toStem() const; bool isValid() const; diff --git a/taglib/mp4/mp4itemfactory.cpp b/taglib/mp4/mp4itemfactory.cpp index c402bf98..56aec794 100644 --- a/taglib/mp4/mp4itemfactory.cpp +++ b/taglib/mp4/mp4itemfactory.cpp @@ -87,6 +87,8 @@ std::pair ItemFactory::parseItem( return parseGnre(atom, data); case ItemHandlerType::Covr: return parseCovr(atom, data); + case ItemHandlerType::Stem: + return parseStem(atom, data); case ItemHandlerType::TextImplicit: return parseText(atom, data, -1); case ItemHandlerType::Text: @@ -128,6 +130,8 @@ ByteVector ItemFactory::renderItem( return renderInt(name, item); case ItemHandlerType::Covr: return renderCovr(name, item); + case ItemHandlerType::Stem: + return renderStem(name, item); case ItemHandlerType::TextImplicit: return renderText(name, item, TypeImplicit); case ItemHandlerType::Text: @@ -175,8 +179,8 @@ std::pair ItemFactory::itemFromProperty( case ItemHandlerType::TextImplicit: case ItemHandlerType::Text: return {name, values}; - case ItemHandlerType::Covr: + case ItemHandlerType::Stem: debug("MP4: Invalid item \"" + name + "\" for property"); break; case ItemHandlerType::Unknown: @@ -222,6 +226,7 @@ std::pair ItemFactory::itemToProperty( return {key, item.toStringList()}; case ItemHandlerType::Covr: + case ItemHandlerType::Stem: debug("MP4: Invalid item \"" + itemName + "\" for property"); break; case ItemHandlerType::Unknown: @@ -303,6 +308,7 @@ ItemFactory::NameHandlerMap ItemFactory::nameHandlerMap() const {"akID", ItemHandlerType::Byte}, {"gnre", ItemHandlerType::Gnre}, {"covr", ItemHandlerType::Covr}, + {"stem", ItemHandlerType::Stem}, {"purl", ItemHandlerType::TextImplicit}, {"egid", ItemHandlerType::TextImplicit}, }; @@ -633,6 +639,12 @@ std::pair ItemFactory::parseCovr( }; } +std::pair ItemFactory::parseStem( + const MP4::Atom *atom, const ByteVector &data) +{ + return {atom->name(), Item(Stem(data))}; +} + ByteVector ItemFactory::renderAtom( const ByteVector &name, const ByteVector &data) @@ -742,6 +754,13 @@ ByteVector ItemFactory::renderCovr( return renderAtom(name, data); } +ByteVector ItemFactory::renderStem( + const ByteVector &name, const MP4::Item &item) +{ + auto data = item.toStem().data(); + return renderAtom(name, data); +} + ByteVector ItemFactory::renderFreeForm( const String &name, const MP4::Item &item) { diff --git a/taglib/mp4/mp4itemfactory.h b/taglib/mp4/mp4itemfactory.h index 88590e1c..9a3ca6bd 100644 --- a/taglib/mp4/mp4itemfactory.h +++ b/taglib/mp4/mp4itemfactory.h @@ -142,6 +142,7 @@ namespace TagLib { Byte, Gnre, Covr, + Stem, TextImplicit, Text }; @@ -214,6 +215,8 @@ namespace TagLib { const MP4::Atom *atom, const ByteVector &bytes); static std::pair parseCovr( const MP4::Atom *atom, const ByteVector &data); + static std::pair parseStem( + const MP4::Atom *atom, const ByteVector &data); // Functions used by renderItem() to render atom data for items. static ByteVector renderAtom( @@ -242,6 +245,8 @@ namespace TagLib { const ByteVector &name, const MP4::Item &item); static ByteVector renderCovr( const ByteVector &name, const MP4::Item &item); + static ByteVector renderStem( + const ByteVector &name, const MP4::Item &item); private: static ItemFactory factory; diff --git a/taglib/mp4/mp4stem.cpp b/taglib/mp4/mp4stem.cpp new file mode 100644 index 00000000..2ae13527 --- /dev/null +++ b/taglib/mp4/mp4stem.cpp @@ -0,0 +1,64 @@ +/************************************************************************** + copyright : (C) 2026 by Antoine Colombier + email : antoine@mixxx.org + **************************************************************************/ + +/*************************************************************************** + * 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 "mp4stem.h" + +using namespace TagLib; + +MP4::Stem::Stem(const ByteVector &data) : + d(std::make_shared()) +{ + d->data = data; +} + +MP4::Stem::Stem() = default; +MP4::Stem::Stem(const Stem &) = default; +MP4::Stem &MP4::Stem::operator=(const Stem &) = default; + +void +MP4::Stem::swap(Stem &item) noexcept +{ + using std::swap; + + swap(d, item.d); +} + +MP4::Stem::~Stem() = default; + +ByteVector +MP4::Stem::data() const +{ + return d->data; +} + +bool MP4::Stem::operator==(const Stem &other) const +{ + return data() == other.data(); +} + +bool MP4::Stem::operator!=(const Stem &other) const +{ + return !(*this == other); +} diff --git a/taglib/mp4/mp4stem.h b/taglib/mp4/mp4stem.h new file mode 100644 index 00000000..4a161dc2 --- /dev/null +++ b/taglib/mp4/mp4stem.h @@ -0,0 +1,78 @@ +/************************************************************************** + copyright : (C) 2026 by Antoine Colombier + email : antoine@mixxx.org + **************************************************************************/ + +/*************************************************************************** + * 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_MP4STEM_H +#define TAGLIB_MP4STEM_H + +#include "tbytevector.h" +#include "taglib_export.h" + +namespace TagLib::MP4 { + //! STEM + class StemPrivate + { + public: + ByteVector data; + }; + + class TAGLIB_EXPORT Stem + { + public: + Stem(); + explicit Stem(const ByteVector &data); + ~Stem(); + + Stem(const Stem &item); + + /*! + * Copies the contents of \a item into this Stem. + */ + Stem &operator=(const Stem &item); + + /*! + * Exchanges the content of the Stem with the content of \a item. + */ + void swap(Stem &item) noexcept; + + //! The Stem data + ByteVector data() const; + + /*! + * Returns \c true if the Stem and \a other contain the same data. + */ + bool operator==(const Stem &other) const; + + /*! + * Returns \c true if the Stem and \a other have different data. + */ + bool operator!=(const Stem &other) const; + + private: + TAGLIB_MSVC_SUPPRESS_WARNING_NEEDS_TO_HAVE_DLL_INTERFACE + std::shared_ptr d; + }; +} // namespace TagLib::MP4 + +#endif diff --git a/taglib/mp4/mp4tag.cpp b/taglib/mp4/mp4tag.cpp index 6e094963..1640cb05 100644 --- a/taglib/mp4/mp4tag.cpp +++ b/taglib/mp4/mp4tag.cpp @@ -32,6 +32,7 @@ #include "mp4itemfactory.h" #include "mp4atom.h" #include "mp4coverart.h" +#include "mp4stem.h" using namespace TagLib; @@ -65,15 +66,22 @@ MP4::Tag::Tag(TagLib::File *file, MP4::Atoms *atoms, d->atoms = atoms; const MP4::Atom *ilst = atoms->find("moov", "udta", "meta", "ilst"); - if(!ilst) { - //debug("Atom moov.udta.meta.ilst not found."); - return; + if(ilst) { + for(const auto &atom : ilst->children()) { + file->seek(atom->offset() + 8); + ByteVector data = d->file->readBlock(atom->length() - 8); + if(const auto &[name, itm] = d->factory->parseItem(atom, data); + itm.isValid()) { + addItem(name, itm); + } + } } - for(const auto &atom : ilst->children()) { - file->seek(atom->offset() + 8); - ByteVector data = d->file->readBlock(atom->length() - 8); - if(const auto &[name, itm] = d->factory->parseItem(atom, data); + const MP4::Atom *stem = atoms->find("moov", "udta", "stem"); + if(stem) { + file->seek(stem->offset() + 8); + ByteVector data = d->file->readBlock(stem->length() - 8); + if(const auto &[name, itm] = d->factory->parseItem(stem, data); itm.isValid()) { addItem(name, itm); } @@ -100,18 +108,33 @@ MP4::Tag::renderAtom(const ByteVector &name, const ByteVector &data) const bool MP4::Tag::save() { - ByteVector data; + ByteVector ilstData, stemData; for(const auto &[name, itm] : std::as_const(d->items)) { - data.append(d->factory->renderItem(name, itm)); + if(name == "stem"){ + stemData.append(d->factory->renderItem(name, itm)); + } else { + ilstData.append(d->factory->renderItem(name, itm)); + } } - data = renderAtom("ilst", data); + ilstData = renderAtom("ilst", ilstData); AtomList path = d->atoms->path("moov", "udta", "meta", "ilst"); if(path.size() == 4) { - saveExisting(data, path); + saveExisting(ilstData, path); } else { - saveNew(data); + ByteVector metaData = renderAtom("meta", ByteVector(4, '\0') + + renderAtom("hdlr", ByteVector(8, '\0') + ByteVector("mdirappl") + + ByteVector(9, '\0')) + + ilstData + padIlst(ilstData)); + saveNew(metaData); + } + + path = d->atoms->path("moov", "udta", "stem"); + if(path.size() == 3) { + saveExisting(stemData, path); + } else if (!stemData.isEmpty()) { + saveNew(stemData); } return true; @@ -127,6 +150,11 @@ MP4::Tag::strip() saveExisting(ByteVector(), path); } + path = d->atoms->path("moov", "udta", "stem"); + if(path.size() == 3) { + saveExisting(ByteVector(), path); + } + return true; } @@ -227,11 +255,6 @@ MP4::Tag::updateOffsets(offset_t delta, offset_t offset) void MP4::Tag::saveNew(ByteVector data) { - data = renderAtom("meta", ByteVector(4, '\0') + - renderAtom("hdlr", ByteVector(8, '\0') + ByteVector("mdirappl") + - ByteVector(9, '\0')) + - data + padIlst(data)); - AtomList path = d->atoms->path("moov", "udta"); if(path.size() != 2) { path = d->atoms->path("moov"); @@ -510,13 +533,17 @@ StringList MP4::Tag::complexPropertyKeys() const if(d->items.contains("covr")) { keys.append("PICTURE"); } + if(d->items.contains("stem")) { + keys.append("STEM"); + } return keys; } List MP4::Tag::complexProperties(const String &key) const { List props; - if(const String uppercaseKey = key.upper(); uppercaseKey == "PICTURE") { + const String uppercaseKey = key.upper(); + if(uppercaseKey == "PICTURE") { const CoverArtList pictures = d->items.value("covr").toCoverArtList(); for(const CoverArt &picture : pictures) { String mimeType = "image/"; @@ -543,12 +570,20 @@ List MP4::Tag::complexProperties(const String &key) const props.append(property); } } + else if(uppercaseKey == "STEM" && d->items.contains("stem")) { + const Stem stem = d->items.value("stem").toStem(); + + VariantMap property; + property.insert("manifest", stem.data()); + props.append(property); + } return props; } bool MP4::Tag::setComplexProperties(const String &key, const List &value) { - if(const String uppercaseKey = key.upper(); uppercaseKey == "PICTURE") { + const String uppercaseKey = key.upper(); + if(uppercaseKey == "PICTURE") { CoverArtList pictures; for(const auto &property : value) { auto mimeType = property.value("mimeType").value(); @@ -568,6 +603,13 @@ bool MP4::Tag::setComplexProperties(const String &key, const List &v } d->items["covr"] = pictures; } + else if(uppercaseKey == "STEM") { + if (!value.isEmpty()) { + d->items["stem"] = Stem(value.front().value("manifest").value()); + } else { + d->items.erase("stem"); + } + } else { return false; } diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 2d4a0c5d..103e97f7 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -131,6 +131,7 @@ IF(WITH_MP4) test_mp4.cpp test_mp4item.cpp test_mp4coverart.cpp + test_mp4stem.cpp ) ENDIF() IF(WITH_ASF) diff --git a/tests/test_mp4stem.cpp b/tests/test_mp4stem.cpp new file mode 100644 index 00000000..043952d9 --- /dev/null +++ b/tests/test_mp4stem.cpp @@ -0,0 +1,153 @@ +/*************************************************************************** + copyright : (C) 2026 by Antoine Colombier + email : antoine@mixxx.org + ***************************************************************************/ + +/*************************************************************************** + * 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 + +#include "tag.h" +#include "mp4file.h" +#include +#include "utils.h" + +using namespace std; +using namespace TagLib; + +namespace { +const String STEM_KEY("STEM"); +} + +class TestMP4Stem : public CppUnit::TestFixture +{ + CPPUNIT_TEST_SUITE(TestMP4Stem); + CPPUNIT_TEST(testCreate); + CPPUNIT_TEST(testUpdate); + CPPUNIT_TEST(testRemove); + CPPUNIT_TEST_SUITE_END(); + +protected: + static void createTestFile(ScopedFileCopy ©){ + MP4::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(!f.hasMP4Tag()); + auto &tag = *f.tag(); + CPPUNIT_ASSERT_EQUAL(0U, tag.complexProperties(STEM_KEY).size()); + CPPUNIT_ASSERT(tag.setComplexProperties(STEM_KEY, List({{{"manifest", ByteVector("{some text data}")}}}))); + f.save(); + } + +public: + + void testCreate() + { + ScopedFileCopy copy("no-tags", ".m4a"); + createTestFile(copy); + + // Assert whether the newly created stem content is as expected + { + MP4::File f(copy.fileName().c_str()); + auto &tag = *f.tag(); + CPPUNIT_ASSERT(f.hasMP4Tag()); + auto stems = tag.complexProperties(STEM_KEY); + CPPUNIT_ASSERT_EQUAL(1U, stems.size()); + CPPUNIT_ASSERT(stems.front().contains("manifest")); + CPPUNIT_ASSERT_EQUAL(Variant(ByteVector("{some text data}")), stems.front().find("manifest")->second); + } + } + + void testUpdate() + { + ScopedFileCopy copy("no-tags", ".m4a"); + createTestFile(copy); + + // Prepare so large test data, to ensure that free padding is correctly used + char *buffer = new char[1025]{'X'}; + buffer[1024] = '\0'; + String artist(buffer); + std::memset(buffer, 'Y', 1024); + String title(buffer); + std::memset(buffer, 'Z', 1024); + ByteVector newStem(buffer); + delete [] buffer; + + // Update tags and stems to force atom offset recalculation + { + MP4::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(f.setComplexProperties("PICTURE", List({ + { + {"data", ByteVector("DummyData")}, + {"pictureType", "Front Cover"}, + {"mimeType", String("image/png")}, + {"description", String("Test")} + } + }))); + CPPUNIT_ASSERT(f.tag()->setComplexProperties(STEM_KEY, List({{{"manifest", newStem}}}))); + f.tag()->setArtist(artist); + f.tag()->setTitle(title); + f.save(); + } + // Reload the file to assert its tags + { + MP4::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(f.hasMP4Tag()); + + auto stems = f.tag()->complexProperties(STEM_KEY); + CPPUNIT_ASSERT_EQUAL(1U, stems.size()); + CPPUNIT_ASSERT(stems.front().contains("manifest")); + CPPUNIT_ASSERT_EQUAL(Variant(newStem), stems.front().find("manifest")->second); + + auto pictures = f.tag()->complexProperties("PICTURE"); + CPPUNIT_ASSERT_EQUAL(1U, pictures.size()); + CPPUNIT_ASSERT_EQUAL(VariantMap({ + { + {"data", ByteVector("DummyData")}, + {"mimeType", String("image/png")}, + } + }), pictures.front()); + + CPPUNIT_ASSERT_EQUAL(title, f.tag()->title()); + CPPUNIT_ASSERT_EQUAL(artist, f.tag()->artist()); + } + } + + void testRemove() + { + ScopedFileCopy copy("no-tags", ".m4a"); + createTestFile(copy); + + // Remove the stem + { + MP4::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(f.hasMP4Tag()); + CPPUNIT_ASSERT(f.tag()->setComplexProperties(STEM_KEY, List())); + f.save(); + } + { + MP4::File f(copy.fileName().c_str()); + CPPUNIT_ASSERT(!f.hasMP4Tag()); + CPPUNIT_ASSERT(!f.tag()->complexPropertyKeys().contains(STEM_KEY)); + } + } + +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4Stem);