diff --git a/cockatrice/CMakeLists.txt b/cockatrice/CMakeLists.txt index c25b319db..846167d93 100644 --- a/cockatrice/CMakeLists.txt +++ b/cockatrice/CMakeLists.txt @@ -67,7 +67,10 @@ set(cockatrice_SOURCES src/client/ui/line_edit_completer.cpp src/client/ui/phases_toolbar.cpp src/client/ui/picture_loader/picture_loader.cpp + src/client/ui/picture_loader/picture_loader_request_status_display_widget.cpp + src/client/ui/picture_loader/picture_loader_status_bar.cpp src/client/ui/picture_loader/picture_loader_worker.cpp + src/client/ui/picture_loader/picture_loader_worker_work.cpp src/client/ui/picture_loader/picture_to_load.cpp src/client/ui/pixel_map_generator.cpp src/client/ui/theme_manager.cpp diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader.cpp b/cockatrice/src/client/ui/picture_loader/picture_loader.cpp index 61ea8d0ad..f0a0113fe 100644 --- a/cockatrice/src/client/ui/picture_loader/picture_loader.cpp +++ b/cockatrice/src/client/ui/picture_loader/picture_loader.cpp @@ -7,12 +7,14 @@ #include #include #include +#include #include #include #include #include #include #include +#include #include #include #include @@ -27,6 +29,16 @@ PictureLoader::PictureLoader() : QObject(nullptr) connect(&SettingsCache::instance(), &SettingsCache::picDownloadChanged, this, &PictureLoader::picDownloadChanged); connect(worker, &PictureLoaderWorker::imageLoaded, this, &PictureLoader::imageLoaded); + + statusBar = new PictureLoaderStatusBar(nullptr); + QMainWindow *mainWindow = qobject_cast(QApplication::activeWindow()); + if (mainWindow) { + mainWindow->statusBar()->addPermanentWidget(statusBar); + } + + connect(worker, &PictureLoaderWorker::imageLoadQueued, statusBar, &PictureLoaderStatusBar::addQueuedImageLoad); + connect(worker, &PictureLoaderWorker::imageLoadSuccessful, statusBar, + &PictureLoaderStatusBar::addSuccessfulImageLoad); } PictureLoader::~PictureLoader() diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader.h b/cockatrice/src/client/ui/picture_loader/picture_loader.h index e2769f567..87ebfa862 100644 --- a/cockatrice/src/client/ui/picture_loader/picture_loader.h +++ b/cockatrice/src/client/ui/picture_loader/picture_loader.h @@ -2,6 +2,7 @@ #define PICTURELOADER_H #include "../../../game/cards/card_info.h" +#include "picture_loader_status_bar.h" #include "picture_loader_worker.h" #include @@ -27,6 +28,7 @@ private: void operator=(PictureLoader const &); PictureLoaderWorker *worker; + PictureLoaderStatusBar *statusBar; public: static void getPixmap(QPixmap &pixmap, CardInfoPtr card, QSize size); diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.cpp b/cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.cpp new file mode 100644 index 000000000..3647eb71e --- /dev/null +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.cpp @@ -0,0 +1,32 @@ +#include "picture_loader_request_status_display_widget.h" + +PictureLoaderRequestStatusDisplayWidget::PictureLoaderRequestStatusDisplayWidget(QWidget *parent, + const QUrl &_url, + PictureLoaderWorkerWork *worker) + : QWidget(parent) +{ + layout = new QHBoxLayout(this); + + if (worker->cardToDownload.getCard()) { + name = new QLabel(this); + name->setText(worker->cardToDownload.getCard()->getName()); + setShortname = new QLabel(this); + setShortname->setText(worker->cardToDownload.getSetName()); + providerId = new QLabel(this); + providerId->setText(worker->cardToDownload.getCard()->getProperty("uuid")); + + layout->addWidget(name); + layout->addWidget(setShortname); + layout->addWidget(providerId); + } + + startTime = new QLabel(QDateTime::currentDateTime().toString(), this); + elapsedTime = new QLabel("0", this); + finished = new QLabel("False", this); + url = new QLabel(_url.toString(), this); + + layout->addWidget(startTime); + layout->addWidget(elapsedTime); + layout->addWidget(finished); + layout->addWidget(url); +} \ No newline at end of file diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.h b/cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.h new file mode 100644 index 000000000..01eee1b48 --- /dev/null +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.h @@ -0,0 +1,66 @@ +#ifndef PICTURE_LOADER_REQUEST_STATUS_DISPLAY_WIDGET_H +#define PICTURE_LOADER_REQUEST_STATUS_DISPLAY_WIDGET_H +#include "picture_loader_worker_work.h" + +#include +#include +#include + +class PictureLoaderRequestStatusDisplayWidget : public QWidget +{ + Q_OBJECT +public: + PictureLoaderRequestStatusDisplayWidget(QWidget *parent, const QUrl &url, PictureLoaderWorkerWork *worker); + PictureLoaderWorkerWork *worker; + + void setFinished() + { + finished->setText("True"); + update(); + repaint(); + } + + bool getFinished() const + { + return finished->text() == "True"; + } + + void setElapsedTime(const QString &_elapsedTime) const + { + elapsedTime->setText(_elapsedTime); + } + + int queryElapsedSeconds() + { + if (!finished) { + int elapsedSeconds = QDateTime::fromString(startTime->text()).secsTo(QDateTime::currentDateTime()); + elapsedTime->setText(QString::number(elapsedSeconds)); + update(); + repaint(); + return elapsedSeconds; + } + return elapsedTime->text().toInt(); + } + + QString getStartTime() const + { + return startTime->text(); + } + + QString getUrl() const + { + return url->text(); + } + +private: + QHBoxLayout *layout; + QLabel *name; + QLabel *setShortname; + QLabel *providerId; + QLabel *startTime; + QLabel *elapsedTime; + QLabel *finished; + QLabel *url; +}; + +#endif // PICTURE_LOADER_REQUEST_STATUS_DISPLAY_WIDGET_H diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.cpp b/cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.cpp new file mode 100644 index 000000000..67b50a1f2 --- /dev/null +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.cpp @@ -0,0 +1,58 @@ +#include "picture_loader_status_bar.h" + +#include "picture_loader_request_status_display_widget.h" + +PictureLoaderStatusBar::PictureLoaderStatusBar(QWidget *parent) : QWidget(parent) +{ + layout = new QHBoxLayout(this); + progressBar = new QProgressBar(this); + progressBar->setMaximum(0); + progressBar->setFormat("%v/%m"); + layout->addWidget(progressBar); + + loadLog = new SettingsButtonWidget(this); + layout->addWidget(loadLog); + + cleaner = new QTimer(this); + cleaner->setInterval(1000); + connect(cleaner, &QTimer::timeout, this, &PictureLoaderStatusBar::cleanOldEntries); + cleaner->start(); + + setLayout(layout); +} + +void PictureLoaderStatusBar::cleanOldEntries() +{ + if (!loadLog || !loadLog->popup) { + return; + } + for (PictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : + loadLog->popup->findChildren()) { + statusDisplayWidget->queryElapsedSeconds(); + if (statusDisplayWidget->getFinished() && + QDateTime::fromString(statusDisplayWidget->getStartTime()).secsTo(QDateTime::currentDateTime()) > 10) { + loadLog->removeSettingsWidget(statusDisplayWidget); + progressBar->setMaximum(progressBar->maximum() - 1); + progressBar->setValue(progressBar->value() - 1); + } + } +} + +void PictureLoaderStatusBar::addQueuedImageLoad(const QUrl &url, PictureLoaderWorkerWork *worker) +{ + loadLog->addSettingsWidget(new PictureLoaderRequestStatusDisplayWidget(loadLog, url, worker)); + progressBar->setMaximum(progressBar->maximum() + 1); +} + +void PictureLoaderStatusBar::addSuccessfulImageLoad(const QUrl &url, PictureLoaderWorkerWork *worker) +{ + Q_UNUSED(worker) + progressBar->setValue(progressBar->value() + 1); + for (PictureLoaderRequestStatusDisplayWidget *statusDisplayWidget : + loadLog->popup->findChildren()) { + if (statusDisplayWidget->getUrl() == url.toString()) { + statusDisplayWidget->queryElapsedSeconds(); + statusDisplayWidget->setFinished(); + } + } +} \ No newline at end of file diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.h b/cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.h new file mode 100644 index 000000000..6a507cbb0 --- /dev/null +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.h @@ -0,0 +1,29 @@ +#ifndef PICTURE_LOADER_STATUS_BAR_H +#define PICTURE_LOADER_STATUS_BAR_H + +#include "../widgets/quick_settings/settings_button_widget.h" +#include "picture_loader_worker_work.h" + +#include +#include +#include + +class PictureLoaderStatusBar : public QWidget +{ + Q_OBJECT +public: + explicit PictureLoaderStatusBar(QWidget *parent); + +public slots: + void addQueuedImageLoad(const QUrl &url, PictureLoaderWorkerWork *worker); + void addSuccessfulImageLoad(const QUrl &url, PictureLoaderWorkerWork *worker); + void cleanOldEntries(); + +private: + QHBoxLayout *layout; + QProgressBar *progressBar; + SettingsButtonWidget *loadLog; + QTimer *cleaner; +}; + +#endif // PICTURE_LOADER_STATUS_BAR_H diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_worker.cpp b/cockatrice/src/client/ui/picture_loader/picture_loader_worker.cpp index fce81fe6a..5829539cb 100644 --- a/cockatrice/src/client/ui/picture_loader/picture_loader_worker.cpp +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_worker.cpp @@ -2,13 +2,15 @@ #include "../../../game/cards/card_database_manager.h" #include "../../../settings/cache_settings.h" +#include "picture_loader_worker_work.h" -#include #include +#include #include #include #include #include +#include // Card back returned by gatherer when card is not found QStringList PictureLoaderWorker::md5Blacklist = QStringList() << "db0c48db407a907c16ade38de048a441"; @@ -19,11 +21,8 @@ PictureLoaderWorker::PictureLoaderWorker() picDownload(SettingsCache::instance().getPicDownload()), downloadRunning(false), loadQueueRunning(false), overrideAllCardArtWithPersonalPreference(SettingsCache::instance().getOverrideAllCardArtWithPersonalPreference()) { - connect(this, &PictureLoaderWorker::startLoadQueue, this, &PictureLoaderWorker::processLoadQueue, - Qt::QueuedConnection); - connect(&SettingsCache::instance(), &SettingsCache::picsPathChanged, this, &PictureLoaderWorker::picsPathChanged); - connect(&SettingsCache::instance(), &SettingsCache::picDownloadChanged, this, - &PictureLoaderWorker::picDownloadChanged); + connect(&SettingsCache::instance(), SIGNAL(picsPathChanged()), this, SLOT(picsPathChanged())); + connect(&SettingsCache::instance(), SIGNAL(picDownloadChanged()), this, SLOT(picDownloadChanged())); connect(&SettingsCache::instance(), &SettingsCache::overrideAllCardArtWithPersonalPreferenceChanged, this, &PictureLoaderWorker::setOverrideAllCardArtWithPersonalPreference); @@ -34,18 +33,17 @@ PictureLoaderWorker::PictureLoaderWorker() #if (QT_VERSION >= QT_VERSION_CHECK(5, 15, 0)) networkManager->setTransferTimeout(); #endif - auto cache = new QNetworkDiskCache(this); + cache = new QNetworkDiskCache(this); cache->setCacheDirectory(SettingsCache::instance().getNetworkCachePath()); cache->setMaximumCacheSize(1024L * 1024L * static_cast(SettingsCache::instance().getNetworkCacheSizeInMB())); // Note: the settings is in MB, but QNetworkDiskCache uses bytes - connect(&SettingsCache::instance(), &SettingsCache::networkCacheSizeChanged, cache, - [cache](int newSizeInMB) { cache->setMaximumCacheSize(1024L * 1024L * static_cast(newSizeInMB)); }); + connect(&SettingsCache::instance(), &SettingsCache::networkCacheSizeChanged, this, + [this](int newSizeInMB) { cache->setMaximumCacheSize(1024L * 1024L * static_cast(newSizeInMB)); }); networkManager->setCache(cache); // Use a ManualRedirectPolicy since we keep track of redirects in picDownloadFinished // We can't use NoLessSafeRedirectPolicy because it is not applied with AlwaysCache networkManager->setRedirectPolicy(QNetworkRequest::ManualRedirectPolicy); - connect(networkManager, &QNetworkAccessManager::finished, this, &PictureLoaderWorker::picDownloadFinished); cacheFilePath = SettingsCache::instance().getRedirectCachePath() + REDIRECT_CACHE_FILENAME; loadRedirectCache(); @@ -57,244 +55,104 @@ PictureLoaderWorker::PictureLoaderWorker() pictureLoaderThread = new QThread; pictureLoaderThread->start(QThread::LowPriority); moveToThread(pictureLoaderThread); + + connect(&requestTimer, &QTimer::timeout, this, &PictureLoaderWorker::processQueuedRequests); + requestTimer.setInterval(1000); + requestTimer.start(); } PictureLoaderWorker::~PictureLoaderWorker() { + saveRedirectCache(); pictureLoaderThread->deleteLater(); } -void PictureLoaderWorker::processLoadQueue() +void PictureLoaderWorker::queueRequest(const QUrl &url, PictureLoaderWorkerWork *worker) { - if (loadQueueRunning) { - return; - } - - loadQueueRunning = true; - while (true) { - mutex.lock(); - if (loadQueue.isEmpty()) { - mutex.unlock(); - loadQueueRunning = false; - return; - } - cardBeingLoaded = loadQueue.takeFirst(); - mutex.unlock(); - - QString setName = cardBeingLoaded.getSetName(); - QString cardName = cardBeingLoaded.getCard()->getName(); - QString correctedCardName = cardBeingLoaded.getCard()->getCorrectedName(); - - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardName << " set: " << setName << "]: Trying to load picture"; - - // FIXME: This is a hack so that to keep old Cockatrice behavior - // (ignoring provider ID) when the "override all card art with personal - // preference" is set. - // - // Figure out a proper way to integrate the two systems at some point. - // - // Note: need to go through a member for - // overrideAllCardArtWithPersonalPreference as reading from the - // SettingsCache instance from the PictureLoaderWorker thread could - // cause race conditions. - // - // XXX: Reading from the CardDatabaseManager instance from the - // PictureLoaderWorker thread might not be safe either - bool searchCustomPics = overrideAllCardArtWithPersonalPreference || - CardDatabaseManager::getInstance()->isProviderIdForPreferredPrinting( - cardName, cardBeingLoaded.getCard()->getPixmapCacheKey()); - if (searchCustomPics && cardImageExistsOnDisk(setName, correctedCardName, searchCustomPics)) { - continue; - } - - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardName << " set: " << setName << "]: No custom picture, trying to download"; - cardsToDownload.append(cardBeingLoaded); - cardBeingLoaded.clear(); - if (!downloadRunning) { - startNextPicDownload(); - } - } -} - -bool PictureLoaderWorker::cardImageExistsOnDisk(QString &setName, QString &correctedCardname, bool searchCustomPics) -{ - QImage image; - QImageReader imgReader; - imgReader.setDecideFormatFromContent(true); - QList picsPaths = QList(); - - if (searchCustomPics) { - QDirIterator it(customPicsPath, QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); - - // Recursively check all subdirectories of the CUSTOM folder - while (it.hasNext()) { - QString thisPath(it.next()); - QFileInfo thisFileInfo(thisPath); - - if (thisFileInfo.isFile() && - (thisFileInfo.fileName() == correctedCardname || thisFileInfo.completeBaseName() == correctedCardname || - thisFileInfo.baseName() == correctedCardname)) { - picsPaths << thisPath; // Card found in the CUSTOM directory, somewhere - } - } - } - - if (!setName.isEmpty()) { - picsPaths << picsPath + "/" + setName + "/" + correctedCardname - // We no longer store downloaded images there, but don't just ignore - // stuff that old versions have put there. - << picsPath + "/downloadedPics/" + setName + "/" + correctedCardname; - } - - // Iterates through the list of paths, searching for images with the desired - // name with any QImageReader-supported - // extension - for (const auto &_picsPath : picsPaths) { - imgReader.setFileName(_picsPath); - if (imgReader.read(&image)) { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << correctedCardname << " set: " << setName << "]: Picture found on disk."; - imageLoaded(cardBeingLoaded.getCard(), image); - return true; - } - imgReader.setFileName(_picsPath + ".full"); - if (imgReader.read(&image)) { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << correctedCardname << " set: " << setName << "]: Picture.full found on disk."; - imageLoaded(cardBeingLoaded.getCard(), image); - return true; - } - imgReader.setFileName(_picsPath + ".xlhq"); - if (imgReader.read(&image)) { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << correctedCardname << " set: " << setName << "]: Picture.xlhq found on disk."; - imageLoaded(cardBeingLoaded.getCard(), image); - return true; - } - } - - return false; -} - -void PictureLoaderWorker::startNextPicDownload() -{ - if (cardsToDownload.isEmpty()) { - cardBeingDownloaded.clear(); - downloadRunning = false; - return; - } - - downloadRunning = true; - - cardBeingDownloaded = cardsToDownload.takeFirst(); - - QString picUrl = cardBeingDownloaded.getCurrentUrl(); - - if (picUrl.isEmpty()) { - downloadRunning = false; - picDownloadFailed(); - } else { - QUrl url(picUrl); - qCDebug(PictureLoaderWorkerLog).nospace() << "[card: " << cardBeingDownloaded.getCard()->getCorrectedName() - << " set: " << cardBeingDownloaded.getSetName() - << "]: Trying to fetch picture from url " << url.toDisplayString(); - makeRequest(url); - } -} - -void PictureLoaderWorker::picDownloadFailed() -{ - /* Take advantage of short circuiting here to call the nextUrl until one - is not available. Only once nextUrl evaluates to false will this move - on to nextSet. If the Urls for a particular card are empty, this will - effectively go through the sets for that card. */ - if (cardBeingDownloaded.nextUrl() || cardBeingDownloaded.nextSet()) { - mutex.lock(); - loadQueue.prepend(cardBeingDownloaded); - mutex.unlock(); - } else { - qCWarning(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getCorrectedName() - << " set: " << cardBeingDownloaded.getSetName() << "]: Picture NOT found, " - << (picDownload ? "download failed" : "downloads disabled") - << ", no more url combinations to try: BAILING OUT"; - imageLoaded(cardBeingDownloaded.getCard(), QImage()); - cardBeingDownloaded.clear(); - } - emit startLoadQueue(); -} - -bool PictureLoaderWorker::imageIsBlackListed(const QByteArray &picData) -{ - QString md5sum = QCryptographicHash::hash(picData, QCryptographicHash::Md5).toHex(); - return md5Blacklist.contains(md5sum); -} - -QNetworkReply *PictureLoaderWorker::makeRequest(const QUrl &url) -{ - // Check if the redirect is cached QUrl cachedRedirect = getCachedRedirect(url); if (!cachedRedirect.isEmpty()) { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getCorrectedName() - << " set: " << cardBeingDownloaded.getSetName() << "]: Using cached redirect for " << url.toDisplayString() - << " to " << cachedRedirect.toDisplayString(); - return makeRequest(cachedRedirect); // Use the cached redirect + queueRequest(cachedRedirect, worker); + } + if (cache->metaData(url).isValid()) { + makeRequest(url, worker); + } else { + requestLoadQueue.append(qMakePair(url, worker)); + emit imageLoadQueued(url, worker); + } +} + +QNetworkReply *PictureLoaderWorker::makeRequest(const QUrl &url, PictureLoaderWorkerWork *worker) +{ + // Check for cached redirects + QUrl cachedRedirect = getCachedRedirect(url); + if (!cachedRedirect.isEmpty()) { + emit imageLoadSuccessful(url, worker); + return makeRequest(cachedRedirect, worker); } QNetworkRequest req(url); - - // QNetworkDiskCache leaks file descriptors when downloading compressed - // files, see https://bugreports.qt.io/browse/QTBUG-135641 - // - // We can set the Accept-Encoding header manually to disable Qt's automatic - // response decompression, but then we would have to deal with decompression - // ourselves. - // - // Since we are dowloading images that are usually stored in a - // compressed format (e.g. jpeg or webp), it's not clear compression at the - // HTTP level helps; in fact, images are typically returned without - // compression. Redirects, on the other hand, are compressed and cause file - // descriptor leaks -- but since redirects have no payload, we don't really - // care either. - // - // In the end, just do the simple thing and disable HTTP compression. - req.setRawHeader("accept-encoding", "identity"); - if (!picDownload) { req.setAttribute(QNetworkRequest::CacheLoadControlAttribute, QNetworkRequest::AlwaysCache); } QNetworkReply *reply = networkManager->get(req); - connect(reply, &QNetworkReply::finished, this, [this, reply, url]() { + // Connect reply handling + connect(reply, &QNetworkReply::finished, this, [this, reply, url, worker]() { QVariant redirectTarget = reply->attribute(QNetworkRequest::RedirectionTargetAttribute); - if (redirectTarget.isValid()) { QUrl redirectUrl = redirectTarget.toUrl(); if (redirectUrl.isRelative()) { redirectUrl = url.resolved(redirectUrl); } - cacheRedirect(url, redirectUrl); - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getCorrectedName() - << " set: " << cardBeingDownloaded.getSetName() << "]: Caching redirect from " << url.toDisplayString() - << " to " << redirectUrl.toDisplayString(); } + if (reply->error() == QNetworkReply::NoError) { + worker->picDownloadFinished(reply); + emit imageLoadSuccessful(url, worker); + + // If we hit a cached image, we get to make another request for free. + if (reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool()) { + if (!requestLoadQueue.isEmpty()) { + auto request = requestLoadQueue.takeFirst(); + makeRequest(request.first, request.second); + } + } + } else if (reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt() == 429) { + qInfo() << "Too many requests."; + } reply->deleteLater(); }); return reply; } +void PictureLoaderWorker::processQueuedRequests() +{ + for (int i = 0; i < 10; i++) { + if (!requestLoadQueue.isEmpty()) { + auto request = requestLoadQueue.takeFirst(); + makeRequest(request.first, request.second); + } + } +} + +void PictureLoaderWorker::enqueueImageLoad(const CardInfoPtr &card) +{ + auto worker = new PictureLoaderWorkerWork(this, card); + Q_UNUSED(worker); +} + +void PictureLoaderWorker::imageLoadedSuccessfully(CardInfoPtr card, const QImage &image) +{ + emit imageLoaded(std::move(card), image); +} + void PictureLoaderWorker::cacheRedirect(const QUrl &originalUrl, const QUrl &redirectUrl) { redirectCache[originalUrl] = qMakePair(redirectUrl, QDateTime::currentDateTimeUtc()); - saveRedirectCache(); + // saveRedirectCache(); } QUrl PictureLoaderWorker::getCachedRedirect(const QUrl &originalUrl) const @@ -353,129 +211,6 @@ void PictureLoaderWorker::cleanStaleEntries() } } -void PictureLoaderWorker::picDownloadFinished(QNetworkReply *reply) -{ - bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); - - if (reply->error()) { - if (isFromCache) { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getName() << " set: " << cardBeingDownloaded.getSetName() - << "]: Removing corrupted cache file for url " << reply->url().toDisplayString() << " and retrying (" - << reply->errorString() << ")"; - - networkManager->cache()->remove(reply->url()); - - makeRequest(reply->url()); - } else { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getName() << " set: " << cardBeingDownloaded.getSetName() - << "]: " << (picDownload ? "Download" : "Cache search") << " failed for url " - << reply->url().toDisplayString() << " (" << reply->errorString() << ")"; - - picDownloadFailed(); - startNextPicDownload(); - } - - reply->deleteLater(); - return; - } - - // List of status codes from https://doc.qt.io/qt-6/qnetworkreply.html#redirected - int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); - if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 305 || statusCode == 307 || - statusCode == 308) { - QUrl redirectUrl = reply->header(QNetworkRequest::LocationHeader).toUrl(); - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getName() << " set: " << cardBeingDownloaded.getSetName() - << "]: following " << (isFromCache ? "cached redirect" : "redirect") << " to " - << redirectUrl.toDisplayString(); - makeRequest(redirectUrl); - reply->deleteLater(); - return; - } - - // peek is used to keep the data in the buffer for use by QImageReader - const QByteArray &picData = reply->peek(reply->size()); - - if (imageIsBlackListed(picData)) { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getName() << " set: " << cardBeingDownloaded.getSetName() - << "]: Picture found, but blacklisted, will consider it as not found"; - - picDownloadFailed(); - reply->deleteLater(); - startNextPicDownload(); - return; - } - - QImage testImage; - - QImageReader imgReader; - imgReader.setDecideFormatFromContent(true); - imgReader.setDevice(reply); - - bool logSuccessMessage = false; - - static const int riffHeaderSize = 12; // RIFF_HEADER_SIZE from webp/format_constants.h - auto replyHeader = reply->peek(riffHeaderSize); - - if (replyHeader.startsWith("RIFF") && replyHeader.endsWith("WEBP")) { - auto imgBuf = QBuffer(this); - imgBuf.setData(reply->readAll()); - - auto movie = QMovie(&imgBuf); - movie.start(); - movie.stop(); - - imageLoaded(cardBeingDownloaded.getCard(), movie.currentImage()); - logSuccessMessage = true; - } else if (imgReader.read(&testImage)) { - imageLoaded(cardBeingDownloaded.getCard(), testImage); - logSuccessMessage = true; - } else { - qCDebug(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getName() << " set: " << cardBeingDownloaded.getSetName() - << "]: Possible " << (isFromCache ? "cached" : "downloaded") << " picture at " - << reply->url().toDisplayString() << " could not be loaded: " << reply->errorString(); - - picDownloadFailed(); - } - - if (logSuccessMessage) { - qCInfo(PictureLoaderWorkerLog).nospace() - << "[card: " << cardBeingDownloaded.getCard()->getName() << " set: " << cardBeingDownloaded.getSetName() - << "]: Image successfully " << (isFromCache ? "loaded from cached" : "downloaded from") << " url " - << reply->url().toDisplayString(); - } - - reply->deleteLater(); - startNextPicDownload(); -} - -void PictureLoaderWorker::enqueueImageLoad(CardInfoPtr card) -{ - QMutexLocker locker(&mutex); - - // avoid queueing the same card more than once - if (!card || card == cardBeingLoaded.getCard() || card == cardBeingDownloaded.getCard()) { - return; - } - - for (const PictureToLoad &pic : loadQueue) { - if (pic.getCard() == card) - return; - } - - for (const PictureToLoad &pic : cardsToDownload) { - if (pic.getCard() == card) - return; - } - - loadQueue.append(PictureToLoad(card)); - emit startLoadQueue(); -} - void PictureLoaderWorker::picDownloadChanged() { QMutexLocker locker(&mutex); diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_worker.h b/cockatrice/src/client/ui/picture_loader/picture_loader_worker.h index 47d2cca5a..2e0735dbb 100644 --- a/cockatrice/src/client/ui/picture_loader/picture_loader_worker.h +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_worker.h @@ -1,13 +1,18 @@ #ifndef PICTURE_LOADER_WORKER_H #define PICTURE_LOADER_WORKER_H +#include "../../../game/cards/card_database.h" #include "../../../game/cards/card_info.h" +#include "picture_loader_worker_work.h" #include "picture_to_load.h" #include #include #include +#include #include +#include +#include #define REDIRECT_HEADER_NAME "redirects" #define REDIRECT_ORIGINAL_URL "original" @@ -17,16 +22,23 @@ inline Q_LOGGING_CATEGORY(PictureLoaderWorkerLog, "picture_loader.worker"); +class PictureLoaderWorkerWork; class PictureLoaderWorker : public QObject { Q_OBJECT public: explicit PictureLoaderWorker(); ~PictureLoaderWorker() override; + void queueRequest(const QUrl &url, PictureLoaderWorkerWork *worker); - void enqueueImageLoad(CardInfoPtr card); + void enqueueImageLoad(const CardInfoPtr &card); void clearNetworkCache(); +public slots: + QNetworkReply *makeRequest(const QUrl &url, PictureLoaderWorkerWork *workThread); + void processQueuedRequests(); + void imageLoadedSuccessfully(CardInfoPtr card, const QImage &image); + private: static QStringList md5Blacklist; @@ -35,6 +47,7 @@ private: QList loadQueue; QMutex mutex; QNetworkAccessManager *networkManager; + QNetworkDiskCache *cache; QHash> redirectCache; // Stores redirect and timestamp QString cacheFilePath; // Path to persistent storage static constexpr int CacheTTLInDays = 30; // TODO: Make user configurable @@ -42,18 +55,11 @@ private: PictureToLoad cardBeingLoaded; PictureToLoad cardBeingDownloaded; bool picDownload, downloadRunning, loadQueueRunning; + QQueue> requestLoadQueue; + QList> requestQueue; + QTimer requestTimer; // Timer for processing delayed requests bool overrideAllCardArtWithPersonalPreference; - void startNextPicDownload(); - /** Emit the `imageLoaded` signal and return `true` if a picture is found on - disk, return `false` otherwise. - - If `searchCustomPics` is `true`, the CUSTOM folder is searched for a - matching image first; otherwise, only the set-based folders are used. */ - bool cardImageExistsOnDisk(QString &setName, QString &correctedCardName, bool searchCustomPics); - - bool imageIsBlackListed(const QByteArray &); - QNetworkReply *makeRequest(const QUrl &url); void cacheRedirect(const QUrl &originalUrl, const QUrl &redirectUrl); QUrl getCachedRedirect(const QUrl &originalUrl) const; void loadRedirectCache(); @@ -61,18 +67,15 @@ private: void cleanStaleEntries(); private slots: - void picDownloadFinished(QNetworkReply *reply); - void picDownloadFailed(); - void picDownloadChanged(); void picsPathChanged(); void setOverrideAllCardArtWithPersonalPreference(bool _overrideAllCardArtWithPersonalPreference); -public slots: - void processLoadQueue(); signals: void startLoadQueue(); void imageLoaded(CardInfoPtr card, const QImage &image); + void imageLoadQueued(const QUrl &url, PictureLoaderWorkerWork *worker); + void imageLoadSuccessful(const QUrl &url, PictureLoaderWorkerWork *worker); }; #endif // PICTURE_LOADER_WORKER_H diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.cpp b/cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.cpp new file mode 100644 index 000000000..bc9787767 --- /dev/null +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.cpp @@ -0,0 +1,236 @@ +#include "picture_loader_worker_work.h" + +#include "../../../game/cards/card_database_manager.h" +#include "../../../settings/cache_settings.h" +#include "picture_loader_worker.h" + +#include +#include +#include +#include +#include +#include +#include + +// Card back returned by gatherer when card is not found +QStringList PictureLoaderWorkerWork::md5Blacklist = QStringList() << "db0c48db407a907c16ade38de048a441"; + +PictureLoaderWorkerWork::PictureLoaderWorkerWork(PictureLoaderWorker *_worker, const CardInfoPtr &toLoad) + : QThread(nullptr), worker(_worker), cardToDownload(toLoad) +{ + connect(this, &PictureLoaderWorkerWork::requestImageDownload, worker, &PictureLoaderWorker::queueRequest, + Qt::QueuedConnection); + connect(this, &PictureLoaderWorkerWork::imageLoaded, worker, &PictureLoaderWorker::imageLoadedSuccessfully, + Qt::QueuedConnection); + pictureLoaderThread = new QThread; + pictureLoaderThread->start(QThread::LowPriority); + moveToThread(pictureLoaderThread); + startNextPicDownload(); +} + +PictureLoaderWorkerWork::~PictureLoaderWorkerWork() +{ + pictureLoaderThread->deleteLater(); +} + +bool PictureLoaderWorkerWork::cardImageExistsOnDisk(QString &setName, QString &correctedCardname) +{ + QImage image; + QImageReader imgReader; + imgReader.setDecideFormatFromContent(true); + QList picsPaths = QList(); + QDirIterator it(SettingsCache::instance().getCustomPicsPath(), + QDirIterator::Subdirectories | QDirIterator::FollowSymlinks); + + // Recursively check all subdirectories of the CUSTOM folder + while (it.hasNext()) { + QString thisPath(it.next()); + QFileInfo thisFileInfo(thisPath); + + if (thisFileInfo.isFile() && + (thisFileInfo.fileName() == correctedCardname || thisFileInfo.completeBaseName() == correctedCardname || + thisFileInfo.baseName() == correctedCardname)) { + picsPaths << thisPath; // Card found in the CUSTOM directory, somewhere + } + } + + if (!setName.isEmpty()) { + picsPaths << SettingsCache::instance().getPicsPath() + "/" + setName + "/" + correctedCardname + // We no longer store downloaded images there, but don't just ignore + // stuff that old versions have put there. + << SettingsCache::instance().getPicsPath() + "/downloadedPics/" + setName + "/" + correctedCardname; + } + + // Iterates through the list of paths, searching for images with the desired + // name with any QImageReader-supported + // extension + for (const auto &_picsPath : picsPaths) { + imgReader.setFileName(_picsPath); + if (imgReader.read(&image)) { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << correctedCardname << " set: " << setName << "]: Picture found on disk."; + imageLoaded(cardToDownload.getCard(), image); + return true; + } + imgReader.setFileName(_picsPath + ".full"); + if (imgReader.read(&image)) { + qCDebug(PictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << correctedCardname + << " set: " << setName << "]: Picture.full found on disk."; + imageLoaded(cardToDownload.getCard(), image); + return true; + } + imgReader.setFileName(_picsPath + ".xlhq"); + if (imgReader.read(&image)) { + qCDebug(PictureLoaderWorkerWorkLog).nospace() << "PictureLoader: [card: " << correctedCardname + << " set: " << setName << "]: Picture.xlhq found on disk."; + imageLoaded(cardToDownload.getCard(), image); + return true; + } + } + + return false; +} + +void PictureLoaderWorkerWork::startNextPicDownload() +{ + QString picUrl = cardToDownload.getCurrentUrl(); + + if (picUrl.isEmpty()) { + downloadRunning = false; + picDownloadFailed(); + } else { + QUrl url(picUrl); + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: Trying to fetch picture from url " + << url.toDisplayString(); + emit requestImageDownload(url, this); + } +} + +void PictureLoaderWorkerWork::picDownloadFailed() +{ + /* Take advantage of short-circuiting here to call the nextUrl until one + is not available. Only once nextUrl evaluates to false will this move + on to nextSet. If the Urls for a particular card are empty, this will + effectively go through the sets for that card. */ + if (cardToDownload.nextUrl() || cardToDownload.nextSet()) { + startNextPicDownload(); + } else { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getCorrectedName() + << " set: " << cardToDownload.getSetName() << "]: Picture NOT found, " + << (picDownload ? "download failed" : "downloads disabled") + << ", no more url combinations to try: BAILING OUT"; + imageLoaded(cardToDownload.getCard(), QImage()); + } + emit startLoadQueue(); +} + +void PictureLoaderWorkerWork::picDownloadFinished(QNetworkReply *reply) +{ + bool isFromCache = reply->attribute(QNetworkRequest::SourceIsFromCacheAttribute).toBool(); + + if (reply->error()) { + if (isFromCache) { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getName() + << " set: " << cardToDownload.getSetName() << "]: Removing corrupted cache file for url " + << reply->url().toDisplayString() << " and retrying (" << reply->errorString() << ")"; + + networkManager->cache()->remove(reply->url()); + + requestImageDownload(reply->url(), this); + } else { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getName() + << " set: " << cardToDownload.getSetName() << "]: " << (picDownload ? "Download" : "Cache search") + << " failed for url " << reply->url().toDisplayString() << " (" << reply->errorString() << ")"; + + picDownloadFailed(); + startNextPicDownload(); + } + + reply->deleteLater(); + return; + } + + // List of status codes from https://doc.qt.io/qt-6/qnetworkreply.html#redirected + int statusCode = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); + if (statusCode == 301 || statusCode == 302 || statusCode == 303 || statusCode == 305 || statusCode == 307 || + statusCode == 308) { + QUrl redirectUrl = reply->header(QNetworkRequest::LocationHeader).toUrl(); + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getName() + << " set: " << cardToDownload.getSetName() << "]: following " + << (isFromCache ? "cached redirect" : "redirect") << " to " << redirectUrl.toDisplayString(); + requestImageDownload(redirectUrl, this); + reply->deleteLater(); + return; + } + + // peek is used to keep the data in the buffer for use by QImageReader + const QByteArray &picData = reply->peek(reply->size()); + + if (imageIsBlackListed(picData)) { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getName() + << " set: " << cardToDownload.getSetName() + << "]: Picture found, but blacklisted, will consider it as not found"; + + picDownloadFailed(); + reply->deleteLater(); + startNextPicDownload(); + return; + } + + QImage testImage; + + QImageReader imgReader; + imgReader.setDecideFormatFromContent(true); + imgReader.setDevice(reply); + + bool logSuccessMessage = false; + + static const int riffHeaderSize = 12; // RIFF_HEADER_SIZE from webp/format_constants.h + auto replyHeader = reply->peek(riffHeaderSize); + + if (replyHeader.startsWith("RIFF") && replyHeader.endsWith("WEBP")) { + auto imgBuf = QBuffer(this); + imgBuf.setData(reply->readAll()); + + auto movie = QMovie(&imgBuf); + movie.start(); + movie.stop(); + + imageLoaded(cardToDownload.getCard(), movie.currentImage()); + logSuccessMessage = true; + } else if (imgReader.read(&testImage)) { + imageLoaded(cardToDownload.getCard(), testImage); + logSuccessMessage = true; + } else { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getName() + << " set: " << cardToDownload.getSetName() << "]: Possible " << (isFromCache ? "cached" : "downloaded") + << " picture at " << reply->url().toDisplayString() << " could not be loaded: " << reply->errorString(); + + picDownloadFailed(); + } + + if (logSuccessMessage) { + qCDebug(PictureLoaderWorkerWorkLog).nospace() + << "PictureLoader: [card: " << cardToDownload.getCard()->getName() + << " set: " << cardToDownload.getSetName() << "]: Image successfully " + << (isFromCache ? "loaded from cached" : "downloaded from") << " url " << reply->url().toDisplayString(); + } else { + startNextPicDownload(); + } + + reply->deleteLater(); +} + +bool PictureLoaderWorkerWork::imageIsBlackListed(const QByteArray &picData) +{ + QString md5sum = QCryptographicHash::hash(picData, QCryptographicHash::Md5).toHex(); + return md5Blacklist.contains(md5sum); +} \ No newline at end of file diff --git a/cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.h b/cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.h new file mode 100644 index 000000000..bbc1564ec --- /dev/null +++ b/cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.h @@ -0,0 +1,50 @@ +#ifndef PICTURE_LOADER_WORKER_WORK_H +#define PICTURE_LOADER_WORKER_WORK_H + +#include "../../../game/cards/card_database.h" +#include "picture_loader_worker.h" +#include "picture_to_load.h" + +#include +#include +#include +#include +#include + +#define REDIRECT_HEADER_NAME "redirects" +#define REDIRECT_ORIGINAL_URL "original" +#define REDIRECT_URL "redirect" +#define REDIRECT_TIMESTAMP "timestamp" +#define REDIRECT_CACHE_FILENAME "cache.ini" + +inline Q_LOGGING_CATEGORY(PictureLoaderWorkerWorkLog, "picture_loader.worker"); + +class PictureLoaderWorker; +class PictureLoaderWorkerWork : public QThread +{ + Q_OBJECT +public: + explicit PictureLoaderWorkerWork(PictureLoaderWorker *worker, const CardInfoPtr &toLoad); + ~PictureLoaderWorkerWork() override; + PictureLoaderWorker *worker; + PictureToLoad cardToDownload; +public slots: + void picDownloadFinished(QNetworkReply *reply); + void picDownloadFailed(); + +private: + static QStringList md5Blacklist; + QThread *pictureLoaderThread; + QNetworkAccessManager *networkManager; + bool picDownload, downloadRunning, loadQueueRunning; + void startNextPicDownload(); + bool cardImageExistsOnDisk(QString &setName, QString &correctedCardName); + bool imageIsBlackListed(const QByteArray &); + +signals: + void startLoadQueue(); + void imageLoaded(CardInfoPtr card, const QImage &image); + void requestImageDownload(const QUrl &url, PictureLoaderWorkerWork *instance); +}; + +#endif // PICTURE_LOADER_WORKER_WORK_H diff --git a/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.cpp b/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.cpp index 0818050d8..17087c8bc 100644 --- a/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.cpp +++ b/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.cpp @@ -26,6 +26,11 @@ void SettingsButtonWidget::addSettingsWidget(QWidget *toAdd) const popup->addSettingsWidget(toAdd); } +void SettingsButtonWidget::removeSettingsWidget(QWidget *toRemove) const +{ + popup->removeSettingsWidget(toRemove); +} + void SettingsButtonWidget::setButtonIcon(QPixmap iconMap) { button->setIcon(iconMap); diff --git a/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.h b/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.h index 4234ed438..3483b7625 100644 --- a/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.h +++ b/cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.h @@ -13,6 +13,7 @@ class SettingsButtonWidget : public QWidget public: explicit SettingsButtonWidget(QWidget *parent = nullptr); void addSettingsWidget(QWidget *toAdd) const; + void removeSettingsWidget(QWidget *toRemove) const; void setButtonIcon(QPixmap iconMap); protected: @@ -25,6 +26,8 @@ private slots: private: QHBoxLayout *layout; QToolButton *button; + +public: SettingsPopupWidget *popup; }; diff --git a/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.cpp b/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.cpp index 258f1163e..9b2e55e0f 100644 --- a/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.cpp +++ b/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.cpp @@ -29,6 +29,12 @@ void SettingsPopupWidget::addSettingsWidget(QWidget *toAdd) const containerLayout->addWidget(toAdd); // Add to containerWidget's layout } +void SettingsPopupWidget::removeSettingsWidget(QWidget *toRemove) const +{ + containerLayout->removeWidget(toRemove); + toRemove->deleteLater(); +} + void SettingsPopupWidget::adjustSizeToFitScreen() { QScreen *screen = QApplication::screenAt(this->pos()); diff --git a/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.h b/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.h index d57c123c4..6b6157b45 100644 --- a/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.h +++ b/cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.h @@ -13,6 +13,7 @@ class SettingsPopupWidget : public QWidget public: explicit SettingsPopupWidget(QWidget *parent = nullptr); void addSettingsWidget(QWidget *toAdd) const; + void removeSettingsWidget(QWidget *toRemove) const; void adjustSizeToFitScreen(); signals: diff --git a/oracle/CMakeLists.txt b/oracle/CMakeLists.txt index 130dfdaab..ba5dd7284 100644 --- a/oracle/CMakeLists.txt +++ b/oracle/CMakeLists.txt @@ -21,8 +21,13 @@ set(oracle_SOURCES ../cockatrice/src/game/cards/card_database_manager.cpp ../cockatrice/src/game/cards/card_info.cpp ../cockatrice/src/client/ui/picture_loader/picture_loader.cpp + ../cockatrice/src/client/ui/picture_loader/picture_loader_request_status_display_widget.cpp + ../cockatrice/src/client/ui/picture_loader/picture_loader_status_bar.cpp ../cockatrice/src/client/ui/picture_loader/picture_loader_worker.cpp + ../cockatrice/src/client/ui/picture_loader/picture_loader_worker_work.cpp ../cockatrice/src/client/ui/picture_loader/picture_to_load.cpp + ../cockatrice/src/client/ui/widgets/quick_settings/settings_button_widget.cpp + ../cockatrice/src/client/ui/widgets/quick_settings/settings_popup_widget.cpp ../cockatrice/src/game/cards/card_database_parser/card_database_parser.cpp ../cockatrice/src/game/cards/card_database_parser/cockatrice_xml_3.cpp ../cockatrice/src/game/cards/card_database_parser/cockatrice_xml_4.cpp