diff --git a/taglib/CMakeLists.txt b/taglib/CMakeLists.txt index 7d5b03d0..793c45e6 100644 --- a/taglib/CMakeLists.txt +++ b/taglib/CMakeLists.txt @@ -90,6 +90,7 @@ set(tag_HDRS toolkit/tbytevectorlist.h toolkit/tvariant.h toolkit/tbytevectorstream.h + toolkit/tdeferredwritestream.h toolkit/tiostream.h toolkit/tfile.h toolkit/tfilestream.h @@ -511,6 +512,7 @@ set(toolkit_SRCS toolkit/tbytevectorlist.cpp toolkit/tvariant.cpp toolkit/tbytevectorstream.cpp + toolkit/tdeferredwritestream.cpp toolkit/tiostream.cpp toolkit/tfile.cpp toolkit/tfilestream.cpp diff --git a/taglib/toolkit/tdeferredwritestream.cpp b/taglib/toolkit/tdeferredwritestream.cpp new file mode 100644 index 00000000..f1ed6df5 --- /dev/null +++ b/taglib/toolkit/tdeferredwritestream.cpp @@ -0,0 +1,416 @@ +/*************************************************************************** + 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 "tdeferredwritestream.h" + +#include +#include + +#include "tdebug.h" +#include "tstring.h" + +using namespace TagLib; + +namespace +{ + // The block size moved bytes are copied in when committing. + constexpr offset_t moveBlockSize = 1024 * 1024; + + // A run of the content: either bytes of the underlying stream starting at + // source, or bytes held in data. + struct Piece + { + bool isOriginal; + offset_t source; + ByteVector data; + offset_t size; + }; +} // namespace + +class DeferredWriteStream::DeferredWriteStreamPrivate +{ +public: + DeferredWriteStreamPrivate(IOStream *stream) : + stream(stream) + { + } + + IOStream *stream; + // Pieces in content order; starts[i] is where pieces[i] begins. + std::vector pieces; + std::vector starts; + offset_t position { 0 }; + bool changed { false }; + + void reset() + { + pieces.clear(); + starts.clear(); + const offset_t length = stream->length(); + if(length > 0) { + pieces.push_back({ true, 0, ByteVector(), length }); + starts.push_back(0); + } + changed = false; + } + + offset_t contentLength() const + { + return pieces.empty() ? 0 : starts.back() + pieces.back().size; + } + + void updateStarts(size_t from) + { + starts.resize(pieces.size()); + for(size_t i = from; i < pieces.size(); ++i) + starts[i] = i == 0 ? 0 : starts[i - 1] + pieces[i - 1].size; + } + + // Ensures a piece boundary at offset, which must not exceed the content + // length; returns the index of the piece that starts there. + size_t split(offset_t offset) + { + const auto it = std::upper_bound(starts.begin(), starts.end(), offset); + if(it == starts.begin()) + return 0; + + const size_t index = static_cast(it - starts.begin()) - 1; + const offset_t inner = offset - starts[index]; + if(inner == 0) + return index; + if(inner >= pieces[index].size) + return index + 1; + + Piece &piece = pieces[index]; + Piece tail { piece.isOriginal, piece.source + inner, ByteVector(), piece.size - inner }; + if(!piece.isOriginal) { + tail.data = piece.data.mid(static_cast(inner)); + piece.data.resize(static_cast(inner)); + } + piece.size = inner; + + pieces.insert(pieces.begin() + static_cast(index) + 1, tail); + updateStarts(index + 1); + return index + 1; + } + + // Replaces the content in [start, start + length) with data; start may be + // past the end, which is filled with zero bytes first. + void replace(offset_t start, offset_t length, const ByteVector &data) + { + const offset_t total = contentLength(); + if(start > total) { + replace(total, 0, ByteVector(static_cast(start - total), '\0')); + } + + const offset_t end = std::min(start + length, contentLength()); + const size_t first = split(start); + const size_t last = split(end); + pieces.erase(pieces.begin() + static_cast(first), + pieces.begin() + static_cast(last)); + + if(!data.isEmpty()) { + pieces.insert(pieces.begin() + static_cast(first), + { false, 0, data, static_cast(data.size()) }); + } + + updateStarts(first); + merge(first); + changed = true; + } + + // Joins in-memory pieces around index, so repeated small writes do not + // fragment the table. + void merge(size_t index) + { + size_t from = index > 0 ? index - 1 : 0; + while(from + 1 < pieces.size() && from <= index + 1) { + Piece &a = pieces[from]; + Piece &b = pieces[from + 1]; + if(!a.isOriginal && !b.isOriginal) { + a.data.append(b.data); + a.size += b.size; + pieces.erase(pieces.begin() + static_cast(from) + 1); + } + else if(a.isOriginal && b.isOriginal && a.source + a.size == b.source) { + a.size += b.size; + pieces.erase(pieces.begin() + static_cast(from) + 1); + } + else { + ++from; + } + } + updateStarts(index > 0 ? index - 1 : 0); + } + + ByteVector read(offset_t offset, offset_t length) + { + ByteVector result; + const offset_t end = std::min(offset + length, contentLength()); + if(offset >= end) + return result; + + const auto it = std::upper_bound(starts.begin(), starts.end(), offset); + size_t index = static_cast(it - starts.begin()) - 1; + + while(offset < end && index < pieces.size()) { + const Piece &piece = pieces[index]; + const offset_t inner = offset - starts[index]; + const offset_t count = std::min(piece.size - inner, end - offset); + + if(piece.isOriginal) { + stream->seek(piece.source + inner); + result.append(stream->readBlock(static_cast(count))); + } + else { + result.append(piece.data.mid(static_cast(inner), static_cast(count))); + } + + offset += count; + ++index; + } + return result; + } + + // Copies count bytes of the underlying stream from source to destination, + // in blocks ordered so that an overlapping range is read before it is + // overwritten. + void move(offset_t source, offset_t destination, offset_t count) + { + if(destination < source) { + for(offset_t done = 0; done < count; done += moveBlockSize) { + const offset_t block = std::min(moveBlockSize, count - done); + stream->seek(source + done); + const ByteVector bytes = stream->readBlock(static_cast(block)); + stream->seek(destination + done); + stream->writeBlock(bytes); + } + } + else { + for(offset_t remaining = count; remaining > 0;) { + const offset_t block = std::min(moveBlockSize, remaining); + remaining -= block; + stream->seek(source + remaining); + const ByteVector bytes = stream->readBlock(static_cast(block)); + stream->seek(destination + remaining); + stream->writeBlock(bytes); + } + } + } +}; + +//////////////////////////////////////////////////////////////////////////////// +// public members +//////////////////////////////////////////////////////////////////////////////// + +DeferredWriteStream::DeferredWriteStream(IOStream *stream) : + d(std::make_unique(stream)) +{ + d->reset(); +} + +DeferredWriteStream::~DeferredWriteStream() = default; + +bool DeferredWriteStream::commit() +{ + if(!d->changed) + return true; + + if(!isOpen() || readOnly()) { + debug("DeferredWriteStream::commit() -- stream is not writable."); + return false; + } + + // Insertions and removals never reorder the underlying bytes, so original + // pieces appear in source order. That is what makes moving them in place + // safe: those moving towards the start are copied first, front to back, + // then those moving towards the end, back to front. + offset_t previousEnd = 0; + for(const auto &piece : d->pieces) { + if(!piece.isOriginal) + continue; + if(piece.source < previousEnd) { + debug("DeferredWriteStream::commit() -- pieces out of order."); + return false; + } + previousEnd = piece.source + piece.size; + } + + // Original pieces moving the same distance with only changed bytes between + // them are moved as one run; the bytes carried along under the changes are + // overwritten when the changes are written. + struct Run { offset_t source; offset_t destination; offset_t size; }; + std::vector runs; + for(size_t i = 0; i < d->pieces.size(); ++i) { + const Piece &piece = d->pieces[i]; + if(!piece.isOriginal) + continue; + const offset_t destination = d->starts[i]; + if(!runs.empty()) { + Run &last = runs.back(); + if(destination - piece.source == last.destination - last.source && + (i == 0 || !d->pieces[i - 1].isOriginal)) { + last.size = piece.source + piece.size - last.source; + continue; + } + } + runs.push_back({ piece.source, destination, piece.size }); + } + + for(const auto &run : runs) { + if(run.destination < run.source) + d->move(run.source, run.destination, run.size); + } + + for(auto it = runs.rbegin(); it != runs.rend(); ++it) { + if(it->destination > it->source) + d->move(it->source, it->destination, it->size); + } + + for(size_t i = 0; i < d->pieces.size(); ++i) { + const Piece &piece = d->pieces[i]; + if(!piece.isOriginal) { + d->stream->seek(d->starts[i]); + d->stream->writeBlock(piece.data); + } + } + + const offset_t length = d->contentLength(); + if(d->stream->length() > length) + d->stream->truncate(length); + + d->reset(); + return true; +} + +bool DeferredWriteStream::hasChanges() const +{ + return d->changed; +} + +offset_t DeferredWriteStream::bytesToMove() const +{ + offset_t total = 0; + for(size_t i = 0; i < d->pieces.size(); ++i) { + if(d->pieces[i].isOriginal && d->starts[i] != d->pieces[i].source) + total += d->pieces[i].size; + } + return total; +} + +bool DeferredWriteStream::writeTo(IOStream *destination) +{ + if(!destination->isOpen() || destination->readOnly()) { + debug("DeferredWriteStream::writeTo() -- destination is not writable."); + return false; + } + + const offset_t length = d->contentLength(); + destination->seek(0); + for(offset_t done = 0; done < length; done += moveBlockSize) { + destination->writeBlock(d->read(done, std::min(moveBlockSize, length - done))); + } + if(destination->length() > length) + destination->truncate(length); + return true; +} + +FileName DeferredWriteStream::name() const +{ + return d->stream->name(); +} + +ByteVector DeferredWriteStream::readBlock(size_t length) +{ + ByteVector bytes = d->read(d->position, static_cast(length)); + d->position += bytes.size(); + return bytes; +} + +void DeferredWriteStream::writeBlock(const ByteVector &data) +{ + d->replace(d->position, data.size(), data); + d->position += data.size(); +} + +void DeferredWriteStream::insert(const ByteVector &data, offset_t start, size_t replace) +{ + d->replace(start, static_cast(replace), data); + d->position = start + data.size(); +} + +void DeferredWriteStream::removeBlock(offset_t start, size_t length) +{ + d->replace(start, static_cast(length), ByteVector()); + d->position = d->contentLength(); +} + +bool DeferredWriteStream::readOnly() const +{ + return d->stream->readOnly(); +} + +bool DeferredWriteStream::isOpen() const +{ + return d->stream->isOpen(); +} + +void DeferredWriteStream::seek(offset_t offset, Position p) +{ + switch(p) { + case Beginning: + d->position = offset; + break; + case Current: + d->position += offset; + break; + case End: + d->position = d->contentLength() + offset; + break; + } +} + +void DeferredWriteStream::clear() +{ + d->stream->clear(); +} + +offset_t DeferredWriteStream::tell() const +{ + return d->position; +} + +offset_t DeferredWriteStream::length() +{ + return d->contentLength(); +} + +void DeferredWriteStream::truncate(offset_t length) +{ + const offset_t total = d->contentLength(); + if(length < total) + d->replace(length, total - length, ByteVector()); + else if(length > total) + d->replace(total, 0, ByteVector(static_cast(length - total), '\0')); +} diff --git a/taglib/toolkit/tdeferredwritestream.h b/taglib/toolkit/tdeferredwritestream.h new file mode 100644 index 00000000..a851a9dd --- /dev/null +++ b/taglib/toolkit/tdeferredwritestream.h @@ -0,0 +1,170 @@ +/*************************************************************************** + 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_DEFERREDWRITESTREAM_H +#define TAGLIB_DEFERREDWRITESTREAM_H + +#include "tbytevector.h" +#include "tiostream.h" +#include "taglib_export.h" +#include "taglib.h" + +namespace TagLib { + + //! A stream that keeps every change to another stream in memory until commit() + + /*! + * Writes, insertions, removals and truncations are recorded against the + * underlying stream instead of applied to it, and reads see the result. + * commit() then writes the final content in one pass: bytes that end up + * where they started are not written at all, and bytes that move are moved + * once, however many insertions and removals moved them. + * + * A save that changes several blocks ahead of the audio data, each of which + * would move all of the audio when written to a FileStream, moves it at most + * once through this stream. + * + * Only changed bytes are held in memory; unchanged ranges are read from the + * underlying stream on demand. The underlying stream must not be changed by + * anything else until commit() returns. + */ + class TAGLIB_EXPORT DeferredWriteStream : public IOStream + { + public: + /*! + * Construct a DeferredWriteStream over \a stream, which is not owned and + * must outlive it. + */ + DeferredWriteStream(IOStream *stream); + + /*! + * Destroys this DeferredWriteStream instance. Changes not committed are + * discarded. + */ + ~DeferredWriteStream() override; + + DeferredWriteStream(const DeferredWriteStream &) = delete; + DeferredWriteStream &operator=(const DeferredWriteStream &) = delete; + + /*! + * Writes every recorded change to the underlying stream. Returns \c false + * if the underlying stream is read only or not open, in which case nothing + * is written. Afterwards the stream reads the underlying stream directly + * again. + */ + bool commit(); + + /*! + * Returns \c true if there are changes not yet committed. + */ + bool hasChanges() const; + + /*! + * Returns how many bytes of the underlying stream commit() would move to + * a new position; 0 when it would only overwrite or append. + */ + offset_t bytesToMove() const; + + /*! + * Writes the content, changes included, to \a destination from its + * start, truncating it to the content's length. The underlying stream and + * the recorded changes are left as they are. Returns \c false if + * \a destination is read only or not open. + */ + bool writeTo(IOStream *destination); + + /*! + * Returns the underlying stream's name. + */ + FileName name() const override; + + /*! + * Reads a block of size \a length at the current get pointer. + */ + ByteVector readBlock(size_t length) override; + + /*! + * Records \a data as written at the current get pointer. + */ + void writeBlock(const ByteVector &data) override; + + /*! + * Records \a data as inserted at position \a start, overwriting \a replace + * bytes of the content. + */ + void insert(const ByteVector &data, offset_t start = 0, size_t replace = 0) override; + + /*! + * Records the removal of \a length bytes starting at \a start. + */ + void removeBlock(offset_t start = 0, size_t length = 0) override; + + /*! + * Returns the underlying stream's read only state. + */ + bool readOnly() const override; + + /*! + * Returns the underlying stream's open state. + */ + bool isOpen() const override; + + /*! + * Move the I/O pointer to \a offset in the stream from position \a p. This + * defaults to seeking from the beginning of the stream. + * + * \see Position + */ + void seek(offset_t offset, Position p = Beginning) override; + + /*! + * Resets the underlying stream's end-of-stream and error flags. + */ + void clear() override; + + /*! + * Returns the current offset within the stream. + */ + offset_t tell() const override; + + /*! + * Returns the length of the stream, changes included. + */ + offset_t length() override; + + /*! + * Records the stream as truncated, or extended with zero bytes, to + * \a length. + */ + void truncate(offset_t length) override; + + private: + class DeferredWriteStreamPrivate; + TAGLIB_MSVC_SUPPRESS_WARNING_NEEDS_TO_HAVE_DLL_INTERFACE + std::unique_ptr d; + }; + +} // namespace TagLib + +#endif diff --git a/tests/CMakeLists.txt b/tests/CMakeLists.txt index 6b3015b1..d7b8ed4b 100644 --- a/tests/CMakeLists.txt +++ b/tests/CMakeLists.txt @@ -81,6 +81,7 @@ SET(test_runner_SRCS test_bytevector.cpp test_bytevectorlist.cpp test_bytevectorstream.cpp + test_deferredwritestream.cpp test_string.cpp test_propertymap.cpp test_variant.cpp diff --git a/tests/test_deferredwritestream.cpp b/tests/test_deferredwritestream.cpp new file mode 100644 index 00000000..a39dd9fd --- /dev/null +++ b/tests/test_deferredwritestream.cpp @@ -0,0 +1,284 @@ +/*************************************************************************** + 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 +#include +#include + +#include "tbytevectorstream.h" +#include "tdeferredwritestream.h" +#include "tfilestream.h" +#include "mp4file.h" +#include "mp4tag.h" +#include "mp4chapter.h" +#include "plainfile.h" +#include +#include "utils.h" + +using namespace std; +using namespace TagLib; + +namespace +{ + // Patterned rather than constant, so a moved range never reads back as unchanged. + ByteVector pattern(unsigned int size) + { + ByteVector data(size); + unsigned int state = 0x13579BDF; + for(unsigned int i = 0; i < size; ++i) { + state = state * 1664525 + 1013904223; + data[i] = static_cast(state >> 24); + } + return data; + } + + ByteVector wholeContent(IOStream &stream) + { + stream.seek(0); + return stream.readBlock(static_cast(stream.length())); + } + + class CountingStream : public ByteVectorStream + { + public: + using ByteVectorStream::ByteVectorStream; + long long written { 0 }; + + void writeBlock(const ByteVector &data) override + { + written += data.size(); + ByteVectorStream::writeBlock(data); + } + }; + + MP4::ChapterList chapters() + { + MP4::ChapterList list; + for(int i = 0; i < 3; ++i) + list.append(MP4::Chapter(String("Chapter " + to_string(i + 1)), i * 100LL)); + return list; + } + + void editMP4(MP4::File &f) + { + f.tag()->setTitle("A title long enough to grow the moov atom"); + f.setNeroChapters(chapters()); + f.setQtChapters(chapters()); + } +} // namespace + +class TestDeferredWriteStream : public CppUnit::TestFixture +{ + CPPUNIT_TEST_SUITE(TestDeferredWriteStream); + CPPUNIT_TEST(testRandomEditsMatchByteVectorStream); + CPPUNIT_TEST(testCommitWritesOnlyWhatMoved); + CPPUNIT_TEST(testGrowthMovesTheRestOnce); + CPPUNIT_TEST(testBytesToMove); + CPPUNIT_TEST(testReadOnlyCommitRefused); + CPPUNIT_TEST(testMP4SaveMatchesFileStream); + CPPUNIT_TEST_SUITE_END(); + +public: + + // Replays seeded random writes, insertions, removals and truncations against a + // DeferredWriteStream and a ByteVectorStream, comparing their content after each + // operation, then compares writeTo() and commit() with the expected bytes. + void testRandomEditsMatchByteVectorStream() + { + for(unsigned int seed = 0; seed < 200; ++seed) { + ByteVectorStream underlying(pattern(20000)); + ByteVectorStream reference(pattern(20000)); + DeferredWriteStream deferred(&underlying); + mt19937 random(seed); + + auto below = [&random](long long bound) { + return bound <= 0 ? 0LL : static_cast(random() % static_cast(bound)); + }; + auto bytes = [&random](long long count) { + ByteVector data(static_cast(count)); + for(unsigned int i = 0; i < data.size(); ++i) + data[i] = static_cast(random()); + return data; + }; + + for(int operation = 0; operation < 24; ++operation) { + const long long length = reference.length(); + + // Weighted so most of the original content survives to be moved by the commit. + const auto kind = random() % 20; + if(kind < 6) { + const long long at = below(length + 16); + const ByteVector data = bytes(1 + below(300)); + reference.seek(at); + reference.writeBlock(data); + deferred.seek(at); + deferred.writeBlock(data); + } + else if(kind < 12) { + const long long at = below(length + 1); + const auto replace = static_cast(below(min(length - at, 64LL) + 1)); + const ByteVector data = bytes(below(2000)); + reference.insert(data, at, replace); + deferred.insert(data, at, replace); + } + else if(kind < 17) { + const long long at = below(length + 1); + const auto count = static_cast(below(min(length - at, 2000LL) + 1)); + reference.removeBlock(at, count); + deferred.removeBlock(at, count); + } + else if(kind < 18) { + const long long to = max(0LL, length - 64 + below(128)); + reference.truncate(to); + deferred.truncate(to); + } + else { + const long long at = below(length + 1); + const auto count = static_cast(below(500)); + reference.seek(at); + deferred.seek(at); + CPPUNIT_ASSERT_EQUAL(reference.readBlock(count), deferred.readBlock(count)); + CPPUNIT_ASSERT_EQUAL(reference.tell(), deferred.tell()); + } + + CPPUNIT_ASSERT_EQUAL(reference.length(), deferred.length()); + CPPUNIT_ASSERT_EQUAL(wholeContent(reference), wholeContent(deferred)); + } + + ByteVectorStream copy(pattern(1)); + CPPUNIT_ASSERT(deferred.writeTo(©)); + CPPUNIT_ASSERT_EQUAL(*reference.data(), *copy.data()); + + CPPUNIT_ASSERT(deferred.commit()); + CPPUNIT_ASSERT_EQUAL(*reference.data(), *underlying.data()); + CPPUNIT_ASSERT_EQUAL(*reference.data(), wholeContent(deferred)); + } + } + + // Growing a block near the start and shrinking one further on moves only the bytes + // between them; the rest of the stream keeps its place and is not written. + void testCommitWritesOnlyWhatMoved() + { + CountingStream underlying(pattern(1 << 20)); + DeferredWriteStream deferred(&underlying); + deferred.insert(ByteVector(16, 'x'), 100, 0); + deferred.removeBlock(4116, 16); + + CPPUNIT_ASSERT(deferred.commit()); + CPPUNIT_ASSERT_EQUAL(16LL + 4000LL, underlying.written); + } + + void testGrowthMovesTheRestOnce() + { + CountingStream underlying(pattern(1 << 20)); + DeferredWriteStream deferred(&underlying); + deferred.insert(ByteVector(16, 'x'), 100, 0); + + CPPUNIT_ASSERT(deferred.commit()); + CPPUNIT_ASSERT_EQUAL(16LL + (1LL << 20) - 100LL, underlying.written); + } + + // Overwriting and appending leave every original byte where it was; inserting or + // removing at 100 moves the bytes after the edit. + void testBytesToMove() + { + const ByteVector data(16, 'x'); + { + ByteVectorStream underlying(pattern(1000)); + DeferredWriteStream deferred(&underlying); + CPPUNIT_ASSERT(!deferred.hasChanges()); + deferred.seek(100); + deferred.writeBlock(data); + CPPUNIT_ASSERT(deferred.hasChanges()); + CPPUNIT_ASSERT_EQUAL(static_cast(0), deferred.bytesToMove()); + } + { + ByteVectorStream underlying(pattern(1000)); + DeferredWriteStream deferred(&underlying); + deferred.seek(0, IOStream::End); + deferred.writeBlock(data); + CPPUNIT_ASSERT_EQUAL(static_cast(0), deferred.bytesToMove()); + } + { + ByteVectorStream underlying(pattern(1000)); + DeferredWriteStream deferred(&underlying); + deferred.insert(data, 100, 0); + CPPUNIT_ASSERT_EQUAL(static_cast(900), deferred.bytesToMove()); + } + { + ByteVectorStream underlying(pattern(1000)); + DeferredWriteStream deferred(&underlying); + deferred.removeBlock(100, 16); + CPPUNIT_ASSERT_EQUAL(static_cast(884), deferred.bytesToMove()); + } + } + + void testReadOnlyCommitRefused() + { + const ByteVector before = PlainFile(TEST_FILE_PATH_C("blank_video.m4v")).readAll(); + + FileStream file(TEST_FILE_PATH_C("blank_video.m4v"), true); + DeferredWriteStream deferred(&file); + deferred.insert(ByteVector("x", 1), 0, 0); + + CPPUNIT_ASSERT(!deferred.commit()); + CPPUNIT_ASSERT_EQUAL(before, PlainFile(TEST_FILE_PATH_C("blank_video.m4v")).readAll()); + } + + // A save of a title and both chapter lists on a file with moov ahead of mdat comes + // out byte-identical through the deferring stream. + void testMP4SaveMatchesFileStream() + { + const ByteVector original = PlainFile(TEST_FILE_PATH_C("blank_video.m4v")).readAll(); + + ByteVectorStream plain(original); + { + MP4::File f(&plain); + CPPUNIT_ASSERT(f.isValid()); + editMP4(f); + CPPUNIT_ASSERT(f.save()); + } + + ScopedFileCopy copy("blank_video", ".m4v"); + offset_t moved = 0; + { + FileStream file(copy.fileName().c_str()); + DeferredWriteStream deferred(&file); + MP4::File f(&deferred); + CPPUNIT_ASSERT(f.isValid()); + editMP4(f); + CPPUNIT_ASSERT(f.save()); + moved = deferred.bytesToMove(); + CPPUNIT_ASSERT(deferred.commit()); + } + + CPPUNIT_ASSERT_EQUAL(*plain.data(), PlainFile(copy.fileName().c_str()).readAll()); + + // The grown moov displaces all of mdat (and the part of moov after the edit). + const offset_t mdatSize = static_cast(original.size()) - (original.find("mdat") - 4); + CPPUNIT_ASSERT(moved >= mdatSize); + } +}; + +CPPUNIT_TEST_SUITE_REGISTRATION(TestDeferredWriteStream); diff --git a/tests/test_sizes.cpp b/tests/test_sizes.cpp index ebc36a3a..20e8d3d5 100644 --- a/tests/test_sizes.cpp +++ b/tests/test_sizes.cpp @@ -30,6 +30,7 @@ #include "tbytevector.h" #include "tbytevectorlist.h" #include "tbytevectorstream.h" +#include "tdeferredwritestream.h" #include "tdebuglistener.h" #include "tfile.h" #include "tfilestream.h" @@ -179,6 +180,7 @@ public: CPPUNIT_ASSERT_EQUAL(classSize(0, false), sizeof(TagLib::ByteVector)); CPPUNIT_ASSERT_EQUAL(classSize(2, false), sizeof(TagLib::ByteVectorList)); CPPUNIT_ASSERT_EQUAL(classSize(1, true), sizeof(TagLib::ByteVectorStream)); + CPPUNIT_ASSERT_EQUAL(classSize(1, true), sizeof(TagLib::DeferredWriteStream)); CPPUNIT_ASSERT_EQUAL(classSize(0, true), sizeof(TagLib::DebugListener)); CPPUNIT_ASSERT_EQUAL(classSize(0, true), sizeof(TagLib::File)); CPPUNIT_ASSERT_EQUAL(classSize(1, false), sizeof(TagLib::FileRef));