1 // vim: set tabstop=4 shiftwidth=4 expandtab:
2 /*  Gwenview - A simple image viewer for KDE
3     Copyright 2000-2007 Aurélien Gâteau <agateau@kde.org>
4     This class is based on the ImagePreviewJob class from Konqueror.
5 */
6 /*  This file is part of the KDE project
7     Copyright (C) 2000 David Faure <faure@kde.org>
8                   2000 Carsten Pfeiffer <pfeiffer@kde.org>
9 
10     This program is free software; you can redistribute it and/or modify
11     it under the terms of the GNU General Public License as published by
12     the Free Software Foundation; either version 2 of the License, or
13     (at your option) any later version.
14 
15     This program is distributed in the hope that it will be useful,
16     but WITHOUT ANY WARRANTY; without even the implied warranty of
17     MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
18     GNU General Public License for more details.
19 
20     You should have received a copy of the GNU General Public License
21     along with this program; if not, write to the Free Software
22     Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
23 */
24 #include "thumbnailprovider.h"
25 
26 #include <sys/stat.h>
27 #include <sys/types.h>
28 #include <unistd.h>
29 
30 // Qt
31 #include <QApplication>
32 #include <QCryptographicHash>
33 #include <QDir>
34 #include <QFile>
35 #include <QStandardPaths>
36 #include <QTemporaryFile>
37 
38 // KF
39 #include <KIO/JobUiDelegate>
40 #include <KIO/PreviewJob>
41 #include <KJobWidgets>
42 
43 // Local
44 #include "gwenview_lib_debug.h"
45 #include "mimetypeutils.h"
46 #include "thumbnailgenerator.h"
47 #include "thumbnailwriter.h"
48 #include "urlutils.h"
49 
50 namespace Gwenview
51 {
52 #undef ENABLE_LOG
53 #undef LOG
54 //#define ENABLE_LOG
55 #ifdef ENABLE_LOG
56 #define LOG(x) qCDebug(GWENVIEW_LIB_LOG) << x
57 #else
58 #define LOG(x) ;
59 #endif
60 
Q_GLOBAL_STATIC(ThumbnailWriter,sThumbnailWriter)61 Q_GLOBAL_STATIC(ThumbnailWriter, sThumbnailWriter)
62 
63 static QString generateOriginalUri(const QUrl &url_)
64 {
65     QUrl url = url_;
66     return url.adjusted(QUrl::RemovePassword).url();
67 }
68 
generateThumbnailPath(const QString & uri,ThumbnailGroup::Enum group)69 static QString generateThumbnailPath(const QString &uri, ThumbnailGroup::Enum group)
70 {
71     QString baseDir = ThumbnailProvider::thumbnailBaseDir(group);
72     QCryptographicHash md5(QCryptographicHash::Md5);
73     md5.addData(QFile::encodeName(uri));
74     return baseDir + QFile::encodeName(QString::fromLatin1(md5.result().toHex())) + QStringLiteral(".png");
75 }
76 
77 //------------------------------------------------------------------------
78 //
79 // ThumbnailProvider static methods
80 //
81 //------------------------------------------------------------------------
82 static QString sThumbnailBaseDir;
thumbnailBaseDir()83 QString ThumbnailProvider::thumbnailBaseDir()
84 {
85     if (sThumbnailBaseDir.isEmpty()) {
86         const QByteArray customDir = qgetenv("GV_THUMBNAIL_DIR");
87         if (customDir.isEmpty()) {
88             sThumbnailBaseDir = QStandardPaths::writableLocation(QStandardPaths::GenericCacheLocation) + QStringLiteral("/thumbnails/");
89         } else {
90             sThumbnailBaseDir = QFile::decodeName(customDir) + QLatin1Char('/');
91         }
92     }
93     return sThumbnailBaseDir;
94 }
95 
setThumbnailBaseDir(const QString & dir)96 void ThumbnailProvider::setThumbnailBaseDir(const QString &dir)
97 {
98     sThumbnailBaseDir = dir;
99 }
100 
thumbnailBaseDir(ThumbnailGroup::Enum group)101 QString ThumbnailProvider::thumbnailBaseDir(ThumbnailGroup::Enum group)
102 {
103     QString dir = thumbnailBaseDir();
104     switch (group) {
105     case ThumbnailGroup::Normal:
106         dir += QStringLiteral("normal/");
107         break;
108     case ThumbnailGroup::Large:
109         dir += QStringLiteral("large/");
110         break;
111     case ThumbnailGroup::Large2x:
112     default:
113         dir += QLatin1String("x-gwenview/"); // Should never be hit, but just in case
114     }
115     return dir;
116 }
117 
deleteImageThumbnail(const QUrl & url)118 void ThumbnailProvider::deleteImageThumbnail(const QUrl &url)
119 {
120     QString uri = generateOriginalUri(url);
121     QFile::remove(generateThumbnailPath(uri, ThumbnailGroup::Normal));
122     QFile::remove(generateThumbnailPath(uri, ThumbnailGroup::Large));
123 }
124 
moveThumbnailHelper(const QString & oldUri,const QString & newUri,ThumbnailGroup::Enum group)125 static void moveThumbnailHelper(const QString &oldUri, const QString &newUri, ThumbnailGroup::Enum group)
126 {
127     QString oldPath = generateThumbnailPath(oldUri, group);
128     QString newPath = generateThumbnailPath(newUri, group);
129     QImage thumb;
130     if (!thumb.load(oldPath)) {
131         return;
132     }
133     thumb.setText(QStringLiteral("Thumb::URI"), newUri);
134     thumb.save(newPath, "png");
135     QFile::remove(QFile::encodeName(oldPath));
136 }
137 
moveThumbnail(const QUrl & oldUrl,const QUrl & newUrl)138 void ThumbnailProvider::moveThumbnail(const QUrl &oldUrl, const QUrl &newUrl)
139 {
140     QString oldUri = generateOriginalUri(oldUrl);
141     QString newUri = generateOriginalUri(newUrl);
142     moveThumbnailHelper(oldUri, newUri, ThumbnailGroup::Normal);
143     moveThumbnailHelper(oldUri, newUri, ThumbnailGroup::Large);
144 }
145 
146 //------------------------------------------------------------------------
147 //
148 // ThumbnailProvider implementation
149 //
150 //------------------------------------------------------------------------
ThumbnailProvider()151 ThumbnailProvider::ThumbnailProvider()
152     : KIO::Job()
153     , mState(STATE_NEXTTHUMB)
154     , mOriginalTime(0)
155 {
156     LOG(this);
157 
158     // Make sure we have a place to store our thumbnails
159     QString thumbnailDirNormal = ThumbnailProvider::thumbnailBaseDir(ThumbnailGroup::Normal);
160     QString thumbnailDirLarge = ThumbnailProvider::thumbnailBaseDir(ThumbnailGroup::Large);
161     QDir().mkpath(thumbnailDirNormal);
162     QDir().mkpath(thumbnailDirLarge);
163     QFile::setPermissions(thumbnailDirNormal, QFileDevice::WriteOwner | QFileDevice::ReadOwner | QFileDevice::ExeOwner);
164     QFile::setPermissions(thumbnailDirLarge, QFileDevice::WriteOwner | QFileDevice::ReadOwner | QFileDevice::ExeOwner);
165 
166     // Look for images and store the items in our todo list
167     mCurrentItem = KFileItem();
168     mThumbnailGroup = ThumbnailGroup::Large;
169     createNewThumbnailGenerator();
170 }
171 
~ThumbnailProvider()172 ThumbnailProvider::~ThumbnailProvider()
173 {
174     LOG(this);
175     disconnect(mThumbnailGenerator, nullptr, this, nullptr);
176     disconnect(mThumbnailGenerator, nullptr, sThumbnailWriter, nullptr);
177     abortSubjob();
178     mThumbnailGenerator->cancel();
179     if (mPreviousThumbnailGenerator) {
180         disconnect(mPreviousThumbnailGenerator, nullptr, sThumbnailWriter, nullptr);
181     }
182     sThumbnailWriter->requestInterruption();
183     sThumbnailWriter->wait();
184 }
185 
stop()186 void ThumbnailProvider::stop()
187 {
188     // Clear mItems and create a new ThumbnailGenerator if mThumbnailGenerator is running,
189     // but also make sure that at most two ThumbnailGenerators are running.
190     // startCreatingThumbnail() will take care that these two threads won't work on the same item.
191     mItems.clear();
192     abortSubjob();
193     if (!mThumbnailGenerator->isStopped() && !mPreviousThumbnailGenerator) {
194         mPreviousThumbnailGenerator = mThumbnailGenerator;
195         mPreviousThumbnailGenerator->cancel();
196         disconnect(mPreviousThumbnailGenerator, nullptr, this, nullptr);
197         connect(mPreviousThumbnailGenerator, SIGNAL(finished()), mPreviousThumbnailGenerator, SLOT(deleteLater()));
198         createNewThumbnailGenerator();
199         mCurrentItem = KFileItem();
200     }
201 }
202 
pendingItems() const203 const KFileItemList &ThumbnailProvider::pendingItems() const
204 {
205     return mItems;
206 }
207 
setThumbnailGroup(ThumbnailGroup::Enum group)208 void ThumbnailProvider::setThumbnailGroup(ThumbnailGroup::Enum group)
209 {
210     mThumbnailGroup = group;
211 }
212 
appendItems(const KFileItemList & items)213 void ThumbnailProvider::appendItems(const KFileItemList &items)
214 {
215     if (!mItems.isEmpty()) {
216         QSet<KFileItem> itemSet{mItems.begin(), mItems.end()};
217 
218         for (const KFileItem &item : items) {
219             if (!itemSet.contains(item)) {
220                 mItems.append(item);
221             }
222         }
223     } else {
224         mItems = items;
225     }
226 
227     if (mCurrentItem.isNull()) {
228         determineNextIcon();
229     }
230 }
231 
removeItems(const KFileItemList & itemList)232 void ThumbnailProvider::removeItems(const KFileItemList &itemList)
233 {
234     if (mItems.isEmpty()) {
235         return;
236     }
237     for (const KFileItem &item : itemList) {
238         // If we are removing the next item, update to be the item after or the
239         // first if we removed the last item
240         mItems.removeAll(item);
241 
242         if (item == mCurrentItem) {
243             abortSubjob();
244         }
245     }
246 
247     // No more current item, carry on to the next remaining item
248     if (mCurrentItem.isNull()) {
249         determineNextIcon();
250     }
251 }
252 
removePendingItems()253 void ThumbnailProvider::removePendingItems()
254 {
255     mItems.clear();
256 }
257 
isRunning() const258 bool ThumbnailProvider::isRunning() const
259 {
260     return !mCurrentItem.isNull();
261 }
262 
263 //-Internal--------------------------------------------------------------
createNewThumbnailGenerator()264 void ThumbnailProvider::createNewThumbnailGenerator()
265 {
266     mThumbnailGenerator = new ThumbnailGenerator;
267     connect(mThumbnailGenerator, SIGNAL(done(QImage, QSize)), SLOT(thumbnailReady(QImage, QSize)), Qt::QueuedConnection);
268 
269     connect(mThumbnailGenerator,
270             SIGNAL(thumbnailReadyToBeCached(QString, QImage)),
271             sThumbnailWriter,
272             SLOT(queueThumbnail(QString, QImage)),
273             Qt::QueuedConnection);
274 }
275 
abortSubjob()276 void ThumbnailProvider::abortSubjob()
277 {
278     if (hasSubjobs()) {
279         LOG("Killing subjob");
280         KJob *job = subjobs().first();
281         job->kill();
282         removeSubjob(job);
283         mCurrentItem = KFileItem();
284     }
285 }
286 
determineNextIcon()287 void ThumbnailProvider::determineNextIcon()
288 {
289     LOG(this);
290     mState = STATE_NEXTTHUMB;
291 
292     // No more items ?
293     if (mItems.isEmpty()) {
294         LOG("No more items. Nothing to do");
295         mCurrentItem = KFileItem();
296         Q_EMIT finished();
297         return;
298     }
299 
300     mCurrentItem = mItems.takeFirst();
301     LOG("mCurrentItem.url=" << mCurrentItem.url());
302 
303     // First, stat the orig file
304     mState = STATE_STATORIG;
305     mCurrentUrl = mCurrentItem.url().adjusted(QUrl::NormalizePathSegments);
306     mOriginalFileSize = mCurrentItem.size();
307 
308     // Do direct stat instead of using KIO if the file is local (faster)
309     if (UrlUtils::urlIsFastLocalFile(mCurrentUrl)) {
310         QFileInfo fileInfo(mCurrentUrl.toLocalFile());
311         mOriginalTime = fileInfo.lastModified().toSecsSinceEpoch();
312         QMetaObject::invokeMethod(this, &ThumbnailProvider::checkThumbnail, Qt::QueuedConnection);
313     } else {
314         KIO::Job *job = KIO::stat(mCurrentUrl, KIO::HideProgressInfo);
315         KJobWidgets::setWindow(job, qApp->activeWindow());
316         LOG("KIO::stat orig" << mCurrentUrl.url());
317         addSubjob(job);
318     }
319     LOG("/determineNextIcon" << this);
320 }
321 
slotResult(KJob * job)322 void ThumbnailProvider::slotResult(KJob *job)
323 {
324     LOG(mState);
325     removeSubjob(job);
326     Q_ASSERT(subjobs().isEmpty()); // We should have only one job at a time
327 
328     switch (mState) {
329     case STATE_NEXTTHUMB:
330         Q_ASSERT(false);
331         determineNextIcon();
332         return;
333 
334     case STATE_STATORIG: {
335         // Could not stat original, drop this one and move on to the next one
336         if (job->error()) {
337             emitThumbnailLoadingFailed();
338             determineNextIcon();
339             return;
340         }
341 
342         // Get modification time of the original file
343         KIO::UDSEntry entry = static_cast<KIO::StatJob *>(job)->statResult();
344         mOriginalTime = entry.numberValue(KIO::UDSEntry::UDS_MODIFICATION_TIME, -1);
345         checkThumbnail();
346         return;
347     }
348 
349     case STATE_DOWNLOADORIG:
350         if (job->error()) {
351             emitThumbnailLoadingFailed();
352             LOG("Delete temp file" << mTempPath);
353             QFile::remove(mTempPath);
354             mTempPath.clear();
355             determineNextIcon();
356         } else {
357             startCreatingThumbnail(mTempPath);
358         }
359         return;
360 
361     case STATE_PREVIEWJOB:
362         determineNextIcon();
363         return;
364     }
365 }
366 
thumbnailReady(const QImage & _img,const QSize & _size)367 void ThumbnailProvider::thumbnailReady(const QImage &_img, const QSize &_size)
368 {
369     QImage img = _img;
370     QSize size = _size;
371     if (!img.isNull()) {
372         emitThumbnailLoaded(img, size);
373     } else {
374         emitThumbnailLoadingFailed();
375     }
376     if (!mTempPath.isEmpty()) {
377         LOG("Delete temp file" << mTempPath);
378         QFile::remove(mTempPath);
379         mTempPath.clear();
380     }
381     determineNextIcon();
382 }
383 
loadThumbnailFromCache() const384 QImage ThumbnailProvider::loadThumbnailFromCache() const
385 {
386     if (mThumbnailGroup > ThumbnailGroup::Large) {
387         return QImage();
388     }
389 
390     QImage image = sThumbnailWriter->value(mThumbnailPath);
391     if (!image.isNull()) {
392         return image;
393     }
394 
395     image = QImage(mThumbnailPath);
396     if (image.isNull() && mThumbnailGroup == ThumbnailGroup::Normal) {
397         // If there is a large-sized thumbnail, generate the normal-sized version from it
398         QString largeThumbnailPath = generateThumbnailPath(mOriginalUri, ThumbnailGroup::Large);
399         QImage largeImage(largeThumbnailPath);
400         if (largeImage.isNull()) {
401             return image;
402         }
403         int size = ThumbnailGroup::pixelSize(ThumbnailGroup::Normal);
404         image = largeImage.scaled(size, size, Qt::KeepAspectRatio, Qt::SmoothTransformation);
405         const QStringList textKeys = largeImage.textKeys();
406         for (const QString &key : textKeys) {
407             QString text = largeImage.text(key);
408             image.setText(key, text);
409         }
410         sThumbnailWriter->queueThumbnail(mThumbnailPath, image);
411     }
412 
413     return image;
414 }
415 
checkThumbnail()416 void ThumbnailProvider::checkThumbnail()
417 {
418     if (mCurrentItem.isNull()) {
419         // This can happen if current item has been removed by removeItems()
420         determineNextIcon();
421         return;
422     }
423 
424     // If we are in the thumbnail dir, just load the file
425     if (mCurrentUrl.isLocalFile() && mCurrentUrl.adjusted(QUrl::RemoveFilename | QUrl::StripTrailingSlash).path().startsWith(thumbnailBaseDir())) {
426         QImage image(mCurrentUrl.toLocalFile());
427         emitThumbnailLoaded(image, image.size());
428         determineNextIcon();
429         return;
430     }
431 
432     mOriginalUri = generateOriginalUri(mCurrentUrl);
433     mThumbnailPath = generateThumbnailPath(mOriginalUri, mThumbnailGroup);
434 
435     LOG("Stat thumb" << mThumbnailPath);
436 
437     QImage thumb = loadThumbnailFromCache();
438     KIO::filesize_t fileSize = thumb.text(QStringLiteral("Thumb::Size")).toULongLong();
439     if (!thumb.isNull()) {
440         if (thumb.text(QStringLiteral("Thumb::URI")) == mOriginalUri && thumb.text(QStringLiteral("Thumb::MTime")).toInt() == mOriginalTime
441             && (fileSize == 0 || fileSize == mOriginalFileSize)) {
442             int width = 0, height = 0;
443             QSize size;
444             bool ok;
445 
446             width = thumb.text(QStringLiteral("Thumb::Image::Width")).toInt(&ok);
447             if (ok)
448                 height = thumb.text(QStringLiteral("Thumb::Image::Height")).toInt(&ok);
449             if (ok) {
450                 size = QSize(width, height);
451             } else {
452                 LOG("Thumbnail for" << mOriginalUri << "does not contain correct image size information");
453                 // Don't try to determine the size of a video, it probably won't work and
454                 // will cause high I/O usage with big files (bug #307007).
455                 if (MimeTypeUtils::urlKind(mCurrentUrl) == MimeTypeUtils::KIND_VIDEO) {
456                     emitThumbnailLoaded(thumb, QSize());
457                     determineNextIcon();
458                     return;
459                 }
460             }
461             emitThumbnailLoaded(thumb, size);
462             determineNextIcon();
463             return;
464         }
465     }
466 
467     // Thumbnail not found or not valid
468     if (MimeTypeUtils::fileItemKind(mCurrentItem) == MimeTypeUtils::KIND_RASTER_IMAGE) {
469         if (mCurrentUrl.isLocalFile()) {
470             // Original is a local file, create the thumbnail
471             startCreatingThumbnail(mCurrentUrl.toLocalFile());
472         } else {
473             // Original is remote, download it
474             mState = STATE_DOWNLOADORIG;
475 
476             QTemporaryFile tempFile;
477             tempFile.setAutoRemove(false);
478             if (!tempFile.open()) {
479                 qCWarning(GWENVIEW_LIB_LOG) << "Couldn't create temp file to download " << mCurrentUrl.toDisplayString();
480                 emitThumbnailLoadingFailed();
481                 determineNextIcon();
482                 return;
483             }
484             mTempPath = tempFile.fileName();
485 
486             QUrl url = QUrl::fromLocalFile(mTempPath);
487             KIO::Job *job = KIO::file_copy(mCurrentUrl, url, -1, KIO::Overwrite | KIO::HideProgressInfo);
488             KJobWidgets::setWindow(job, qApp->activeWindow());
489             LOG("Download remote file" << mCurrentUrl.toDisplayString() << "to" << url.toDisplayString());
490             addSubjob(job);
491         }
492     } else {
493         // Not a raster image, use a KPreviewJob
494         LOG("Starting a KPreviewJob for" << mCurrentItem.url());
495         mState = STATE_PREVIEWJOB;
496         KFileItemList list;
497         list.append(mCurrentItem);
498         const int pixelSize = ThumbnailGroup::pixelSize(mThumbnailGroup);
499         if (mPreviewPlugins.isEmpty()) {
500             mPreviewPlugins = KIO::PreviewJob::availablePlugins();
501         }
502         KIO::Job *job = KIO::filePreview(list, QSize(pixelSize, pixelSize), &mPreviewPlugins);
503         // KJobWidgets::setWindow(job, qApp->activeWindow());
504         connect(job, SIGNAL(gotPreview(KFileItem, QPixmap)), this, SLOT(slotGotPreview(KFileItem, QPixmap)));
505         connect(job, SIGNAL(failed(KFileItem)), this, SLOT(emitThumbnailLoadingFailed()));
506         addSubjob(job);
507     }
508 }
509 
startCreatingThumbnail(const QString & pixPath)510 void ThumbnailProvider::startCreatingThumbnail(const QString &pixPath)
511 {
512     LOG("Creating thumbnail from" << pixPath);
513     // If mPreviousThumbnailGenerator is already working on our current item
514     // its thumbnail will be passed to sThumbnailWriter when ready. So we
515     // connect mPreviousThumbnailGenerator's signal "finished" to determineNextIcon
516     // which will load the thumbnail from sThumbnailWriter or from disk
517     // (because we re-add mCurrentItem to mItems).
518     if (mPreviousThumbnailGenerator && !mPreviousThumbnailGenerator->isStopped() && mOriginalUri == mPreviousThumbnailGenerator->originalUri()
519         && mOriginalTime == mPreviousThumbnailGenerator->originalTime() && mOriginalFileSize == mPreviousThumbnailGenerator->originalFileSize()
520         && mCurrentItem.mimetype() == mPreviousThumbnailGenerator->originalMimeType()) {
521         connect(mPreviousThumbnailGenerator, SIGNAL(finished()), SLOT(determineNextIcon()));
522         mItems.prepend(mCurrentItem);
523         return;
524     }
525     mThumbnailGenerator->load(mOriginalUri, mOriginalTime, mOriginalFileSize, mCurrentItem.mimetype(), pixPath, mThumbnailPath, mThumbnailGroup);
526 }
527 
slotGotPreview(const KFileItem & item,const QPixmap & pixmap)528 void ThumbnailProvider::slotGotPreview(const KFileItem &item, const QPixmap &pixmap)
529 {
530     if (mCurrentItem.isNull()) {
531         // This can happen if current item has been removed by removeItems()
532         return;
533     }
534     LOG(mCurrentItem.url());
535     QSize size;
536     Q_EMIT thumbnailLoaded(item, pixmap, size, mOriginalFileSize);
537 }
538 
emitThumbnailLoaded(const QImage & img,const QSize & size)539 void ThumbnailProvider::emitThumbnailLoaded(const QImage &img, const QSize &size)
540 {
541     if (mCurrentItem.isNull()) {
542         // This can happen if current item has been removed by removeItems()
543         return;
544     }
545     LOG(mCurrentItem.url());
546     QPixmap thumb = QPixmap::fromImage(img);
547     Q_EMIT thumbnailLoaded(mCurrentItem, thumb, size, mOriginalFileSize);
548 }
549 
emitThumbnailLoadingFailed()550 void ThumbnailProvider::emitThumbnailLoadingFailed()
551 {
552     if (mCurrentItem.isNull()) {
553         // This can happen if current item has been removed by removeItems()
554         return;
555     }
556     LOG(mCurrentItem.url());
557     Q_EMIT thumbnailLoadingFailed(mCurrentItem);
558 }
559 
isThumbnailWriterEmpty()560 bool ThumbnailProvider::isThumbnailWriterEmpty()
561 {
562     return sThumbnailWriter->isEmpty();
563 }
564 
565 } // namespace
566