mirror of
https://github.com/taglib/taglib.git
synced 2026-06-10 00:09:18 -04:00
MP4: Add Nero-style chapter marker support
Implement read/write/remove of Nero-style chapter markers (chpl atom) in MP4 files. The chpl atom lives at moov/udta/chpl, storing up to 255 chapter entries with 100-nanosecond timestamps and UTF-8 titles. Includes CppUnit tests covering round-trip read/write, remove, tag preservation, and reading from files with no chapters.
This commit is contained in:
@@ -196,6 +196,7 @@ if(WITH_MP4)
|
||||
mp4/mp4coverart.h
|
||||
mp4/mp4stem.h
|
||||
mp4/mp4itemfactory.h
|
||||
mp4/mp4chapterlist.h
|
||||
)
|
||||
endif()
|
||||
if(WITH_MOD)
|
||||
@@ -372,6 +373,7 @@ if(WITH_MP4)
|
||||
mp4/mp4coverart.cpp
|
||||
mp4/mp4stem.cpp
|
||||
mp4/mp4itemfactory.cpp
|
||||
mp4/mp4chapterlist.cpp
|
||||
)
|
||||
endif()
|
||||
|
||||
|
||||
338
taglib/mp4/mp4chapterlist.cpp
Normal file
338
taglib/mp4/mp4chapterlist.cpp
Normal file
@@ -0,0 +1,338 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#include "mp4chapterlist.h"
|
||||
|
||||
#include <algorithm>
|
||||
|
||||
#include "tdebug.h"
|
||||
#include "mp4file.h"
|
||||
#include "mp4atom.h"
|
||||
|
||||
using namespace TagLib;
|
||||
|
||||
namespace
|
||||
{
|
||||
// Nero chpl version 1 header: version(1) + flags(3) + reserved(4) + count(1) = 9 bytes
|
||||
constexpr int chplHeaderSize = 9;
|
||||
|
||||
ByteVector renderAtom(const ByteVector &name, const ByteVector &data)
|
||||
{
|
||||
return ByteVector::fromUInt(static_cast<unsigned int>(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());
|
||||
long size = file->readBlock(4).toUInt();
|
||||
if(size == 1) {
|
||||
// 64-bit size
|
||||
file->seek(4, TagLib::File::Current);
|
||||
long long longSize = file->readBlock(8).toLongLong();
|
||||
file->seek((*it)->offset() + 8);
|
||||
file->writeBlock(ByteVector::fromLongLong(longSize + delta));
|
||||
}
|
||||
else {
|
||||
// 32-bit size
|
||||
file->seek((*it)->offset());
|
||||
file->writeBlock(ByteVector::fromUInt(static_cast<unsigned int>(size + delta)));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Update stco/co64/tfhd chunk offsets when file content shifts.
|
||||
// Mirrors MP4::Tag::updateOffsets().
|
||||
void updateChunkOffsets(TagLib::File *file, MP4::Atoms *atoms,
|
||||
offset_t delta, offset_t offset)
|
||||
{
|
||||
if(MP4::Atom *moov = atoms->find("moov")) {
|
||||
const MP4::AtomList stco = moov->findall("stco", true);
|
||||
for(const auto &atom : stco) {
|
||||
if(atom->offset() > offset)
|
||||
atom->addToOffset(delta);
|
||||
file->seek(atom->offset() + 12);
|
||||
ByteVector data = file->readBlock(atom->length() - 12);
|
||||
unsigned int count = data.toUInt();
|
||||
file->seek(atom->offset() + 16);
|
||||
unsigned int pos = 4;
|
||||
while(count--) {
|
||||
auto o = static_cast<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;
|
||||
while(count--) {
|
||||
long long o = data.toLongLong(pos);
|
||||
if(o > offset)
|
||||
o += delta;
|
||||
file->writeBlock(ByteVector::fromLongLong(o));
|
||||
pos += 8;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if(MP4::Atom *moof = atoms->find("moof")) {
|
||||
const MP4::AtomList tfhd = moof->findall("tfhd", true);
|
||||
for(const auto &atom : tfhd) {
|
||||
if(atom->offset() > offset)
|
||||
atom->addToOffset(delta);
|
||||
file->seek(atom->offset() + 9);
|
||||
ByteVector data = file->readBlock(atom->length() - 9);
|
||||
if(const unsigned int flags = data.toUInt(0, 3, true);
|
||||
flags & 1) {
|
||||
long long o = data.toLongLong(7U);
|
||||
if(o > offset)
|
||||
o += delta;
|
||||
file->seek(atom->offset() + 16);
|
||||
file->writeBlock(ByteVector::fromLongLong(o));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build the binary payload for a chpl atom (version 1).
|
||||
ByteVector renderChplData(const MP4::ChapterList &chapters)
|
||||
{
|
||||
unsigned int count = std::min(static_cast<unsigned int>(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
|
||||
data.append(ByteVector::fromLongLong(ch.startTime));
|
||||
|
||||
// Title: 1-byte length + UTF-8 bytes (max 255 bytes)
|
||||
ByteVector titleBytes = ch.title.data(String::UTF8);
|
||||
unsigned int titleLen = std::min(static_cast<unsigned int>(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;
|
||||
|
||||
if(data.size() < static_cast<unsigned int>(chplHeaderSize))
|
||||
return chapters;
|
||||
|
||||
unsigned int pos = 0;
|
||||
unsigned char 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;
|
||||
|
||||
unsigned int count = static_cast<unsigned char>(data[pos++]);
|
||||
|
||||
for(unsigned int i = 0; i < count && pos + 9 <= data.size(); ++i) {
|
||||
long long startTime = data.toLongLong(pos);
|
||||
pos += 8;
|
||||
|
||||
unsigned int titleLen = static_cast<unsigned char>(data[pos++]);
|
||||
|
||||
String title;
|
||||
if(titleLen > 0 && pos + titleLen <= data.size()) {
|
||||
title = String(data.mid(pos, titleLen), String::UTF8);
|
||||
pos += titleLen;
|
||||
}
|
||||
|
||||
MP4::Chapter ch;
|
||||
ch.startTime = startTime;
|
||||
ch.title = title;
|
||||
chapters.append(ch);
|
||||
}
|
||||
|
||||
return chapters;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
// public members
|
||||
////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
MP4::ChapterList
|
||||
MP4::MP4ChapterList::read(const char *path)
|
||||
{
|
||||
MP4::File file(path, false);
|
||||
if(!file.isOpen() || !file.isValid()) {
|
||||
debug("MP4ChapterList::read() -- Could not open file");
|
||||
return ChapterList();
|
||||
}
|
||||
|
||||
Atoms atoms(&file);
|
||||
|
||||
Atom *chpl = atoms.find("moov", "udta", "chpl");
|
||||
if(!chpl)
|
||||
return ChapterList();
|
||||
|
||||
// Read the atom content (skip 8-byte atom header)
|
||||
file.seek(chpl->offset() + 8);
|
||||
ByteVector data = file.readBlock(chpl->length() - 8);
|
||||
|
||||
return parseChplData(data);
|
||||
}
|
||||
|
||||
bool
|
||||
MP4::MP4ChapterList::write(const char *path, const ChapterList &chapters)
|
||||
{
|
||||
MP4::File file(path, false);
|
||||
if(!file.isOpen() || !file.isValid() || file.readOnly()) {
|
||||
debug("MP4ChapterList::write() -- Could not open file for writing");
|
||||
return false;
|
||||
}
|
||||
|
||||
Atoms atoms(&file);
|
||||
|
||||
if(!atoms.find("moov")) {
|
||||
debug("MP4ChapterList::write() -- No moov atom found");
|
||||
return false;
|
||||
}
|
||||
|
||||
ByteVector chplPayload = renderChplData(chapters);
|
||||
ByteVector chplAtom = renderAtom("chpl", chplPayload);
|
||||
|
||||
Atom *existingChpl = atoms.find("moov", "udta", "chpl");
|
||||
|
||||
if(existingChpl) {
|
||||
// Replace existing chpl atom
|
||||
offset_t offset = existingChpl->offset();
|
||||
offset_t oldLength = existingChpl->length();
|
||||
offset_t delta = static_cast<offset_t>(chplAtom.size()) - oldLength;
|
||||
|
||||
file.insert(chplAtom, offset, oldLength);
|
||||
|
||||
if(delta != 0) {
|
||||
// Update parent sizes: moov and udta
|
||||
AtomList parentPath = atoms.path("moov", "udta", "chpl");
|
||||
updateParentSizes(&file, parentPath, delta, 1); // ignore chpl itself
|
||||
updateChunkOffsets(&file, &atoms, delta, offset);
|
||||
}
|
||||
}
|
||||
else {
|
||||
// Need to insert a new chpl atom
|
||||
AtomList udtaPath = atoms.path("moov", "udta");
|
||||
|
||||
if(udtaPath.size() == 2) {
|
||||
// udta exists -- insert chpl at the beginning of udta's content
|
||||
offset_t insertOffset = udtaPath.back()->offset() + 8;
|
||||
file.insert(chplAtom, insertOffset, 0);
|
||||
|
||||
updateParentSizes(&file, udtaPath, chplAtom.size());
|
||||
updateChunkOffsets(&file, &atoms, chplAtom.size(), insertOffset);
|
||||
}
|
||||
else {
|
||||
// No udta -- insert udta + chpl at the beginning of moov's content
|
||||
ByteVector udtaAtom = renderAtom("udta", chplAtom);
|
||||
|
||||
AtomList moovPath = atoms.path("moov");
|
||||
if(moovPath.isEmpty()) {
|
||||
debug("MP4ChapterList::write() -- No moov atom in path");
|
||||
return false;
|
||||
}
|
||||
|
||||
offset_t insertOffset = moovPath.back()->offset() + 8;
|
||||
file.insert(udtaAtom, insertOffset, 0);
|
||||
|
||||
updateParentSizes(&file, moovPath, udtaAtom.size());
|
||||
updateChunkOffsets(&file, &atoms, udtaAtom.size(), insertOffset);
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
bool
|
||||
MP4::MP4ChapterList::remove(const char *path)
|
||||
{
|
||||
MP4::File file(path, false);
|
||||
if(!file.isOpen() || !file.isValid() || file.readOnly()) {
|
||||
debug("MP4ChapterList::remove() -- Could not open file for writing");
|
||||
return false;
|
||||
}
|
||||
|
||||
Atoms atoms(&file);
|
||||
|
||||
Atom *chpl = atoms.find("moov", "udta", "chpl");
|
||||
if(!chpl) {
|
||||
// No chpl atom -- nothing to remove
|
||||
return true;
|
||||
}
|
||||
|
||||
offset_t offset = chpl->offset();
|
||||
offset_t length = chpl->length();
|
||||
|
||||
file.removeBlock(offset, length);
|
||||
|
||||
// Update parent sizes with negative delta
|
||||
AtomList parentPath = atoms.path("moov", "udta", "chpl");
|
||||
updateParentSizes(&file, parentPath, -length, 1); // ignore chpl itself
|
||||
updateChunkOffsets(&file, &atoms, -length, offset);
|
||||
|
||||
return true;
|
||||
}
|
||||
77
taglib/mp4/mp4chapterlist.h
Normal file
77
taglib/mp4/mp4chapterlist.h
Normal file
@@ -0,0 +1,77 @@
|
||||
/**************************************************************************
|
||||
copyright : (C) 2026 by Ryan Francesconi
|
||||
**************************************************************************/
|
||||
|
||||
/***************************************************************************
|
||||
* This library is free software; you can redistribute it and/or modify *
|
||||
* it under the terms of the GNU Lesser General Public License version *
|
||||
* 2.1 as published by the Free Software Foundation. *
|
||||
* *
|
||||
* This library is distributed in the hope that it will be useful, but *
|
||||
* WITHOUT ANY WARRANTY; without even the implied warranty of *
|
||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU *
|
||||
* Lesser General Public License for more details. *
|
||||
* *
|
||||
* You should have received a copy of the GNU Lesser General Public *
|
||||
* License along with this library; if not, write to the Free Software *
|
||||
* Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA *
|
||||
* 02110-1301 USA *
|
||||
* *
|
||||
* Alternatively, this file is available under the Mozilla Public *
|
||||
* License Version 1.1. You may obtain a copy of the License at *
|
||||
* http://www.mozilla.org/MPL/ *
|
||||
***************************************************************************/
|
||||
|
||||
#ifndef TAGLIB_MP4CHAPTERLIST_H
|
||||
#define TAGLIB_MP4CHAPTERLIST_H
|
||||
|
||||
#include "tlist.h"
|
||||
#include "tstring.h"
|
||||
#include "taglib_export.h"
|
||||
|
||||
namespace TagLib {
|
||||
namespace MP4 {
|
||||
|
||||
/*!
|
||||
* A single Nero-style chapter marker.
|
||||
*/
|
||||
struct TAGLIB_EXPORT Chapter {
|
||||
long long startTime; //!< Start time in 100-nanosecond units
|
||||
String title;
|
||||
};
|
||||
|
||||
using ChapterList = List<Chapter>;
|
||||
|
||||
/*!
|
||||
* Reads, writes, and removes Nero-style chapter markers (chpl atom)
|
||||
* from MP4 files. Operates independently of MP4::Tag -- the chpl atom
|
||||
* lives at moov/udta/chpl, a sibling of the metadata ilst path.
|
||||
*/
|
||||
class TAGLIB_EXPORT MP4ChapterList
|
||||
{
|
||||
public:
|
||||
/*!
|
||||
* Reads chapter markers from the MP4 file at \a path.
|
||||
* Returns an empty list if the file has no chpl atom.
|
||||
*/
|
||||
static ChapterList read(const char *path);
|
||||
|
||||
/*!
|
||||
* Writes chapter markers to the MP4 file at \a path,
|
||||
* replacing any existing chpl atom. The chapter count is
|
||||
* capped at 255 (Nero format limit).
|
||||
* Returns \c true on success.
|
||||
*/
|
||||
static bool write(const char *path, const ChapterList &chapters);
|
||||
|
||||
/*!
|
||||
* Removes the chpl atom from the MP4 file at \a path.
|
||||
* Returns \c true on success, or if no chpl atom exists.
|
||||
*/
|
||||
static bool remove(const char *path);
|
||||
};
|
||||
|
||||
} // namespace MP4
|
||||
} // namespace TagLib
|
||||
|
||||
#endif
|
||||
@@ -34,6 +34,7 @@
|
||||
#include "mp4atom.h"
|
||||
#include "mp4file.h"
|
||||
#include "mp4itemfactory.h"
|
||||
#include "mp4chapterlist.h"
|
||||
#include "plainfile.h"
|
||||
#include <cppunit/extensions/HelperMacros.h>
|
||||
#include "utils.h"
|
||||
@@ -102,6 +103,10 @@ class TestMP4 : public CppUnit::TestFixture
|
||||
CPPUNIT_TEST(testNonFullMetaAtom);
|
||||
CPPUNIT_TEST(testItemFactory);
|
||||
CPPUNIT_TEST(testNonPrintableAtom);
|
||||
CPPUNIT_TEST(testChapterListWrite);
|
||||
CPPUNIT_TEST(testChapterListRemove);
|
||||
CPPUNIT_TEST(testChapterListWithExistingTags);
|
||||
CPPUNIT_TEST(testChapterListReadEmpty);
|
||||
CPPUNIT_TEST_SUITE_END();
|
||||
|
||||
public:
|
||||
@@ -873,6 +878,162 @@ public:
|
||||
CPPUNIT_ASSERT_EQUAL(String("TITLE"), f.tag()->title());
|
||||
}
|
||||
}
|
||||
void testChapterListWrite()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// File should have no chapters initially
|
||||
{
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str());
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
}
|
||||
|
||||
// Write chapters
|
||||
{
|
||||
MP4::ChapterList chapters;
|
||||
MP4::Chapter ch1;
|
||||
ch1.startTime = 0;
|
||||
ch1.title = "Introduction";
|
||||
chapters.append(ch1);
|
||||
|
||||
MP4::Chapter ch2;
|
||||
ch2.startTime = 300000000LL; // 30 seconds in 100ns units
|
||||
ch2.title = "Main Content";
|
||||
chapters.append(ch2);
|
||||
|
||||
MP4::Chapter ch3;
|
||||
ch3.startTime = 600000000LL; // 60 seconds
|
||||
ch3.title = "Conclusion";
|
||||
chapters.append(ch3);
|
||||
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters));
|
||||
}
|
||||
|
||||
// Read back and verify
|
||||
{
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(3U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(0LL, chapters[0].startTime);
|
||||
CPPUNIT_ASSERT_EQUAL(String("Introduction"), chapters[0].title);
|
||||
CPPUNIT_ASSERT_EQUAL(300000000LL, chapters[1].startTime);
|
||||
CPPUNIT_ASSERT_EQUAL(String("Main Content"), chapters[1].title);
|
||||
CPPUNIT_ASSERT_EQUAL(600000000LL, chapters[2].startTime);
|
||||
CPPUNIT_ASSERT_EQUAL(String("Conclusion"), chapters[2].title);
|
||||
}
|
||||
|
||||
// Overwrite with different chapters
|
||||
{
|
||||
MP4::ChapterList chapters;
|
||||
MP4::Chapter ch1;
|
||||
ch1.startTime = 0;
|
||||
ch1.title = "Part One";
|
||||
chapters.append(ch1);
|
||||
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters));
|
||||
}
|
||||
|
||||
// Verify overwrite
|
||||
{
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Part One"), chapters[0].title);
|
||||
}
|
||||
}
|
||||
|
||||
void testChapterListRemove()
|
||||
{
|
||||
ScopedFileCopy copy("no-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// Write chapters
|
||||
{
|
||||
MP4::ChapterList chapters;
|
||||
MP4::Chapter ch1;
|
||||
ch1.startTime = 0;
|
||||
ch1.title = "Chapter 1";
|
||||
chapters.append(ch1);
|
||||
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters));
|
||||
}
|
||||
|
||||
// Verify written
|
||||
{
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(1U, chapters.size());
|
||||
}
|
||||
|
||||
// Remove chapters
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str()));
|
||||
|
||||
// Verify removed
|
||||
{
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str());
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
}
|
||||
|
||||
// Remove from file with no chapters should also succeed
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str()));
|
||||
}
|
||||
|
||||
void testChapterListWithExistingTags()
|
||||
{
|
||||
ScopedFileCopy copy("has-tags", ".m4a");
|
||||
string filename = copy.fileName();
|
||||
|
||||
// File has existing tags -- verify they survive chapter operations
|
||||
String originalArtist;
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
originalArtist = f.tag()->artist();
|
||||
CPPUNIT_ASSERT(!originalArtist.isEmpty());
|
||||
}
|
||||
|
||||
// Write chapters
|
||||
{
|
||||
MP4::ChapterList chapters;
|
||||
MP4::Chapter ch1;
|
||||
ch1.startTime = 0;
|
||||
ch1.title = "Intro";
|
||||
chapters.append(ch1);
|
||||
|
||||
MP4::Chapter ch2;
|
||||
ch2.startTime = 100000000LL; // 10 seconds
|
||||
ch2.title = "Verse";
|
||||
chapters.append(ch2);
|
||||
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::write(filename.c_str(), chapters));
|
||||
}
|
||||
|
||||
// Verify chapters are written AND existing tags are preserved
|
||||
{
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(filename.c_str());
|
||||
CPPUNIT_ASSERT_EQUAL(2U, chapters.size());
|
||||
CPPUNIT_ASSERT_EQUAL(String("Intro"), chapters[0].title);
|
||||
CPPUNIT_ASSERT_EQUAL(String("Verse"), chapters[1].title);
|
||||
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
|
||||
}
|
||||
|
||||
// Remove chapters and verify tags still survive
|
||||
CPPUNIT_ASSERT(MP4::MP4ChapterList::remove(filename.c_str()));
|
||||
{
|
||||
MP4::File f(filename.c_str());
|
||||
CPPUNIT_ASSERT(f.isValid());
|
||||
CPPUNIT_ASSERT_EQUAL(originalArtist, f.tag()->artist());
|
||||
}
|
||||
}
|
||||
|
||||
void testChapterListReadEmpty()
|
||||
{
|
||||
// Reading from a file with no chpl atom should return empty list
|
||||
MP4::ChapterList chapters = MP4::MP4ChapterList::read(
|
||||
TEST_FILE_PATH_C("no-tags.m4a"));
|
||||
CPPUNIT_ASSERT(chapters.isEmpty());
|
||||
}
|
||||
};
|
||||
|
||||
CPPUNIT_TEST_SUITE_REGISTRATION(TestMP4);
|
||||
|
||||
Reference in New Issue
Block a user