You cannot select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
257 lines
8.0 KiB
C++
257 lines
8.0 KiB
C++
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
|
|
// SPDX-License-Identifier: GPL-3.0-or-later
|
|
|
|
#include "thumbnailcache.h"
|
|
#include "thumbnailers_p.h"
|
|
|
|
#include <QCryptographicHash>
|
|
#include <QDateTime>
|
|
#include <QDir>
|
|
#include <QFileInfo>
|
|
#include <QImageReader>
|
|
#include <QMimeDatabase>
|
|
#include <QSaveFile>
|
|
#include <QStandardPaths>
|
|
#include <QUrl>
|
|
|
|
namespace {
|
|
|
|
struct Flavor
|
|
{
|
|
int size;
|
|
const char *name;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
// Sizes and names follow the freedesktop.org Thumbnail Managing Standard.
|
|
static constexpr Flavor flavors[] = {
|
|
{128, "normal"},
|
|
{256, "large"},
|
|
{512, "x-large"},
|
|
{1024, "xx-large"},
|
|
};
|
|
|
|
static const QString uriKey = QStringLiteral("Thumb::URI");
|
|
static const QString mtimeKey = QStringLiteral("Thumb::MTime");
|
|
static const QString sizeKey = QStringLiteral("Thumb::Size");
|
|
|
|
static const Flavor &flavorFor(const QSize &requestedSize)
|
|
{
|
|
const int extent = qMax(requestedSize.width(), requestedSize.height());
|
|
for (const Flavor &flavor : flavors) {
|
|
if (extent <= flavor.size)
|
|
return flavor;
|
|
}
|
|
|
|
return flavors[std::size(flavors) - 1];
|
|
}
|
|
|
|
static bool ensurePrivateDirectory(const QString &path)
|
|
{
|
|
if (QFileInfo(path).isDir())
|
|
return true;
|
|
|
|
// Thumbnails leak the content of private files, so the spec requires owner-only access.
|
|
return QDir().mkpath(path)
|
|
&& QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner
|
|
| QFileDevice::ExeOwner);
|
|
}
|
|
|
|
// QImageReader::text() splits keys at the first colon, so only a decoded image sees Thumb::*.
|
|
static bool isCurrent(const QImage &image, const QString &uri, qint64 mtime, qint64 size)
|
|
{
|
|
if (image.isNull() || image.text(uriKey) != uri)
|
|
return false;
|
|
|
|
// Other implementations may write a fractional MTime.
|
|
bool valid = false;
|
|
const double cachedMtime = image.text(mtimeKey).toDouble(&valid);
|
|
if (!valid || qint64(cachedMtime) != mtime)
|
|
return false;
|
|
|
|
const QString cachedSize = image.text(sizeKey);
|
|
return cachedSize.isEmpty() || cachedSize.toLongLong() == size;
|
|
}
|
|
|
|
static bool isReadableMimeType(const QString &mimeType)
|
|
{
|
|
static const QList<QByteArray> readable = QImageReader::supportedMimeTypes();
|
|
return readable.contains(mimeType.toLatin1());
|
|
}
|
|
|
|
static void setMetadata(QImage &image, const QString &uri, qint64 mtime, qint64 size)
|
|
{
|
|
image.setText(uriKey, uri);
|
|
image.setText(mtimeKey, QString::number(mtime));
|
|
image.setText(sizeKey, QString::number(size));
|
|
}
|
|
|
|
namespace {
|
|
|
|
// Where the thumbnail of one file at one size lives in the cache.
|
|
struct Entry
|
|
{
|
|
bool valid = false;
|
|
bool insideCache = false;
|
|
QString path;
|
|
QSize cacheSize;
|
|
QString uri;
|
|
qint64 mtime = 0;
|
|
qint64 size = 0;
|
|
QString root;
|
|
QString directory;
|
|
QString target;
|
|
QString failDirectory;
|
|
QString failTarget;
|
|
};
|
|
|
|
} // namespace
|
|
|
|
static Entry locate(const QString &filePath, const QSize &requestedSize)
|
|
{
|
|
Entry entry;
|
|
if (!requestedSize.isValid() || requestedSize.isEmpty())
|
|
return entry;
|
|
|
|
const QUrl url(filePath);
|
|
entry.path = url.isLocalFile() ? url.toLocalFile() : filePath;
|
|
const QFileInfo info(entry.path);
|
|
if (!info.isFile())
|
|
return entry;
|
|
|
|
const Flavor &flavor = flavorFor(requestedSize);
|
|
entry.valid = true;
|
|
entry.cacheSize = QSize(flavor.size, flavor.size);
|
|
entry.root = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation)
|
|
+ QStringLiteral("/thumbnails");
|
|
entry.insideCache = info.absoluteFilePath().startsWith(entry.root + '/');
|
|
|
|
const QByteArray encodedUri = QUrl::fromLocalFile(info.absoluteFilePath()).toEncoded();
|
|
const QString name =
|
|
QString::fromLatin1(QCryptographicHash::hash(encodedUri, QCryptographicHash::Md5).toHex())
|
|
+ QStringLiteral(".png");
|
|
entry.uri = QString::fromLatin1(encodedUri);
|
|
entry.mtime = info.lastModified().toSecsSinceEpoch();
|
|
entry.size = info.size();
|
|
entry.directory = entry.root + '/' + QLatin1StringView(flavor.name);
|
|
entry.target = entry.directory + '/' + name;
|
|
entry.failDirectory = entry.root + QStringLiteral("/fail/cutefish");
|
|
entry.failTarget = entry.failDirectory + '/' + name;
|
|
return entry;
|
|
}
|
|
|
|
ThumbnailCache *ThumbnailCache::self()
|
|
{
|
|
static ThumbnailCache cache;
|
|
return &cache;
|
|
}
|
|
|
|
bool ThumbnailCache::canThumbnail(const QString &mimeType)
|
|
{
|
|
return isReadableMimeType(mimeType) || Thumbnailers::self()->contains(mimeType);
|
|
}
|
|
|
|
Thumbnail ThumbnailCache::requestThumbnail(const QString &filePath, const QSize &requestedSize)
|
|
{
|
|
const Entry entry = locate(filePath, requestedSize);
|
|
if (!entry.valid)
|
|
return {};
|
|
|
|
// The spec forbids thumbnailing the cache itself, which would otherwise feed on its output.
|
|
if (entry.insideCache)
|
|
return {{}, generateThumbnail(entry.path, entry.cacheSize)};
|
|
|
|
const QImage cached(entry.target);
|
|
if (isCurrent(cached, entry.uri, entry.mtime, entry.size))
|
|
return {entry.target, cached};
|
|
|
|
if (isCurrent(QImage(entry.failTarget), entry.uri, entry.mtime, entry.size))
|
|
return {};
|
|
|
|
QImage image = generateThumbnail(entry.path, entry.cacheSize);
|
|
if (image.isNull()) {
|
|
// Remember the failure so broken images are not decoded again on every scroll.
|
|
QImage marker(1, 1, QImage::Format_ARGB32);
|
|
marker.fill(Qt::transparent);
|
|
setMetadata(marker, entry.uri, entry.mtime, entry.size);
|
|
|
|
if (ensurePrivateDirectory(entry.root) && ensurePrivateDirectory(entry.failDirectory))
|
|
writeCacheFile(entry.failTarget, marker);
|
|
|
|
return {};
|
|
}
|
|
|
|
setMetadata(image, entry.uri, entry.mtime, entry.size);
|
|
|
|
// An unwritable cache must not hide the thumbnail; only the path goes missing.
|
|
if (!ensurePrivateDirectory(entry.root) || !ensurePrivateDirectory(entry.directory))
|
|
return {{}, image};
|
|
|
|
return {writeCacheFile(entry.target, image), image};
|
|
}
|
|
|
|
bool ThumbnailCache::hasCachedThumbnail(const QString &filePath, const QSize &requestedSize)
|
|
{
|
|
const Entry entry = locate(filePath, requestedSize);
|
|
if (!entry.valid || entry.insideCache)
|
|
return false;
|
|
|
|
// Without decoding it, an entry older than the file is the likely stale one.
|
|
const QFileInfo cached(entry.target);
|
|
return cached.isFile() && cached.lastModified().toSecsSinceEpoch() >= entry.mtime;
|
|
}
|
|
|
|
Thumbnail ThumbnailCache::cachedThumbnail(const QString &filePath, const QSize &requestedSize)
|
|
{
|
|
const Entry entry = locate(filePath, requestedSize);
|
|
if (!entry.valid || entry.insideCache)
|
|
return {};
|
|
|
|
const QImage cached(entry.target);
|
|
if (!isCurrent(cached, entry.uri, entry.mtime, entry.size))
|
|
return {};
|
|
|
|
return {entry.target, cached};
|
|
}
|
|
|
|
QImage ThumbnailCache::generateThumbnail(const QString &source, const QSize &requestedSize)
|
|
{
|
|
if (requestedSize.isEmpty())
|
|
return {};
|
|
|
|
QImageReader reader(source);
|
|
const QSize originalSize = reader.size();
|
|
if (!originalSize.isValid()) {
|
|
const QString mimeType = QMimeDatabase().mimeTypeForFile(source).name();
|
|
return Thumbnailers::self()->generate(source, mimeType, requestedSize);
|
|
}
|
|
|
|
reader.setQuality(90);
|
|
reader.setAutoTransform(true);
|
|
reader.setScaledSize(originalSize.scaled(requestedSize, Qt::KeepAspectRatio));
|
|
QImage image = reader.read();
|
|
if (image.isNull())
|
|
return {};
|
|
|
|
if (image.width() > requestedSize.width() || image.height() > requestedSize.height())
|
|
image = image.scaled(requestedSize, Qt::KeepAspectRatio, Qt::SmoothTransformation);
|
|
|
|
return image;
|
|
}
|
|
|
|
QString ThumbnailCache::writeCacheFile(const QString &path, const QImage &image)
|
|
{
|
|
if (image.isNull())
|
|
return {};
|
|
|
|
// Concurrent requests and separate processes must never see a partial cache entry.
|
|
QSaveFile file(path);
|
|
if (!file.open(QIODevice::WriteOnly)
|
|
|| !file.setPermissions(QFileDevice::ReadOwner | QFileDevice::WriteOwner)
|
|
|| !image.save(&file, "PNG") || !file.commit())
|
|
return {};
|
|
|
|
return path;
|
|
}
|