From 9c56f191e5e3b3daa5eb9e381cc27845e2a731dc Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Sat, 4 Apr 2026 07:30:47 -0700 Subject: [PATCH 01/11] 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. --- taglib/CMakeLists.txt | 2 + taglib/mp4/mp4chapterlist.cpp | 338 ++++++++++++++++++++++++++++++++++ taglib/mp4/mp4chapterlist.h | 77 ++++++++ tests/test_mp4.cpp | 161 ++++++++++++++++ 4 files changed, 578 insertions(+) create mode 100644 taglib/mp4/mp4chapterlist.cpp create mode 100644 taglib/mp4/mp4chapterlist.h diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index 9e45b261..84da534e 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -196,6 +196,7 @@ if(WITH_MP4) mp4/mp4coverart.h mp4/mp4stem.h mp4/mp4itemfactory.h + mp4/mp4chapterlist.h ) endif() if(WITH_MOD) @@ -372,6 +373,7 @@ if(WITH_MP4) mp4/mp4coverart.cpp mp4/mp4stem.cpp mp4/mp4itemfactory.cpp + mp4/mp4chapterlist.cpp ) endif() diff --git a/taglib/mp4/mp4chapterlist.cpp b/taglib/mp4/mp4chapterlist.cpp new file mode 100644 index 00000000..22184435 --- /dev/null +++ b/taglib/mp4/mp4chapterlist.cpp @@ -0,0 +1,338 @@ +/************************************************************************** + 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 "mp4chapterlist.h" + +#include + +#include "tdebug.h" +#include "mp4file.h" +#include "mp4atom.h" + +using namespace TagLib; + +namespace +{ + // Nero chpl version 1 header: version(1) + flags(3) + reserved(4) + count(1) = 9 bytes + constexpr int chplHeaderSize = 9; + + ByteVector renderAtom(const ByteVector &name, const ByteVector &data) + { + return ByteVector::fromUInt(static_cast(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(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()); + long size = file->readBlock(4).toUInt(); + if(size == 1) { + // 64-bit size + file->seek(4, TagLib::File::Current); + 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(size + delta))); + } + } + } + + // Update stco/co64/tfhd chunk offsets when file content shifts. + // Mirrors MP4::Tag::updateOffsets(). + void updateChunkOffsets(TagLib::File *file, MP4::Atoms *atoms, + offset_t delta, offset_t offset) + { + if(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; + while(count--) { + auto o = static_cast(data.toUInt(pos)); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromUInt(static_cast(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; + while(count--) { + long long o = data.toLongLong(pos); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromLongLong(o)); + pos += 8; + } + } + } + + if(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) + { + unsigned int count = std::min(static_cast(chapters.size()), 255U); + + ByteVector data; + // Version (1 byte) + flags (3 bytes) + reserved (4 bytes) + data.append(static_cast(0x01)); // version 1 + data.append(ByteVector(3, '\0')); // flags + data.append(ByteVector(4, '\0')); // reserved + + // Chapter count + data.append(static_cast(count & 0xFF)); + + unsigned int i = 0; + for(const auto &ch : chapters) { + if(i++ >= count) + break; + + // Start time: 8 bytes big-endian + data.append(ByteVector::fromLongLong(ch.startTime)); + + // Title: 1-byte length + UTF-8 bytes (max 255 bytes) + ByteVector titleBytes = ch.title.data(String::UTF8); + unsigned int titleLen = std::min(static_cast(titleBytes.size()), 255U); + data.append(static_cast(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; + + if(data.size() < static_cast(chplHeaderSize)) + return chapters; + + unsigned int pos = 0; + unsigned char version = static_cast(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; + + unsigned int count = static_cast(data[pos++]); + + for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) { + long long startTime = data.toLongLong(pos); + pos += 8; + + unsigned int titleLen = static_cast(data[pos++]); + + String title; + if(titleLen > 0 && pos + titleLen <= data.size()) { + title = String(data.mid(pos, titleLen), String::UTF8); + pos += titleLen; + } + + MP4::Chapter ch; + ch.startTime = startTime; + ch.title = title; + chapters.append(ch); + } + + return chapters; + } + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// public members +//////////////////////////////////////////////////////////////////////////////// + +MP4::ChapterList +MP4::MP4ChapterList::read(const char *path) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid()) { + debug("MP4ChapterList::read() -- Could not open file"); + return ChapterList(); + } + + Atoms atoms(&file); + + Atom *chpl = atoms.find("moov", "udta", "chpl"); + if(!chpl) + return ChapterList(); + + // Read the atom content (skip 8-byte atom header) + file.seek(chpl->offset() + 8); + ByteVector data = file.readBlock(chpl->length() - 8); + + return parseChplData(data); +} + +bool +MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid() || file.readOnly()) { + debug("MP4ChapterList::write() -- Could not open file for writing"); + return false; + } + + Atoms atoms(&file); + + if(!atoms.find("moov")) { + debug("MP4ChapterList::write() -- No moov atom found"); + return false; + } + + ByteVector chplPayload = renderChplData(chapters); + ByteVector chplAtom = renderAtom("chpl", chplPayload); + + Atom *existingChpl = atoms.find("moov", "udta", "chpl"); + + if(existingChpl) { + // Replace existing chpl atom + offset_t offset = existingChpl->offset(); + offset_t oldLength = existingChpl->length(); + offset_t delta = static_cast(chplAtom.size()) - oldLength; + + file.insert(chplAtom, offset, oldLength); + + if(delta != 0) { + // Update parent sizes: moov and udta + 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 + AtomList udtaPath = atoms.path("moov", "udta"); + + if(udtaPath.size() == 2) { + // udta exists -- insert chpl at the beginning of udta's content + 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 + ByteVector udtaAtom = renderAtom("udta", chplAtom); + + AtomList moovPath = atoms.path("moov"); + if(moovPath.isEmpty()) { + debug("MP4ChapterList::write() -- No moov atom in path"); + return false; + } + + offset_t insertOffset = moovPath.back()->offset() + 8; + file.insert(udtaAtom, insertOffset, 0); + + updateParentSizes(&file, moovPath, udtaAtom.size()); + updateChunkOffsets(&file, &atoms, udtaAtom.size(), insertOffset); + } + } + + return true; +} + +bool +MP4::MP4ChapterList::remove(const char *path) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid() || file.readOnly()) { + debug("MP4ChapterList::remove() -- Could not open file for writing"); + return false; + } + + Atoms atoms(&file); + + Atom *chpl = atoms.find("moov", "udta", "chpl"); + if(!chpl) { + // No chpl atom -- nothing to remove + return true; + } + + offset_t offset = chpl->offset(); + offset_t length = chpl->length(); + + file.removeBlock(offset, length); + + // Update parent sizes with negative delta + AtomList parentPath = atoms.path("moov", "udta", "chpl"); + updateParentSizes(&file, parentPath, -length, 1); // ignore chpl itself + updateChunkOffsets(&file, &atoms, -length, offset); + + return true; +} diff --git a/taglib/mp4/mp4chapterlist.h b/taglib/mp4/mp4chapterlist.h new file mode 100644 index 00000000..37cb723f --- /dev/null +++ b/taglib/mp4/mp4chapterlist.h @@ -0,0 +1,77 @@ +/************************************************************************** + 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 "tlist.h" +#include "tstring.h" +#include "taglib_export.h" + +namespace TagLib { + namespace MP4 { + + /*! + * A single Nero-style chapter marker. + */ + struct TAGLIB_EXPORT Chapter { + long long startTime; //!< Start time in 100-nanosecond units + String title; + }; + + using ChapterList = List; + + /*! + * 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 TAGLIB_EXPORT MP4ChapterList + { + public: + /*! + * Reads chapter markers from the MP4 file at \a path. + * Returns an empty list if the file has no chpl atom. + */ + static ChapterList read(const char *path); + + /*! + * Writes chapter markers to the MP4 file at \a path, + * replacing any existing chpl atom. The chapter count is + * capped at 255 (Nero format limit). + * Returns \c true on success. + */ + static bool write(const char *path, const ChapterList &chapters); + + /*! + * Removes the chpl atom from the MP4 file at \a path. + * Returns \c true on success, or if no chpl atom exists. + */ + static bool remove(const char *path); + }; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 03f96ae5..4afea33f 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -34,6 +34,7 @@ #include "mp4atom.h" #include "mp4file.h" #include "mp4itemfactory.h" +#include "mp4chapterlist.h" #include "plainfile.h" #include #include "utils.h" @@ -102,6 +103,10 @@ 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_SUITE_END(); public: @@ -873,6 +878,162 @@ 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::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Write chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Introduction"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 300000000LL; // 30 seconds in 100ns units + ch2.title = "Main Content"; + chapters.append(ch2); + + MP4::Chapter ch3; + ch3.startTime = 600000000LL; // 60 seconds + ch3.title = "Conclusion"; + chapters.append(ch3); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Read back and verify + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(String("Introduction"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(String("Main Content"), chapters[1].title); + CPPUNIT_ASSERT_EQUAL(600000000LL, chapters[2].startTime); + CPPUNIT_ASSERT_EQUAL(String("Conclusion"), chapters[2].title); + } + + // Overwrite with different chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Part One"; + chapters.append(ch1); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Verify overwrite + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + 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::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Chapter 1"; + chapters.append(ch1); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Verify written + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(1U, chapters.size()); + } + + // Remove chapters + CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + + // Verify removed + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Remove from file with no chapters should also succeed + CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + } + + 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 + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Intro"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 100000000LL; // 10 seconds + ch2.title = "Verse"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + } + + // Verify chapters are written AND existing tags are preserved + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title); + + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + } + + // Remove chapters and verify tags still survive + CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + } + } + + void testChapterListReadEmpty() + { + // Reading from a file with no chpl atom should return empty list + MP4::ChapterList chapters = MP4::MP4ChapterList::read( + TEST_FILE_PATH_C("no-tags.m4a")); + CPPUNIT_ASSERT(chapters.isEmpty()); + } }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From 4a73d73b20a1ead2cd2c4a37b5eae98ac4908b4f Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Sat, 4 Apr 2026 07:31:03 -0700 Subject: [PATCH 02/11] 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. --- taglib/CMakeLists.txt | 2 + taglib/mp4/mp4qtchapterlist.cpp | 1213 +++++++++++++++++++++++++++++++ taglib/mp4/mp4qtchapterlist.h | 78 ++ tests/test_mp4.cpp | 279 +++++++ 4 files changed, 1572 insertions(+) create mode 100644 taglib/mp4/mp4qtchapterlist.cpp create mode 100644 taglib/mp4/mp4qtchapterlist.h diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index 84da534e..2651249a 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -197,6 +197,7 @@ if(WITH_MP4) mp4/mp4stem.h mp4/mp4itemfactory.h mp4/mp4chapterlist.h + mp4/mp4qtchapterlist.h ) endif() if(WITH_MOD) @@ -374,6 +375,7 @@ if(WITH_MP4) mp4/mp4stem.cpp mp4/mp4itemfactory.cpp mp4/mp4chapterlist.cpp + mp4/mp4qtchapterlist.cpp ) endif() diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp new file mode 100644 index 00000000..c9d5ef88 --- /dev/null +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -0,0 +1,1213 @@ +/************************************************************************** + 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 "mp4qtchapterlist.h" + +#include +#include +#include + +#include "tdebug.h" +#include "mp4file.h" +#include "mp4atom.h" + +using namespace TagLib; + +namespace +{ + + // -- Atom building helpers ------------------------------------------------ + + ByteVector renderAtom(const ByteVector &name, const ByteVector &data) + { + return ByteVector::fromUInt(static_cast(data.size() + 8)) + name + data; + } + + //! Build a full-box (version + flags) atom. + ByteVector renderFullBox(const ByteVector &name, unsigned char version, + unsigned int flags, const ByteVector &data) + { + ByteVector vf; + vf.append(static_cast(version)); + vf.append(ByteVector::fromUInt(flags).mid(1, 3)); // 3 bytes of flags + vf.append(data); + return renderAtom(name, vf); + } + + // -- Parent / offset fixup (mirrors mp4chapterlist.cpp) ------------------- + + void updateParentSizes(TagLib::File *file, const MP4::AtomList &path, + offset_t delta, int ignore = 0) + { + if(static_cast(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()); + long size = file->readBlock(4).toUInt(); + if(size == 1) { + file->seek(4, TagLib::File::Current); + long long longSize = file->readBlock(8).toLongLong(); + file->seek((*it)->offset() + 8); + file->writeBlock(ByteVector::fromLongLong(longSize + delta)); + } + else { + file->seek((*it)->offset()); + file->writeBlock(ByteVector::fromUInt(static_cast(size + delta))); + } + } + } + + void updateChunkOffsets(TagLib::File *file, MP4::Atoms *atoms, + offset_t delta, offset_t offset) + { + if(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; + while(count--) { + auto o = static_cast(data.toUInt(pos)); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromUInt(static_cast(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; + while(count--) { + long long o = data.toLongLong(pos); + if(o > offset) + o += delta; + file->writeBlock(ByteVector::fromLongLong(o)); + pos += 8; + } + } + } + + if(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)); + } + } + } + } + + // -- Duration reading ----------------------------------------------------- + + //! Movie-level header info from mvhd. + struct MovieInfo { + unsigned int timescale = 0; + unsigned int duration = 0; // in mvhd timescale units + long long durationMs = 0; // converted to milliseconds + }; + + //! Reads movie-level info from mvhd. + MovieInfo readMovieInfo(TagLib::File *file, MP4::Atoms *atoms) + { + MovieInfo info; + MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return info; + + MP4::Atom *mvhd = moov->find("mvhd"); + if(!mvhd) + return info; + + file->seek(mvhd->offset()); + ByteVector data = file->readBlock(mvhd->length()); + if(data.size() < 8 + 4) + return info; + + unsigned char version = static_cast(data[8]); + long long timescale, duration; + if(version == 1 && data.size() >= 8 + 28) { + timescale = data.toUInt(28U); + duration = data.toLongLong(32U); + } + else if(data.size() >= 8 + 16 + 4) { + timescale = data.toUInt(20U); + duration = data.toUInt(24U); + } + else { + return info; + } + + if(timescale > 0 && duration > 0) { + info.timescale = static_cast(timescale); + info.duration = static_cast(duration); + info.durationMs = static_cast( + static_cast(duration) * 1000.0 / static_cast(timescale) + 0.5); + } + return info; + } + + // -- Audio track helpers -------------------------------------------------- + + struct TrackInfo { + MP4::Atom *trak = nullptr; + unsigned int trackId = 0; + }; + + //! Finds the first audio track (hdlr handler_type == "soun"). + TrackInfo findAudioTrack(TagLib::File *file, MP4::Atoms *atoms) + { + TrackInfo info; + MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return info; + + const MP4::AtomList trakList = moov->findall("trak"); + for(const auto &trak : trakList) { + const MP4::Atom *hdlr = trak->find("mdia", "hdlr"); + if(!hdlr) + continue; + file->seek(hdlr->offset()); + ByteVector data = file->readBlock(hdlr->length()); + // handler_type is at offset 16 from atom start (8 header + 4 version/flags + 4 pre_defined) + if(data.containsAt("soun", 16)) { + info.trak = trak; + // Read track_id from tkhd + if(MP4::Atom *tkhd = trak->find("tkhd")) { + file->seek(tkhd->offset()); + ByteVector tkhdData = file->readBlock(tkhd->length()); + unsigned char version = static_cast(tkhdData[8]); + if(version == 1 && tkhdData.size() >= 8 + 20 + 4) { + info.trackId = tkhdData.toUInt(28U); + } + else if(tkhdData.size() >= 8 + 12 + 4) { + info.trackId = tkhdData.toUInt(20U); + } + } + return info; + } + } + return info; + } + + //! Reads the next_track_ID from mvhd. + unsigned int getNextTrackId(TagLib::File *file, MP4::Atoms *atoms) + { + MP4::Atom *moov = atoms->find("moov"); + if(!moov) return 0; + + MP4::Atom *mvhd = moov->find("mvhd"); + if(!mvhd) return 0; + + file->seek(mvhd->offset()); + ByteVector data = file->readBlock(mvhd->length()); + unsigned char version = static_cast(data[8]); + + // next_track_ID is the last 4 bytes of mvhd + // version 0: header(8) + version/flags(4) + creation(4) + modification(4) + // + timescale(4) + duration(4) + ... total fixed = 108 bytes + // version 1: header(8) + version/flags(4) + creation(8) + modification(8) + // + timescale(4) + duration(8) + ... total fixed = 120 bytes + unsigned int nextTrackIdOffset = (version == 1) ? 120 - 4 : 108 - 4; + if(data.size() >= nextTrackIdOffset + 4) + return data.toUInt(nextTrackIdOffset); + + return 0; + } + + //! Writes next_track_ID in mvhd. + void setNextTrackId(TagLib::File *file, MP4::Atoms *atoms, unsigned int newId) + { + MP4::Atom *moov = atoms->find("moov"); + if(!moov) return; + + MP4::Atom *mvhd = moov->find("mvhd"); + if(!mvhd) return; + + file->seek(mvhd->offset()); + ByteVector data = file->readBlock(mvhd->length()); + unsigned char version = static_cast(data[8]); + + unsigned int nextTrackIdOffset = (version == 1) ? 120 - 4 : 108 - 4; + if(data.size() >= nextTrackIdOffset + 4) { + file->seek(mvhd->offset() + nextTrackIdOffset); + file->writeBlock(ByteVector::fromUInt(newId)); + } + } + + // -- Chapter track finder ------------------------------------------------- + + //! Finds an existing chapter track by scanning for tref/chap in the audio track. + //! tref is NOT in TagLib's container list, so we read it manually. + MP4::Atom *findChapterTrak(TagLib::File *file, MP4::Atoms *atoms, + MP4::Atom *audioTrak) + { + if(!audioTrak) + return nullptr; + + MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return nullptr; + + for(const auto &child : audioTrak->children()) { + if(child->name() == "tref") { + file->seek(child->offset() + 8); + offset_t trefEnd = child->offset() + child->length(); + + while(file->tell() + 8 <= trefEnd) { + offset_t boxStart = file->tell(); + ByteVector header = file->readBlock(8); + if(header.size() < 8) + break; + + unsigned int boxSize = header.toUInt(); + if(boxSize < 8) + break; + + ByteVector boxName = header.mid(4, 4); + + if(boxName == "chap" && boxSize >= 12) { + ByteVector refData = file->readBlock(boxSize - 8); + unsigned int refTrackId = refData.toUInt(); + + const MP4::AtomList allTraks = moov->findall("trak"); + + for(const auto &t : allTraks) { + MP4::Atom *tkhd = t->find("tkhd"); + if(!tkhd) + continue; + + file->seek(tkhd->offset()); + ByteVector tkhdData = file->readBlock(tkhd->length()); + if(tkhdData.size() < 24) + continue; + + unsigned char version = static_cast(tkhdData[8]); + unsigned int tid; + if(version == 1 && tkhdData.size() >= 32) { + tid = tkhdData.toUInt(28U); + } + else { + tid = tkhdData.toUInt(20U); + } + + if(tid == refTrackId) + return t; + } + } + + file->seek(boxStart + boxSize); + } + } + } + + return nullptr; + } + + // -- Text sample building ------------------------------------------------- + + //! Size of the 'encd' (encoding) atom appended to each text sample. + //! encd declares UTF-8 encoding: size(4) + "encd"(4) + 0x0100(4) = 12 bytes. + constexpr unsigned int encdAtomSize = 12; + + //! Builds a single text sample: 2-byte big-endian length + UTF-8 text + encd atom. + ByteVector buildTextSample(const String &title) + { + ByteVector utf8 = title.data(String::UTF8); + unsigned int textLen = static_cast(utf8.size()); + + ByteVector sample; + sample.append(ByteVector::fromShort(static_cast(textLen))); + if(textLen > 0) + sample.append(utf8); + + // Append encd atom (encoding declaration: UTF-8) immediately after text. + // No padding -- AVFoundation expects encd to follow directly after the text. + ByteVector encdData; + encdData.append(ByteVector::fromShort(0)); // padding + encdData.append(ByteVector::fromShort(0x0100)); // UTF-8 encoding + sample.append(renderAtom("encd", encdData)); + + return sample; + } + + //! Calculate the actual size of each chapter text sample. + //! Each sample is: 2-byte length prefix + UTF-8 text + 12-byte encd atom. + //! Returns a vector of per-sample sizes (no padding, sizes may differ). + std::vector calculateSampleSizes(const MP4::ChapterList &chapters) + { + std::vector sizes; + for(const auto &ch : chapters) { + unsigned int textLen = static_cast(ch.title.data(String::UTF8).size()); + sizes.push_back(2 + textLen + encdAtomSize); + } + return sizes; + } + + // -- stbl atom builders --------------------------------------------------- + + //! stsd: text sample description (required for QT text tracks) + ByteVector buildStsd() + { + // QT text sample entry matching ffmpeg's chapter track output byte-for-byte. + // Entry body (after 8-byte size+"text" header) is 51 bytes: + // reserved(6) + dref_index(2) + display_flags(4) + justification(4) + // + bgColor(6) + textBox(8) + fontID(2) + style_flags(1) + font_size(1) + // + text_color_RGBA(4) + ftab_atom(13) + // + // Hardcoded from ffmpeg's known-good output to avoid subtle field-size + // mismatches with the under-documented QT text sample entry format. + const unsigned char entryBody[] = { + // reserved (6 bytes) + data reference index (2 bytes) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x01, + // display flags (4) = 1 + 0x00, 0x00, 0x00, 0x01, + // text justification (4) = 0 + 0x00, 0x00, 0x00, 0x00, + // background color RGB (6 bytes, QT text format: 2 bytes per channel) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // default text box: top(2), left(2), bottom(2), right(2) = all zero + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // font ID (2) = 1 + 0x00, 0x01, + // font style flags(1) + font size(1) + text color RGBA(4) + 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, + // ftab atom: size(4)=13 + "ftab"(4) + entry_count(2)=1 + fontID(2)=1 + name_len(1)=0 + 0x00, 0x00, 0x00, 0x0d, 0x66, 0x74, 0x61, 0x62, + 0x00, 0x01, 0x00, 0x01, 0x00 + }; + + ByteVector sampleEntry; + unsigned int entrySize = 8 + sizeof(entryBody); + sampleEntry.append(ByteVector::fromUInt(entrySize)); + sampleEntry.append(ByteVector("text", 4)); + sampleEntry.append(ByteVector(reinterpret_cast(entryBody), + sizeof(entryBody))); + + ByteVector stsdPayload; + stsdPayload.append(ByteVector::fromUInt(1)); // entry count + stsdPayload.append(sampleEntry); + + return renderFullBox("stsd", 0, 0, stsdPayload); + } + + //! stts: time-to-sample table. + //! For N chapters, entry i has duration = chapter[i+1].start - chapter[i].start. + //! The last chapter runs to the end of the file. + ByteVector buildStts(const MP4::ChapterList &chapters, unsigned int timescale, + long long durationMs) + { + unsigned int count = static_cast(chapters.size()); + if(count == 0) + return ByteVector(); + + // Convert 100-ns units to timescale units + auto toTimescale = [timescale](long long time100ns) -> unsigned int { + return static_cast( + static_cast(time100ns) * static_cast(timescale) / 10000000.0 + 0.5); + }; + + unsigned int totalDuration = static_cast( + static_cast(durationMs) * static_cast(timescale) / 1000.0 + 0.5); + + // Build per-sample durations + std::vector durations; + auto it = chapters.begin(); + for(unsigned int i = 0; i < count; ++i, ++it) { + auto next = it; + ++next; + unsigned int startTs = toTimescale(it->startTime); + unsigned int dur; + if(next != chapters.end()) { + unsigned int nextTs = toTimescale(next->startTime); + dur = nextTs - startTs; + } + else { + // Last chapter runs to end of file + dur = totalDuration > startTs ? totalDuration - startTs : 0; + } + durations.push_back(dur); + } + + // One stts entry per sample (sampleCount=1 each), matching ffmpeg's output. + // AVFoundation requires this layout rather than run-length encoding. + ByteVector payload; + payload.append(ByteVector::fromUInt(count)); + for(auto d : durations) { + payload.append(ByteVector::fromUInt(1)); // sample count + payload.append(ByteVector::fromUInt(d)); // sample delta + } + + return renderFullBox("stts", 0, 0, payload); + } + + //! stsz: sample size table with per-sample entries (matching ffmpeg output). + ByteVector buildStsz(const std::vector &sampleSizes) + { + ByteVector payload; + payload.append(ByteVector::fromUInt(0)); // default_sample_size = 0 (per-sample) + payload.append(ByteVector::fromUInt(static_cast(sampleSizes.size()))); + for(auto sz : sampleSizes) + payload.append(ByteVector::fromUInt(sz)); + return renderFullBox("stsz", 0, 0, payload); + } + + //! stsc: sample-to-chunk table. All samples in one chunk. + ByteVector buildStsc(unsigned int sampleCount) + { + // All samples in a single chunk: one entry saying + // "starting at chunk 1, N samples per chunk, description index 1" + ByteVector payload; + payload.append(ByteVector::fromUInt(1)); // entry count + payload.append(ByteVector::fromUInt(1)); // first chunk + payload.append(ByteVector::fromUInt(sampleCount)); // samples per chunk + payload.append(ByteVector::fromUInt(1)); // sample description index + + return renderFullBox("stsc", 0, 0, payload); + } + + //! stco: chunk offset table. Single chunk offset pointing to the + //! start of the contiguous text sample data. + ByteVector buildStco(unsigned int offset) + { + ByteVector payload; + payload.append(ByteVector::fromUInt(1)); // entry count = 1 + payload.append(ByteVector::fromUInt(offset)); // chunk offset + return renderFullBox("stco", 0, 0, payload); + } + + // -- Full trak builder ---------------------------------------------------- + + //! Builds a complete chapter text trak atom. + //! \a textDataOffset is where the text samples will start in the file. + //! \a sampleSizes contains per-sample sizes for the stsz table. + //! \a movieDuration is the movie-level duration in mvhd timescale units (for edts/elst). + ByteVector buildChapterTrak(unsigned int trackId, unsigned int timescale, + long long durationMs, + const MP4::ChapterList &chapters, + const std::vector &sampleSizes, + offset_t textDataOffset, + unsigned int movieDuration) + { + unsigned int count = static_cast(chapters.size()); + unsigned int totalDuration = static_cast( + static_cast(durationMs) * static_cast(timescale) / 1000.0 + 0.5); + + // Single chunk offset -- all samples are contiguous starting at textDataOffset + unsigned int chunkOffset = static_cast(textDataOffset); + + // -- tkhd (track header) -- + // version 0: 8 header + 4 ver/flags + 4 creation + 4 modification + // + 4 track_id + 4 reserved + 4 duration + 8 reserved + // + 2 layer + 2 alternate_group + 2 volume + 2 reserved + // + 36 matrix + 4 width + 4 height = 92 bytes total + ByteVector tkhdData; + tkhdData.append(ByteVector(4, '\0')); // creation time + tkhdData.append(ByteVector(4, '\0')); // modification time + tkhdData.append(ByteVector::fromUInt(trackId)); + tkhdData.append(ByteVector(4, '\0')); // reserved + // Duration in mvhd timescale. + tkhdData.append(ByteVector::fromUInt(totalDuration)); + tkhdData.append(ByteVector(8, '\0')); // reserved + tkhdData.append(ByteVector::fromShort(0)); // layer + tkhdData.append(ByteVector::fromShort(0)); // alternate_group + tkhdData.append(ByteVector::fromShort(0)); // volume (0 for text) + tkhdData.append(ByteVector::fromShort(0)); // reserved + // Identity matrix (3x3 fixed point) + tkhdData.append(ByteVector::fromUInt(0x00010000)); // a = 1.0 + tkhdData.append(ByteVector(4, '\0')); // b + tkhdData.append(ByteVector(4, '\0')); // u + tkhdData.append(ByteVector(4, '\0')); // c + tkhdData.append(ByteVector::fromUInt(0x00010000)); // d = 1.0 + tkhdData.append(ByteVector(4, '\0')); // v + tkhdData.append(ByteVector(4, '\0')); // x + tkhdData.append(ByteVector(4, '\0')); // y + tkhdData.append(ByteVector::fromUInt(0x40000000)); // w = 1.0 + tkhdData.append(ByteVector::fromUInt(0)); // width + tkhdData.append(ByteVector::fromUInt(0)); // height + + // flags = 0x02: track_in_movie only (matches ffmpeg's chapter track output). + // Chapter tracks are NOT track_enabled(1) -- they are disabled but present in movie. + ByteVector tkhd = renderFullBox("tkhd", 0, 0x02, tkhdData); + + // -- mdhd (media header) -- + ByteVector mdhdData; + mdhdData.append(ByteVector(4, '\0')); // creation time + mdhdData.append(ByteVector(4, '\0')); // modification time + mdhdData.append(ByteVector::fromUInt(timescale)); + mdhdData.append(ByteVector::fromUInt(totalDuration)); + // language: 0x0000 (matches ffmpeg chapter track output) + mdhdData.append(ByteVector::fromShort(0)); + mdhdData.append(ByteVector::fromShort(0)); // pre_defined + + ByteVector mdhd = renderFullBox("mdhd", 0, 0, mdhdData); + + // -- hdlr (handler reference) -- + ByteVector hdlrData; + hdlrData.append(ByteVector(4, '\0')); // pre_defined + hdlrData.append(ByteVector("text", 4)); // handler_type + hdlrData.append(ByteVector(12, '\0')); // reserved + // name: null-terminated "Chapter" string + hdlrData.append(ByteVector("Chapter", 7)); + hdlrData.append(static_cast(0)); + + ByteVector hdlr = renderFullBox("hdlr", 0, 0, hdlrData); + + // -- gmhd (base media information header) -- + // QT text/chapter tracks use gmhd with gmin + text children. + + // gmin: graphicsMode(2) + opcolor(6) + balance(2) + reserved(2) + ByteVector gminData; + gminData.append(ByteVector::fromShort(0x0040)); // graphicsMode = ditherCopy (0x40) + gminData.append(ByteVector("\x80\x00\x80\x00\x80\x00", 6)); // opcolor (gray) + gminData.append(ByteVector::fromShort(0)); // balance + gminData.append(ByteVector::fromShort(0)); // reserved + ByteVector gmin = renderFullBox("gmin", 0, 0, gminData); + + // text media information atom: matrix(36) + ... = 36 bytes of data + ByteVector textInfoData; + textInfoData.append(ByteVector::fromShort(1)); // 0x0001 + textInfoData.append(ByteVector(14, '\0')); // reserved + textInfoData.append(ByteVector::fromShort(1)); // 0x0001 + textInfoData.append(ByteVector(14, '\0')); // reserved + textInfoData.append(ByteVector::fromUInt(0x40000000)); // 1.0 fixed point + ByteVector textInfo = renderAtom("text", textInfoData); + + ByteVector gmhdContent; + gmhdContent.append(gmin); + gmhdContent.append(textInfo); + ByteVector gmhd = renderAtom("gmhd", gmhdContent); + + // -- dinf / dref (data reference) -- + ByteVector drefEntry; + // "url " self-reference entry (flag 1 = data is in this file) + drefEntry = renderFullBox("url ", 0, 1, ByteVector()); + + ByteVector drefData; + drefData.append(ByteVector::fromUInt(1)); // entry count + drefData.append(drefEntry); + ByteVector dref = renderFullBox("dref", 0, 0, drefData); + ByteVector dinf = renderAtom("dinf", dref); + + // -- stbl (sample table) -- + ByteVector stsd = buildStsd(); + ByteVector stts = buildStts(chapters, timescale, durationMs); + ByteVector stsz = buildStsz(sampleSizes); + ByteVector stsc = buildStsc(count); + ByteVector stco = buildStco(chunkOffset); + + ByteVector stblContent; + stblContent.append(stsd); + stblContent.append(stts); + stblContent.append(stsz); + stblContent.append(stsc); + stblContent.append(stco); + ByteVector stbl = renderAtom("stbl", stblContent); + + // -- minf (media information) -- + ByteVector minfContent; + minfContent.append(gmhd); + minfContent.append(dinf); + minfContent.append(stbl); + ByteVector minf = renderAtom("minf", minfContent); + + // -- mdia (media) -- + ByteVector mdiaContent; + mdiaContent.append(mdhd); + mdiaContent.append(hdlr); + mdiaContent.append(minf); + ByteVector mdia = renderAtom("mdia", mdiaContent); + + // -- edts / elst (edit list) -- + // AVFoundation requires an edit list for the chapter track. + // Single entry: play the whole media from time 0, at normal rate. + ByteVector elstData; + elstData.append(ByteVector::fromUInt(1)); // entry count + elstData.append(ByteVector::fromUInt(movieDuration)); // segment duration (mvhd timescale) + elstData.append(ByteVector::fromUInt(0)); // media time = 0 + elstData.append(ByteVector::fromUInt(0x00010000)); // media rate = 1.0 (fixed point) + ByteVector elst = renderFullBox("elst", 0, 0, elstData); + ByteVector edts = renderAtom("edts", elst); + + // -- trak -- + ByteVector trakContent; + trakContent.append(tkhd); + trakContent.append(edts); + trakContent.append(mdia); + ByteVector trak = renderAtom("trak", trakContent); + + return trak; + } + + // -- tref / chap builder -------------------------------------------------- + + //! Builds a tref atom containing a chap reference to the given track ID. + ByteVector buildTref(unsigned int chapterTrackId) + { + ByteVector chapData; + chapData.append(ByteVector::fromUInt(chapterTrackId)); + ByteVector chap = renderAtom("chap", chapData); + return renderAtom("tref", chap); + } + + // -- Reading helpers ------------------------------------------------------ + + //! Reads chapter track duration info from the chapter trak's mdhd. + struct ChapterTrackInfo { + unsigned int timescale = 0; + unsigned int totalDuration = 0; + }; + + ChapterTrackInfo readChapterTrackInfo(TagLib::File *file, MP4::Atom *chapterTrak) + { + ChapterTrackInfo info; + + MP4::Atom *mdhd = chapterTrak->find("mdia", "mdhd"); + if(!mdhd) + return info; + + file->seek(mdhd->offset()); + ByteVector data = file->readBlock(mdhd->length()); + if(data.size() < 8 + 4) + return info; + + unsigned char version = static_cast(data[8]); + if(version == 1 && data.size() >= 40) { + // v1 mdhd: header(8) + ver/flags(4) + creation(8) + modification(8) + // + timescale(4)@28 + duration(8)@32 + lang(2) + pre(2) = 44 + info.timescale = data.toUInt(28U); + info.totalDuration = static_cast(data.toLongLong(32U)); + } + else if(version == 0 && data.size() >= 28) { + // v0 mdhd: header(8) + ver/flags(4) + creation(4) + modification(4) + // + timescale(4)@20 + duration(4)@24 + lang(2) + pre(2) = 32 + info.timescale = data.toUInt(20U); + info.totalDuration = data.toUInt(24U); + } + return info; + } + + //! Reads stts entries from the chapter track. + struct SttsEntry { + unsigned int sampleCount; + unsigned int sampleDelta; + }; + + std::vector readStts(TagLib::File *file, MP4::Atom *chapterTrak) + { + std::vector entries; + MP4::Atom *stts = chapterTrak->find("mdia", "minf", "stbl", "stts"); + if(!stts) + return entries; + + file->seek(stts->offset() + 12); // skip header(8) + version/flags(4) + ByteVector data = file->readBlock(stts->length() - 12); + if(data.size() < 4) + return entries; + + unsigned int count = data.toUInt(); + unsigned int pos = 4; + for(unsigned int i = 0; i < count && pos + 8 <= data.size(); ++i) { + SttsEntry e; + e.sampleCount = data.toUInt(pos); + e.sampleDelta = data.toUInt(pos + 4); + entries.push_back(e); + pos += 8; + } + return entries; + } + + //! Reads chunk offsets from stco. + std::vector readStco(TagLib::File *file, MP4::Atom *chapterTrak) + { + std::vector offsets; + MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco"); + if(!stco) + return offsets; + + file->seek(stco->offset() + 12); + ByteVector data = file->readBlock(stco->length() - 12); + if(data.size() < 4) + return offsets; + + unsigned int count = data.toUInt(); + unsigned int pos = 4; + for(unsigned int i = 0; i < count && pos + 4 <= data.size(); ++i) { + offsets.push_back(data.toUInt(pos)); + pos += 4; + } + return offsets; + } + + //! Reads sample sizes. Returns (defaultSize, perSampleSizes). + //! If defaultSize > 0, perSampleSizes is empty. + struct SampleSizeInfo { + unsigned int defaultSize = 0; + unsigned int sampleCount = 0; + std::vector perSampleSizes; + }; + + SampleSizeInfo readStsz(TagLib::File *file, MP4::Atom *chapterTrak) + { + SampleSizeInfo info; + MP4::Atom *stsz = chapterTrak->find("mdia", "minf", "stbl", "stsz"); + if(!stsz) + return info; + + file->seek(stsz->offset() + 12); + ByteVector data = file->readBlock(stsz->length() - 12); + if(data.size() < 8) + return info; + + info.defaultSize = data.toUInt(); + info.sampleCount = data.toUInt(4U); + + if(info.defaultSize == 0) { + unsigned int pos = 8; + for(unsigned int i = 0; i < info.sampleCount && pos + 4 <= data.size(); ++i) { + info.perSampleSizes.push_back(data.toUInt(pos)); + pos += 4; + } + } + return info; + } + + //! Resolves chunk-level offsets (stco) into per-sample file offsets + //! using the stsc (sample-to-chunk) table and sample sizes from stsz. + //! This handles both single-chunk and multi-chunk layouts. + std::vector resolveSampleOffsets(TagLib::File *file, + MP4::Atom *chapterTrak, + const SampleSizeInfo &sizeInfo) + { + std::vector chunkOffsets = readStco(file, chapterTrak); + if(chunkOffsets.empty()) + return {}; + + // Read stsc entries + struct StscEntry { + unsigned int firstChunk; + unsigned int samplesPerChunk; + unsigned int descIndex; + }; + std::vector stscEntries; + + MP4::Atom *stsc = chapterTrak->find("mdia", "minf", "stbl", "stsc"); + if(stsc) { + file->seek(stsc->offset() + 12); + ByteVector data = file->readBlock(stsc->length() - 12); + if(data.size() >= 4) { + unsigned int entryCount = data.toUInt(); + unsigned int pos = 4; + for(unsigned int i = 0; i < entryCount && pos + 12 <= data.size(); ++i) { + StscEntry e; + e.firstChunk = data.toUInt(pos); + e.samplesPerChunk = data.toUInt(pos + 4); + e.descIndex = data.toUInt(pos + 8); + stscEntries.push_back(e); + pos += 12; + } + } + } + + // Default: 1 sample per chunk if no stsc + if(stscEntries.empty()) { + stscEntries.push_back({1, 1, 1}); + } + + // Resolve per-sample offsets by walking chunks + std::vector sampleOffsets; + unsigned int totalChunks = static_cast(chunkOffsets.size()); + unsigned int sampleIndex = 0; + + for(unsigned int chunkIdx = 0; chunkIdx < totalChunks; ++chunkIdx) { + // Find which stsc entry applies to this chunk (1-based) + unsigned int chunkNum = chunkIdx + 1; + unsigned int samplesInChunk = stscEntries[0].samplesPerChunk; + for(unsigned int e = 0; e < stscEntries.size(); ++e) { + if(stscEntries[e].firstChunk <= chunkNum) { + samplesInChunk = stscEntries[e].samplesPerChunk; + } + else { + break; + } + } + + unsigned int offsetInChunk = 0; + for(unsigned int s = 0; s < samplesInChunk; ++s) { + sampleOffsets.push_back(chunkOffsets[chunkIdx] + offsetInChunk); + + // Advance by this sample's size + unsigned int sz = sizeInfo.defaultSize; + if(sz == 0 && sampleIndex < sizeInfo.perSampleSizes.size()) + sz = sizeInfo.perSampleSizes[sampleIndex]; + offsetInChunk += sz; + sampleIndex++; + } + } + + return sampleOffsets; + } + + //! Read a text sample at a given file offset. + String readTextSample(TagLib::File *file, unsigned int offset, unsigned int maxSize) + { + file->seek(offset); + ByteVector data = file->readBlock(maxSize); + if(data.size() < 2) + return String(); + + unsigned int textLen = data.toUShort(); + if(textLen == 0 || textLen + 2 > data.size()) + return String(); + + return String(data.mid(2, textLen), String::UTF8); + } + + // -- Remove helpers ------------------------------------------------------- + + //! Removes the tref atom from the audio track. + //! Updates trak size, parent sizes, and chunk offsets. + //! audioTrak's in-memory children list is NOT modified (caller re-parses if needed). + void removeAudioTref(TagLib::File *file, MP4::Atoms *atoms, MP4::Atom *audioTrak) + { + for(const auto &child : audioTrak->children()) { + if(child->name() != "tref") + continue; + + offset_t trefOff = child->offset(); + offset_t trefLen = child->length(); + + file->removeBlock(trefOff, trefLen); + + // Fix audio trak size on disk + file->seek(audioTrak->offset()); + unsigned int trakSize = file->readBlock(4).toUInt(); + file->seek(audioTrak->offset()); + file->writeBlock(ByteVector::fromUInt( + static_cast(trakSize - trefLen))); + + MP4::AtomList moovPath = atoms->path("moov"); + updateParentSizes(file, moovPath, -trefLen); + updateChunkOffsets(file, atoms, -trefLen, trefOff); + return; + } + } + +} // namespace + +//////////////////////////////////////////////////////////////////////////////// +// public members +//////////////////////////////////////////////////////////////////////////////// + +MP4::ChapterList +MP4::MP4QTChapterList::read(const char *path) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid()) + return ChapterList(); + + Atoms atoms(&file); + + TrackInfo audio = findAudioTrack(&file, &atoms); + if(!audio.trak) + return ChapterList(); + + Atom *chapterTrak = findChapterTrak(&file, &atoms, audio.trak); + if(!chapterTrak) + return ChapterList(); + + ChapterTrackInfo trackInfo = readChapterTrackInfo(&file, chapterTrak); + if(trackInfo.timescale == 0) + return ChapterList(); + + std::vector sttsEntries = readStts(&file, chapterTrak); + SampleSizeInfo sizeInfo = readStsz(&file, chapterTrak); + std::vector offsets = resolveSampleOffsets(&file, chapterTrak, sizeInfo); + + if(offsets.empty()) + return ChapterList(); + + ChapterList chapters; + unsigned int sampleIndex = 0; + long long currentTime = 0; + + for(const auto &entry : sttsEntries) { + for(unsigned int s = 0; s < entry.sampleCount; ++s) { + if(sampleIndex >= offsets.size()) + break; + + unsigned int sampleSize = sizeInfo.defaultSize; + if(sampleSize == 0 && sampleIndex < sizeInfo.perSampleSizes.size()) + sampleSize = sizeInfo.perSampleSizes[sampleIndex]; + + String title = readTextSample(&file, offsets[sampleIndex], sampleSize); + + long long startTime100ns = static_cast( + static_cast(currentTime) * 10000000.0 / + static_cast(trackInfo.timescale) + 0.5); + + Chapter ch; + ch.startTime = startTime100ns; + ch.title = title; + chapters.append(ch); + + currentTime += entry.sampleDelta; + sampleIndex++; + } + } + + // Strip a leading dummy chapter (empty title at time 0) that was inserted + // during write to preserve non-zero first-chapter start times. + if(chapters.size() > 1) { + const Chapter &first = chapters.front(); + if(first.startTime == 0 && first.title.isEmpty()) { + chapters.erase(chapters.begin()); + } + } + + return chapters; +} + +bool +MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid() || file.readOnly()) { + debug("MP4QTChapterList::write() -- Could not open file for writing"); + return false; + } + + // ---- Phase 1: Parse and gather info ---- + + Atoms atoms(&file); + + Atom *moov = atoms.find("moov"); + if(!moov) { + debug("MP4QTChapterList::write() -- No moov atom found"); + return false; + } + + MovieInfo movieInfo = readMovieInfo(&file, &atoms); + if(movieInfo.durationMs <= 0) { + debug("MP4QTChapterList::write() -- Could not determine file duration"); + return false; + } + long long durationMs = movieInfo.durationMs; + + TrackInfo audio = findAudioTrack(&file, &atoms); + if(!audio.trak) { + debug("MP4QTChapterList::write() -- No audio track found"); + return false; + } + + // ---- Phase 2: Remove existing chapter data (if any) ---- + + // Pointer to the Atoms object we'll use for the insert phase. + // Points to `atoms` for fresh writes, or `cleanAtoms` after cleanup. + Atoms *activeAtoms = &atoms; + // Optional second parse -- only constructed when replacing existing chapters. + std::unique_ptr cleanAtoms; + + Atom *existingChapter = findChapterTrak(&file, &atoms, audio.trak); + if(existingChapter) { + // Remove chapter trak FIRST (higher offset in file). + offset_t chapterOff = existingChapter->offset(); + offset_t chapterLen = existingChapter->length(); + + // Remove from in-memory tree so updateChunkOffsets skips its stco. + moov->removeChild(existingChapter); + delete existingChapter; + + file.removeBlock(chapterOff, chapterLen); + + AtomList moovPath = atoms.path("moov"); + updateParentSizes(&file, moovPath, -chapterLen); + updateChunkOffsets(&file, &atoms, -chapterLen, chapterOff); + + // Remove tref from audio trak (lower offset, still valid). + removeAudioTref(&file, &atoms, audio.trak); + + // Re-parse to get clean state after removals. + cleanAtoms = std::make_unique(&file); + activeAtoms = cleanAtoms.get(); + + moov = activeAtoms->find("moov"); + if(!moov) { + debug("MP4QTChapterList::write() -- moov disappeared after cleanup"); + return false; + } + audio = findAudioTrack(&file, activeAtoms); + if(!audio.trak) { + debug("MP4QTChapterList::write() -- No audio track after cleanup"); + return false; + } + } + + // ---- Phase 3: Build and insert new chapter data ---- + + // QT chapter tracks always start at media time 0. If the first chapter has a + // non-zero start time, prepend a dummy chapter at time 0 with an empty title + // so the absolute positions are preserved as stts durations. + ChapterList workingChapters(chapters); + if(!workingChapters.isEmpty() && workingChapters.front().startTime > 0) { + Chapter dummy; + dummy.startTime = 0; + dummy.title = String(); + workingChapters.prepend(dummy); + } + + unsigned int nextId = getNextTrackId(&file, activeAtoms); + unsigned int chapterTrackId = nextId > 0 ? nextId : audio.trackId + 1; + constexpr unsigned int timescale = 1000; + std::vector sampleSizes = calculateSampleSizes(workingChapters); + + // Build tref/chap atom for audio track + ByteVector trefAtom = buildTref(chapterTrackId); + + // Two-pass build for chapter trak: first to measure size, then with correct stco offsets. + ByteVector trakMeasure = buildChapterTrak( + chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, 0, + movieInfo.duration); + offset_t totalInsert = static_cast(trefAtom.size() + trakMeasure.size()); + // Text samples go inside an mdat atom at EOF. stco offsets point past the 8-byte mdat header. + offset_t textDataOffset = file.length() + totalInsert + 8; + + // Build final trak with correct stco offsets pointing to where text data will land. + ByteVector trakAtom = buildChapterTrak( + chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, textDataOffset, + movieInfo.duration); + + // Combined payload: tref (goes inside audio trak) + chapter trak (moov sibling) + ByteVector combinedPayload = trefAtom; + combinedPayload.append(trakAtom); + + // Insert at the end of the audio trak boundary. + // tref is logically inside audio trak; chapter trak is logically after it. + offset_t insertOffset = audio.trak->offset() + audio.trak->length(); + + file.insert(combinedPayload, insertOffset, 0); + + // Fix audio trak size on disk -- only tref goes inside + file.seek(audio.trak->offset()); + unsigned int audioTrakSize = file.readBlock(4).toUInt(); + unsigned int newAudioTrakSize = static_cast(audioTrakSize + trefAtom.size()); + file.seek(audio.trak->offset()); + file.writeBlock(ByteVector::fromUInt(newAudioTrakSize)); + + // Fix moov size -- both tref and chapter trak are inside moov + AtomList moovPath = activeAtoms->path("moov"); + updateParentSizes(&file, moovPath, combinedPayload.size()); + + // Fix existing chunk offsets -- only the ORIGINAL atom tree is iterated, + // so the new chapter trak's stco (which already has correct offsets) is untouched. + updateChunkOffsets(&file, activeAtoms, combinedPayload.size(), insertOffset); + + // ---- Phase 4: Append text samples in mdat at EOF ---- + + ByteVector textSamples; + for(const auto &ch : workingChapters) { + textSamples.append(buildTextSample(ch.title)); + } + // Wrap text samples in an mdat atom so players can find them. + ByteVector mdatAtom = renderAtom("mdat", textSamples); + + file.seek(0, TagLib::File::End); + file.writeBlock(mdatAtom); + + // ---- Phase 5: Update mvhd next_track_ID ---- + // mvhd is before insertOffset, so its offset is unchanged. + + unsigned int currentNextId = getNextTrackId(&file, activeAtoms); + if(chapterTrackId >= currentNextId) { + setNextTrackId(&file, activeAtoms, chapterTrackId + 1); + } + + return true; +} + +bool +MP4::MP4QTChapterList::remove(const char *path) +{ + MP4::File file(path, false); + if(!file.isOpen() || !file.isValid() || file.readOnly()) { + debug("MP4QTChapterList::remove() -- Could not open file for writing"); + return false; + } + + Atoms atoms(&file); + + TrackInfo audio = findAudioTrack(&file, &atoms); + if(!audio.trak) + return true; // No audio track -- nothing to do + + Atom *chapterTrak = findChapterTrak(&file, &atoms, audio.trak); + if(!chapterTrak) + return true; // No chapter track -- nothing to do + + Atom *moov = atoms.find("moov"); + if(!moov) + return false; + + // Remove chapter trak FIRST (higher offset in file). + offset_t chapterOff = chapterTrak->offset(); + offset_t chapterLen = chapterTrak->length(); + + // Remove from in-memory tree so updateChunkOffsets skips its stco. + moov->removeChild(chapterTrak); + delete chapterTrak; + + file.removeBlock(chapterOff, chapterLen); + + AtomList moovPath = atoms.path("moov"); + updateParentSizes(&file, moovPath, -chapterLen); + updateChunkOffsets(&file, &atoms, -chapterLen, chapterOff); + + // Remove tref from audio trak (lower offset, still valid after chapter trak removal). + removeAudioTref(&file, &atoms, audio.trak); + + return true; +} diff --git a/taglib/mp4/mp4qtchapterlist.h b/taglib/mp4/mp4qtchapterlist.h new file mode 100644 index 00000000..e20abf2a --- /dev/null +++ b/taglib/mp4/mp4qtchapterlist.h @@ -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 "mp4chapterlist.h" + +namespace TagLib { + 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 TAGLIB_EXPORT MP4QTChapterList + { + public: + /*! + * Reads chapter markers from the QuickTime chapter track in the + * MP4 file at \a path. Returns an empty list if the file has no + * chapter track (i.e. no \c tref/chap reference to a text track). + */ + static ChapterList read(const char *path); + + /*! + * Writes chapter markers as a QuickTime chapter track to the MP4 + * file at \a path, replacing any existing chapter track. The + * file's duration is read internally from the movie header. + * Returns \c true on success. + */ + static bool write(const char *path, const ChapterList &chapters); + + /*! + * Removes the QuickTime chapter track and its \c tref/chap + * reference from the MP4 file at \a path. + * Returns \c true on success, or if no chapter track exists. + */ + static bool remove(const char *path); + }; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 4afea33f..5c21fd01 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -35,6 +35,7 @@ #include "mp4file.h" #include "mp4itemfactory.h" #include "mp4chapterlist.h" +#include "mp4qtchapterlist.h" #include "plainfile.h" #include #include "utils.h" @@ -107,6 +108,13 @@ class TestMP4 : public CppUnit::TestFixture 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_SUITE_END(); public: @@ -1034,6 +1042,277 @@ public: TEST_FILE_PATH_C("no-tags.m4a")); CPPUNIT_ASSERT(chapters.isEmpty()); } + + void testQTChapterListWrite() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // File should have no QT chapters initially + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Write chapters (times in 100-nanosecond units) + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Intro"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 150000000LL; // 15 seconds + ch2.title = "Verse"; + chapters.append(ch2); + + MP4::Chapter ch3; + ch3.startTime = 300000000LL; // 30 seconds + ch3.title = "Outro"; + chapters.append(ch3); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Read back and verify + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(150000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title); + CPPUNIT_ASSERT_EQUAL(300000000LL, 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::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Chapter 1"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 100000000LL; // 10 seconds + ch2.title = "Chapter 2"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Verify written + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + } + + // Remove chapters + CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str())); + + // Verify removed + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + // Remove from file with no chapters should also succeed + CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str())); + } + + 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 + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Intro"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 100000000LL; // 10 seconds + ch2.title = "Verse"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Verify chapters are written AND existing tags are preserved + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title); + + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + } + + // Remove chapters and verify tags still survive + CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str())); + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.isValid()); + CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist()); + } + } + + void testQTChapterListReadEmpty() + { + // Reading from a file with no chapter track should return empty list + MP4::ChapterList chapters = MP4::MP4QTChapterList::read( + TEST_FILE_PATH_C("no-tags.m4a")); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + + void testQTChapterListOverwrite() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Write initial chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Old1"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 50000000LL; // 5 seconds + ch2.title = "Old2"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Verify initial + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + } + + // Overwrite with different chapters + { + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "New1"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 100000000LL; // 10 seconds + ch2.title = "New2"; + chapters.append(ch2); + + MP4::Chapter ch3; + ch3.startTime = 200000000LL; // 20 seconds + ch3.title = "New3"; + chapters.append(ch3); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Verify overwrite + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + 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::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Start"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 15000000LL; // 1.5 seconds in 100ns units + ch2.title = "Precise"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Read back and verify timestamps + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(15000000LL, 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::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 100000000LL; // 10 seconds + ch1.title = "One"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 200000000LL; // 20 seconds + ch2.title = "Two"; + chapters.append(ch2); + + MP4::Chapter ch3; + ch3.startTime = 300000000LL; // 30 seconds + ch3.title = "Three"; + chapters.append(ch3); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + } + + // Read back -- dummy chapter at time 0 should be stripped + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(100000000LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(300000000LL, 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); + } + } }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From c5ea13bb34a9781fc40444b5d5ea964440a5283b Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Tue, 14 Apr 2026 14:23:35 -0700 Subject: [PATCH 03/11] overloads for read, write, remove MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- taglib/mp4/mp4chapterlist.cpp | 58 +++++++++++------- taglib/mp4/mp4chapterlist.h | 22 +++++++ taglib/mp4/mp4qtchapterlist.cpp | 99 ++++++++++++++++++------------ taglib/mp4/mp4qtchapterlist.h | 22 +++++++ tests/test_mp4.cpp | 105 ++++++++++++++++++++++++++++++++ 5 files changed, 246 insertions(+), 60 deletions(-) diff --git a/taglib/mp4/mp4chapterlist.cpp b/taglib/mp4/mp4chapterlist.cpp index 22184435..dca06cda 100644 --- a/taglib/mp4/mp4chapterlist.cpp +++ b/taglib/mp4/mp4chapterlist.cpp @@ -34,9 +34,6 @@ using namespace TagLib; namespace { - // Nero chpl version 1 header: version(1) + flags(3) + reserved(4) + count(1) = 9 bytes - constexpr int chplHeaderSize = 9; - ByteVector renderAtom(const ByteVector &name, const ByteVector &data) { return ByteVector::fromUInt(static_cast(data.size() + 8)) + name + data; @@ -171,7 +168,8 @@ namespace { MP4::ChapterList chapters; - if(data.size() < static_cast(chplHeaderSize)) + // Minimum: version(1) + flags(3) + count(1) = 5 bytes (version 0 layout) + if(data.size() < 5) return chapters; unsigned int pos = 0; @@ -225,15 +223,21 @@ MP4::MP4ChapterList::read(const char *path) return ChapterList(); } - Atoms atoms(&file); + return read(&file); +} + +MP4::ChapterList +MP4::MP4ChapterList::read(MP4::File *file) +{ + Atoms atoms(file); Atom *chpl = atoms.find("moov", "udta", "chpl"); if(!chpl) return ChapterList(); // Read the atom content (skip 8-byte atom header) - file.seek(chpl->offset() + 8); - ByteVector data = file.readBlock(chpl->length() - 8); + file->seek(chpl->offset() + 8); + ByteVector data = file->readBlock(chpl->length() - 8); return parseChplData(data); } @@ -247,7 +251,13 @@ MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) return false; } - Atoms atoms(&file); + return write(&file, chapters); +} + +bool +MP4::MP4ChapterList::write(MP4::File *file, const ChapterList &chapters) +{ + Atoms atoms(file); if(!atoms.find("moov")) { debug("MP4ChapterList::write() -- No moov atom found"); @@ -265,13 +275,13 @@ MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) offset_t oldLength = existingChpl->length(); offset_t delta = static_cast(chplAtom.size()) - oldLength; - file.insert(chplAtom, offset, oldLength); + file->insert(chplAtom, offset, oldLength); if(delta != 0) { // Update parent sizes: moov and udta AtomList parentPath = atoms.path("moov", "udta", "chpl"); - updateParentSizes(&file, parentPath, delta, 1); // ignore chpl itself - updateChunkOffsets(&file, &atoms, delta, offset); + updateParentSizes(file, parentPath, delta, 1); // ignore chpl itself + updateChunkOffsets(file, &atoms, delta, offset); } } else { @@ -281,10 +291,10 @@ MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) if(udtaPath.size() == 2) { // udta exists -- insert chpl at the beginning of udta's content offset_t insertOffset = udtaPath.back()->offset() + 8; - file.insert(chplAtom, insertOffset, 0); + file->insert(chplAtom, insertOffset, 0); - updateParentSizes(&file, udtaPath, chplAtom.size()); - updateChunkOffsets(&file, &atoms, chplAtom.size(), insertOffset); + updateParentSizes(file, udtaPath, chplAtom.size()); + updateChunkOffsets(file, &atoms, chplAtom.size(), insertOffset); } else { // No udta -- insert udta + chpl at the beginning of moov's content @@ -297,10 +307,10 @@ MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) } offset_t insertOffset = moovPath.back()->offset() + 8; - file.insert(udtaAtom, insertOffset, 0); + file->insert(udtaAtom, insertOffset, 0); - updateParentSizes(&file, moovPath, udtaAtom.size()); - updateChunkOffsets(&file, &atoms, udtaAtom.size(), insertOffset); + updateParentSizes(file, moovPath, udtaAtom.size()); + updateChunkOffsets(file, &atoms, udtaAtom.size(), insertOffset); } } @@ -316,7 +326,13 @@ MP4::MP4ChapterList::remove(const char *path) return false; } - Atoms atoms(&file); + return remove(&file); +} + +bool +MP4::MP4ChapterList::remove(MP4::File *file) +{ + Atoms atoms(file); Atom *chpl = atoms.find("moov", "udta", "chpl"); if(!chpl) { @@ -327,12 +343,12 @@ MP4::MP4ChapterList::remove(const char *path) offset_t offset = chpl->offset(); offset_t length = chpl->length(); - file.removeBlock(offset, length); + file->removeBlock(offset, length); // Update parent sizes with negative delta AtomList parentPath = atoms.path("moov", "udta", "chpl"); - updateParentSizes(&file, parentPath, -length, 1); // ignore chpl itself - updateChunkOffsets(&file, &atoms, -length, offset); + updateParentSizes(file, parentPath, -length, 1); // ignore chpl itself + updateChunkOffsets(file, &atoms, -length, offset); return true; } diff --git a/taglib/mp4/mp4chapterlist.h b/taglib/mp4/mp4chapterlist.h index 37cb723f..3f739fb2 100644 --- a/taglib/mp4/mp4chapterlist.h +++ b/taglib/mp4/mp4chapterlist.h @@ -28,6 +28,7 @@ #include "tlist.h" #include "tstring.h" #include "taglib_export.h" +#include "mp4file.h" namespace TagLib { namespace MP4 { @@ -56,6 +57,13 @@ namespace TagLib { */ static ChapterList read(const char *path); + /*! + * Reads chapter markers from the already-opened \a file. + * Avoids a second open when the caller already has the file open. + * Returns an empty list if the file has no chpl atom. + */ + static ChapterList read(MP4::File *file); + /*! * Writes chapter markers to the MP4 file at \a path, * replacing any existing chpl atom. The chapter count is @@ -64,11 +72,25 @@ namespace TagLib { */ static bool write(const char *path, const ChapterList &chapters); + /*! + * 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. + */ + static bool write(MP4::File *file, const ChapterList &chapters); + /*! * Removes the chpl atom from the MP4 file at \a path. * Returns \c true on success, or if no chpl atom exists. */ static bool remove(const char *path); + + /*! + * Removes the chpl atom from the already-opened \a file. + * Returns \c true on success, or if no chpl atom exists. + */ + static bool remove(MP4::File *file); }; } // namespace MP4 diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp index c9d5ef88..a726c748 100644 --- a/taglib/mp4/mp4qtchapterlist.cpp +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -950,23 +950,29 @@ MP4::MP4QTChapterList::read(const char *path) if(!file.isOpen() || !file.isValid()) return ChapterList(); - Atoms atoms(&file); + return read(&file); +} - TrackInfo audio = findAudioTrack(&file, &atoms); +MP4::ChapterList +MP4::MP4QTChapterList::read(MP4::File *file) +{ + Atoms atoms(file); + + TrackInfo audio = findAudioTrack(file, &atoms); if(!audio.trak) return ChapterList(); - Atom *chapterTrak = findChapterTrak(&file, &atoms, audio.trak); + Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak); if(!chapterTrak) return ChapterList(); - ChapterTrackInfo trackInfo = readChapterTrackInfo(&file, chapterTrak); + ChapterTrackInfo trackInfo = readChapterTrackInfo(file, chapterTrak); if(trackInfo.timescale == 0) return ChapterList(); - std::vector sttsEntries = readStts(&file, chapterTrak); - SampleSizeInfo sizeInfo = readStsz(&file, chapterTrak); - std::vector offsets = resolveSampleOffsets(&file, chapterTrak, sizeInfo); + std::vector sttsEntries = readStts(file, chapterTrak); + SampleSizeInfo sizeInfo = readStsz(file, chapterTrak); + std::vector offsets = resolveSampleOffsets(file, chapterTrak, sizeInfo); if(offsets.empty()) return ChapterList(); @@ -984,7 +990,7 @@ MP4::MP4QTChapterList::read(const char *path) if(sampleSize == 0 && sampleIndex < sizeInfo.perSampleSizes.size()) sampleSize = sizeInfo.perSampleSizes[sampleIndex]; - String title = readTextSample(&file, offsets[sampleIndex], sampleSize); + String title = readTextSample(file, offsets[sampleIndex], sampleSize); long long startTime100ns = static_cast( static_cast(currentTime) * 10000000.0 / @@ -1021,24 +1027,33 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) return false; } + return write(&file, chapters); +} + +bool +MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) +{ + // Writing an empty list is equivalent to removing the chapter track. + if(chapters.isEmpty()) + return remove(file); + // ---- Phase 1: Parse and gather info ---- - Atoms atoms(&file); - + Atoms atoms(file); Atom *moov = atoms.find("moov"); if(!moov) { debug("MP4QTChapterList::write() -- No moov atom found"); return false; } - MovieInfo movieInfo = readMovieInfo(&file, &atoms); + MovieInfo movieInfo = readMovieInfo(file, &atoms); if(movieInfo.durationMs <= 0) { debug("MP4QTChapterList::write() -- Could not determine file duration"); return false; } long long durationMs = movieInfo.durationMs; - TrackInfo audio = findAudioTrack(&file, &atoms); + TrackInfo audio = findAudioTrack(file, &atoms); if(!audio.trak) { debug("MP4QTChapterList::write() -- No audio track found"); return false; @@ -1052,7 +1067,7 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) // Optional second parse -- only constructed when replacing existing chapters. std::unique_ptr cleanAtoms; - Atom *existingChapter = findChapterTrak(&file, &atoms, audio.trak); + Atom *existingChapter = findChapterTrak(file, &atoms, audio.trak); if(existingChapter) { // Remove chapter trak FIRST (higher offset in file). offset_t chapterOff = existingChapter->offset(); @@ -1062,17 +1077,17 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) moov->removeChild(existingChapter); delete existingChapter; - file.removeBlock(chapterOff, chapterLen); + file->removeBlock(chapterOff, chapterLen); AtomList moovPath = atoms.path("moov"); - updateParentSizes(&file, moovPath, -chapterLen); - updateChunkOffsets(&file, &atoms, -chapterLen, chapterOff); + updateParentSizes(file, moovPath, -chapterLen); + updateChunkOffsets(file, &atoms, -chapterLen, chapterOff); // Remove tref from audio trak (lower offset, still valid). - removeAudioTref(&file, &atoms, audio.trak); + removeAudioTref(file, &atoms, audio.trak); // Re-parse to get clean state after removals. - cleanAtoms = std::make_unique(&file); + cleanAtoms = std::make_unique(file); activeAtoms = cleanAtoms.get(); moov = activeAtoms->find("moov"); @@ -1080,7 +1095,7 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) debug("MP4QTChapterList::write() -- moov disappeared after cleanup"); return false; } - audio = findAudioTrack(&file, activeAtoms); + audio = findAudioTrack(file, activeAtoms); if(!audio.trak) { debug("MP4QTChapterList::write() -- No audio track after cleanup"); return false; @@ -1100,7 +1115,7 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) workingChapters.prepend(dummy); } - unsigned int nextId = getNextTrackId(&file, activeAtoms); + unsigned int nextId = getNextTrackId(file, activeAtoms); unsigned int chapterTrackId = nextId > 0 ? nextId : audio.trackId + 1; constexpr unsigned int timescale = 1000; std::vector sampleSizes = calculateSampleSizes(workingChapters); @@ -1114,7 +1129,7 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) movieInfo.duration); offset_t totalInsert = static_cast(trefAtom.size() + trakMeasure.size()); // Text samples go inside an mdat atom at EOF. stco offsets point past the 8-byte mdat header. - offset_t textDataOffset = file.length() + totalInsert + 8; + offset_t textDataOffset = file->length() + totalInsert + 8; // Build final trak with correct stco offsets pointing to where text data will land. ByteVector trakAtom = buildChapterTrak( @@ -1129,22 +1144,22 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) // tref is logically inside audio trak; chapter trak is logically after it. offset_t insertOffset = audio.trak->offset() + audio.trak->length(); - file.insert(combinedPayload, insertOffset, 0); + file->insert(combinedPayload, insertOffset, 0); // Fix audio trak size on disk -- only tref goes inside - file.seek(audio.trak->offset()); - unsigned int audioTrakSize = file.readBlock(4).toUInt(); + file->seek(audio.trak->offset()); + unsigned int audioTrakSize = file->readBlock(4).toUInt(); unsigned int newAudioTrakSize = static_cast(audioTrakSize + trefAtom.size()); - file.seek(audio.trak->offset()); - file.writeBlock(ByteVector::fromUInt(newAudioTrakSize)); + file->seek(audio.trak->offset()); + file->writeBlock(ByteVector::fromUInt(newAudioTrakSize)); // Fix moov size -- both tref and chapter trak are inside moov AtomList moovPath = activeAtoms->path("moov"); - updateParentSizes(&file, moovPath, combinedPayload.size()); + updateParentSizes(file, moovPath, combinedPayload.size()); // Fix existing chunk offsets -- only the ORIGINAL atom tree is iterated, // so the new chapter trak's stco (which already has correct offsets) is untouched. - updateChunkOffsets(&file, activeAtoms, combinedPayload.size(), insertOffset); + updateChunkOffsets(file, activeAtoms, combinedPayload.size(), insertOffset); // ---- Phase 4: Append text samples in mdat at EOF ---- @@ -1155,15 +1170,15 @@ MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) // Wrap text samples in an mdat atom so players can find them. ByteVector mdatAtom = renderAtom("mdat", textSamples); - file.seek(0, TagLib::File::End); - file.writeBlock(mdatAtom); + file->seek(0, TagLib::File::End); + file->writeBlock(mdatAtom); // ---- Phase 5: Update mvhd next_track_ID ---- // mvhd is before insertOffset, so its offset is unchanged. - unsigned int currentNextId = getNextTrackId(&file, activeAtoms); + unsigned int currentNextId = getNextTrackId(file, activeAtoms); if(chapterTrackId >= currentNextId) { - setNextTrackId(&file, activeAtoms, chapterTrackId + 1); + setNextTrackId(file, activeAtoms, chapterTrackId + 1); } return true; @@ -1178,13 +1193,19 @@ MP4::MP4QTChapterList::remove(const char *path) return false; } - Atoms atoms(&file); + return remove(&file); +} - TrackInfo audio = findAudioTrack(&file, &atoms); +bool +MP4::MP4QTChapterList::remove(MP4::File *file) +{ + Atoms atoms(file); + + TrackInfo audio = findAudioTrack(file, &atoms); if(!audio.trak) return true; // No audio track -- nothing to do - Atom *chapterTrak = findChapterTrak(&file, &atoms, audio.trak); + Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak); if(!chapterTrak) return true; // No chapter track -- nothing to do @@ -1200,14 +1221,14 @@ MP4::MP4QTChapterList::remove(const char *path) moov->removeChild(chapterTrak); delete chapterTrak; - file.removeBlock(chapterOff, chapterLen); + file->removeBlock(chapterOff, chapterLen); AtomList moovPath = atoms.path("moov"); - updateParentSizes(&file, moovPath, -chapterLen); - updateChunkOffsets(&file, &atoms, -chapterLen, chapterOff); + updateParentSizes(file, moovPath, -chapterLen); + updateChunkOffsets(file, &atoms, -chapterLen, chapterOff); // Remove tref from audio trak (lower offset, still valid after chapter trak removal). - removeAudioTref(&file, &atoms, audio.trak); + removeAudioTref(file, &atoms, audio.trak); return true; } diff --git a/taglib/mp4/mp4qtchapterlist.h b/taglib/mp4/mp4qtchapterlist.h index e20abf2a..af0d6b4c 100644 --- a/taglib/mp4/mp4qtchapterlist.h +++ b/taglib/mp4/mp4qtchapterlist.h @@ -56,6 +56,14 @@ namespace TagLib { */ static ChapterList read(const char *path); + /*! + * Reads chapter markers from the QuickTime chapter track in the + * already-opened \a file. Avoids a second open when the caller + * already has the file open. + * Returns an empty list if the file has no chapter track. + */ + static ChapterList read(MP4::File *file); + /*! * Writes chapter markers as a QuickTime chapter track to the MP4 * file at \a path, replacing any existing chapter track. The @@ -64,12 +72,26 @@ namespace TagLib { */ static bool write(const char *path, const ChapterList &chapters); + /*! + * Writes chapter markers as a QuickTime chapter track to the + * already-opened \a file, replacing any existing chapter track. + * Returns \c true on success. + */ + static bool write(MP4::File *file, const ChapterList &chapters); + /*! * Removes the QuickTime chapter track and its \c tref/chap * reference from the MP4 file at \a path. * Returns \c true on success, or if no chapter track exists. */ static bool remove(const char *path); + + /*! + * 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. + */ + static bool remove(MP4::File *file); }; } // namespace MP4 diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 5c21fd01..b2be6734 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -115,6 +115,8 @@ class TestMP4 : public CppUnit::TestFixture CPPUNIT_TEST(testQTChapterListOverwrite); CPPUNIT_TEST(testQTChapterListTimestampPrecision); CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter); + CPPUNIT_TEST(testChapterListFileAPI); + CPPUNIT_TEST(testQTChapterListFileAPI); CPPUNIT_TEST_SUITE_END(); public: @@ -1313,6 +1315,109 @@ public: CPPUNIT_ASSERT_EQUAL(String("Three"), chapters[2].title); } } + void testChapterListFileAPI() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Write chapters via the file-based API + { + MP4::File file(filename.c_str(), false); + CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); + + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Alpha"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 200000000LL; // 20 seconds + ch2.title = "Beta"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::write(&file, chapters)); + } + + // Read back via the file-based API + { + MP4::File file(filename.c_str(), false); + CPPUNIT_ASSERT(file.isOpen() && file.isValid()); + + MP4::ChapterList chapters = MP4::MP4ChapterList::read(&file); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(String("Alpha"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(String("Beta"), chapters[1].title); + } + + // Remove via the file-based API + { + MP4::File file(filename.c_str(), false); + CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); + + CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(&file)); + } + + // Verify removed + { + MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + } + + void testQTChapterListFileAPI() + { + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + // Write chapters via the file-based API + { + MP4::File file(filename.c_str(), false); + CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); + + MP4::ChapterList chapters; + MP4::Chapter ch1; + ch1.startTime = 0; + ch1.title = "Alpha"; + chapters.append(ch1); + + MP4::Chapter ch2; + ch2.startTime = 200000000LL; // 20 seconds + ch2.title = "Beta"; + chapters.append(ch2); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(&file, chapters)); + } + + // Read back via the file-based API + { + MP4::File file(filename.c_str(), false); + CPPUNIT_ASSERT(file.isOpen() && file.isValid()); + + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(&file); + CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); + CPPUNIT_ASSERT_EQUAL(String("Alpha"), chapters[0].title); + CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(String("Beta"), chapters[1].title); + } + + // Remove via the file-based API + { + MP4::File file(filename.c_str(), false); + CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); + + CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(&file)); + } + + // Verify removed + { + MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + CPPUNIT_ASSERT(chapters.isEmpty()); + } + } }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From ba2441b378eaa0121fbf60bcc540a741c4424939 Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Wed, 15 Apr 2026 09:29:52 -0700 Subject: [PATCH 04/11] corrected nanosecond unit change -> milliseconds MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- taglib/mp4/mp4chapterlist.cpp | 8 ++--- taglib/mp4/mp4chapterlist.h | 2 +- taglib/mp4/mp4qtchapterlist.cpp | 10 +++---- tests/test_mp4.cpp | 52 ++++++++++++++++----------------- 4 files changed, 36 insertions(+), 36 deletions(-) diff --git a/taglib/mp4/mp4chapterlist.cpp b/taglib/mp4/mp4chapterlist.cpp index dca06cda..606de55b 100644 --- a/taglib/mp4/mp4chapterlist.cpp +++ b/taglib/mp4/mp4chapterlist.cpp @@ -149,8 +149,8 @@ namespace if(i++ >= count) break; - // Start time: 8 bytes big-endian - data.append(ByteVector::fromLongLong(ch.startTime)); + // 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); @@ -188,7 +188,7 @@ namespace unsigned int count = static_cast(data[pos++]); for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) { - long long startTime = data.toLongLong(pos); + long long startTime100ns = data.toLongLong(pos); pos += 8; unsigned int titleLen = static_cast(data[pos++]); @@ -200,7 +200,7 @@ namespace } MP4::Chapter ch; - ch.startTime = startTime; + ch.startTime = startTime100ns / 10000LL; ch.title = title; chapters.append(ch); } diff --git a/taglib/mp4/mp4chapterlist.h b/taglib/mp4/mp4chapterlist.h index 3f739fb2..1bb9ed83 100644 --- a/taglib/mp4/mp4chapterlist.h +++ b/taglib/mp4/mp4chapterlist.h @@ -37,7 +37,7 @@ namespace TagLib { * A single Nero-style chapter marker. */ struct TAGLIB_EXPORT Chapter { - long long startTime; //!< Start time in 100-nanosecond units + long long startTime; //!< Start time in milliseconds String title; }; diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp index a726c748..3afc572a 100644 --- a/taglib/mp4/mp4qtchapterlist.cpp +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -446,9 +446,9 @@ namespace return ByteVector(); // Convert 100-ns units to timescale units - auto toTimescale = [timescale](long long time100ns) -> unsigned int { + auto toTimescale = [timescale](long long timeMs) -> unsigned int { return static_cast( - static_cast(time100ns) * static_cast(timescale) / 10000000.0 + 0.5); + static_cast(timeMs) * static_cast(timescale) / 1000.0 + 0.5); }; unsigned int totalDuration = static_cast( @@ -992,12 +992,12 @@ MP4::MP4QTChapterList::read(MP4::File *file) String title = readTextSample(file, offsets[sampleIndex], sampleSize); - long long startTime100ns = static_cast( - static_cast(currentTime) * 10000000.0 / + long long startTimeMs = static_cast( + static_cast(currentTime) * 1000.0 / static_cast(trackInfo.timescale) + 0.5); Chapter ch; - ch.startTime = startTime100ns; + ch.startTime = startTimeMs; ch.title = title; chapters.append(ch); diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index b2be6734..1e6b8899 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -908,12 +908,12 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 300000000LL; // 30 seconds in 100ns units + ch2.startTime = 30000LL; // 30 seconds in ms ch2.title = "Main Content"; chapters.append(ch2); MP4::Chapter ch3; - ch3.startTime = 600000000LL; // 60 seconds + ch3.startTime = 60000LL; // 60 seconds in ms ch3.title = "Conclusion"; chapters.append(ch3); @@ -926,9 +926,9 @@ public: CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); CPPUNIT_ASSERT_EQUAL(String("Introduction"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(30000LL, chapters[1].startTime); CPPUNIT_ASSERT_EQUAL(String("Main Content"), chapters[1].title); - CPPUNIT_ASSERT_EQUAL(600000000LL, chapters[2].startTime); + CPPUNIT_ASSERT_EQUAL(60000LL, chapters[2].startTime); CPPUNIT_ASSERT_EQUAL(String("Conclusion"), chapters[2].title); } @@ -1009,7 +1009,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 100000000LL; // 10 seconds + ch2.startTime = 10000LL; // 10 seconds in ms ch2.title = "Verse"; chapters.append(ch2); @@ -1065,12 +1065,12 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 150000000LL; // 15 seconds + ch2.startTime = 15000LL; // 15 seconds in ms ch2.title = "Verse"; chapters.append(ch2); MP4::Chapter ch3; - ch3.startTime = 300000000LL; // 30 seconds + ch3.startTime = 30000LL; // 30 seconds in ms ch3.title = "Outro"; chapters.append(ch3); @@ -1083,9 +1083,9 @@ public: CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(150000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(15000LL, chapters[1].startTime); CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title); - CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[2].startTime); + CPPUNIT_ASSERT_EQUAL(30000LL, chapters[2].startTime); CPPUNIT_ASSERT_EQUAL(String("Outro"), chapters[2].title); } } @@ -1104,7 +1104,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 100000000LL; // 10 seconds + ch2.startTime = 10000LL; // 10 seconds in ms ch2.title = "Chapter 2"; chapters.append(ch2); @@ -1153,7 +1153,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 100000000LL; // 10 seconds + ch2.startTime = 10000LL; // 10 seconds in ms ch2.title = "Verse"; chapters.append(ch2); @@ -1203,7 +1203,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 50000000LL; // 5 seconds + ch2.startTime = 5000LL; // 5 seconds in ms ch2.title = "Old2"; chapters.append(ch2); @@ -1225,12 +1225,12 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 100000000LL; // 10 seconds + ch2.startTime = 10000LL; // 10 seconds in ms ch2.title = "New2"; chapters.append(ch2); MP4::Chapter ch3; - ch3.startTime = 200000000LL; // 20 seconds + ch3.startTime = 20000LL; // 20 seconds in ms ch3.title = "New3"; chapters.append(ch3); @@ -1261,7 +1261,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 15000000LL; // 1.5 seconds in 100ns units + ch2.startTime = 1500LL; // 1.5 seconds in ms ch2.title = "Precise"; chapters.append(ch2); @@ -1273,7 +1273,7 @@ public: MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); - CPPUNIT_ASSERT_EQUAL(15000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(1500LL, chapters[1].startTime); } } @@ -1286,17 +1286,17 @@ public: { MP4::ChapterList chapters; MP4::Chapter ch1; - ch1.startTime = 100000000LL; // 10 seconds + ch1.startTime = 10000LL; // 10 seconds in ms ch1.title = "One"; chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 200000000LL; // 20 seconds + ch2.startTime = 20000LL; // 20 seconds in ms ch2.title = "Two"; chapters.append(ch2); MP4::Chapter ch3; - ch3.startTime = 300000000LL; // 30 seconds + ch3.startTime = 30000LL; // 30 seconds in ms ch3.title = "Three"; chapters.append(ch3); @@ -1307,9 +1307,9 @@ public: { MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); CPPUNIT_ASSERT_EQUAL(3U, chapters.size()); - CPPUNIT_ASSERT_EQUAL(100000000LL, chapters[0].startTime); - CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime); - CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[2].startTime); + 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); @@ -1332,7 +1332,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 200000000LL; // 20 seconds + ch2.startTime = 20000LL; // 20 seconds in ms ch2.title = "Beta"; chapters.append(ch2); @@ -1348,7 +1348,7 @@ public: CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); CPPUNIT_ASSERT_EQUAL(String("Alpha"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(20000LL, chapters[1].startTime); CPPUNIT_ASSERT_EQUAL(String("Beta"), chapters[1].title); } @@ -1384,7 +1384,7 @@ public: chapters.append(ch1); MP4::Chapter ch2; - ch2.startTime = 200000000LL; // 20 seconds + ch2.startTime = 20000LL; // 20 seconds in ms ch2.title = "Beta"; chapters.append(ch2); @@ -1400,7 +1400,7 @@ public: CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); CPPUNIT_ASSERT_EQUAL(String("Alpha"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(200000000LL, chapters[1].startTime); + CPPUNIT_ASSERT_EQUAL(20000LL, chapters[1].startTime); CPPUNIT_ASSERT_EQUAL(String("Beta"), chapters[1].title); } From 0df52e39933c2dbef07db48abc06871159b1f32f Mon Sep 17 00:00:00 2001 From: Urs Fleisch Date: Sat, 18 Apr 2026 19:46:55 +0200 Subject: [PATCH 05/11] 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. --- taglib/mp4/mp4chapterlist.cpp | 6 ++++-- taglib/mp4/mp4qtchapterlist.cpp | 6 ++++-- 2 files changed, 8 insertions(+), 4 deletions(-) diff --git a/taglib/mp4/mp4chapterlist.cpp b/taglib/mp4/mp4chapterlist.cpp index 606de55b..5314e4fa 100644 --- a/taglib/mp4/mp4chapterlist.cpp +++ b/taglib/mp4/mp4chapterlist.cpp @@ -83,7 +83,8 @@ namespace unsigned int count = data.toUInt(); 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(data.toUInt(pos)); if(o > offset) o += delta; @@ -101,7 +102,8 @@ namespace unsigned int count = data.toUInt(); 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; diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp index 3afc572a..ccdeccfb 100644 --- a/taglib/mp4/mp4qtchapterlist.cpp +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -95,7 +95,8 @@ namespace unsigned int count = data.toUInt(); 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(data.toUInt(pos)); if(o > offset) o += delta; @@ -113,7 +114,8 @@ namespace unsigned int count = data.toUInt(); 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; From 78c7208bc9697e867945e93eeedcd048ab21478a Mon Sep 17 00:00:00 2001 From: Urs Fleisch Date: Sun, 19 Apr 2026 20:29:12 +0200 Subject: [PATCH 06/11] Integrate MP4 chapters into MP4::File --- taglib/CMakeLists.txt | 8 +- taglib/mp4/mp4chapter.cpp | 79 +++ taglib/mp4/mp4chapter.h | 98 ++++ taglib/mp4/mp4chapterholder.cpp | 44 ++ taglib/mp4/mp4chapterholder.h | 110 ++++ taglib/mp4/mp4file.cpp | 28 +- taglib/mp4/mp4file.h | 21 + ...chapterlist.cpp => mp4nerochapterlist.cpp} | 146 ++---- ...{mp4chapterlist.h => mp4nerochapterlist.h} | 47 +- taglib/mp4/mp4qtchapterlist.cpp | 308 +++++------ taglib/mp4/mp4qtchapterlist.h | 38 +- tests/test_mp4.cpp | 493 ++++++------------ 12 files changed, 754 insertions(+), 666 deletions(-) create mode 100644 taglib/mp4/mp4chapter.cpp create mode 100644 taglib/mp4/mp4chapter.h create mode 100644 taglib/mp4/mp4chapterholder.cpp create mode 100644 taglib/mp4/mp4chapterholder.h rename taglib/mp4/{mp4chapterlist.cpp => mp4nerochapterlist.cpp} (71%) rename taglib/mp4/{mp4chapterlist.h => mp4nerochapterlist.h} (65%) diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index 2651249a..aa304fb3 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -196,7 +196,9 @@ if(WITH_MP4) mp4/mp4coverart.h mp4/mp4stem.h mp4/mp4itemfactory.h - mp4/mp4chapterlist.h + mp4/mp4chapter.h + mp4/mp4chapterholder.h + mp4/mp4nerochapterlist.h mp4/mp4qtchapterlist.h ) endif() @@ -374,7 +376,9 @@ if(WITH_MP4) mp4/mp4coverart.cpp mp4/mp4stem.cpp mp4/mp4itemfactory.cpp - mp4/mp4chapterlist.cpp + mp4/mp4chapter.cpp + mp4/mp4chapterholder.cpp + mp4/mp4nerochapterlist.cpp mp4/mp4qtchapterlist.cpp ) endif() diff --git a/taglib/mp4/mp4chapter.cpp b/taglib/mp4/mp4chapter.cpp new file mode 100644 index 00000000..baa1a26c --- /dev/null +++ b/taglib/mp4/mp4chapter.cpp @@ -0,0 +1,79 @@ +/************************************************************************** + 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()) +{ + d->title = title; + d->startTime = startTime; +} + +MP4::Chapter::Chapter(const Chapter &other) : + d(std::make_unique(*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; + +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; +} diff --git a/taglib/mp4/mp4chapter.h b/taglib/mp4/mp4chapter.h new file mode 100644 index 00000000..91627bb2 --- /dev/null +++ b/taglib/mp4/mp4chapter.h @@ -0,0 +1,98 @@ +/************************************************************************** + 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 +#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; + + /*! + * 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 d; + }; + + //! List of chapters. + using ChapterList = List; + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/taglib/mp4/mp4chapterholder.cpp b/taglib/mp4/mp4chapterholder.cpp new file mode 100644 index 00000000..8d9c1ddb --- /dev/null +++ b/taglib/mp4/mp4chapterholder.cpp @@ -0,0 +1,44 @@ +/************************************************************************** + 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/ * + ***************************************************************************/ + +#include "mp4chapterholder.h" + +using namespace TagLib; + +MP4::ChapterList MP4::ChapterHolder::chapters() const +{ + return chapterList; +} + +void MP4::ChapterHolder::setChapters(const ChapterList &chapters) +{ + chapterList = chapters; + modified = true; +} + +bool MP4::ChapterHolder::isModified() const +{ + return modified; +} diff --git a/taglib/mp4/mp4chapterholder.h b/taglib/mp4/mp4chapterholder.h new file mode 100644 index 00000000..94e8a52f --- /dev/null +++ b/taglib/mp4/mp4chapterholder.h @@ -0,0 +1,110 @@ +/************************************************************************** + 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; + + /*! + * Set list of chapters. + */ + void setChapters(const ChapterList &chapters); + + /*! + * Returns \c true if the list of chapters has been modified. + */ + bool isModified() const; + + 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 + ChapterList getChaptersLazy(std::unique_ptr &holder, TagLib::File *file) + { + if (!holder) { + holder = std::make_unique(); + 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 + void setChaptersLazy(std::unique_ptr &holder, const ChapterList& chapters) + { + if (!holder) { + holder = std::make_unique(); + } + holder->setChapters(chapters); + } + + /*! + * 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 + bool saveChaptersIfModified(std::unique_ptr &holder, TagLib::File *file) + { + if(holder && holder->isModified()) { + return holder->write(file); + } + return true; + } + + } // namespace MP4 +} // namespace TagLib + +#endif diff --git a/taglib/mp4/mp4file.cpp b/taglib/mp4/mp4file.cpp index cf760be2..5f8e5bec 100644 --- a/taglib/mp4/mp4file.cpp +++ b/taglib/mp4/mp4file.cpp @@ -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 tag; std::unique_ptr atoms; std::unique_ptr properties; + std::unique_ptr neroChapterList; + std::unique_ptr 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 diff --git a/taglib/mp4/mp4file.h b/taglib/mp4/mp4file.h index faf215e4..ba5ce608 100644 --- a/taglib/mp4/mp4file.h +++ b/taglib/mp4/mp4file.h @@ -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. * diff --git a/taglib/mp4/mp4chapterlist.cpp b/taglib/mp4/mp4nerochapterlist.cpp similarity index 71% rename from taglib/mp4/mp4chapterlist.cpp rename to taglib/mp4/mp4nerochapterlist.cpp index 5314e4fa..644e96b1 100644 --- a/taglib/mp4/mp4chapterlist.cpp +++ b/taglib/mp4/mp4nerochapterlist.cpp @@ -22,7 +22,7 @@ * http://www.mozilla.org/MPL/ * ***************************************************************************/ -#include "mp4chapterlist.h" +#include "mp4nerochapterlist.h" #include @@ -36,7 +36,7 @@ namespace { ByteVector renderAtom(const ByteVector &name, const ByteVector &data) { - return ByteVector::fromUInt(static_cast(data.size() + 8)) + name + data; + return ByteVector::fromUInt(data.size() + 8) + name + data; } // Update parent atom sizes along a path when child size changes by delta. @@ -52,11 +52,10 @@ namespace for(auto it = path.begin(); it != itEnd; ++it) { file->seek((*it)->offset()); - long size = file->readBlock(4).toUInt(); - if(size == 1) { + if(const long size = file->readBlock(4).toUInt(); size == 1) { // 64-bit size file->seek(4, TagLib::File::Current); - long long longSize = file->readBlock(8).toLongLong(); + const long long longSize = file->readBlock(8).toLongLong(); file->seek((*it)->offset() + 8); file->writeBlock(ByteVector::fromLongLong(longSize + delta)); } @@ -70,10 +69,10 @@ namespace // Update stco/co64/tfhd chunk offsets when file content shifts. // Mirrors MP4::Tag::updateOffsets(). - void updateChunkOffsets(TagLib::File *file, MP4::Atoms *atoms, + void updateChunkOffsets(TagLib::File *file, const MP4::Atoms *atoms, offset_t delta, offset_t offset) { - if(MP4::Atom *moov = atoms->find("moov")) { + 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) @@ -113,7 +112,7 @@ namespace } } - if(MP4::Atom *moof = atoms->find("moof")) { + 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) @@ -135,7 +134,7 @@ namespace // Build the binary payload for a chpl atom (version 1). ByteVector renderChplData(const MP4::ChapterList &chapters) { - unsigned int count = std::min(static_cast(chapters.size()), 255U); + const unsigned int count = std::min(chapters.size(), 255U); ByteVector data; // Version (1 byte) + flags (3 bytes) + reserved (4 bytes) @@ -152,11 +151,11 @@ namespace break; // Start time: 8 bytes big-endian, on-disk format is 100-nanosecond units - data.append(ByteVector::fromLongLong(ch.startTime * 10000LL)); + data.append(ByteVector::fromLongLong(ch.startTime() * 10000LL)); // Title: 1-byte length + UTF-8 bytes (max 255 bytes) - ByteVector titleBytes = ch.title.data(String::UTF8); - unsigned int titleLen = std::min(static_cast(titleBytes.size()), 255U); + ByteVector titleBytes = ch.title().data(String::UTF8); + const unsigned int titleLen = std::min(titleBytes.size(), 255U); data.append(static_cast(titleLen & 0xFF)); if(titleLen > 0) data.append(titleBytes.mid(0, titleLen)); @@ -175,7 +174,7 @@ namespace return chapters; unsigned int pos = 0; - unsigned char version = static_cast(data[pos++]); + const auto version = static_cast(data[pos++]); // Skip flags (3 bytes) pos += 3; @@ -187,13 +186,13 @@ namespace if(pos >= data.size()) return chapters; - unsigned int count = static_cast(data[pos++]); + const unsigned int count = static_cast(data[pos++]); for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) { - long long startTime100ns = data.toLongLong(pos); + const long long startTime100ns = data.toLongLong(pos); pos += 8; - unsigned int titleLen = static_cast(data[pos++]); + const unsigned int titleLen = static_cast(data[pos++]); String title; if(titleLen > 0 && pos + titleLen <= data.size()) { @@ -201,10 +200,7 @@ namespace pos += titleLen; } - MP4::Chapter ch; - ch.startTime = startTime100ns / 10000LL; - ch.title = title; - chapters.append(ch); + chapters.append(MP4::Chapter(title, startTime100ns / 10000LL)); } return chapters; @@ -216,83 +212,61 @@ namespace // public members //////////////////////////////////////////////////////////////////////////////// -MP4::ChapterList -MP4::MP4ChapterList::read(const char *path) +bool MP4::NeroChapterList::read(TagLib::File *file) { - MP4::File file(path, false); - if(!file.isOpen() || !file.isValid()) { - debug("MP4ChapterList::read() -- Could not open file"); - return ChapterList(); + 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 read(&file); + return false; } -MP4::ChapterList -MP4::MP4ChapterList::read(MP4::File *file) +bool MP4::NeroChapterList::write(TagLib::File *file) { - Atoms atoms(file); + // Writing an empty list is equivalent to removing the chapters. + if(chapterList.isEmpty()) + return remove(file); - Atom *chpl = atoms.find("moov", "udta", "chpl"); - if(!chpl) - return ChapterList(); - - // Read the atom content (skip 8-byte atom header) - file->seek(chpl->offset() + 8); - ByteVector data = file->readBlock(chpl->length() - 8); - - return parseChplData(data); -} - -bool -MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters) -{ - MP4::File file(path, false); - if(!file.isOpen() || !file.isValid() || file.readOnly()) { - debug("MP4ChapterList::write() -- Could not open file for writing"); - return false; - } - - return write(&file, chapters); -} - -bool -MP4::MP4ChapterList::write(MP4::File *file, const ChapterList &chapters) -{ - Atoms atoms(file); + const Atoms atoms(file); if(!atoms.find("moov")) { debug("MP4ChapterList::write() -- No moov atom found"); return false; } - ByteVector chplPayload = renderChplData(chapters); - ByteVector chplAtom = renderAtom("chpl", chplPayload); + const ByteVector chplPayload = renderChplData(chapterList); + const ByteVector chplAtom = renderAtom("chpl", chplPayload); - Atom *existingChpl = atoms.find("moov", "udta", "chpl"); - - if(existingChpl) { + if(const Atom *existingChpl = atoms.find("moov", "udta", "chpl")) { // Replace existing chpl atom - offset_t offset = existingChpl->offset(); - offset_t oldLength = existingChpl->length(); - offset_t delta = static_cast(chplAtom.size()) - oldLength; + const offset_t offset = existingChpl->offset(); + const offset_t oldLength = existingChpl->length(); + const offset_t delta = static_cast(chplAtom.size()) - oldLength; file->insert(chplAtom, offset, oldLength); if(delta != 0) { // Update parent sizes: moov and udta - AtomList parentPath = atoms.path("moov", "udta", "chpl"); + 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 - AtomList udtaPath = atoms.path("moov", "udta"); - if(udtaPath.size() == 2) { + if(AtomList udtaPath = atoms.path("moov", "udta"); udtaPath.size() == 2) { // udta exists -- insert chpl at the beginning of udta's content - offset_t insertOffset = udtaPath.back()->offset() + 8; + const offset_t insertOffset = udtaPath.back()->offset() + 8; file->insert(chplAtom, insertOffset, 0); updateParentSizes(file, udtaPath, chplAtom.size()); @@ -300,7 +274,7 @@ MP4::MP4ChapterList::write(MP4::File *file, const ChapterList &chapters) } else { // No udta -- insert udta + chpl at the beginning of moov's content - ByteVector udtaAtom = renderAtom("udta", chplAtom); + const ByteVector udtaAtom = renderAtom("udta", chplAtom); AtomList moovPath = atoms.path("moov"); if(moovPath.isEmpty()) { @@ -308,7 +282,7 @@ MP4::MP4ChapterList::write(MP4::File *file, const ChapterList &chapters) return false; } - offset_t insertOffset = moovPath.back()->offset() + 8; + const offset_t insertOffset = moovPath.back()->offset() + 8; file->insert(udtaAtom, insertOffset, 0); updateParentSizes(file, moovPath, udtaAtom.size()); @@ -316,39 +290,29 @@ MP4::MP4ChapterList::write(MP4::File *file, const ChapterList &chapters) } } + modified = false; return true; } -bool -MP4::MP4ChapterList::remove(const char *path) +bool MP4::NeroChapterList::remove(TagLib::File *file) { - MP4::File file(path, false); - if(!file.isOpen() || !file.isValid() || file.readOnly()) { - debug("MP4ChapterList::remove() -- Could not open file for writing"); - return false; - } + const Atoms atoms(file); + chapterList.clear(); + modified = false; - return remove(&file); -} - -bool -MP4::MP4ChapterList::remove(MP4::File *file) -{ - Atoms atoms(file); - - Atom *chpl = atoms.find("moov", "udta", "chpl"); + const Atom *chpl = atoms.find("moov", "udta", "chpl"); if(!chpl) { // No chpl atom -- nothing to remove return true; } - offset_t offset = chpl->offset(); - offset_t length = chpl->length(); + const offset_t offset = chpl->offset(); + const offset_t length = chpl->length(); file->removeBlock(offset, length); // Update parent sizes with negative delta - AtomList parentPath = atoms.path("moov", "udta", "chpl"); + const AtomList parentPath = atoms.path("moov", "udta", "chpl"); updateParentSizes(file, parentPath, -length, 1); // ignore chpl itself updateChunkOffsets(file, &atoms, -length, offset); diff --git a/taglib/mp4/mp4chapterlist.h b/taglib/mp4/mp4nerochapterlist.h similarity index 65% rename from taglib/mp4/mp4chapterlist.h rename to taglib/mp4/mp4nerochapterlist.h index 1bb9ed83..3d2ac4c2 100644 --- a/taglib/mp4/mp4chapterlist.h +++ b/taglib/mp4/mp4nerochapterlist.h @@ -25,52 +25,25 @@ #ifndef TAGLIB_MP4CHAPTERLIST_H #define TAGLIB_MP4CHAPTERLIST_H -#include "tlist.h" -#include "tstring.h" -#include "taglib_export.h" -#include "mp4file.h" +#include "mp4chapterholder.h" namespace TagLib { + class File; namespace MP4 { - /*! - * A single Nero-style chapter marker. - */ - struct TAGLIB_EXPORT Chapter { - long long startTime; //!< Start time in milliseconds - String title; - }; - - using ChapterList = List; - /*! * 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 TAGLIB_EXPORT MP4ChapterList + class NeroChapterList : public ChapterHolder { public: - /*! - * Reads chapter markers from the MP4 file at \a path. - * Returns an empty list if the file has no chpl atom. - */ - static ChapterList read(const char *path); - /*! * Reads chapter markers from the already-opened \a file. - * Avoids a second open when the caller already has the file open. - * Returns an empty list if the file has no chpl atom. + * Returns \c false if the file has no chpl atom. */ - static ChapterList read(MP4::File *file); - - /*! - * Writes chapter markers to the MP4 file at \a path, - * replacing any existing chpl atom. The chapter count is - * capped at 255 (Nero format limit). - * Returns \c true on success. - */ - static bool write(const char *path, const ChapterList &chapters); + bool read(TagLib::File *file); /*! * Writes chapter markers to the already-opened \a file, @@ -78,19 +51,13 @@ namespace TagLib { * The chapter count is capped at 255 (Nero format limit). * Returns \c true on success. */ - static bool write(MP4::File *file, const ChapterList &chapters); - - /*! - * Removes the chpl atom from the MP4 file at \a path. - * Returns \c true on success, or if no chpl atom exists. - */ - static bool remove(const char *path); + 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. */ - static bool remove(MP4::File *file); + bool remove(TagLib::File *file); }; } // namespace MP4 diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp index ccdeccfb..22359c4a 100644 --- a/taglib/mp4/mp4qtchapterlist.cpp +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -24,7 +24,6 @@ #include "mp4qtchapterlist.h" -#include #include #include @@ -41,7 +40,7 @@ namespace ByteVector renderAtom(const ByteVector &name, const ByteVector &data) { - return ByteVector::fromUInt(static_cast(data.size() + 8)) + name + data; + return ByteVector::fromUInt(data.size() + 8) + name + data; } //! Build a full-box (version + flags) atom. @@ -68,10 +67,9 @@ namespace for(auto it = path.begin(); it != itEnd; ++it) { file->seek((*it)->offset()); - long size = file->readBlock(4).toUInt(); - if(size == 1) { + if(const long size = file->readBlock(4).toUInt(); size == 1) { file->seek(4, TagLib::File::Current); - long long longSize = file->readBlock(8).toLongLong(); + const long long longSize = file->readBlock(8).toLongLong(); file->seek((*it)->offset() + 8); file->writeBlock(ByteVector::fromLongLong(longSize + delta)); } @@ -82,10 +80,10 @@ namespace } } - void updateChunkOffsets(TagLib::File *file, MP4::Atoms *atoms, + void updateChunkOffsets(TagLib::File *file, const MP4::Atoms *atoms, offset_t delta, offset_t offset) { - if(MP4::Atom *moov = atoms->find("moov")) { + 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) @@ -125,7 +123,7 @@ namespace } } - if(MP4::Atom *moof = atoms->find("moof")) { + 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) @@ -154,14 +152,14 @@ namespace }; //! Reads movie-level info from mvhd. - MovieInfo readMovieInfo(TagLib::File *file, MP4::Atoms *atoms) + MovieInfo readMovieInfo(TagLib::File *file, const MP4::Atoms *atoms) { MovieInfo info; MP4::Atom *moov = atoms->find("moov"); if(!moov) return info; - MP4::Atom *mvhd = moov->find("mvhd"); + const MP4::Atom *mvhd = moov->find("mvhd"); if(!mvhd) return info; @@ -170,7 +168,7 @@ namespace if(data.size() < 8 + 4) return info; - unsigned char version = static_cast(data[8]); + const auto version = static_cast(data[8]); long long timescale, duration; if(version == 1 && data.size() >= 8 + 28) { timescale = data.toUInt(28U); @@ -201,10 +199,10 @@ namespace }; //! Finds the first audio track (hdlr handler_type == "soun"). - TrackInfo findAudioTrack(TagLib::File *file, MP4::Atoms *atoms) + TrackInfo findAudioTrack(TagLib::File *file, const MP4::Atoms *atoms) { TrackInfo info; - MP4::Atom *moov = atoms->find("moov"); + const MP4::Atom *moov = atoms->find("moov"); if(!moov) return info; @@ -214,16 +212,16 @@ namespace if(!hdlr) continue; file->seek(hdlr->offset()); - ByteVector data = file->readBlock(hdlr->length()); // handler_type is at offset 16 from atom start (8 header + 4 version/flags + 4 pre_defined) - if(data.containsAt("soun", 16)) { + if(ByteVector data = file->readBlock(hdlr->length()); + data.containsAt("soun", 16)) { info.trak = trak; // Read track_id from tkhd - if(MP4::Atom *tkhd = trak->find("tkhd")) { + if(const MP4::Atom *tkhd = trak->find("tkhd")) { file->seek(tkhd->offset()); ByteVector tkhdData = file->readBlock(tkhd->length()); - unsigned char version = static_cast(tkhdData[8]); - if(version == 1 && tkhdData.size() >= 8 + 20 + 4) { + if(const auto version = static_cast(tkhdData[8]); + version == 1 && tkhdData.size() >= 8 + 20 + 4) { info.trackId = tkhdData.toUInt(28U); } else if(tkhdData.size() >= 8 + 12 + 4) { @@ -237,45 +235,45 @@ namespace } //! Reads the next_track_ID from mvhd. - unsigned int getNextTrackId(TagLib::File *file, MP4::Atoms *atoms) + unsigned int getNextTrackId(TagLib::File *file, const MP4::Atoms *atoms) { MP4::Atom *moov = atoms->find("moov"); if(!moov) return 0; - MP4::Atom *mvhd = moov->find("mvhd"); + const MP4::Atom *mvhd = moov->find("mvhd"); if(!mvhd) return 0; file->seek(mvhd->offset()); ByteVector data = file->readBlock(mvhd->length()); - unsigned char version = static_cast(data[8]); + const auto version = static_cast(data[8]); // next_track_ID is the last 4 bytes of mvhd // version 0: header(8) + version/flags(4) + creation(4) + modification(4) // + timescale(4) + duration(4) + ... total fixed = 108 bytes // version 1: header(8) + version/flags(4) + creation(8) + modification(8) // + timescale(4) + duration(8) + ... total fixed = 120 bytes - unsigned int nextTrackIdOffset = (version == 1) ? 120 - 4 : 108 - 4; - if(data.size() >= nextTrackIdOffset + 4) + if(const unsigned int nextTrackIdOffset = version == 1 ? 120 - 4 : 108 - 4; + data.size() >= nextTrackIdOffset + 4) return data.toUInt(nextTrackIdOffset); return 0; } //! Writes next_track_ID in mvhd. - void setNextTrackId(TagLib::File *file, MP4::Atoms *atoms, unsigned int newId) + void setNextTrackId(TagLib::File *file, const MP4::Atoms *atoms, unsigned int newId) { MP4::Atom *moov = atoms->find("moov"); if(!moov) return; - MP4::Atom *mvhd = moov->find("mvhd"); + const MP4::Atom *mvhd = moov->find("mvhd"); if(!mvhd) return; file->seek(mvhd->offset()); ByteVector data = file->readBlock(mvhd->length()); - unsigned char version = static_cast(data[8]); + const auto version = static_cast(data[8]); - unsigned int nextTrackIdOffset = (version == 1) ? 120 - 4 : 108 - 4; - if(data.size() >= nextTrackIdOffset + 4) { + if(const unsigned int nextTrackIdOffset = version == 1 ? 120 - 4 : 108 - 4; + data.size() >= nextTrackIdOffset + 4) { file->seek(mvhd->offset() + nextTrackIdOffset); file->writeBlock(ByteVector::fromUInt(newId)); } @@ -285,41 +283,40 @@ namespace //! Finds an existing chapter track by scanning for tref/chap in the audio track. //! tref is NOT in TagLib's container list, so we read it manually. - MP4::Atom *findChapterTrak(TagLib::File *file, MP4::Atoms *atoms, - MP4::Atom *audioTrak) + MP4::Atom *findChapterTrak(TagLib::File *file, const MP4::Atoms *atoms, + const MP4::Atom *audioTrak) { if(!audioTrak) return nullptr; - MP4::Atom *moov = atoms->find("moov"); + const MP4::Atom *moov = atoms->find("moov"); if(!moov) return nullptr; for(const auto &child : audioTrak->children()) { if(child->name() == "tref") { file->seek(child->offset() + 8); - offset_t trefEnd = child->offset() + child->length(); + const offset_t trefEnd = child->offset() + child->length(); while(file->tell() + 8 <= trefEnd) { - offset_t boxStart = file->tell(); + const offset_t boxStart = file->tell(); ByteVector header = file->readBlock(8); if(header.size() < 8) break; - unsigned int boxSize = header.toUInt(); + const unsigned int boxSize = header.toUInt(); if(boxSize < 8) break; - ByteVector boxName = header.mid(4, 4); - - if(boxName == "chap" && boxSize >= 12) { + if(ByteVector boxName = header.mid(4, 4); + boxName == "chap" && boxSize >= 12) { ByteVector refData = file->readBlock(boxSize - 8); - unsigned int refTrackId = refData.toUInt(); + const unsigned int refTrackId = refData.toUInt(); const MP4::AtomList allTraks = moov->findall("trak"); for(const auto &t : allTraks) { - MP4::Atom *tkhd = t->find("tkhd"); + const MP4::Atom *tkhd = t->find("tkhd"); if(!tkhd) continue; @@ -328,7 +325,7 @@ namespace if(tkhdData.size() < 24) continue; - unsigned char version = static_cast(tkhdData[8]); + const auto version = static_cast(tkhdData[8]); unsigned int tid; if(version == 1 && tkhdData.size() >= 32) { tid = tkhdData.toUInt(28U); @@ -359,8 +356,8 @@ namespace //! Builds a single text sample: 2-byte big-endian length + UTF-8 text + encd atom. ByteVector buildTextSample(const String &title) { - ByteVector utf8 = title.data(String::UTF8); - unsigned int textLen = static_cast(utf8.size()); + const ByteVector utf8 = title.data(String::UTF8); + const unsigned int textLen = utf8.size(); ByteVector sample; sample.append(ByteVector::fromShort(static_cast(textLen))); @@ -384,7 +381,7 @@ namespace { std::vector sizes; for(const auto &ch : chapters) { - unsigned int textLen = static_cast(ch.title.data(String::UTF8).size()); + const unsigned int textLen = ch.title().data(String::UTF8).size(); sizes.push_back(2 + textLen + encdAtomSize); } return sizes; @@ -424,7 +421,7 @@ namespace }; ByteVector sampleEntry; - unsigned int entrySize = 8 + sizeof(entryBody); + constexpr unsigned int entrySize = 8 + sizeof(entryBody); sampleEntry.append(ByteVector::fromUInt(entrySize)); sampleEntry.append(ByteVector("text", 4)); sampleEntry.append(ByteVector(reinterpret_cast(entryBody), @@ -443,7 +440,7 @@ namespace ByteVector buildStts(const MP4::ChapterList &chapters, unsigned int timescale, long long durationMs) { - unsigned int count = static_cast(chapters.size()); + const unsigned int count = chapters.size(); if(count == 0) return ByteVector(); @@ -453,7 +450,7 @@ namespace static_cast(timeMs) * static_cast(timescale) / 1000.0 + 0.5); }; - unsigned int totalDuration = static_cast( + const auto totalDuration = static_cast( static_cast(durationMs) * static_cast(timescale) / 1000.0 + 0.5); // Build per-sample durations @@ -462,10 +459,10 @@ namespace for(unsigned int i = 0; i < count; ++i, ++it) { auto next = it; ++next; - unsigned int startTs = toTimescale(it->startTime); + const unsigned int startTs = toTimescale(it->startTime()); unsigned int dur; if(next != chapters.end()) { - unsigned int nextTs = toTimescale(next->startTime); + const unsigned int nextTs = toTimescale(next->startTime()); dur = nextTs - startTs; } else { @@ -479,7 +476,7 @@ namespace // AVFoundation requires this layout rather than run-length encoding. ByteVector payload; payload.append(ByteVector::fromUInt(count)); - for(auto d : durations) { + for(const auto d : durations) { payload.append(ByteVector::fromUInt(1)); // sample count payload.append(ByteVector::fromUInt(d)); // sample delta } @@ -493,7 +490,7 @@ namespace ByteVector payload; payload.append(ByteVector::fromUInt(0)); // default_sample_size = 0 (per-sample) payload.append(ByteVector::fromUInt(static_cast(sampleSizes.size()))); - for(auto sz : sampleSizes) + for(const auto sz : sampleSizes) payload.append(ByteVector::fromUInt(sz)); return renderFullBox("stsz", 0, 0, payload); } @@ -535,12 +532,12 @@ namespace offset_t textDataOffset, unsigned int movieDuration) { - unsigned int count = static_cast(chapters.size()); - unsigned int totalDuration = static_cast( + unsigned int count = chapters.size(); + auto totalDuration = static_cast( static_cast(durationMs) * static_cast(timescale) / 1000.0 + 0.5); // Single chunk offset -- all samples are contiguous starting at textDataOffset - unsigned int chunkOffset = static_cast(textDataOffset); + auto chunkOffset = static_cast(textDataOffset); // -- tkhd (track header) -- // version 0: 8 header + 4 ver/flags + 4 creation + 4 modification @@ -692,7 +689,7 @@ namespace { ByteVector chapData; chapData.append(ByteVector::fromUInt(chapterTrackId)); - ByteVector chap = renderAtom("chap", chapData); + const ByteVector chap = renderAtom("chap", chapData); return renderAtom("tref", chap); } @@ -708,7 +705,7 @@ namespace { ChapterTrackInfo info; - MP4::Atom *mdhd = chapterTrak->find("mdia", "mdhd"); + const MP4::Atom *mdhd = chapterTrak->find("mdia", "mdhd"); if(!mdhd) return info; @@ -717,8 +714,8 @@ namespace if(data.size() < 8 + 4) return info; - unsigned char version = static_cast(data[8]); - if(version == 1 && data.size() >= 40) { + if(const auto version = static_cast(data[8]); + version == 1 && data.size() >= 40) { // v1 mdhd: header(8) + ver/flags(4) + creation(8) + modification(8) // + timescale(4)@28 + duration(8)@32 + lang(2) + pre(2) = 44 info.timescale = data.toUInt(28U); @@ -742,16 +739,16 @@ namespace std::vector readStts(TagLib::File *file, MP4::Atom *chapterTrak) { std::vector entries; - MP4::Atom *stts = chapterTrak->find("mdia", "minf", "stbl", "stts"); + const MP4::Atom *stts = chapterTrak->find("mdia", "minf", "stbl", "stts"); if(!stts) return entries; file->seek(stts->offset() + 12); // skip header(8) + version/flags(4) - ByteVector data = file->readBlock(stts->length() - 12); + const ByteVector data = file->readBlock(stts->length() - 12); if(data.size() < 4) return entries; - unsigned int count = data.toUInt(); + const unsigned int count = data.toUInt(); unsigned int pos = 4; for(unsigned int i = 0; i < count && pos + 8 <= data.size(); ++i) { SttsEntry e; @@ -767,16 +764,16 @@ namespace std::vector readStco(TagLib::File *file, MP4::Atom *chapterTrak) { std::vector offsets; - MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco"); + const MP4::Atom *stco = chapterTrak->find("mdia", "minf", "stbl", "stco"); if(!stco) return offsets; file->seek(stco->offset() + 12); - ByteVector data = file->readBlock(stco->length() - 12); + const ByteVector data = file->readBlock(stco->length() - 12); if(data.size() < 4) return offsets; - unsigned int count = data.toUInt(); + const unsigned int count = data.toUInt(); unsigned int pos = 4; for(unsigned int i = 0; i < count && pos + 4 <= data.size(); ++i) { offsets.push_back(data.toUInt(pos)); @@ -796,12 +793,12 @@ namespace SampleSizeInfo readStsz(TagLib::File *file, MP4::Atom *chapterTrak) { SampleSizeInfo info; - MP4::Atom *stsz = chapterTrak->find("mdia", "minf", "stbl", "stsz"); + const MP4::Atom *stsz = chapterTrak->find("mdia", "minf", "stbl", "stsz"); if(!stsz) return info; file->seek(stsz->offset() + 12); - ByteVector data = file->readBlock(stsz->length() - 12); + const ByteVector data = file->readBlock(stsz->length() - 12); if(data.size() < 8) return info; @@ -825,7 +822,7 @@ namespace MP4::Atom *chapterTrak, const SampleSizeInfo &sizeInfo) { - std::vector chunkOffsets = readStco(file, chapterTrak); + const std::vector chunkOffsets = readStco(file, chapterTrak); if(chunkOffsets.empty()) return {}; @@ -837,12 +834,11 @@ namespace }; std::vector stscEntries; - MP4::Atom *stsc = chapterTrak->find("mdia", "minf", "stbl", "stsc"); - if(stsc) { + if(const MP4::Atom *stsc = chapterTrak->find("mdia", "minf", "stbl", "stsc")) { file->seek(stsc->offset() + 12); - ByteVector data = file->readBlock(stsc->length() - 12); - if(data.size() >= 4) { - unsigned int entryCount = data.toUInt(); + if(const ByteVector data = file->readBlock(stsc->length() - 12); + data.size() >= 4) { + const unsigned int entryCount = data.toUInt(); unsigned int pos = 4; for(unsigned int i = 0; i < entryCount && pos + 12 <= data.size(); ++i) { StscEntry e; @@ -862,16 +858,16 @@ namespace // Resolve per-sample offsets by walking chunks std::vector sampleOffsets; - unsigned int totalChunks = static_cast(chunkOffsets.size()); + const auto totalChunks = static_cast(chunkOffsets.size()); unsigned int sampleIndex = 0; for(unsigned int chunkIdx = 0; chunkIdx < totalChunks; ++chunkIdx) { // Find which stsc entry applies to this chunk (1-based) - unsigned int chunkNum = chunkIdx + 1; + const unsigned int chunkNum = chunkIdx + 1; unsigned int samplesInChunk = stscEntries[0].samplesPerChunk; - for(unsigned int e = 0; e < stscEntries.size(); ++e) { - if(stscEntries[e].firstChunk <= chunkNum) { - samplesInChunk = stscEntries[e].samplesPerChunk; + for(const auto & stscEntry : stscEntries) { + if(stscEntry.firstChunk <= chunkNum) { + samplesInChunk = stscEntry.samplesPerChunk; } else { break; @@ -898,11 +894,11 @@ namespace String readTextSample(TagLib::File *file, unsigned int offset, unsigned int maxSize) { file->seek(offset); - ByteVector data = file->readBlock(maxSize); + const ByteVector data = file->readBlock(maxSize); if(data.size() < 2) return String(); - unsigned int textLen = data.toUShort(); + const unsigned int textLen = data.toUShort(); if(textLen == 0 || textLen + 2 > data.size()) return String(); @@ -914,25 +910,25 @@ namespace //! Removes the tref atom from the audio track. //! Updates trak size, parent sizes, and chunk offsets. //! audioTrak's in-memory children list is NOT modified (caller re-parses if needed). - void removeAudioTref(TagLib::File *file, MP4::Atoms *atoms, MP4::Atom *audioTrak) + void removeAudioTref(TagLib::File *file, const MP4::Atoms *atoms, const MP4::Atom *audioTrak) { for(const auto &child : audioTrak->children()) { if(child->name() != "tref") continue; - offset_t trefOff = child->offset(); - offset_t trefLen = child->length(); + const offset_t trefOff = child->offset(); + const offset_t trefLen = child->length(); file->removeBlock(trefOff, trefLen); // Fix audio trak size on disk file->seek(audioTrak->offset()); - unsigned int trakSize = file->readBlock(4).toUInt(); + const unsigned int trakSize = file->readBlock(4).toUInt(); file->seek(audioTrak->offset()); file->writeBlock(ByteVector::fromUInt( static_cast(trakSize - trefLen))); - MP4::AtomList moovPath = atoms->path("moov"); + const MP4::AtomList moovPath = atoms->path("moov"); updateParentSizes(file, moovPath, -trefLen); updateChunkOffsets(file, atoms, -trefLen, trefOff); return; @@ -945,41 +941,31 @@ namespace // public members //////////////////////////////////////////////////////////////////////////////// -MP4::ChapterList -MP4::MP4QTChapterList::read(const char *path) +bool MP4::QtChapterList::read(TagLib::File *file) { - MP4::File file(path, false); - if(!file.isOpen() || !file.isValid()) - return ChapterList(); - - return read(&file); -} - -MP4::ChapterList -MP4::MP4QTChapterList::read(MP4::File *file) -{ - Atoms atoms(file); + const Atoms atoms(file); + modified = false; + chapterList.clear(); TrackInfo audio = findAudioTrack(file, &atoms); if(!audio.trak) - return ChapterList(); + return false; Atom *chapterTrak = findChapterTrak(file, &atoms, audio.trak); if(!chapterTrak) - return ChapterList(); + return false; ChapterTrackInfo trackInfo = readChapterTrackInfo(file, chapterTrak); if(trackInfo.timescale == 0) - return ChapterList(); + return false; - std::vector sttsEntries = readStts(file, chapterTrak); - SampleSizeInfo sizeInfo = readStsz(file, chapterTrak); - std::vector offsets = resolveSampleOffsets(file, chapterTrak, sizeInfo); + const std::vector sttsEntries = readStts(file, chapterTrak); + const SampleSizeInfo sizeInfo = readStsz(file, chapterTrak); + const std::vector offsets = resolveSampleOffsets(file, chapterTrak, sizeInfo); if(offsets.empty()) - return ChapterList(); + return false; - ChapterList chapters; unsigned int sampleIndex = 0; long long currentTime = 0; @@ -994,14 +980,11 @@ MP4::MP4QTChapterList::read(MP4::File *file) String title = readTextSample(file, offsets[sampleIndex], sampleSize); - long long startTimeMs = static_cast( + const auto startTimeMs = static_cast( static_cast(currentTime) * 1000.0 / static_cast(trackInfo.timescale) + 0.5); - Chapter ch; - ch.startTime = startTimeMs; - ch.title = title; - chapters.append(ch); + chapterList.append(Chapter(title, startTimeMs)); currentTime += entry.sampleDelta; sampleIndex++; @@ -1010,33 +993,20 @@ MP4::MP4QTChapterList::read(MP4::File *file) // Strip a leading dummy chapter (empty title at time 0) that was inserted // during write to preserve non-zero first-chapter start times. - if(chapters.size() > 1) { - const Chapter &first = chapters.front(); - if(first.startTime == 0 && first.title.isEmpty()) { - chapters.erase(chapters.begin()); + if(chapterList.size() > 1) { + if(const Chapter &first = chapterList.front(); + first.startTime() == 0 && first.title().isEmpty()) { + chapterList.erase(chapterList.begin()); } } - return chapters; + return true; } -bool -MP4::MP4QTChapterList::write(const char *path, const ChapterList &chapters) -{ - MP4::File file(path, false); - if(!file.isOpen() || !file.isValid() || file.readOnly()) { - debug("MP4QTChapterList::write() -- Could not open file for writing"); - return false; - } - - return write(&file, chapters); -} - -bool -MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) +bool MP4::QtChapterList::write(TagLib::File *file) { // Writing an empty list is equivalent to removing the chapter track. - if(chapters.isEmpty()) + if(chapterList.isEmpty()) return remove(file); // ---- Phase 1: Parse and gather info ---- @@ -1048,12 +1018,12 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) return false; } - MovieInfo movieInfo = readMovieInfo(file, &atoms); + const MovieInfo movieInfo = readMovieInfo(file, &atoms); if(movieInfo.durationMs <= 0) { debug("MP4QTChapterList::write() -- Could not determine file duration"); return false; } - long long durationMs = movieInfo.durationMs; + const long long durationMs = movieInfo.durationMs; TrackInfo audio = findAudioTrack(file, &atoms); if(!audio.trak) { @@ -1065,15 +1035,14 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) // Pointer to the Atoms object we'll use for the insert phase. // Points to `atoms` for fresh writes, or `cleanAtoms` after cleanup. - Atoms *activeAtoms = &atoms; + const Atoms *activeAtoms = &atoms; // Optional second parse -- only constructed when replacing existing chapters. std::unique_ptr cleanAtoms; - Atom *existingChapter = findChapterTrak(file, &atoms, audio.trak); - if(existingChapter) { + if(Atom *existingChapter = findChapterTrak(file, &atoms, audio.trak)) { // Remove chapter trak FIRST (higher offset in file). - offset_t chapterOff = existingChapter->offset(); - offset_t chapterLen = existingChapter->length(); + const offset_t chapterOff = existingChapter->offset(); + const offset_t chapterLen = existingChapter->length(); // Remove from in-memory tree so updateChunkOffsets skips its stco. moov->removeChild(existingChapter); @@ -1081,7 +1050,7 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) file->removeBlock(chapterOff, chapterLen); - AtomList moovPath = atoms.path("moov"); + const AtomList moovPath = atoms.path("moov"); updateParentSizes(file, moovPath, -chapterLen); updateChunkOffsets(file, &atoms, -chapterLen, chapterOff); @@ -1109,32 +1078,29 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) // QT chapter tracks always start at media time 0. If the first chapter has a // non-zero start time, prepend a dummy chapter at time 0 with an empty title // so the absolute positions are preserved as stts durations. - ChapterList workingChapters(chapters); - if(!workingChapters.isEmpty() && workingChapters.front().startTime > 0) { - Chapter dummy; - dummy.startTime = 0; - dummy.title = String(); - workingChapters.prepend(dummy); + ChapterList workingChapters(chapterList); + if(!workingChapters.isEmpty() && workingChapters.front().startTime() > 0) { + workingChapters.prepend(Chapter(String(), 0)); } - unsigned int nextId = getNextTrackId(file, activeAtoms); - unsigned int chapterTrackId = nextId > 0 ? nextId : audio.trackId + 1; + const unsigned int nextId = getNextTrackId(file, activeAtoms); + const unsigned int chapterTrackId = nextId > 0 ? nextId : audio.trackId + 1; constexpr unsigned int timescale = 1000; - std::vector sampleSizes = calculateSampleSizes(workingChapters); + const std::vector sampleSizes = calculateSampleSizes(workingChapters); // Build tref/chap atom for audio track - ByteVector trefAtom = buildTref(chapterTrackId); + const ByteVector trefAtom = buildTref(chapterTrackId); // Two-pass build for chapter trak: first to measure size, then with correct stco offsets. - ByteVector trakMeasure = buildChapterTrak( + const ByteVector trakMeasure = buildChapterTrak( chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, 0, movieInfo.duration); - offset_t totalInsert = static_cast(trefAtom.size() + trakMeasure.size()); + const auto totalInsert = static_cast(trefAtom.size() + trakMeasure.size()); // Text samples go inside an mdat atom at EOF. stco offsets point past the 8-byte mdat header. - offset_t textDataOffset = file->length() + totalInsert + 8; + const offset_t textDataOffset = file->length() + totalInsert + 8; // Build final trak with correct stco offsets pointing to where text data will land. - ByteVector trakAtom = buildChapterTrak( + const ByteVector trakAtom = buildChapterTrak( chapterTrackId, timescale, durationMs, workingChapters, sampleSizes, textDataOffset, movieInfo.duration); @@ -1144,19 +1110,19 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) // Insert at the end of the audio trak boundary. // tref is logically inside audio trak; chapter trak is logically after it. - offset_t insertOffset = audio.trak->offset() + audio.trak->length(); + const offset_t insertOffset = audio.trak->offset() + audio.trak->length(); file->insert(combinedPayload, insertOffset, 0); // Fix audio trak size on disk -- only tref goes inside file->seek(audio.trak->offset()); - unsigned int audioTrakSize = file->readBlock(4).toUInt(); - unsigned int newAudioTrakSize = static_cast(audioTrakSize + trefAtom.size()); + const unsigned int audioTrakSize = file->readBlock(4).toUInt(); + const unsigned int newAudioTrakSize = audioTrakSize + trefAtom.size(); file->seek(audio.trak->offset()); file->writeBlock(ByteVector::fromUInt(newAudioTrakSize)); // Fix moov size -- both tref and chapter trak are inside moov - AtomList moovPath = activeAtoms->path("moov"); + const AtomList moovPath = activeAtoms->path("moov"); updateParentSizes(file, moovPath, combinedPayload.size()); // Fix existing chunk offsets -- only the ORIGINAL atom tree is iterated, @@ -1167,10 +1133,10 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) ByteVector textSamples; for(const auto &ch : workingChapters) { - textSamples.append(buildTextSample(ch.title)); + textSamples.append(buildTextSample(ch.title())); } // Wrap text samples in an mdat atom so players can find them. - ByteVector mdatAtom = renderAtom("mdat", textSamples); + const ByteVector mdatAtom = renderAtom("mdat", textSamples); file->seek(0, TagLib::File::End); file->writeBlock(mdatAtom); @@ -1178,30 +1144,20 @@ MP4::MP4QTChapterList::write(MP4::File *file, const ChapterList &chapters) // ---- Phase 5: Update mvhd next_track_ID ---- // mvhd is before insertOffset, so its offset is unchanged. - unsigned int currentNextId = getNextTrackId(file, activeAtoms); - if(chapterTrackId >= currentNextId) { + if(const unsigned int currentNextId = getNextTrackId(file, activeAtoms); + chapterTrackId >= currentNextId) { setNextTrackId(file, activeAtoms, chapterTrackId + 1); } + modified = false; return true; } -bool -MP4::MP4QTChapterList::remove(const char *path) -{ - MP4::File file(path, false); - if(!file.isOpen() || !file.isValid() || file.readOnly()) { - debug("MP4QTChapterList::remove() -- Could not open file for writing"); - return false; - } - - return remove(&file); -} - -bool -MP4::MP4QTChapterList::remove(MP4::File *file) +bool MP4::QtChapterList::remove(TagLib::File *file) { Atoms atoms(file); + chapterList.clear(); + modified = false; TrackInfo audio = findAudioTrack(file, &atoms); if(!audio.trak) @@ -1216,8 +1172,8 @@ MP4::MP4QTChapterList::remove(MP4::File *file) return false; // Remove chapter trak FIRST (higher offset in file). - offset_t chapterOff = chapterTrak->offset(); - offset_t chapterLen = chapterTrak->length(); + const offset_t chapterOff = chapterTrak->offset(); + const offset_t chapterLen = chapterTrak->length(); // Remove from in-memory tree so updateChunkOffsets skips its stco. moov->removeChild(chapterTrak); @@ -1225,7 +1181,7 @@ MP4::MP4QTChapterList::remove(MP4::File *file) file->removeBlock(chapterOff, chapterLen); - AtomList moovPath = atoms.path("moov"); + const AtomList moovPath = atoms.path("moov"); updateParentSizes(file, moovPath, -chapterLen); updateChunkOffsets(file, &atoms, -chapterLen, chapterOff); diff --git a/taglib/mp4/mp4qtchapterlist.h b/taglib/mp4/mp4qtchapterlist.h index af0d6b4c..c2900156 100644 --- a/taglib/mp4/mp4qtchapterlist.h +++ b/taglib/mp4/mp4qtchapterlist.h @@ -25,9 +25,10 @@ #ifndef TAGLIB_MP4QTCHAPTERLIST_H #define TAGLIB_MP4QTCHAPTERLIST_H -#include "mp4chapterlist.h" +#include "mp4chapterholder.h" namespace TagLib { + class File; namespace MP4 { /*! @@ -46,52 +47,29 @@ namespace TagLib { * \c MP4ChapterList so that existing \c Chapter / \c ChapterList * types can be shared. */ - class TAGLIB_EXPORT MP4QTChapterList + class QtChapterList : public ChapterHolder { public: /*! * Reads chapter markers from the QuickTime chapter track in the - * MP4 file at \a path. Returns an empty list if the file has no - * chapter track (i.e. no \c tref/chap reference to a text track). + * already-opened \a file. + * Returns \c false if the file has no chapter track. */ - static ChapterList read(const char *path); - - /*! - * Reads chapter markers from the QuickTime chapter track in the - * already-opened \a file. Avoids a second open when the caller - * already has the file open. - * Returns an empty list if the file has no chapter track. - */ - static ChapterList read(MP4::File *file); - - /*! - * Writes chapter markers as a QuickTime chapter track to the MP4 - * file at \a path, replacing any existing chapter track. The - * file's duration is read internally from the movie header. - * Returns \c true on success. - */ - static bool write(const char *path, const ChapterList &chapters); + 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. */ - static bool write(MP4::File *file, const ChapterList &chapters); - - /*! - * Removes the QuickTime chapter track and its \c tref/chap - * reference from the MP4 file at \a path. - * Returns \c true on success, or if no chapter track exists. - */ - static bool remove(const char *path); + 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. */ - static bool remove(MP4::File *file); + bool remove(TagLib::File *file); }; } // namespace MP4 diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 1e6b8899..27496975 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -34,8 +34,6 @@ #include "mp4atom.h" #include "mp4file.h" #include "mp4itemfactory.h" -#include "mp4chapterlist.h" -#include "mp4qtchapterlist.h" #include "plainfile.h" #include #include "utils.h" @@ -115,8 +113,6 @@ class TestMP4 : public CppUnit::TestFixture CPPUNIT_TEST(testQTChapterListOverwrite); CPPUNIT_TEST(testQTChapterListTimestampPrecision); CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter); - CPPUNIT_TEST(testChapterListFileAPI); - CPPUNIT_TEST(testQTChapterListFileAPI); CPPUNIT_TEST_SUITE_END(); public: @@ -888,6 +884,7 @@ public: CPPUNIT_ASSERT_EQUAL(String("TITLE"), f.tag()->title()); } } + void testChapterListWrite() { ScopedFileCopy copy("no-tags", ".m4a"); @@ -895,59 +892,47 @@ public: // File should have no chapters initially { - MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); CPPUNIT_ASSERT(chapters.isEmpty()); } // Write chapters { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Introduction"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 30000LL; // 30 seconds in ms - ch2.title = "Main Content"; - chapters.append(ch2); - - MP4::Chapter ch3; - ch3.startTime = 60000LL; // 60 seconds in ms - ch3.title = "Conclusion"; - chapters.append(ch3); - - CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), 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::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + 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); - } + 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 - { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Part One"; - chapters.append(ch1); - - CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + // Overwrite with different chapters + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Part One", 0) + }); + CPPUNIT_ASSERT(f.save()); } // Verify overwrite { - MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + 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); + CPPUNIT_ASSERT_EQUAL(String("Part One"), chapters[0].title()); } } @@ -958,32 +943,34 @@ public: // Write chapters { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Chapter 1"; - chapters.append(ch1); - - CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + MP4::File f(filename.c_str()); + f.setNeroChapters(MP4::ChapterList{ + MP4::Chapter("Chapter 1", 0) + }); + CPPUNIT_ASSERT(f.save()); } // Verify written { - MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); CPPUNIT_ASSERT_EQUAL(1U, chapters.size()); - } - // Remove chapters - CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + // Remove chapters + f.setNeroChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } // Verify removed { - MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.neroChapters(); CPPUNIT_ASSERT(chapters.isEmpty()); - } - // Remove from file with no chapters should also succeed - CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); + // Remove from file with no chapters should also succeed + f.setNeroChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } } void testChapterListWithExistingTags() @@ -998,51 +985,47 @@ public: CPPUNIT_ASSERT(f.isValid()); originalArtist = f.tag()->artist(); CPPUNIT_ASSERT(!originalArtist.isEmpty()); - } - // Write chapters - { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Intro"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 10000LL; // 10 seconds in ms - ch2.title = "Verse"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters)); + // 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::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); - CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); - CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title); - 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()); } - // Remove chapters and verify tags still survive - CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str())); { 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 - MP4::ChapterList chapters = MP4::MP4ChapterList::read( - TEST_FILE_PATH_C("no-tags.m4a")); - CPPUNIT_ASSERT(chapters.isEmpty()); + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.neroChapters().isEmpty()); + } } void testQTChapterListWrite() @@ -1052,41 +1035,33 @@ public: // File should have no QT chapters initially { - MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); CPPUNIT_ASSERT(chapters.isEmpty()); } // Write chapters (times in 100-nanosecond units) { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Intro"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 15000LL; // 15 seconds in ms - ch2.title = "Verse"; - chapters.append(ch2); - - MP4::Chapter ch3; - ch3.startTime = 30000LL; // 30 seconds in ms - ch3.title = "Outro"; - chapters.append(ch3); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + 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); + 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()); } } @@ -1097,37 +1072,35 @@ public: // Write chapters first { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Chapter 1"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 10000LL; // 10 seconds in ms - ch2.title = "Chapter 2"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); - } - // Remove chapters - CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str())); + // Remove chapters + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } // Verify removed { - MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); CPPUNIT_ASSERT(chapters.isEmpty()); - } - // Remove from file with no chapters should also succeed - CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str())); + // Remove from file with no chapters should also succeed + f.setQtChapters(MP4::ChapterList()); + CPPUNIT_ASSERT(f.save()); + } } void testQTChapterListWithExistingTags() @@ -1142,51 +1115,49 @@ public: CPPUNIT_ASSERT(f.isValid()); originalArtist = f.tag()->artist(); CPPUNIT_ASSERT(!originalArtist.isEmpty()); - } - // Write chapters - { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Intro"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 10000LL; // 10 seconds in ms - ch2.title = "Verse"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + // 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); - CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); - CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title); - 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()); } - // Remove chapters and verify tags still survive - CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(filename.c_str())); { 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 - MP4::ChapterList chapters = MP4::MP4QTChapterList::read( - TEST_FILE_PATH_C("no-tags.m4a")); - CPPUNIT_ASSERT(chapters.isEmpty()); + ScopedFileCopy copy("no-tags", ".m4a"); + string filename = copy.fileName(); + + { + MP4::File f(filename.c_str()); + CPPUNIT_ASSERT(f.qtChapters().isEmpty()); + } } void testQTChapterListOverwrite() @@ -1196,54 +1167,40 @@ public: // Write initial chapters { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Old1"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 5000LL; // 5 seconds in ms - ch2.title = "Old2"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + MP4::File f(filename.c_str()); + MP4::ChapterList chapters = f.qtChapters(); CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); } // Overwrite with different chapters { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "New1"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 10000LL; // 10 seconds in ms - ch2.title = "New2"; - chapters.append(ch2); - - MP4::Chapter ch3; - ch3.startTime = 20000LL; // 20 seconds in ms - ch3.title = "New3"; - chapters.append(ch3); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + 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); + CPPUNIT_ASSERT_EQUAL(String("New1"), chapters[0].title()); + CPPUNIT_ASSERT_EQUAL(String("New2"), chapters[1].title()); + CPPUNIT_ASSERT_EQUAL(String("New3"), chapters[2].title()); } } @@ -1254,26 +1211,21 @@ public: // Write chapters at precise times { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Start"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 1500LL; // 1.5 seconds in ms - ch2.title = "Precise"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + 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); + CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime()); + CPPUNIT_ASSERT_EQUAL(1500LL, chapters[1].startTime()); } } @@ -1284,140 +1236,29 @@ public: // Write chapters where first chapter is NOT at time 0 { - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 10000LL; // 10 seconds in ms - ch1.title = "One"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 20000LL; // 20 seconds in ms - ch2.title = "Two"; - chapters.append(ch2); - - MP4::Chapter ch3; - ch3.startTime = 30000LL; // 30 seconds in ms - ch3.title = "Three"; - chapters.append(ch3); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(filename.c_str(), chapters)); + 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::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); + 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); - } - } - void testChapterListFileAPI() - { - ScopedFileCopy copy("no-tags", ".m4a"); - string filename = copy.fileName(); - - // Write chapters via the file-based API - { - MP4::File file(filename.c_str(), false); - CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); - - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Alpha"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 20000LL; // 20 seconds in ms - ch2.title = "Beta"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4ChapterList::write(&file, chapters)); - } - - // Read back via the file-based API - { - MP4::File file(filename.c_str(), false); - CPPUNIT_ASSERT(file.isOpen() && file.isValid()); - - MP4::ChapterList chapters = MP4::MP4ChapterList::read(&file); - CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); - CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); - CPPUNIT_ASSERT_EQUAL(String("Alpha"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(20000LL, chapters[1].startTime); - CPPUNIT_ASSERT_EQUAL(String("Beta"), chapters[1].title); - } - - // Remove via the file-based API - { - MP4::File file(filename.c_str(), false); - CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); - - CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(&file)); - } - - // Verify removed - { - MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str()); - CPPUNIT_ASSERT(chapters.isEmpty()); + 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()); } } - void testQTChapterListFileAPI() - { - ScopedFileCopy copy("no-tags", ".m4a"); - string filename = copy.fileName(); - - // Write chapters via the file-based API - { - MP4::File file(filename.c_str(), false); - CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); - - MP4::ChapterList chapters; - MP4::Chapter ch1; - ch1.startTime = 0; - ch1.title = "Alpha"; - chapters.append(ch1); - - MP4::Chapter ch2; - ch2.startTime = 20000LL; // 20 seconds in ms - ch2.title = "Beta"; - chapters.append(ch2); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::write(&file, chapters)); - } - - // Read back via the file-based API - { - MP4::File file(filename.c_str(), false); - CPPUNIT_ASSERT(file.isOpen() && file.isValid()); - - MP4::ChapterList chapters = MP4::MP4QTChapterList::read(&file); - CPPUNIT_ASSERT_EQUAL(2U, chapters.size()); - CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime); - CPPUNIT_ASSERT_EQUAL(String("Alpha"), chapters[0].title); - CPPUNIT_ASSERT_EQUAL(20000LL, chapters[1].startTime); - CPPUNIT_ASSERT_EQUAL(String("Beta"), chapters[1].title); - } - - // Remove via the file-based API - { - MP4::File file(filename.c_str(), false); - CPPUNIT_ASSERT(file.isOpen() && file.isValid() && !file.readOnly()); - - CPPUNIT_ASSERT(MP4::MP4QTChapterList::remove(&file)); - } - - // Verify removed - { - MP4::ChapterList chapters = MP4::MP4QTChapterList::read(filename.c_str()); - CPPUNIT_ASSERT(chapters.isEmpty()); - } - } }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From ae171ee237ee9e79962df0c2d791cea3494fb652 Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Wed, 22 Apr 2026 05:44:23 -0700 Subject: [PATCH 07/11] 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. --- taglib/mp4/mp4qtchapterlist.cpp | 95 +++++++++++++++++++++------------ 1 file changed, 62 insertions(+), 33 deletions(-) diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp index 22359c4a..d6149713 100644 --- a/taglib/mp4/mp4qtchapterlist.cpp +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -935,6 +935,66 @@ namespace } } + // Removes the QT chapter trak, the tref from the audio track, and the orphaned + // mdat atom that holds the chapter text samples. + // + // The chapter mdat was appended at EOF by write(). Its location is derived + // from the chapter track's stco entry before the track is deleted. Both the + // chapter trak and tref live inside moov, which precedes the mdat, so removing + // them shifts the mdat by -(chapterLen + trefLen). + void removeQTChapterTrack(TagLib::File *file, const MP4::Atoms *atoms, + MP4::Atom *moov, MP4::Atom *chapterTrak, + const MP4::Atom *audioTrak) + { + // Read the first stco chunk offset BEFORE deleting the trak: the chapter + // mdat data starts at stco[0], so the mdat atom header is 8 bytes earlier. + offset_t chapterMdatOffset = -1; + { + const std::vector stco = readStco(file, chapterTrak); + if(!stco.empty() && stco[0] >= 8) + chapterMdatOffset = static_cast(stco[0]) - 8; + } + + // Record tref length BEFORE removeAudioTref so we can adjust the mdat offset. + offset_t trefLen = 0; + for(const auto &child : audioTrak->children()) { + if(child->name() == "tref") { + trefLen = child->length(); + break; + } + } + + // Remove chapter trak FIRST (higher offset in file). + const offset_t chapterOff = chapterTrak->offset(); + const offset_t chapterLen = chapterTrak->length(); + + // Remove from in-memory tree so updateChunkOffsets skips its stco. + moov->removeChild(chapterTrak); + delete chapterTrak; + + file->removeBlock(chapterOff, chapterLen); + + const MP4::AtomList moovPath = atoms->path("moov"); + updateParentSizes(file, moovPath, -chapterLen); + updateChunkOffsets(file, atoms, -chapterLen, chapterOff); + + // Remove tref from audio trak (lower offset, still valid after chapter trak removal). + removeAudioTref(file, atoms, audioTrak); + + // Remove the orphaned chapter mdat. Both removals above are inside moov, + // which precedes the mdat at EOF, so adjust the stored offset accordingly. + if(chapterMdatOffset >= 0) { + const offset_t adjustedOffset = chapterMdatOffset - chapterLen - trefLen; + file->seek(adjustedOffset); + const ByteVector header = file->readBlock(8); + if(header.size() == 8 && header.mid(4, 4) == "mdat") { + const offset_t mdatSize = header.toUInt(); + if(mdatSize >= 8) + file->removeBlock(adjustedOffset, mdatSize); + } + } + } + } // namespace //////////////////////////////////////////////////////////////////////////////// @@ -1040,22 +1100,7 @@ bool MP4::QtChapterList::write(TagLib::File *file) std::unique_ptr cleanAtoms; if(Atom *existingChapter = findChapterTrak(file, &atoms, audio.trak)) { - // Remove chapter trak FIRST (higher offset in file). - const offset_t chapterOff = existingChapter->offset(); - const offset_t chapterLen = existingChapter->length(); - - // Remove from in-memory tree so updateChunkOffsets skips its stco. - moov->removeChild(existingChapter); - delete existingChapter; - - file->removeBlock(chapterOff, chapterLen); - - const AtomList moovPath = atoms.path("moov"); - updateParentSizes(file, moovPath, -chapterLen); - updateChunkOffsets(file, &atoms, -chapterLen, chapterOff); - - // Remove tref from audio trak (lower offset, still valid). - removeAudioTref(file, &atoms, audio.trak); + removeQTChapterTrack(file, &atoms, moov, existingChapter, audio.trak); // Re-parse to get clean state after removals. cleanAtoms = std::make_unique(file); @@ -1171,22 +1216,6 @@ bool MP4::QtChapterList::remove(TagLib::File *file) if(!moov) return false; - // Remove chapter trak FIRST (higher offset in file). - const offset_t chapterOff = chapterTrak->offset(); - const offset_t chapterLen = chapterTrak->length(); - - // Remove from in-memory tree so updateChunkOffsets skips its stco. - moov->removeChild(chapterTrak); - delete chapterTrak; - - file->removeBlock(chapterOff, chapterLen); - - const AtomList moovPath = atoms.path("moov"); - updateParentSizes(file, moovPath, -chapterLen); - updateChunkOffsets(file, &atoms, -chapterLen, chapterOff); - - // Remove tref from audio trak (lower offset, still valid after chapter trak removal). - removeAudioTref(file, &atoms, audio.trak); - + removeQTChapterTrack(file, &atoms, moov, chapterTrak, audio.trak); return true; } From 5c70f0071f16f7370042b5e08b2fb2040601cc7a Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Wed, 22 Apr 2026 05:49:46 -0700 Subject: [PATCH 08/11] 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. --- tests/test_mp4.cpp | 44 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 44 insertions(+) diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 27496975..39a88900 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -113,6 +113,7 @@ class TestMP4 : public CppUnit::TestFixture CPPUNIT_TEST(testQTChapterListOverwrite); CPPUNIT_TEST(testQTChapterListTimestampPrecision); CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter); + CPPUNIT_TEST(testQTChapterListNoOrphanedMdat); CPPUNIT_TEST_SUITE_END(); public: @@ -1259,6 +1260,49 @@ public: } } + // 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()); + } + }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From 85b6a9eb93d15b0aa5feaf0187290ef7a5e93eff Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Wed, 22 Apr 2026 11:29:28 -0700 Subject: [PATCH 09/11] 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. --- taglib/mp4/mp4qtchapterlist.cpp | 132 ++++++++++++++++++++++++++------ tests/test_mp4.cpp | 100 ++++++++++++++++++++++++ 2 files changed, 209 insertions(+), 23 deletions(-) diff --git a/taglib/mp4/mp4qtchapterlist.cpp b/taglib/mp4/mp4qtchapterlist.cpp index d6149713..93d2abd3 100644 --- a/taglib/mp4/mp4qtchapterlist.cpp +++ b/taglib/mp4/mp4qtchapterlist.cpp @@ -935,30 +935,102 @@ namespace } } - // Removes the QT chapter trak, the tref from the audio track, and the orphaned - // mdat atom that holds the chapter text samples. + //! Finds the top-level mdat atom that covers the given file offset. + const MP4::Atom *findMdatContaining(const MP4::Atoms *atoms, offset_t fileOffset) + { + for(const auto *atom : atoms->atoms()) { + if(atom->name() != "mdat") + continue; + const offset_t dataStart = atom->offset() + 8; + const offset_t end = atom->offset() + atom->length(); + if(fileOffset >= dataStart && fileOffset < end) + return atom; + } + return nullptr; + } + + //! True if any stco/co64 entry in the atom tree points inside the given mdat range. + //! Used to detect mdats that are shared with other tracks (audio data + chapter + //! text co-located in a single mdat) so we never delete live track data. + bool mdatIsUsedByAnyTrack(TagLib::File *file, const MP4::Atoms *atoms, + offset_t mdatStart, offset_t mdatSize) + { + const offset_t dataStart = mdatStart + 8; + const offset_t dataEnd = mdatStart + mdatSize; + + const MP4::Atom *moov = atoms->find("moov"); + if(!moov) + return false; + + for(const auto &stco : moov->findall("stco", true)) { + file->seek(stco->offset() + 12); + ByteVector data = file->readBlock(stco->length() - 12); + if(data.size() < 4) + continue; + unsigned int count = data.toUInt(); + unsigned int pos = 4; + const unsigned int maxPos = data.size() - 4; + while(count-- && pos <= maxPos) { + const auto o = static_cast(data.toUInt(pos)); + if(o >= dataStart && o < dataEnd) + return true; + pos += 4; + } + } + + for(const auto &co64 : moov->findall("co64", true)) { + file->seek(co64->offset() + 12); + ByteVector data = file->readBlock(co64->length() - 12); + if(data.size() < 4) + continue; + unsigned int count = data.toUInt(); + unsigned int pos = 4; + const unsigned int maxPos = data.size() - 8; + while(count-- && pos <= maxPos) { + const offset_t o = data.toLongLong(pos); + if(o >= dataStart && o < dataEnd) + return true; + pos += 8; + } + } + + return false; + } + + // Removes the QT chapter trak and the tref from the audio track, plus the + // chapter text mdat if (and only if) no other track points into it. // - // The chapter mdat was appended at EOF by write(). Its location is derived - // from the chapter track's stco entry before the track is deleted. Both the - // chapter trak and tref live inside moov, which precedes the mdat, so removing - // them shifts the mdat by -(chapterLen + trefLen). + // The chapter mdat identity is resolved by looking up which top-level mdat + // contains the chapter track's first chunk offset. After removing the trak + // and tref, we re-parse and verify the mdat is not referenced by any other + // track before deleting it. Audiobook-style files commonly co-locate chapter + // text inside the main audio mdat; in that case the mdat is shared and MUST + // NOT be deleted -- the text bytes are left as dead space and the chapter + // track alone is removed. void removeQTChapterTrack(TagLib::File *file, const MP4::Atoms *atoms, MP4::Atom *moov, MP4::Atom *chapterTrak, const MP4::Atom *audioTrak) { - // Read the first stco chunk offset BEFORE deleting the trak: the chapter - // mdat data starts at stco[0], so the mdat atom header is 8 bytes earlier. + // Identify the chapter text mdat BEFORE removal (while stco is still valid). offset_t chapterMdatOffset = -1; + offset_t chapterMdatSize = 0; { const std::vector stco = readStco(file, chapterTrak); - if(!stco.empty() && stco[0] >= 8) - chapterMdatOffset = static_cast(stco[0]) - 8; + if(!stco.empty()) { + if(const MP4::Atom *mdat = findMdatContaining(atoms, + static_cast(stco[0]))) { + chapterMdatOffset = mdat->offset(); + chapterMdatSize = mdat->length(); + } + } } - // Record tref length BEFORE removeAudioTref so we can adjust the mdat offset. + // Capture tref/chapter trak locations for mdat offset fix-up below. + offset_t trefOff = -1; offset_t trefLen = 0; for(const auto &child : audioTrak->children()) { if(child->name() == "tref") { + trefOff = child->offset(); trefLen = child->length(); break; } @@ -981,18 +1053,32 @@ namespace // Remove tref from audio trak (lower offset, still valid after chapter trak removal). removeAudioTref(file, atoms, audioTrak); - // Remove the orphaned chapter mdat. Both removals above are inside moov, - // which precedes the mdat at EOF, so adjust the stored offset accordingly. - if(chapterMdatOffset >= 0) { - const offset_t adjustedOffset = chapterMdatOffset - chapterLen - trefLen; - file->seek(adjustedOffset); - const ByteVector header = file->readBlock(8); - if(header.size() == 8 && header.mid(4, 4) == "mdat") { - const offset_t mdatSize = header.toUInt(); - if(mdatSize >= 8) - file->removeBlock(adjustedOffset, mdatSize); - } - } + // Decide whether the chapter mdat is safe to delete. + if(chapterMdatOffset < 0) + return; + + // Shift the original mdat offset by however much of the removed bytes + // preceded it in the file. + offset_t adjustedOffset = chapterMdatOffset; + if(chapterMdatOffset > chapterOff) + adjustedOffset -= chapterLen; + if(trefOff >= 0 && chapterMdatOffset > trefOff) + adjustedOffset -= trefLen; + + // Re-parse to verify the post-removal on-disk state matches expectations + // and that no surviving track references this mdat's data range. + const MP4::Atoms cleanAtoms(file); + if(mdatIsUsedByAnyTrack(file, &cleanAtoms, adjustedOffset, chapterMdatSize)) + return; + + file->seek(adjustedOffset); + const ByteVector header = file->readBlock(8); + if(header.size() != 8 || header.mid(4, 4) != "mdat") + return; + if(static_cast(header.toUInt()) != chapterMdatSize) + return; + + file->removeBlock(adjustedOffset, chapterMdatSize); } } // namespace diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 39a88900..31353cc5 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -114,6 +114,7 @@ class TestMP4 : public CppUnit::TestFixture CPPUNIT_TEST(testQTChapterListTimestampPrecision); CPPUNIT_TEST(testQTChapterListNonZeroFirstChapter); CPPUNIT_TEST(testQTChapterListNoOrphanedMdat); + CPPUNIT_TEST(testQTChapterListSharedMdatPreservesAudio); CPPUNIT_TEST_SUITE_END(); public: @@ -1303,6 +1304,105 @@ public: 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(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); + } + } + }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From 05c2c8671e49c3fc2d25438db53a264ec688b504 Mon Sep 17 00:00:00 2001 From: Ryan Francesconi Date: Thu, 23 Apr 2026 12:19:27 -0700 Subject: [PATCH 10/11] 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. --- tests/test_mp4.cpp | 231 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 231 insertions(+) diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index 31353cc5..dc310385 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -115,6 +115,12 @@ class TestMP4 : public CppUnit::TestFixture 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_SUITE_END(); public: @@ -1403,6 +1409,231 @@ public: } } + // 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()); + } + } + } + }; CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4); From 497c040f04b888ef235c7cae0d59964c2a62bca6 Mon Sep 17 00:00:00 2001 From: Urs Fleisch Date: Sat, 25 Apr 2026 11:46:51 +0200 Subject: [PATCH 11/11] 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. --- taglib/CMakeLists.txt | 1 - taglib/mp4/mp4chapter.cpp | 10 +++ taglib/mp4/mp4chapter.h | 10 +++ taglib/mp4/mp4chapterholder.cpp | 44 ---------- taglib/mp4/mp4chapterholder.h | 26 ++++-- tests/test_mp4.cpp | 151 ++++++++++++++++++++++++++++++++ 6 files changed, 192 insertions(+), 50 deletions(-) delete mode 100644 taglib/mp4/mp4chapterholder.cpp diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index aa304fb3..4c8e5332 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -377,7 +377,6 @@ if(WITH_MP4) mp4/mp4stem.cpp mp4/mp4itemfactory.cpp mp4/mp4chapter.cpp - mp4/mp4chapterholder.cpp mp4/mp4nerochapterlist.cpp mp4/mp4qtchapterlist.cpp ) diff --git a/taglib/mp4/mp4chapter.cpp b/taglib/mp4/mp4chapter.cpp index baa1a26c..2222748f 100644 --- a/taglib/mp4/mp4chapter.cpp +++ b/taglib/mp4/mp4chapter.cpp @@ -61,6 +61,16 @@ MP4::Chapter &MP4::Chapter::Chapter::operator=(const Chapter &other) 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; diff --git a/taglib/mp4/mp4chapter.h b/taglib/mp4/mp4chapter.h index 91627bb2..839f75d8 100644 --- a/taglib/mp4/mp4chapter.h +++ b/taglib/mp4/mp4chapter.h @@ -68,6 +68,16 @@ namespace TagLib { */ 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. */ diff --git a/taglib/mp4/mp4chapterholder.cpp b/taglib/mp4/mp4chapterholder.cpp deleted file mode 100644 index 8d9c1ddb..00000000 --- a/taglib/mp4/mp4chapterholder.cpp +++ /dev/null @@ -1,44 +0,0 @@ -/************************************************************************** - 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/ * - ***************************************************************************/ - -#include "mp4chapterholder.h" - -using namespace TagLib; - -MP4::ChapterList MP4::ChapterHolder::chapters() const -{ - return chapterList; -} - -void MP4::ChapterHolder::setChapters(const ChapterList &chapters) -{ - chapterList = chapters; - modified = true; -} - -bool MP4::ChapterHolder::isModified() const -{ - return modified; -} diff --git a/taglib/mp4/mp4chapterholder.h b/taglib/mp4/mp4chapterholder.h index 94e8a52f..72752ba4 100644 --- a/taglib/mp4/mp4chapterholder.h +++ b/taglib/mp4/mp4chapterholder.h @@ -39,17 +39,22 @@ namespace TagLib { /*! * Get list of chapters. */ - ChapterList chapters() const; + ChapterList chapters() const { return chapterList; } /*! * Set list of chapters. */ - void setChapters(const ChapterList &chapters); + void setChapters(const ChapterList &chapters) { chapterList = chapters; } /*! * Returns \c true if the list of chapters has been modified. */ - bool isModified() const; + bool isModified() const { return modified; } + + /*! + * Set if the contained chapters are modified. + */ + void setModified(bool chaptersModified) { modified = chaptersModified; } protected: ChapterList chapterList; @@ -84,8 +89,15 @@ namespace TagLib { { if (!holder) { holder = std::make_unique(); + // 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); } - holder->setChapters(chapters); } /*! @@ -99,7 +111,11 @@ namespace TagLib { bool saveChaptersIfModified(std::unique_ptr &holder, TagLib::File *file) { if(holder && holder->isModified()) { - return holder->write(file); + if(holder->write(file)) { + holder->setModified(false); + return true; + } + return false; } return true; } diff --git a/tests/test_mp4.cpp b/tests/test_mp4.cpp index dc310385..d037a14f 100644 --- a/tests/test_mp4.cpp +++ b/tests/test_mp4.cpp @@ -34,6 +34,7 @@ #include "mp4atom.h" #include "mp4file.h" #include "mp4itemfactory.h" +#include "mp4chapterholder.h" #include "plainfile.h" #include #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 chapterList; + }; + } // namespace class TestMP4 : public CppUnit::TestFixture @@ -121,6 +172,7 @@ class TestMP4 : public CppUnit::TestFixture CPPUNIT_TEST(testQTChapterListSingleEmptyTitleNotStripped); CPPUNIT_TEST(testNeroAndQTChaptersAreIndependent); CPPUNIT_TEST(testNeroChaptersAloneWhenNoQT); + CPPUNIT_TEST(testLazyReadingAndWritingChapters); CPPUNIT_TEST_SUITE_END(); public: @@ -1634,6 +1686,105 @@ public: } } + 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);