Merge pull requests #1325 #1343 from ryanfrancesconi MP4 chapterlist

ryanfrancesconi/feature/mp4-chapterlist
ryanfrancesconi/fix/qt-chapter-orphaned-mdat
This commit is contained in:
Urs Fleisch
2026-04-26 07:15:40 +02:00
committed by GitHub
11 changed files with 3061 additions and 1 deletions

View File

@@ -196,6 +196,10 @@ if(WITH_MP4)
mp4/mp4coverart.h
mp4/mp4stem.h
mp4/mp4itemfactory.h
mp4/mp4chapter.h
mp4/mp4chapterholder.h
mp4/mp4nerochapterlist.h
mp4/mp4qtchapterlist.h
)
endif()
if(WITH_MOD)
@@ -372,6 +376,9 @@ if(WITH_MP4)
mp4/mp4coverart.cpp
mp4/mp4stem.cpp
mp4/mp4itemfactory.cpp
mp4/mp4chapter.cpp
mp4/mp4nerochapterlist.cpp
mp4/mp4qtchapterlist.cpp
)
endif()

89
taglib/mp4/mp4chapter.cpp Normal file
View File

@@ -0,0 +1,89 @@
/**************************************************************************
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<ChapterPrivate>())
{
d->title = title;
d->startTime = startTime;
}
MP4::Chapter::Chapter(const Chapter &other) :
d(std::make_unique<ChapterPrivate>(*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;
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;
swap(d, other.d);
}
const String &MP4::Chapter::title() const
{
return d->title;
}
long long MP4::Chapter::startTime() const
{
return d->startTime;
}

108
taglib/mp4/mp4chapter.h Normal file
View File

@@ -0,0 +1,108 @@
/**************************************************************************
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 <memory>
#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;
/*!
* 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.
*/
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<ChapterPrivate> d;
};
//! List of chapters.
using ChapterList = List<Chapter>;
} // namespace MP4
} // namespace TagLib
#endif

View File

@@ -0,0 +1,126 @@
/**************************************************************************
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 { return chapterList; }
/*!
* Set list of chapters.
*/
void setChapters(const ChapterList &chapters) { chapterList = chapters; }
/*!
* Returns \c true if the list of chapters has been modified.
*/
bool isModified() const { return modified; }
/*!
* Set if the contained chapters are modified.
*/
void setModified(bool chaptersModified) { modified = chaptersModified; }
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 <typename T>
ChapterList getChaptersLazy(std::unique_ptr<T> &holder, TagLib::File *file)
{
if (!holder) {
holder = std::make_unique<T>();
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 <typename T>
void setChaptersLazy(std::unique_ptr<T> &holder, const ChapterList& chapters)
{
if (!holder) {
holder = std::make_unique<T>();
// 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);
}
}
/*!
* 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 <typename T>
bool saveChaptersIfModified(std::unique_ptr<T> &holder, TagLib::File *file)
{
if(holder && holder->isModified()) {
if(holder->write(file)) {
holder->setModified(false);
return true;
}
return false;
}
return true;
}
} // namespace MP4
} // namespace TagLib
#endif

View File

@@ -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<MP4::Tag> tag;
std::unique_ptr<MP4::Atoms> atoms;
std::unique_ptr<MP4::Properties> properties;
std::unique_ptr<MP4::NeroChapterList> neroChapterList;
std::unique_ptr<MP4::QtChapterList> 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

View File

@@ -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.
*

View File

@@ -0,0 +1,320 @@
/**************************************************************************
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 "mp4nerochapterlist.h"
#include <algorithm>
#include "tdebug.h"
#include "mp4file.h"
#include "mp4atom.h"
using namespace TagLib;
namespace
{
ByteVector renderAtom(const ByteVector &name, const ByteVector &data)
{
return ByteVector::fromUInt(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<int>(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());
if(const long size = file->readBlock(4).toUInt(); size == 1) {
// 64-bit size
file->seek(4, TagLib::File::Current);
const 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<unsigned int>(size + delta)));
}
}
}
// Update stco/co64/tfhd chunk offsets when file content shifts.
// Mirrors MP4::Tag::updateOffsets().
void updateChunkOffsets(TagLib::File *file, const MP4::Atoms *atoms,
offset_t delta, offset_t offset)
{
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)
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;
const unsigned int maxPos = data.size() - 4;
while(count-- && pos <= maxPos) {
auto o = static_cast<offset_t>(data.toUInt(pos));
if(o > offset)
o += delta;
file->writeBlock(ByteVector::fromUInt(static_cast<unsigned int>(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;
const unsigned int maxPos = data.size() - 8;
while(count-- && pos <= maxPos) {
long long o = data.toLongLong(pos);
if(o > offset)
o += delta;
file->writeBlock(ByteVector::fromLongLong(o));
pos += 8;
}
}
}
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)
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)
{
const unsigned int count = std::min(chapters.size(), 255U);
ByteVector data;
// Version (1 byte) + flags (3 bytes) + reserved (4 bytes)
data.append(static_cast<char>(0x01)); // version 1
data.append(ByteVector(3, '\0')); // flags
data.append(ByteVector(4, '\0')); // reserved
// Chapter count
data.append(static_cast<char>(count & 0xFF));
unsigned int i = 0;
for(const auto &ch : chapters) {
if(i++ >= count)
break;
// 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);
const unsigned int titleLen = std::min(titleBytes.size(), 255U);
data.append(static_cast<char>(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;
// Minimum: version(1) + flags(3) + count(1) = 5 bytes (version 0 layout)
if(data.size() < 5)
return chapters;
unsigned int pos = 0;
const auto version = static_cast<unsigned char>(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;
const unsigned int count = static_cast<unsigned char>(data[pos++]);
for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) {
const long long startTime100ns = data.toLongLong(pos);
pos += 8;
const unsigned int titleLen = static_cast<unsigned char>(data[pos++]);
String title;
if(titleLen > 0 && pos + titleLen <= data.size()) {
title = String(data.mid(pos, titleLen), String::UTF8);
pos += titleLen;
}
chapters.append(MP4::Chapter(title, startTime100ns / 10000LL));
}
return chapters;
}
} // namespace
////////////////////////////////////////////////////////////////////////////////
// public members
////////////////////////////////////////////////////////////////////////////////
bool MP4::NeroChapterList::read(TagLib::File *file)
{
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 false;
}
bool MP4::NeroChapterList::write(TagLib::File *file)
{
// Writing an empty list is equivalent to removing the chapters.
if(chapterList.isEmpty())
return remove(file);
const Atoms atoms(file);
if(!atoms.find("moov")) {
debug("MP4ChapterList::write() -- No moov atom found");
return false;
}
const ByteVector chplPayload = renderChplData(chapterList);
const ByteVector chplAtom = renderAtom("chpl", chplPayload);
if(const Atom *existingChpl = atoms.find("moov", "udta", "chpl")) {
// Replace existing chpl atom
const offset_t offset = existingChpl->offset();
const offset_t oldLength = existingChpl->length();
const offset_t delta = static_cast<offset_t>(chplAtom.size()) - oldLength;
file->insert(chplAtom, offset, oldLength);
if(delta != 0) {
// Update parent sizes: moov and udta
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
if(AtomList udtaPath = atoms.path("moov", "udta"); udtaPath.size() == 2) {
// udta exists -- insert chpl at the beginning of udta's content
const 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
const ByteVector udtaAtom = renderAtom("udta", chplAtom);
AtomList moovPath = atoms.path("moov");
if(moovPath.isEmpty()) {
debug("MP4ChapterList::write() -- No moov atom in path");
return false;
}
const offset_t insertOffset = moovPath.back()->offset() + 8;
file->insert(udtaAtom, insertOffset, 0);
updateParentSizes(file, moovPath, udtaAtom.size());
updateChunkOffsets(file, &atoms, udtaAtom.size(), insertOffset);
}
}
modified = false;
return true;
}
bool MP4::NeroChapterList::remove(TagLib::File *file)
{
const Atoms atoms(file);
chapterList.clear();
modified = false;
const Atom *chpl = atoms.find("moov", "udta", "chpl");
if(!chpl) {
// No chpl atom -- nothing to remove
return true;
}
const offset_t offset = chpl->offset();
const offset_t length = chpl->length();
file->removeBlock(offset, length);
// Update parent sizes with negative delta
const AtomList parentPath = atoms.path("moov", "udta", "chpl");
updateParentSizes(file, parentPath, -length, 1); // ignore chpl itself
updateChunkOffsets(file, &atoms, -length, offset);
return true;
}

View File

@@ -0,0 +1,66 @@
/**************************************************************************
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 "mp4chapterholder.h"
namespace TagLib {
class File;
namespace MP4 {
/*!
* 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 NeroChapterList : public ChapterHolder
{
public:
/*!
* Reads chapter markers from the already-opened \a file.
* Returns \c false if the file has no chpl atom.
*/
bool read(TagLib::File *file);
/*!
* 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.
*/
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.
*/
bool remove(TagLib::File *file);
};
} // namespace MP4
} // namespace TagLib
#endif

File diff suppressed because it is too large Load Diff

View File

@@ -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 "mp4chapterholder.h"
namespace TagLib {
class File;
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 QtChapterList : public ChapterHolder
{
public:
/*!
* Reads chapter markers from the QuickTime chapter track in the
* already-opened \a file.
* Returns \c false if the file has no chapter track.
*/
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.
*/
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.
*/
bool remove(TagLib::File *file);
};
} // namespace MP4
} // namespace TagLib
#endif

View File

@@ -34,6 +34,7 @@
#include "mp4atom.h"
#include "mp4file.h"
#include "mp4itemfactory.h"
#include "mp4chapterholder.h"
#include "plainfile.h"
#include <cppunit/extensions/HelperMacros.h>
#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<MockChapterList> chapterList;
};
} // namespace
class TestMP4 : public CppUnit::TestFixture
@@ -102,6 +153,26 @@ 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(testQTChapterListWrite);
CPPUNIT_TEST(testQTChapterListRemove);
CPPUNIT_TEST(testQTChapterListWithExistingTags);
CPPUNIT_TEST(testQTChapterListReadEmpty);
CPPUNIT_TEST(testQTChapterListOverwrite);
CPPUNIT_TEST(testQTChapterListTimestampPrecision);
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(testLazyReadingAndWritingChapters);
CPPUNIT_TEST_SUITE_END();
public:
@@ -873,6 +944,847 @@ 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::File f(filename.c_str());
MP4::ChapterList chapters = f.neroChapters();
CPPUNIT_ASSERT(chapters.isEmpty());
}
// Write 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::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());
// Overwrite with different chapters
f.setNeroChapters(MP4::ChapterList{
MP4::Chapter("Part One", 0)
});
CPPUNIT_ASSERT(f.save());
}
// Verify overwrite
{
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());
}
}
void testChapterListRemove()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters
{
MP4::File f(filename.c_str());
f.setNeroChapters(MP4::ChapterList{
MP4::Chapter("Chapter 1", 0)
});
CPPUNIT_ASSERT(f.save());
}
// Verify written
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.neroChapters();
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
// Remove chapters
f.setNeroChapters(MP4::ChapterList());
CPPUNIT_ASSERT(f.save());
}
// Verify removed
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.neroChapters();
CPPUNIT_ASSERT(chapters.isEmpty());
// Remove from file with no chapters should also succeed
f.setNeroChapters(MP4::ChapterList());
CPPUNIT_ASSERT(f.save());
}
}
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
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::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());
}
{
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
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT(f.neroChapters().isEmpty());
}
}
void testQTChapterListWrite()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// File should have no QT chapters initially
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT(chapters.isEmpty());
}
// Write chapters (times in 100-nanosecond units)
{
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::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());
}
}
void testQTChapterListRemove()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters first
{
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::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
// Remove chapters
f.setQtChapters(MP4::ChapterList());
CPPUNIT_ASSERT(f.save());
}
// Verify removed
{
MP4::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT(chapters.isEmpty());
// Remove from file with no chapters should also succeed
f.setQtChapters(MP4::ChapterList());
CPPUNIT_ASSERT(f.save());
}
}
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
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::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());
}
{
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
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
{
MP4::File f(filename.c_str());
CPPUNIT_ASSERT(f.qtChapters().isEmpty());
}
}
void testQTChapterListOverwrite()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write initial 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::File f(filename.c_str());
MP4::ChapterList chapters = f.qtChapters();
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
}
// Overwrite with different 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::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());
}
}
void testQTChapterListTimestampPrecision()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters at precise times
{
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::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());
}
}
void testQTChapterListNonZeroFirstChapter()
{
ScopedFileCopy copy("no-tags", ".m4a");
string filename = copy.fileName();
// Write chapters where first chapter is NOT at time 0
{
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::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());
}
}
// 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());
}
// 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<unsigned int>(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);
}
}
// 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());
}
}
}
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);