mirror of
https://github.com/taglib/taglib.git
synced 2026-06-10 00:09:18 -04:00
ryanfrancesconi/feature/mp4-chapterlist ryanfrancesconi/fix/qt-chapter-orphaned-mdat
This commit is contained in:
@@ -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
89
taglib/mp4/mp4chapter.cpp
Normal 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
108
taglib/mp4/mp4chapter.h
Normal 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
|
||||
126
taglib/mp4/mp4chapterholder.h
Normal file
126
taglib/mp4/mp4chapterholder.h
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
*
|
||||
|
||||
320
taglib/mp4/mp4nerochapterlist.cpp
Normal file
320
taglib/mp4/mp4nerochapterlist.cpp
Normal 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;
|
||||
}
|
||||
66
taglib/mp4/mp4nerochapterlist.h
Normal file
66
taglib/mp4/mp4nerochapterlist.h
Normal 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
|
||||
1307
taglib/mp4/mp4qtchapterlist.cpp
Normal file
1307
taglib/mp4/mp4qtchapterlist.cpp
Normal file
File diff suppressed because it is too large
Load Diff
78
taglib/mp4/mp4qtchapterlist.h
Normal file
78
taglib/mp4/mp4qtchapterlist.h
Normal 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
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user