feat(io): serve disk-cached thumbnails synchronously

pull/18/head
reionwong 3 weeks ago
parent 8c9159b588
commit 5a235e65d6

@ -2,6 +2,7 @@
// SPDX-License-Identifier: GPL-3.0-or-later
#include "thumbnailcache.h"
#include "thumbnailprovider.h"
#include <QCryptographicHash>
#include <QDir>
@ -137,6 +138,39 @@ private slots:
QVERIFY(!QFileInfo::exists(m_tools.filePath("arguments")));
}
void cachedThumbnailsAreReadWithoutGenerating()
{
const QString image = m_files.filePath("served.png");
QImage source(40, 20, QImage::Format_RGB32);
source.fill(Qt::red);
QVERIFY(source.save(image));
const QUrl url(QStringLiteral("image://thumbnailcache/") + localUrl(image) + QStringLiteral("?mtime=1"));
const QString id = url.toString(QUrl::RemoveScheme | QUrl::RemoveAuthority).mid(1);
CachedThumbnailProvider provider;
QSize size;
QVERIFY(!ThumbnailProvider::isCached(url, QSize(96, 96)));
QVERIFY(provider.requestImage(id, &size, QSize(96, 96)).isNull());
QVERIFY(!ThumbnailCache::self()->requestThumbnail(localUrl(image), QSize(96, 96)).image.isNull());
QVERIFY(ThumbnailProvider::isCached(url, QSize(96, 96)));
QVERIFY(!ThumbnailProvider::isCached(url, QSize(200, 200)));
const QImage served = provider.requestImage(id, &size, QSize(120, 100));
QCOMPARE(size, QSize(128, 64));
QCOMPARE(served.pixelColor(0, 0), QColor(Qt::red));
// A changed file leaves a stale entry behind, which is not served.
QVERIFY(QFile(image).resize(QFileInfo(image).size() + 1));
QVERIFY(provider.requestImage(id, &size, QSize(96, 96)).isNull());
// Nor is one older than the file offered at all.
QFile file(image);
QVERIFY(file.open(QIODevice::ReadWrite));
QVERIFY(file.setFileTime(QDateTime::currentDateTime().addSecs(60), QFileDevice::FileModificationTime));
file.close();
QVERIFY(!ThumbnailProvider::isCached(url, QSize(96, 96)));
}
void failureIsRemembered()
{
QVERIFY(ThumbnailCache::self()->canThumbnail(QStringLiteral("text/csv")));

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "thumbnailcache.h"
#include "thumbnailers_p.h"
@ -80,6 +83,56 @@ void setMetadata(QImage &image, const QString &uri, qint64 mtime, qint64 size)
image.setText(mtimeKey, QString::number(mtime));
image.setText(sizeKey, QString::number(size));
}
// 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;
};
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()
@ -95,63 +148,65 @@ bool ThumbnailCache::canThumbnail(const QString &mimeType)
Thumbnail ThumbnailCache::requestThumbnail(const QString &filePath, const QSize &requestedSize)
{
if (!requestedSize.isValid() || requestedSize.isEmpty())
const Entry entry = locate(filePath, requestedSize);
if (!entry.valid)
return {};
const QUrl url(filePath);
const QString path = url.isLocalFile() ? url.toLocalFile() : filePath;
const QFileInfo info(path);
if (!info.isFile())
return {};
const Flavor &flavor = flavorFor(requestedSize);
const QSize cacheSize(flavor.size, flavor.size);
const QString root = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation)
+ QStringLiteral("/thumbnails");
// The spec forbids thumbnailing the cache itself, which would otherwise feed on its output.
if (info.absoluteFilePath().startsWith(root + '/'))
return {{}, generateThumbnail(path, cacheSize)};
if (entry.insideCache)
return {{}, generateThumbnail(entry.path, entry.cacheSize)};
const QByteArray encodedUri = QUrl::fromLocalFile(info.absoluteFilePath()).toEncoded();
const QString uri = QString::fromLatin1(encodedUri);
const QString name =
QString::fromLatin1(QCryptographicHash::hash(encodedUri, QCryptographicHash::Md5).toHex())
+ QStringLiteral(".png");
const qint64 mtime = info.lastModified().toSecsSinceEpoch();
const qint64 size = info.size();
const QString directory = root + '/' + QLatin1StringView(flavor.name);
const QString target = directory + '/' + name;
const QImage cached(target);
if (isCurrent(cached, uri, mtime, size))
return {target, cached};
const QString failDirectory = root + QStringLiteral("/fail/cutefish");
const QString failTarget = failDirectory + '/' + name;
if (isCurrent(QImage(failTarget), uri, mtime, size))
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(path, cacheSize);
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, uri, mtime, size);
setMetadata(marker, entry.uri, entry.mtime, entry.size);
if (ensurePrivateDirectory(root) && ensurePrivateDirectory(failDirectory))
writeCacheFile(failTarget, marker);
if (ensurePrivateDirectory(entry.root) && ensurePrivateDirectory(entry.failDirectory))
writeCacheFile(entry.failTarget, marker);
return {};
}
setMetadata(image, uri, mtime, size);
setMetadata(image, entry.uri, entry.mtime, entry.size);
// An unwritable cache must not hide the thumbnail; only the path goes missing.
if (!ensurePrivateDirectory(root) || !ensurePrivateDirectory(directory))
if (!ensurePrivateDirectory(entry.root) || !ensurePrivateDirectory(entry.directory))
return {{}, image};
return {writeCacheFile(target, image), 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)

@ -1,3 +1,6 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QImage>
@ -19,6 +22,11 @@ public:
bool canThumbnail(const QString &mimeType);
Thumbnail requestThumbnail(const QString &filePath, const QSize &requestedSize);
/** Whether a cache entry newer than @p filePath exists; it may still turn out stale. */
bool hasCachedThumbnail(const QString &filePath, const QSize &requestedSize);
/** The current cache entry only, never generating one; cheap enough for the GUI thread. */
Thumbnail cachedThumbnail(const QString &filePath, const QSize &requestedSize);
QImage generateThumbnail(const QString &source, const QSize &requestedSize);
QString writeCacheFile(const QString &path, const QImage &image);
};

@ -1,9 +1,13 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#include "thumbnailprovider.h"
#include "thumbnailcache.h"
#include <QImage>
#include <QQuickTextureFactory>
#include <QThreadPool>
#include <QUrl>
#include <atomic>
@ -58,3 +62,23 @@ QQuickImageResponse *ThumbnailProvider::requestImageResponse(const QString &id,
{
return new ThumbnailResponse(id, requestedSize);
}
bool ThumbnailProvider::isCached(const QUrl &source, const QSize &requestedSize)
{
// The id an image provider is given for this source, as QQuickPixmap derives it.
const QString id = source.toString(QUrl::RemoveScheme | QUrl::RemoveAuthority).mid(1);
return ThumbnailCache::self()->hasCachedThumbnail(id, requestedSize);
}
CachedThumbnailProvider::CachedThumbnailProvider()
: QQuickImageProvider(QQuickImageProvider::Image)
{
}
QImage CachedThumbnailProvider::requestImage(const QString &id, QSize *size, const QSize &requestedSize)
{
const QImage image = ThumbnailCache::self()->cachedThumbnail(id, requestedSize).image;
if (size)
*size = image.size();
return image;
}

@ -1,10 +1,30 @@
// SPDX-FileCopyrightText: 2026 Reion Wong <reionwong@gmail.com>
// SPDX-License-Identifier: GPL-3.0-or-later
#pragma once
#include <QQuickAsyncImageProvider>
#include <QQuickImageProvider>
class ThumbnailProvider : public QQuickAsyncImageProvider
{
public:
QQuickImageResponse *requestImageResponse(const QString &id,
const QSize &requestedSize) override;
/**
* Whether the thumbnail @p source ("image://<name>/<id>") at @p requestedSize
* device pixels is in the disk cache, so CachedThumbnailProvider can read it
* synchronously and a view show it in its first frame.
*/
static bool isCached(const QUrl &source, const QSize &requestedSize);
};
/** Serves thumbnails from the disk cache on the calling thread and never generates one. */
class CachedThumbnailProvider : public QQuickImageProvider
{
public:
CachedThumbnailProvider();
QImage requestImage(const QString &id, QSize *size, const QSize &requestedSize) override;
};

Loading…
Cancel
Save