diff --git a/autotests/embeddedimagedatatest.h b/autotests/embeddedimagedatatest.h --- a/autotests/embeddedimagedatatest.h +++ b/autotests/embeddedimagedatatest.h @@ -34,6 +34,8 @@ private Q_SLOTS: void test(); void test_data(); + void testWrite(); + void testWrite_data(); }; #endif // EMBEDDEDIMAGEDATATEST_H diff --git a/autotests/embeddedimagedatatest.cpp b/autotests/embeddedimagedatatest.cpp --- a/autotests/embeddedimagedatatest.cpp +++ b/autotests/embeddedimagedatatest.cpp @@ -25,6 +25,7 @@ #include #include +#include using namespace KFileMetaData; @@ -104,4 +105,81 @@ ; } +void EmbeddedImageDataTest::testWrite() +{ + QFETCH(QString, fileName); + EmbeddedImageData imageData; + + QString testFileName = testFilePath(QStringLiteral("writer") + fileName); + + QFile::copy(testFilePath(fileName), testFileName); + + QFile testFile(testFilePath("test.jpg")); + testFile.open(QIODevice::ReadOnly); + + QMap writeImages; + QMap readImages; + + writeImages.insert(EmbeddedImageData::ImageType::FrontCover, testFile.readAll()); + imageData.writeImageData(testFileName, writeImages); + readImages = imageData.imageData(testFileName); + + QCOMPARE(readImages.value(EmbeddedImageData::FrontCover), writeImages.value(EmbeddedImageData::FrontCover)); + + QFile::remove(testFileName); +} + +void EmbeddedImageDataTest::testWrite_data() +{ + QTest::addColumn("fileName"); + + QTest::addRow("aiff") + << QStringLiteral("test.aif") + ; + + QTest::addRow("ape") + << QStringLiteral("test.ape") + ; + + QTest::addRow("opus") + << QStringLiteral("test.opus") + ; + + QTest::addRow("ogg") + << QStringLiteral("test.ogg") + ; + + QTest::addRow("flac") + << QStringLiteral("test.flac") + ; + + QTest::addRow("mp3") + << QStringLiteral("test.mp3") + ; + + QTest::addRow("m4a") + << QStringLiteral("test.m4a") + ; + + QTest::addRow("mpc") + << QStringLiteral("test.mpc") + ; + + QTest::addRow("speex") + << QStringLiteral("test.spx") + ; + + QTest::addRow("wav") + << QStringLiteral("test.wav") + ; + + QTest::addRow("wavpack") + << QStringLiteral("test.wv") + ; + + QTest::addRow("wma") + << QStringLiteral("test.wma") + ; +} + QTEST_GUILESS_MAIN(EmbeddedImageDataTest) diff --git a/src/embeddedimagedata.h b/src/embeddedimagedata.h --- a/src/embeddedimagedata.h +++ b/src/embeddedimagedata.h @@ -23,18 +23,19 @@ #include "kfilemetadata_export.h" #include +#include #include #include namespace KFileMetaData { /** * \class EmbeddedImageData embeddedimagedata.h * - * \brief The EmbeddedImageData is a class which extracts the images stored - * in the metadata tags of files as byte arrays. For example, the front cover - * art of music albums may be extracted with this class. The byte array will - * mostly contain jpeg or png files, which can be loaded into e.g. QImage. + * \brief The EmbeddedImageData is a class which extracts and writes the images + * stored in the metadata tags of files as byte arrays. For example, the front + * cover art of music albums may be extracted with this class. The byte array + * will mostly contain jpeg or png files, which can be loaded into e.g. QImage. * * \author Alexander Stippich */ @@ -61,6 +62,14 @@ */ QStringList mimeTypes() const; + /** + * Write the images to the metadata tags in a file. + * Currently, only the front cover is supported. + * Mimetype is only correctly set for jpeg and png files. + * @since 5.62 + */ + void writeImageData(const QString &fileUrl, QMap &imageData); + private: class Private; std::unique_ptr d; diff --git a/src/embeddedimagedata.cpp b/src/embeddedimagedata.cpp --- a/src/embeddedimagedata.cpp +++ b/src/embeddedimagedata.cpp @@ -44,6 +44,7 @@ #include #include +#include #include using namespace KFileMetaData; @@ -58,6 +59,13 @@ QByteArray getFrontCoverFromMp4(TagLib::MP4::Tag* mp4Tags) const; QByteArray getFrontCoverFromApe(TagLib::APE::Tag* apeTags) const; QByteArray getFrontCoverFromAsf(TagLib::ASF::Tag* asfTags) const; + void writeFrontCover(const QString &fileUrl, const QString &mimeType, const QByteArray &pictureData); + void writeFrontCoverToID3(TagLib::ID3v2::Tag* Id3Tags, const QByteArray &pictureData); + void writeFrontCoverToFlacPicture(TagLib::List lstPic, const QByteArray &pictureData); + void writeFrontCoverToMp4(TagLib::MP4::Tag* mp4Tags, const QByteArray &pictureData); + void writeFrontCoverToApe(TagLib::APE::Tag* apeTags, const QByteArray &pictureData); + void writeFrontCoverToAsf(TagLib::ASF::Tag* asfTags, const QByteArray &pictureData); + TagLib::String determineMimeType(const QByteArray &pictureData); static const QStringList mMimetypes; }; @@ -102,7 +110,7 @@ { QMap imageData; - const auto &fileMimeType = d->mMimeDatabase.mimeTypeForFile(fileUrl); + const auto fileMimeType = d->mMimeDatabase.mimeTypeForFile(fileUrl); if (fileMimeType.name().startsWith(QLatin1String("audio/"))) { if (types & EmbeddedImageData::FrontCover) { imageData.insert(EmbeddedImageData::FrontCover, d->getFrontCover(fileUrl, fileMimeType.name())); @@ -112,6 +120,19 @@ return imageData; } +void +EmbeddedImageData::writeImageData(const QString &fileUrl, + QMap &imageData) +{ + const auto fileMimeType = d->mMimeDatabase.mimeTypeForFile(fileUrl); + QMap::iterator frontCover = imageData.find(EmbeddedImageData::FrontCover); + if (fileMimeType.name().startsWith(QLatin1String("audio/"))) { + if (frontCover != imageData.end()) { + d->writeFrontCover(fileUrl, fileMimeType.name(), frontCover.value()); + } + } +} + QByteArray EmbeddedImageData::Private::getFrontCover(const QString &fileUrl, const QString &mimeType) const @@ -215,6 +236,109 @@ return QByteArray(); } +void +EmbeddedImageData::Private::writeFrontCover(const QString &fileUrl, + const QString &mimeType, const QByteArray &pictureData) +{ + TagLib::FileStream stream(fileUrl.toUtf8().constData(), false); + if (!stream.isOpen()) { + qWarning() << "Unable to open file: " << fileUrl; + return; + } + + if ((mimeType == QLatin1String("audio/mpeg")) + || (mimeType == QLatin1String("audio/mpeg3")) + || (mimeType == QLatin1String("audio/x-mpeg"))) { + + // Handling multiple tags in mpeg files. + TagLib::MPEG::File mpegFile(&stream, TagLib::ID3v2::FrameFactory::instance(), false); + if (mpegFile.ID3v2Tag()) { + writeFrontCoverToID3(mpegFile.ID3v2Tag(), pictureData); + } + mpegFile.save(); + } else if (mimeType == QLatin1String("audio/x-aiff")) { + + TagLib::RIFF::AIFF::File aiffFile(&stream, false); + if (aiffFile.hasID3v2Tag()) { + writeFrontCoverToID3(aiffFile.tag(), pictureData); + } + aiffFile.save(); + } else if ((mimeType == QLatin1String("audio/wav")) + || (mimeType == QLatin1String("audio/x-wav"))) { + + TagLib::RIFF::WAV::File wavFile(&stream, false); + if (wavFile.hasID3v2Tag()) { + writeFrontCoverToID3(wavFile.ID3v2Tag(), pictureData); + } + wavFile.save(); + } else if (mimeType == QLatin1String("audio/mp4")) { + + TagLib::MP4::File mp4File(&stream, false); + if (mp4File.tag()) { + writeFrontCoverToMp4(mp4File.tag(), pictureData); + } + mp4File.save(); + } else if (mimeType == QLatin1String("audio/x-musepack")) { + + TagLib::MPC::File mpcFile(&stream, false); + if (mpcFile.APETag()) { + writeFrontCoverToApe(mpcFile.APETag(), pictureData); + } + mpcFile.save(); + } else if (mimeType == QLatin1String("audio/x-ape")) { + + TagLib::APE::File apeFile(&stream, false); + if (apeFile.hasAPETag()) { + writeFrontCoverToApe(apeFile.APETag(), pictureData); + } + apeFile.save(); + } else if (mimeType == QLatin1String("audio/x-wavpack")) { + + TagLib::WavPack::File wavpackFile(&stream, false); + if (wavpackFile.hasAPETag()) { + writeFrontCoverToApe(wavpackFile.APETag(), pictureData); + } + wavpackFile.save(); + } else if (mimeType == QLatin1String("audio/x-ms-wma")) { + + TagLib::ASF::File asfFile(&stream, false); + TagLib::ASF::Tag* asfTags = dynamic_cast(asfFile.tag()); + if (asfTags) { + writeFrontCoverToAsf(asfTags, pictureData); + } + asfFile.save(); + } else if (mimeType == QLatin1String("audio/flac")) { + + TagLib::FLAC::File flacFile(&stream, TagLib::ID3v2::FrameFactory::instance(), false); + writeFrontCoverToFlacPicture(flacFile.pictureList(), pictureData); + flacFile.save(); + } else if ((mimeType == QLatin1String("audio/ogg")) + || (mimeType == QLatin1String("audio/x-vorbis+ogg"))) { + + TagLib::Ogg::Vorbis::File oggFile(&stream, false); + if (oggFile.tag()) { + writeFrontCoverToFlacPicture(oggFile.tag()->pictureList(), pictureData); + } + oggFile.save(); + } + else if ((mimeType == QLatin1String("audio/opus")) + || (mimeType == QLatin1String("audio/x-opus+ogg"))) { + + TagLib::Ogg::Opus::File opusFile(&stream, false); + if (opusFile.tag()) { + writeFrontCoverToFlacPicture(opusFile.tag()->pictureList(), pictureData); + } + opusFile.save(); + } else if (mimeType == QLatin1String("audio/speex") || mimeType == QLatin1String("audio/x-speex")) { + + TagLib::Ogg::Speex::File speexFile(&stream, false); + if (speexFile.tag()) { + writeFrontCoverToFlacPicture(speexFile.tag()->pictureList(), pictureData); + } + speexFile.save(); + } +} + QByteArray EmbeddedImageData::Private::getFrontCoverFromID3(TagLib::ID3v2::Tag* Id3Tags) const { @@ -231,6 +355,28 @@ return QByteArray(); } +void +EmbeddedImageData::Private::writeFrontCoverToID3(TagLib::ID3v2::Tag* Id3Tags, const QByteArray &pictureData) +{ + // Try to update existing front cover frame first + TagLib::ID3v2::FrameList lstID3v2; + lstID3v2 = Id3Tags->frameListMap()["APIC"]; + for (auto& frame : qAsConst(lstID3v2)) + { + auto *frontCoverFrame = static_cast(frame); + if (frontCoverFrame->type() == frontCoverFrame->FrontCover) { + frontCoverFrame->setPicture(TagLib::ByteVector(static_cast(pictureData.data()), pictureData.size())); + frontCoverFrame->setMimeType(determineMimeType(pictureData)); + return; + } + } + auto pictureFrame = new TagLib::ID3v2::AttachedPictureFrame; + pictureFrame->setPicture(TagLib::ByteVector(pictureData.data(), pictureData.size())); + pictureFrame->setType(pictureFrame->FrontCover); + pictureFrame->setMimeType(determineMimeType(pictureData)); + Id3Tags->addFrame(pictureFrame); +} + QByteArray EmbeddedImageData::Private::getFrontCoverFromFlacPicture(TagLib::List lstPic) const { @@ -242,6 +388,22 @@ return QByteArray(); } +void +EmbeddedImageData::Private::writeFrontCoverToFlacPicture(TagLib::List lstPic, const QByteArray &pictureData) +{ + for (const auto &picture : qAsConst(lstPic)) { + if (picture->type() == picture->FrontCover) { + picture->setData(TagLib::ByteVector(pictureData.data(), pictureData.size())); + picture->setMimeType(determineMimeType(pictureData)); + return ; + } + } + auto flacPicture = new TagLib::FLAC::Picture; + flacPicture->setMimeType(determineMimeType(pictureData)); + flacPicture->setData(TagLib::ByteVector(pictureData.data(), pictureData.size())); + lstPic.append(flacPicture); +} + QByteArray EmbeddedImageData::Private::getFrontCoverFromMp4(TagLib::MP4::Tag* mp4Tags) const { @@ -255,6 +417,21 @@ return QByteArray(); } +void +EmbeddedImageData::Private::writeFrontCoverToMp4(TagLib::MP4::Tag* mp4Tags, const QByteArray &pictureData) +{ + TagLib::MP4::Item coverArtItem = mp4Tags->item("covr"); + TagLib::MP4::CoverArtList coverArtList; + TagLib::MP4::CoverArt coverArt(TagLib::MP4::CoverArt::Format::Unknown, TagLib::ByteVector(pictureData.data(), pictureData.size())); + if (coverArtItem.isValid()) + { + coverArtList = coverArtItem.toCoverArtList(); + coverArtList.clear(); + } + coverArtList.append(coverArt); + mp4Tags->setItem("covr", coverArtList); +} + QByteArray EmbeddedImageData::Private::getFrontCoverFromApe(TagLib::APE::Tag* apeTags) const { @@ -274,6 +451,25 @@ } return QByteArray(); } + +void +EmbeddedImageData::Private::writeFrontCoverToApe(TagLib::APE::Tag* apeTags, const QByteArray &pictureData) +{ + /* The cover art tag for APEv2 tags starts with the filename as string + * with zero termination followed by the actual picture data */ + TagLib::ByteVector imageData; + TagLib::String name; + if (determineMimeType(pictureData) == TagLib::String("image/png")) { + name = "frontCover.png"; + } else { + name = "frontCover.jpeg"; + } + imageData.append(name.data(TagLib::String::UTF8)); + imageData.append('\0'); + imageData.append(TagLib::ByteVector(pictureData.constData(), pictureData.size())); + apeTags->setData("COVER ART (FRONT)", imageData); +} + QByteArray EmbeddedImageData::Private::getFrontCoverFromAsf(TagLib::ASF::Tag* asfTags) const { @@ -287,3 +483,38 @@ } return QByteArray(); } + +void +EmbeddedImageData::Private::writeFrontCoverToAsf(TagLib::ASF::Tag* asfTags, const QByteArray &pictureData) +{ + TagLib::ASF::AttributeList lstASF = asfTags->attribute("WM/Picture"); + for (const auto& attribute : qAsConst(lstASF)) { + TagLib::ASF::Picture picture = attribute.toPicture(); + if (picture.type() == TagLib::ASF::Picture::FrontCover) { + picture.setPicture(TagLib::ByteVector(pictureData.constData(), pictureData.size())); + picture.setMimeType(determineMimeType(pictureData)); + TagLib::ByteVector pictureData = picture.picture(); + return; + } + } + TagLib::ASF::Picture picture; + picture.setPicture(TagLib::ByteVector(pictureData.constData(), pictureData.size())); + picture.setType(TagLib::ASF::Picture::FrontCover); + lstASF.append(picture); +} + + +TagLib::String EmbeddedImageData::Private::determineMimeType(const QByteArray &pictureData) +{ + if (pictureData.startsWith(QByteArray::fromHex("89504E470D0A1A0A"))) { + return TagLib::String("image/png"); + } else if (pictureData.startsWith(QByteArray::fromHex("FFD8FFDB")) || + pictureData.startsWith(QByteArray::fromHex("FFD8FFE000104A4649460001")) || + pictureData.startsWith(QByteArray::fromHex("FFD8FFEE")) || + pictureData.startsWith(QByteArray::fromHex("FFD8FFE1"))) { + return TagLib::String("image/jpeg"); + } else { + return TagLib::String(); + } +} +