mirror of
https://github.com/Cockatrice/Cockatrice.git
synced 2026-09-21 00:55:09 -07:00
Compare commits
8 commits
bc8146086b
...
8052676b29
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8052676b29 | ||
|
|
77ded1da97 | ||
|
|
7b82ca08da | ||
|
|
da307a82b3 | ||
|
|
e3820c3f34 | ||
|
|
1c6ee62393 | ||
|
|
fd82b140a8 | ||
|
|
5d025ca0bd |
27 changed files with 1117 additions and 55 deletions
|
|
@ -94,7 +94,7 @@ QStringMap &SoundEngine::getAvailableThemes()
|
|||
QDir dir;
|
||||
availableThemes.clear();
|
||||
|
||||
// load themes from user profile dir
|
||||
// Load themes from user profile dir
|
||||
|
||||
dir.setPath(SettingsCache::instance().getDataPath() + "/sounds");
|
||||
|
||||
|
|
@ -104,7 +104,7 @@ QStringMap &SoundEngine::getAvailableThemes()
|
|||
}
|
||||
}
|
||||
|
||||
// load themes from cockatrice system dir
|
||||
// Load themes from Cockatrice system dir
|
||||
dir.setPath(qApp->applicationDirPath() +
|
||||
#ifdef Q_OS_MAC
|
||||
"/../Resources/sounds"
|
||||
|
|
|
|||
|
|
@ -11,6 +11,7 @@
|
|||
#include <QDirIterator>
|
||||
#include <QFileInfo>
|
||||
#include <QMainWindow>
|
||||
#include <QMetaObject>
|
||||
#include <QMovie>
|
||||
#include <QNetworkRequest>
|
||||
#include <QPainter>
|
||||
|
|
@ -55,7 +56,14 @@ CardPictureLoader::CardPictureLoader() : QObject(nullptr)
|
|||
|
||||
CardPictureLoader::~CardPictureLoader()
|
||||
{
|
||||
worker->deleteLater();
|
||||
if (worker) {
|
||||
// Capture the thread first: shutdownThread() blocks until the worker has been freed by the
|
||||
// finished() -> deleteLater chain, after which the worker pointer must not be dereferenced.
|
||||
QThread *pictureLoaderThread = worker->workerThread();
|
||||
worker->shutdownThread();
|
||||
worker = nullptr;
|
||||
delete pictureLoaderThread;
|
||||
}
|
||||
}
|
||||
|
||||
void CardPictureLoader::getCardBackPixmap(QPixmap &pixmap, QSize size)
|
||||
|
|
@ -295,7 +303,15 @@ void CardPictureLoader::clearPixmapCache()
|
|||
|
||||
void CardPictureLoader::clearNetworkCache()
|
||||
{
|
||||
getInstance().worker->clearNetworkCache();
|
||||
auto &worker = *getInstance().worker;
|
||||
// The disk cache and redirect cache are owned by the worker thread; clearing them from the
|
||||
// UI thread would race with the worker's cache reads/writes. Block until the worker thread
|
||||
// has executed the clear so the "Cached card pictures have been reset." message is truthful.
|
||||
if (worker.isRunning()) {
|
||||
QMetaObject::invokeMethod(&worker, "clearNetworkCache", Qt::BlockingQueuedConnection);
|
||||
} else {
|
||||
worker.clearNetworkCache();
|
||||
}
|
||||
}
|
||||
|
||||
void CardPictureLoader::cacheCardPixmaps(const QList<ExactCard> &cards)
|
||||
|
|
|
|||
|
|
@ -16,13 +16,16 @@
|
|||
#include <utility>
|
||||
#include <version_string.h>
|
||||
|
||||
static constexpr int MAX_REQUESTS_PER_SEC = 10;
|
||||
static constexpr int MIN_HOST_QUOTA = 1; ///< Floor for the per-host request allowance
|
||||
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
|
||||
static constexpr int MAX_REQUESTS_PER_SEC = DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT;
|
||||
static constexpr int MIN_HOST_QUOTA = DownloadSettings::MIN_HOST_REQUEST_LIMIT;
|
||||
static constexpr qint64 QUOTA_RECOVER_MS = 60000; ///< Idle time before a reduced quota starts recovering
|
||||
static constexpr int DISPATCH_INTERVAL_MS = 100; ///< Pacing between individual network requests
|
||||
static constexpr qint64 QUOTA_RESET_INTERVAL_MS = 1000; ///< Interval at which the request quota resets
|
||||
|
||||
CardPictureLoaderWorker::CardPictureLoaderWorker()
|
||||
: QObject(nullptr), picDownload(SettingsCache::instance().downloads().getPicDownload()),
|
||||
requestQuota(MAX_REQUESTS_PER_SEC)
|
||||
requestQuota(MAX_REQUESTS_PER_SEC),
|
||||
hostRequestLimits(SettingsCache::instance().downloads().getHostRequestLimits())
|
||||
{
|
||||
networkManager = new QNetworkAccessManager(this);
|
||||
// We need a timeout to ensure requests don't hang indefinitely in case of
|
||||
|
|
@ -57,20 +60,54 @@ CardPictureLoaderWorker::CardPictureLoaderWorker()
|
|||
localLoader = new CardPictureLoaderLocal(this);
|
||||
|
||||
pictureLoaderThread = new QThread;
|
||||
// The worker object frees itself once its thread finishes, so no event loop is left
|
||||
// running and the QThread is never destroyed while still executing.
|
||||
connect(pictureLoaderThread, &QThread::finished, this, &QObject::deleteLater);
|
||||
pictureLoaderThread->start(QThread::LowPriority);
|
||||
moveToThread(pictureLoaderThread);
|
||||
|
||||
// QTimer value members are not QObject children, so moveToThread on the worker doesn't move
|
||||
// them. They must live in the worker's thread to be started from the slot code that runs there.
|
||||
requestTimer.moveToThread(pictureLoaderThread);
|
||||
dispatchTimer.moveToThread(pictureLoaderThread);
|
||||
|
||||
connect(this, &CardPictureLoaderWorker::imageLoadEnqueued, this, &CardPictureLoaderWorker::handleImageLoadEnqueued);
|
||||
|
||||
connect(&requestTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::resetRequestQuota);
|
||||
requestTimer.setInterval(1000);
|
||||
requestTimer.start();
|
||||
requestTimer.setInterval(static_cast<int>(QUOTA_RESET_INTERVAL_MS));
|
||||
|
||||
connect(&dispatchTimer, &QTimer::timeout, this, &CardPictureLoaderWorker::dispatchQueuedRequest);
|
||||
dispatchTimer.setInterval(DISPATCH_INTERVAL_MS);
|
||||
|
||||
connect(&SettingsCache::instance().downloads(), &DownloadSettings::hostRequestLimitsChanged, this,
|
||||
[this] { hostRequestLimits = SettingsCache::instance().downloads().getHostRequestLimits(); });
|
||||
}
|
||||
|
||||
CardPictureLoaderWorker::~CardPictureLoaderWorker()
|
||||
{
|
||||
saveRedirectCache();
|
||||
pictureLoaderThread->deleteLater();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::shutdownThread()
|
||||
{
|
||||
// The finished() -> deleteLater chain (wired in the constructor) frees this worker as soon as
|
||||
// its event loop exits, so nothing - not even a member read - may run once wait() returns.
|
||||
// QThread::quit() and QThread::wait() are thread-safe and may be called from the owning thread.
|
||||
QThread *thread = pictureLoaderThread;
|
||||
if (thread) {
|
||||
thread->quit();
|
||||
thread->wait();
|
||||
}
|
||||
}
|
||||
|
||||
QThread *CardPictureLoaderWorker::workerThread() const
|
||||
{
|
||||
return pictureLoaderThread;
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::isRunning() const
|
||||
{
|
||||
return pictureLoaderThread != nullptr && pictureLoaderThread->isRunning();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWorkerWork *worker)
|
||||
|
|
@ -84,8 +121,8 @@ void CardPictureLoaderWorker::queueRequest(const QUrl &url, CardPictureLoaderWor
|
|||
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
|
||||
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
|
||||
cache->metaData(url).isValid()) {
|
||||
// If we hit a cached url, we get to make the request for free, since it won't contribute towards the
|
||||
// rate-limit
|
||||
// A request that will be served from the disk cache never touches the network and therefore
|
||||
// doesn't use up any of the rate limit, so it gets to skip the queue.
|
||||
makeRequest(url, worker);
|
||||
return;
|
||||
}
|
||||
|
|
@ -107,10 +144,13 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
|
|||
req.setHeader(QNetworkRequest::UserAgentHeader, QString("Cockatrice %1").arg(VERSION_STRING));
|
||||
req.setRawHeader("Accept", "image/avif,image/webp,image/apng,image/,/*;q=0.8");
|
||||
|
||||
bool useNetworkCache =
|
||||
!picDownload && static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
|
||||
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
|
||||
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE;
|
||||
// Cached entries are served straight from the disk cache even when picture downloads are
|
||||
// enabled: re-fetching an already-cached image would burn the rate limit for nothing. Only a
|
||||
// genuine cache miss goes to the network, and only when downloads are enabled.
|
||||
bool useNetworkCache = static_cast<CardPictureLoaderCacheMethod::CacheMethod>(
|
||||
SettingsCache::instance().cacheStorage().getCardPictureLoaderCacheMethod()) ==
|
||||
CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE &&
|
||||
(cache->metaData(url).isValid() || !picDownload);
|
||||
|
||||
req.setAttribute(QNetworkRequest::CacheLoadControlAttribute,
|
||||
useNetworkCache ? QNetworkRequest::AlwaysCache : QNetworkRequest::AlwaysNetwork);
|
||||
|
|
@ -126,17 +166,24 @@ QNetworkReply *CardPictureLoaderWorker::makeRequest(const QUrl &url, CardPicture
|
|||
void CardPictureLoaderWorker::resetRequestQuota()
|
||||
{
|
||||
requestQuota = MAX_REQUESTS_PER_SEC;
|
||||
// Allowances are seeded per host on demand in processSingleRequest(), so a
|
||||
// rate-limited host never gets a fresh full quota mid-second.
|
||||
hostQuotaRemaining.clear();
|
||||
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end(); ++it) {
|
||||
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
|
||||
it.value() = qMin(MAX_REQUESTS_PER_SEC, it.value() + 1);
|
||||
for (auto it = hostRequestQuota.begin(); it != hostRequestQuota.end();) {
|
||||
// An unlocked host has no per-host allowance; drop any stale entry instead of
|
||||
// recovering it towards the UNLIMITED_HOST_QUOTA sentinel, which would poison it.
|
||||
if (hostAllowanceCeiling(it.key()) == DownloadSettings::UNLIMITED_HOST_QUOTA) {
|
||||
it = hostRequestQuota.erase(it);
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
for (const auto &request : requestLoadQueue) {
|
||||
const QString host = request.first.host();
|
||||
hostQuotaRemaining.insert(host, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC));
|
||||
if (!hostLast429.contains(it.key()) || now.msecsTo(hostLast429.value(it.key())) < -QUOTA_RECOVER_MS) {
|
||||
// Recover towards the host's effective allowance ceiling, which may be
|
||||
// lowered by the user's per-host request limits.
|
||||
it.value() = qMin(hostAllowanceCeiling(it.key()), it.value() + 1);
|
||||
}
|
||||
++it;
|
||||
}
|
||||
|
||||
processQueuedRequests();
|
||||
|
|
@ -144,17 +191,71 @@ void CardPictureLoaderWorker::resetRequestQuota()
|
|||
|
||||
void CardPictureLoaderWorker::processQueuedRequests()
|
||||
{
|
||||
while (requestQuota > 0 && processSingleRequest()) {
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
dispatchTimer.stop();
|
||||
return;
|
||||
}
|
||||
// Start lazily from the worker's own thread: QTimer must be started in the thread it lives in.
|
||||
if (!requestTimer.isActive()) {
|
||||
requestTimer.start();
|
||||
}
|
||||
dispatchTimer.start();
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::dispatchQueuedRequest()
|
||||
{
|
||||
if (requestLoadQueue.isEmpty()) {
|
||||
dispatchTimer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
// Unlocked hosts (developer cap UNLIMITED_HOST_QUOTA) skip the dispatch pacing and the
|
||||
// global per-second quota: dispatch every queued request for them back-to-back, bounded
|
||||
// only by their 429 backoff window and Qt's per-host connection pool.
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
for (int i = 0; i < requestLoadQueue.size();) {
|
||||
const auto &request = requestLoadQueue.at(i);
|
||||
const QString host = request.first.host();
|
||||
if (hostAllowanceCeiling(host) == DownloadSettings::UNLIMITED_HOST_QUOTA &&
|
||||
!CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
|
||||
makeRequest(request.first, request.second);
|
||||
requestLoadQueue.removeAt(i);
|
||||
} else {
|
||||
++i;
|
||||
}
|
||||
}
|
||||
|
||||
if (requestLoadQueue.isEmpty() || requestQuota <= 0) {
|
||||
dispatchTimer.stop();
|
||||
return;
|
||||
}
|
||||
|
||||
if (processSingleRequest()) {
|
||||
--requestQuota;
|
||||
} else {
|
||||
// No queued host currently has allowance left in this second; wait for the quota reset.
|
||||
dispatchTimer.stop();
|
||||
}
|
||||
}
|
||||
|
||||
bool CardPictureLoaderWorker::processSingleRequest()
|
||||
{
|
||||
QDateTime now = QDateTime::currentDateTime();
|
||||
for (int i = 0; i < requestLoadQueue.size(); ++i) {
|
||||
const auto &request = requestLoadQueue.at(i);
|
||||
QString host = request.first.host();
|
||||
int allowance = hostQuotaRemaining.value(host, MAX_REQUESTS_PER_SEC);
|
||||
const QString host = request.first.host();
|
||||
// Don't dispatch requests to a host that is currently in its 429 backoff.
|
||||
if (CardPictureLoaderWorkerWork::rateLimiter().isRateLimited(host, now)) {
|
||||
continue;
|
||||
}
|
||||
const int ceiling = hostAllowanceCeiling(host);
|
||||
// Seed the allowance only now, so a host that was rate limited last second
|
||||
// doesn't get a fresh full quota the moment it is queried mid-second. Clamp
|
||||
// against the ceiling so a lowered user cap applies from this second onward.
|
||||
if (!hostQuotaRemaining.contains(host)) {
|
||||
hostQuotaRemaining.insert(host, qMin(ceiling, hostRequestQuota.value(host, ceiling)));
|
||||
}
|
||||
int allowance = hostQuotaRemaining.value(host);
|
||||
if (allowance > 0) {
|
||||
hostQuotaRemaining.insert(host, allowance - 1);
|
||||
makeRequest(request.first, request.second);
|
||||
|
|
@ -165,9 +266,25 @@ bool CardPictureLoaderWorker::processSingleRequest()
|
|||
return false;
|
||||
}
|
||||
|
||||
int CardPictureLoaderWorker::hostAllowanceCeiling(const QString &host) const
|
||||
{
|
||||
const int devCap = DownloadSettings::getDeveloperHostCaps().value(host, MAX_REQUESTS_PER_SEC);
|
||||
if (devCap == DownloadSettings::UNLIMITED_HOST_QUOTA && !hostRequestLimits.contains(host)) {
|
||||
return DownloadSettings::UNLIMITED_HOST_QUOTA;
|
||||
}
|
||||
const int requested = hostRequestLimits.value(host, devCap);
|
||||
return SettingsCache::instance().downloads().clampHostRequestLimit(host, requested);
|
||||
}
|
||||
|
||||
void CardPictureLoaderWorker::onHostRateLimited(const QString &host)
|
||||
{
|
||||
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, MAX_REQUESTS_PER_SEC) / 2));
|
||||
const int ceiling = hostAllowanceCeiling(host);
|
||||
if (ceiling == DownloadSettings::UNLIMITED_HOST_QUOTA) {
|
||||
// Unlocked hosts have no per-host allowance to halve; the shared backoff
|
||||
// window tracked by the rate limiter still paces them.
|
||||
return;
|
||||
}
|
||||
hostRequestQuota.insert(host, qMax(MIN_HOST_QUOTA, hostRequestQuota.value(host, ceiling) / 2));
|
||||
hostLast429.insert(host, QDateTime::currentDateTime());
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -74,10 +74,37 @@ public:
|
|||
*/
|
||||
void onHostRateLimited(const QString &host);
|
||||
|
||||
/** @brief Clears the network cache and redirect cache. */
|
||||
void clearNetworkCache();
|
||||
/**
|
||||
* @brief Stops the worker thread and releases it.
|
||||
*
|
||||
* Called from the owning thread (CardPictureLoader) on its way out. QThread::quit() posts an
|
||||
* exit request to the worker's event loop and QThread::wait() blocks until the loop has
|
||||
* returned and the thread finished. Only QThread members are touched here, so this method is
|
||||
* safe to call from the owning thread. The worker object itself is freed by the finished() ->
|
||||
* deleteLater chain (see the constructor); the QThread object is deleted afterwards by the
|
||||
* owner (CardPictureLoader::~CardPictureLoader), not by this method.
|
||||
*/
|
||||
void shutdownThread();
|
||||
|
||||
/** @return Whether the worker's thread is currently running. */
|
||||
bool isRunning() const;
|
||||
|
||||
/**
|
||||
* @brief Returns the worker's QThread.
|
||||
* @return The worker thread
|
||||
*
|
||||
* Only meaningful while the worker object is alive; capture it before calling shutdownThread().
|
||||
*/
|
||||
QThread *workerThread() const;
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Clears the network cache and redirect cache.
|
||||
*
|
||||
* Runs on the worker thread; invoke it via a queued call when coming from another thread,
|
||||
* since both caches are owned by the worker thread.
|
||||
*/
|
||||
void clearNetworkCache();
|
||||
/**
|
||||
* @brief Makes a network request for the given URL using the specified worker.
|
||||
* @param url URL to load
|
||||
|
|
@ -89,6 +116,9 @@ public slots:
|
|||
/** @brief Processes all queued requests respecting the request quota. */
|
||||
void processQueuedRequests();
|
||||
|
||||
/** @brief Chooses a request from the queue and starts it, respecting the quota and pacing. */
|
||||
void dispatchQueuedRequest();
|
||||
|
||||
/**
|
||||
* @brief Processes a single queued request.
|
||||
* @return true if a request was processed, false if queue is empty.
|
||||
|
|
@ -120,13 +150,23 @@ private:
|
|||
|
||||
int requestQuota; ///< Remaining requests allowed per second
|
||||
QTimer requestTimer; ///< Timer to reset the request quota
|
||||
QTimer dispatchTimer; ///< Timer pacing individual network requests
|
||||
QHash<QString, int> hostRequestQuota; ///< Sustained per-host request allowance
|
||||
QHash<QString, int> hostRequestLimits; ///< User-set per-host request allowances
|
||||
QHash<QString, int> hostQuotaRemaining; ///< Per-host allowance left in the current second
|
||||
QHash<QString, QDateTime> hostLast429; ///< When each host was last rate limited
|
||||
|
||||
CardPictureLoaderLocal *localLoader; ///< Loader for local images
|
||||
QSet<QString> currentlyLoading; ///< Deduplication: contains pixmapCacheKey currently being loaded
|
||||
|
||||
/**
|
||||
* @brief Effective per-host allowance ceiling for a host.
|
||||
* @param host The host to look up
|
||||
* @return The allowance ceiling in requests/second, or DownloadSettings::UNLIMITED_HOST_QUOTA
|
||||
* when the developer unlocked the host and no user limit is set for it.
|
||||
*/
|
||||
[[nodiscard]] int hostAllowanceCeiling(const QString &host) const;
|
||||
|
||||
/** @brief Returns cached redirect URL for the given original URL, if available. */
|
||||
[[nodiscard]] QUrl getCachedRedirect(const QUrl &originalUrl) const;
|
||||
|
||||
|
|
|
|||
|
|
@ -22,6 +22,11 @@ static const QStringList MD5_BLACKLIST = {
|
|||
"fbc7d763c08771c260b39e2115414eeb" // Current card back hash
|
||||
};
|
||||
|
||||
ServerRateLimiter &CardPictureLoaderWorkerWork::rateLimiter()
|
||||
{
|
||||
return s_rateLimiter;
|
||||
}
|
||||
|
||||
CardPictureLoaderWorkerWork::CardPictureLoaderWorkerWork(const CardPictureLoaderWorker *worker, const ExactCard &toLoad)
|
||||
: QObject(nullptr), cardToDownload(CardPictureToLoad(toLoad)),
|
||||
picDownload(SettingsCache::instance().downloads().getPicDownload())
|
||||
|
|
|
|||
|
|
@ -43,6 +43,9 @@ public:
|
|||
|
||||
CardPictureToLoad cardToDownload; ///< The card and associated URLs to try downloading
|
||||
|
||||
/** @brief Shared per-server 429 backoff state. */
|
||||
static ServerRateLimiter &rateLimiter();
|
||||
|
||||
public slots:
|
||||
/**
|
||||
* @brief Handles a finished network reply for the card image.
|
||||
|
|
|
|||
|
|
@ -17,7 +17,7 @@ enum Format
|
|||
PlainText,
|
||||
|
||||
/**
|
||||
* This is cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
|
||||
* This is Cockatrice's native deck file format, and supports deck metadata such as banner cards and tags.
|
||||
* Stored as .cod files.
|
||||
*/
|
||||
Cockatrice
|
||||
|
|
|
|||
|
|
@ -50,7 +50,7 @@ DeckLoader::loadFromFile(const QString &fileName, DeckFileFormat::Format fmt, bo
|
|||
result = deckList.loadFromFile_Native(&file);
|
||||
if (!result) {
|
||||
qCInfo(DeckLoaderLog) << "Failed to load " << fileName
|
||||
<< "as cockatrice format; retrying as plain format";
|
||||
<< "as Cockatrice format; retrying as plain format";
|
||||
file.seek(0);
|
||||
result = deckList.loadFromFile_Plain(&file, CardNameNormalizer());
|
||||
fmt = DeckFileFormat::PlainText;
|
||||
|
|
|
|||
|
|
@ -131,7 +131,7 @@ public:
|
|||
static void printDeckList(QPrinter *printer, const DeckList &deckList);
|
||||
|
||||
/**
|
||||
* Converts the given deck's file to the cockatrice file format.
|
||||
* Converts the given deck's file to the Cockatrice file format.
|
||||
* Uses the lastLoadInfo in the LoadedDeck to determine the current name of the file and where to save to.
|
||||
* @param deck The deck to convert. Should have valid lastLoadInfo. Will update the lastLoadInfo.
|
||||
* @return Whether the conversion succeeded.
|
||||
|
|
|
|||
|
|
@ -224,7 +224,7 @@ QStringMap &ThemeManager::getAvailableThemes()
|
|||
}
|
||||
}
|
||||
|
||||
// load themes from cockatrice system dir
|
||||
// Load themes from Cockatrice system dir
|
||||
dir.setPath(systemThemesBasePath());
|
||||
|
||||
for (QString themeName : dir.entryList(QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)) {
|
||||
|
|
|
|||
|
|
@ -478,13 +478,13 @@ void DlgSettings::closeEvent(QCloseEvent *event)
|
|||
case Invalid:
|
||||
loadErrorMessage = tr("Your card database is invalid.\n\n"
|
||||
"Cockatrice may not function correctly with an invalid database\n\n"
|
||||
"You may need to rerun oracle to update your card database.\n\n"
|
||||
"You may need to rerun Oracle to update your card database.\n\n"
|
||||
"Would you like to change your database location setting?");
|
||||
break;
|
||||
case VersionTooOld:
|
||||
loadErrorMessage = tr("Your card database version is too old.\n\n"
|
||||
"This can cause problems loading card information or images\n\n"
|
||||
"Usually this can be fixed by rerunning oracle to to update your card database.\n\n"
|
||||
"Usually this can be fixed by rerunning Oracle to to update your card database.\n\n"
|
||||
"Would you like to change your database location setting?");
|
||||
break;
|
||||
case NotLoaded:
|
||||
|
|
|
|||
|
|
@ -11,11 +11,14 @@
|
|||
#include <QLineEdit>
|
||||
#include <QMessageBox>
|
||||
#include <QToolBar>
|
||||
#include <QUrl>
|
||||
#include <libcockatrice/settings/download_settings.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <libcockatrice/settings/personal_settings.h>
|
||||
#include <libcockatrice/utility/macros.h>
|
||||
|
||||
static constexpr int UNLOCKED_HOST_LIMIT_MAX = 50; ///< Upper bound for rate limits on hosts unlocked by the developer
|
||||
|
||||
DeckEditorSettingsPage::DeckEditorSettingsPage()
|
||||
{
|
||||
picDownloadCheckBox.setChecked(SettingsCache::instance().downloads().getPicDownload());
|
||||
|
|
@ -65,11 +68,16 @@ DeckEditorSettingsPage::DeckEditorSettingsPage()
|
|||
aRemove->setIcon(themePixmap(QStringLiteral("icons/decrement")));
|
||||
connect(aRemove, &QAction::triggered, this, &DeckEditorSettingsPage::actRemoveURL);
|
||||
|
||||
aRateLimit = new QAction(this);
|
||||
aRateLimit->setIcon(themePixmap(QStringLiteral("icons/cogwheel")));
|
||||
connect(aRateLimit, &QAction::triggered, this, &DeckEditorSettingsPage::actAdjustRateLimit);
|
||||
|
||||
auto *urlToolBar = new QToolBar;
|
||||
urlToolBar->setOrientation(Qt::Vertical);
|
||||
urlToolBar->addAction(aAdd);
|
||||
urlToolBar->addAction(aRemove);
|
||||
urlToolBar->addAction(aEdit);
|
||||
urlToolBar->addAction(aRateLimit);
|
||||
urlToolBar->setSizePolicy(QSizePolicy::Preferred, QSizePolicy::MinimumExpanding);
|
||||
|
||||
auto *urlListLayout = new QHBoxLayout;
|
||||
|
|
@ -164,6 +172,54 @@ void DeckEditorSettingsPage::storeSettings()
|
|||
SettingsCache::instance().downloads().setDownloadUrls(downloadUrls);
|
||||
}
|
||||
|
||||
void DeckEditorSettingsPage::actAdjustRateLimit()
|
||||
{
|
||||
if (urlList->currentItem() == nullptr) {
|
||||
QMessageBox::information(this, tr("Adjust Rate Limit"), tr("Select a URL in the list first."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QString host = QUrl(urlList->currentItem()->text()).host();
|
||||
if (host.isEmpty()) {
|
||||
QMessageBox::information(this, tr("Adjust Rate Limit"), tr("The selected URL does not have a valid host."));
|
||||
return;
|
||||
}
|
||||
|
||||
const QHash<QString, int> &devCaps = DownloadSettings::getDeveloperHostCaps();
|
||||
const QHash<QString, int> currentLimits = SettingsCache::instance().downloads().getHostRequestLimits();
|
||||
const int devCap = devCaps.value(host, DownloadSettings::DEFAULT_HOST_REQUEST_LIMIT);
|
||||
const bool unlocked = devCap == DownloadSettings::UNLIMITED_HOST_QUOTA;
|
||||
|
||||
bool ok = false;
|
||||
int minimum;
|
||||
int maximum;
|
||||
int defaultValue;
|
||||
if (unlocked) {
|
||||
minimum = 0; // 0 means "unlimited"
|
||||
maximum = UNLOCKED_HOST_LIMIT_MAX;
|
||||
defaultValue = currentLimits.value(host, 0);
|
||||
} else {
|
||||
minimum = DownloadSettings::MIN_HOST_REQUEST_LIMIT;
|
||||
maximum = devCap;
|
||||
defaultValue = currentLimits.value(host, devCap);
|
||||
}
|
||||
|
||||
const int value = QInputDialog::getInt(this, tr("Adjust Rate Limit for %1").arg(host),
|
||||
tr("Requests per second (developer maximum is %1):").arg(maximum),
|
||||
defaultValue, minimum, maximum, 1, &ok);
|
||||
if (!ok) {
|
||||
return;
|
||||
}
|
||||
|
||||
QHash<QString, int> limits = currentLimits;
|
||||
if (unlocked ? value == 0 : value == devCap) {
|
||||
limits.remove(host);
|
||||
} else {
|
||||
limits.insert(host, value);
|
||||
}
|
||||
SettingsCache::instance().downloads().setHostRequestLimits(limits);
|
||||
}
|
||||
|
||||
void DeckEditorSettingsPage::urlListChanged(const QModelIndex &, int, int, const QModelIndex &, int)
|
||||
{
|
||||
storeSettings();
|
||||
|
|
@ -244,4 +300,5 @@ void DeckEditorSettingsPage::retranslateUi()
|
|||
aAdd->setText(tr("Add New URL"));
|
||||
aEdit->setText(tr("Edit URL"));
|
||||
aRemove->setText(tr("Remove URL"));
|
||||
}
|
||||
aRateLimit->setText(tr("Adjust Rate Limit"));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ private slots:
|
|||
void actAddURL();
|
||||
void actRemoveURL();
|
||||
void actEditURL();
|
||||
void actAdjustRateLimit();
|
||||
void resetDownloadedURLsButtonClicked();
|
||||
|
||||
private:
|
||||
|
|
@ -34,7 +35,7 @@ private:
|
|||
QLabel urlLinkLabel;
|
||||
QCheckBox picDownloadCheckBox;
|
||||
QListWidget *urlList;
|
||||
QAction *aAdd, *aEdit, *aRemove;
|
||||
QAction *aAdd, *aEdit, *aRemove, *aRateLimit;
|
||||
QCheckBox mcDownloadSpoilersCheckBox;
|
||||
QLabel msDownloadSpoilersLabel;
|
||||
QGroupBox *mpGeneralGroupBox;
|
||||
|
|
|
|||
|
|
@ -92,8 +92,8 @@
|
|||
#include <libcockatrice/settings/updates_settings.h>
|
||||
|
||||
#define GITHUB_PAGES_URL "https://cockatrice.github.io"
|
||||
#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors?type=c"
|
||||
#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#cockatrice"
|
||||
#define GITHUB_CONTRIBUTORS_URL "https://github.com/Cockatrice/Cockatrice/graphs/contributors"
|
||||
#define GITHUB_CONTRIBUTE_URL "https://github.com/Cockatrice/Cockatrice#"
|
||||
#define GITHUB_TRANSIFEX_TRANSLATORS_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translator-Hall-of-Fame"
|
||||
#define GITHUB_TRANSLATOR_FAQ_URL "https://github.com/Cockatrice/Cockatrice/wiki/Translation-FAQ"
|
||||
#define GITHUB_ISSUES_URL "https://github.com/Cockatrice/Cockatrice/issues"
|
||||
|
|
@ -1050,7 +1050,7 @@ void MainWindow::createCardUpdateProcess(bool background)
|
|||
|
||||
if (dir.exists(binaryName)) {
|
||||
updaterCmd = dir.absoluteFilePath(binaryName);
|
||||
} else { // try and find the directory oracle is stored in the build directory
|
||||
} else { // try and find the directory Oracle is stored in the build directory
|
||||
QDir findLocalDir(dir);
|
||||
findLocalDir.cdUp();
|
||||
findLocalDir.cd(getCardUpdaterBinaryName());
|
||||
|
|
|
|||
|
|
@ -231,7 +231,7 @@ int main(int argc, char *argv[])
|
|||
// These values are only used by the settings loader/saver
|
||||
// Wrong or outdated values are kept to not break things
|
||||
QCoreApplication::setOrganizationName("Cockatrice");
|
||||
QCoreApplication::setOrganizationDomain("cockatrice.de");
|
||||
QCoreApplication::setOrganizationDomain("cockatrice.github.io");
|
||||
QCoreApplication::setApplicationName("Cockatrice");
|
||||
QCoreApplication::setApplicationVersion(VERSION_STRING);
|
||||
|
||||
|
|
@ -250,7 +250,7 @@ int main(int argc, char *argv[])
|
|||
|
||||
// Command-line parser
|
||||
QCommandLineParser parser;
|
||||
parser.setApplicationDescription("Cockatrice");
|
||||
parser.setApplicationDescription("Cockatrice Client");
|
||||
parser.addHelpOption();
|
||||
parser.addVersionOption();
|
||||
|
||||
|
|
@ -350,8 +350,8 @@ int main(int argc, char *argv[])
|
|||
qCInfo(MainLog) << "MainWindow constructor finished";
|
||||
|
||||
ui.setWindowIcon(themePixmap(QStringLiteral("cockatrice")));
|
||||
// set name of the app desktop file; used by wayland to load the window icon
|
||||
QGuiApplication::setDesktopFileName("cockatrice");
|
||||
// Set name of the app desktop file; used by wayland to load the window icon
|
||||
QGuiApplication::setDesktopFileName("Cockatrice");
|
||||
|
||||
SettingsCache::instance().network().setClientID(generateClientID());
|
||||
|
||||
|
|
|
|||
|
|
@ -9,6 +9,22 @@ const QStringList DownloadSettings::DEFAULT_DOWNLOAD_URLS = {
|
|||
"https://gatherer.wizards.com/Handlers/Image.ashx?multiverseid=!set:muid!&type=card",
|
||||
"https://gatherer.wizards.com/Handlers/Image.ashx?name=!name!&type=card"};
|
||||
|
||||
// Developer-set ceilings for the per-host request allowance. Users may lower a host's
|
||||
// allowance via the download settings, but can never raise it above these values. Hosts
|
||||
// not listed default to DEFAULT_HOST_REQUEST_LIMIT. A cap of UNLIMITED_HOST_QUOTA marks a
|
||||
// host that is never throttled per host and skips the dispatch pacing (429 backoff still applies).
|
||||
const QHash<QString, int> DownloadSettings::DEVELOPER_HOST_CAPS = {
|
||||
// The Scryfall API enforces 10 requests/second; stay one under so a burst can't trip 429s.
|
||||
{"api.scryfall.com", 9},
|
||||
// The Scryfall image CDN has no documented per-client rate limit.
|
||||
{"cards.scryfall.io", UNLIMITED_HOST_QUOTA},
|
||||
};
|
||||
|
||||
const QHash<QString, int> &DownloadSettings::getDeveloperHostCaps()
|
||||
{
|
||||
return DEVELOPER_HOST_CAPS;
|
||||
}
|
||||
|
||||
DownloadSettings::DownloadSettings(const QString &settingPath, QObject *parent = nullptr)
|
||||
: SettingsManager(settingPath + "downloads.ini", "downloads", QString(), parent)
|
||||
{
|
||||
|
|
@ -50,3 +66,32 @@ void DownloadSettings::setDownloadSpoilerStatus(bool _spoilerStatus)
|
|||
setValue(_spoilerStatus, "downloadSpoilers");
|
||||
emit downloadSpoilerStatusChanged();
|
||||
}
|
||||
|
||||
QHash<QString, int> DownloadSettings::getHostRequestLimits() const
|
||||
{
|
||||
const QVariantMap stored = getValue("hostRequestLimits").toMap();
|
||||
QHash<QString, int> hostRequestLimits;
|
||||
for (auto it = stored.cbegin(); it != stored.cend(); ++it) {
|
||||
hostRequestLimits.insert(it.key(), it.value().toInt());
|
||||
}
|
||||
return hostRequestLimits;
|
||||
}
|
||||
|
||||
void DownloadSettings::setHostRequestLimits(const QHash<QString, int> &hostRequestLimits)
|
||||
{
|
||||
QVariantMap stored;
|
||||
for (auto it = hostRequestLimits.cbegin(); it != hostRequestLimits.cend(); ++it) {
|
||||
stored.insert(it.key(), it.value());
|
||||
}
|
||||
setValue(stored, "hostRequestLimits");
|
||||
emit hostRequestLimitsChanged();
|
||||
}
|
||||
|
||||
int DownloadSettings::clampHostRequestLimit(const QString &host, int requested) const
|
||||
{
|
||||
const int devCap = DEVELOPER_HOST_CAPS.value(host, DEFAULT_HOST_REQUEST_LIMIT);
|
||||
if (devCap == UNLIMITED_HOST_QUOTA) {
|
||||
return qMax(MIN_HOST_REQUEST_LIMIT, requested);
|
||||
}
|
||||
return qBound(MIN_HOST_REQUEST_LIMIT, requested, devCap);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,14 +9,34 @@
|
|||
|
||||
#include "settings_manager.h"
|
||||
|
||||
#include <QHash>
|
||||
|
||||
class DownloadSettings : public SettingsManager
|
||||
{
|
||||
Q_OBJECT
|
||||
friend class SettingsCache;
|
||||
|
||||
static const QStringList DEFAULT_DOWNLOAD_URLS;
|
||||
static const QHash<QString, int> DEVELOPER_HOST_CAPS;
|
||||
|
||||
public:
|
||||
/** @brief Per-host request allowance (requests/second) when no developer cap applies. */
|
||||
static constexpr int DEFAULT_HOST_REQUEST_LIMIT = 10;
|
||||
/** @brief Floor for any per-host request allowance. */
|
||||
static constexpr int MIN_HOST_REQUEST_LIMIT = 1;
|
||||
/** @brief Developer cap marking a host as never throttled per host or by the dispatch pacing. */
|
||||
static constexpr int UNLIMITED_HOST_QUOTA = -1;
|
||||
|
||||
/**
|
||||
* @brief Developer-set per-host allowance ceilings (requests/second), keyed by host.
|
||||
*
|
||||
* Hosts not present default to DEFAULT_HOST_REQUEST_LIMIT. An entry of
|
||||
* UNLIMITED_HOST_QUOTA marks a host that users may still lower, but that is never
|
||||
* throttled per host by default. Users can never raise a host's allowance above its
|
||||
* developer cap.
|
||||
*/
|
||||
static const QHash<QString, int> &getDeveloperHostCaps();
|
||||
|
||||
explicit DownloadSettings(const QString &, QObject *);
|
||||
|
||||
QStringList getAllURLs() const;
|
||||
|
|
@ -27,9 +47,23 @@ public:
|
|||
[[nodiscard]] bool getDownloadSpoilersStatus() const;
|
||||
void setDownloadSpoilerStatus(bool _spoilerStatus);
|
||||
|
||||
/** @brief User-set per-host request allowances (requests/second). Missing hosts use the developer default. */
|
||||
QHash<QString, int> getHostRequestLimits() const;
|
||||
void setHostRequestLimits(const QHash<QString, int> &hostRequestLimits);
|
||||
|
||||
/**
|
||||
* @brief Clamps the user's requested allowance for a host against its developer cap.
|
||||
* @param host The host to clamp for
|
||||
* @param requested The user-requested allowance in requests/second
|
||||
* @return The effective allowance. Users may lower a host's allowance but never raise it
|
||||
* above the developer cap; hosts with UNLIMITED_HOST_QUOTA have no upper bound.
|
||||
*/
|
||||
[[nodiscard]] int clampHostRequestLimit(const QString &host, int requested) const;
|
||||
|
||||
signals:
|
||||
void picDownloadChanged();
|
||||
void downloadSpoilerStatusChanged();
|
||||
void hostRequestLimitsChanged();
|
||||
};
|
||||
|
||||
#endif // COCKATRICE_DOWNLOADSETTINGS_H
|
||||
|
|
|
|||
|
|
@ -50,8 +50,8 @@ int main(int argc, char *argv[])
|
|||
QApplication app(argc, argv);
|
||||
|
||||
QCoreApplication::setOrganizationName("Cockatrice");
|
||||
QCoreApplication::setOrganizationDomain("cockatrice");
|
||||
// this can't be changed, as it influences the default save path for cards.xml
|
||||
QCoreApplication::setOrganizationDomain("Cockatrice");
|
||||
// This can't be changed, as it influences the default save path for cards.xml
|
||||
QCoreApplication::setApplicationName("Cockatrice");
|
||||
|
||||
// If the program is opened with the -s flag, it will only do spoilers. Otherwise it will do MTGJSON/Tokens
|
||||
|
|
@ -83,7 +83,7 @@ int main(int argc, char *argv[])
|
|||
QIcon icon("theme:appicon.svg");
|
||||
wizard.setWindowIcon(icon);
|
||||
// set name of the app desktop file; used by wayland to load the window icon
|
||||
QGuiApplication::setDesktopFileName("oracle");
|
||||
QGuiApplication::setDesktopFileName("Oracle");
|
||||
|
||||
wizard.show();
|
||||
|
||||
|
|
|
|||
|
|
@ -796,7 +796,7 @@ void SaveSetsPage::retranslateUi()
|
|||
{
|
||||
setTitle(tr("Sets imported"));
|
||||
if (wizard()->downloadedPlainXml) {
|
||||
setSubTitle(tr("A cockatrice database file of %1 MB has been downloaded.")
|
||||
setSubTitle(tr("A Cockatrice card database file of %1 MB has been downloaded.")
|
||||
.arg(qRound(wizard()->xmlData.size() / 1000000.0)));
|
||||
} else {
|
||||
setSubTitle(tr("The following sets have been found:"));
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@
|
|||
* Note that "...enters tapped unless..." returns false.
|
||||
*
|
||||
* @param name The name of the card
|
||||
* @param text The oracle text of the card
|
||||
* @param text The Oracle text of the card
|
||||
*/
|
||||
bool parseCipt(const QString &name, const QString &text)
|
||||
{
|
||||
|
|
|
|||
|
|
@ -54,7 +54,7 @@ using ScanProgressCallback = std::function<void(qsizetype bytesRead, qsizetype t
|
|||
* @brief Scans a full MTGJSON document without materializing the JSON tree.
|
||||
*
|
||||
* Splits the top-level "data" object into per-set byte ranges and reads each
|
||||
* set's metadata directly from the raw bytes. The oracle importer can then
|
||||
* set's metadata directly from the raw bytes. The Oracle importer can then
|
||||
* parse one set at a time during import, keeping peak memory far below a single
|
||||
* QJsonDocument::fromJson() over the whole file.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -86,9 +86,10 @@ bool Servatrice_DatabaseInterface::openDatabase()
|
|||
<< dbversion << "to version" << expectedversion;
|
||||
return false;
|
||||
} else if (dbversion > expectedversion) {
|
||||
qCCritical(DatabaseInterfaceLog) << poolStr << "Error opening database: the database schema version"
|
||||
<< dbversion << "is too new, you need to update servatrice"
|
||||
<< "(this servatrice actually uses version" << expectedversion << ")";
|
||||
qCCritical(DatabaseInterfaceLog)
|
||||
<< poolStr << "Error opening database: the database schema version" << dbversion
|
||||
<< "is too new, you need to update Servatrice" << "(Currently running Servatrice actually uses version"
|
||||
<< expectedversion << ")";
|
||||
return false;
|
||||
}
|
||||
} else {
|
||||
|
|
|
|||
|
|
@ -132,3 +132,9 @@ add_subdirectory(loading_from_clipboard)
|
|||
add_subdirectory(movecard_tests)
|
||||
add_subdirectory(oracle)
|
||||
add_subdirectory(settings)
|
||||
|
||||
# picture_loader_benchmark links libcockatrice_settings, which only exists when
|
||||
# a client/UI-capable target is being built.
|
||||
if(WITH_ORACLE OR WITH_CLIENT)
|
||||
add_subdirectory(picture_loader_benchmark)
|
||||
endif()
|
||||
|
|
|
|||
26
tests/picture_loader_benchmark/CMakeLists.txt
Normal file
26
tests/picture_loader_benchmark/CMakeLists.txt
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
# Manual benchmark hitting the real card image hosts. Not registered with
|
||||
# add_test(): it requires a cards.xml, touches the network for minutes at a
|
||||
# time, and needs network access. Build it explicitly and run by hand.
|
||||
add_executable(
|
||||
picture_loader_benchmark_test
|
||||
${VERSION_STRING_CPP}
|
||||
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_local.cpp
|
||||
../../cockatrice/src/interface/card_picture_loader/card_picture_to_load.cpp
|
||||
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker.cpp
|
||||
../../cockatrice/src/interface/card_picture_loader/card_picture_loader_worker_work.cpp
|
||||
../../cockatrice/src/client/settings/cache_settings.h
|
||||
picture_loader_benchmark.cpp
|
||||
settings_cache_mock.cpp
|
||||
)
|
||||
|
||||
target_include_directories(picture_loader_benchmark_test PRIVATE ${CMAKE_SOURCE_DIR}/cockatrice/src)
|
||||
|
||||
target_link_libraries(
|
||||
picture_loader_benchmark_test
|
||||
libcockatrice_card
|
||||
libcockatrice_settings
|
||||
libcockatrice_interfaces
|
||||
libcockatrice_utility
|
||||
Threads::Threads
|
||||
${TEST_QT_MODULES}
|
||||
)
|
||||
479
tests/picture_loader_benchmark/picture_loader_benchmark.cpp
Normal file
479
tests/picture_loader_benchmark/picture_loader_benchmark.cpp
Normal file
|
|
@ -0,0 +1,479 @@
|
|||
/*
|
||||
* Picture loader benchmark / regression suite against the real card image hosts.
|
||||
*
|
||||
* Deliberately not registered with ctest: it hits live Scryfall / Gatherer
|
||||
* endpoints at ~10 requests per second and takes minutes. Run it by hand.
|
||||
*
|
||||
* picture_loader_benchmark_test --carddb /path/to/cards.xml [options]
|
||||
*
|
||||
* Modes
|
||||
* -----
|
||||
* default : for every URL template in the configured download list (or for each
|
||||
* --url given), load #count pictures twice: once cold (network) and
|
||||
* once cached (served from the QNetworkDiskCache). Both passes must
|
||||
* load every card with zero failures. The cached pass must complete
|
||||
* well under the cold time, which is the regression gate for serving
|
||||
* cached pictures instead of re-fetching them. The cold pass must stay
|
||||
* above a pacing lower bound, the regression gate for burst-free
|
||||
* request throttling.
|
||||
* --stress: two CardPictureLoaderWorker instances loading the same cards
|
||||
* concurrently against one host (~20 req/s aggregate), which forces
|
||||
* real 429 responses. Both workers must still complete 100% of their
|
||||
* cards via the shared backoff logic.
|
||||
*/
|
||||
|
||||
#include "client/settings/cache_settings.h"
|
||||
#include "interface/card_picture_loader/card_picture_loader_worker.h"
|
||||
#include "interface/card_picture_loader/card_picture_to_load.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDir>
|
||||
#include <QElapsedTimer>
|
||||
#include <QEventLoop>
|
||||
#include <QFile>
|
||||
#include <QImage>
|
||||
#include <QList>
|
||||
#include <QMessageLogContext>
|
||||
#include <QMetaType>
|
||||
#include <QMutex>
|
||||
#include <QTemporaryDir>
|
||||
#include <QTimer>
|
||||
#include <QUrl>
|
||||
#include <cstdio>
|
||||
#include <libcockatrice/card/database/card_database.h>
|
||||
#include <libcockatrice/card/printing/exact_card.h>
|
||||
#include <libcockatrice/card/printing/printing_info.h>
|
||||
#include <libcockatrice/interfaces/interface_card_database_path_provider.h>
|
||||
#include <libcockatrice/interfaces/noop_card_preference_provider.h>
|
||||
#include <libcockatrice/interfaces/noop_card_set_priority_controller.h>
|
||||
#include <libcockatrice/settings/download_settings.h>
|
||||
#include <optional>
|
||||
|
||||
class BenchmarkCardDatabasePathProvider : public ICardDatabasePathProvider
|
||||
{
|
||||
public:
|
||||
BenchmarkCardDatabasePathProvider(QString _cardsXml, QString _customSetsDir)
|
||||
: cardsXml(std::move(_cardsXml)), customSetsDir(std::move(_customSetsDir))
|
||||
{
|
||||
}
|
||||
|
||||
QString getCardDatabasePath() const override
|
||||
{
|
||||
return cardsXml;
|
||||
}
|
||||
|
||||
QString getCustomCardDatabasePath() const override
|
||||
{
|
||||
return customSetsDir;
|
||||
}
|
||||
|
||||
QString getTokenDatabasePath() const override
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString getSpoilerCardDatabasePath() const override
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
|
||||
private:
|
||||
QString cardsXml;
|
||||
QString customSetsDir;
|
||||
};
|
||||
|
||||
struct PassResult
|
||||
{
|
||||
int enqueued = 0;
|
||||
int finished = 0;
|
||||
int failed = 0;
|
||||
qint64 elapsedMs = 0;
|
||||
QStringList failedCards;
|
||||
};
|
||||
|
||||
static QList<ExactCard> selectCardsForTemplate(CardDatabase &db, const QString &urlTemplate, int maxCards)
|
||||
{
|
||||
QList<ExactCard> selected;
|
||||
const QList<CardInfoPtr> cards = db.getCardList().values();
|
||||
for (const CardInfoPtr &card : cards) {
|
||||
if (selected.size() >= maxCards) {
|
||||
break;
|
||||
}
|
||||
const SetToPrintingsMap &sets = card->getSets();
|
||||
if (sets.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
const QList<PrintingInfo> printings = sets.first();
|
||||
if (printings.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
const ExactCard cardToLoad(card, printings.first());
|
||||
if (CardPictureToLoad(cardToLoad).transformUrl(urlTemplate).isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
selected.append(cardToLoad);
|
||||
}
|
||||
return selected;
|
||||
}
|
||||
|
||||
static PassResult runPass(CardPictureLoaderWorker *worker, const QList<ExactCard> &cards, int timeoutMs)
|
||||
{
|
||||
PassResult result;
|
||||
result.enqueued = cards.size();
|
||||
|
||||
QEventLoop loop;
|
||||
QTimer watchdog;
|
||||
watchdog.setSingleShot(true);
|
||||
watchdog.setInterval(timeoutMs);
|
||||
QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
|
||||
QElapsedTimer clock;
|
||||
QObject::connect(worker, &CardPictureLoaderWorker::imageLoaded, &loop,
|
||||
[&](const ExactCard &card, const QImage &image) {
|
||||
++result.finished;
|
||||
if (image.isNull()) {
|
||||
++result.failed;
|
||||
if (result.failedCards.size() < 10) {
|
||||
result.failedCards.append(card.getName());
|
||||
}
|
||||
}
|
||||
if (result.finished >= result.enqueued) {
|
||||
loop.quit();
|
||||
}
|
||||
});
|
||||
|
||||
clock.start();
|
||||
for (const ExactCard &card : cards) {
|
||||
worker->enqueueImageLoad(card);
|
||||
}
|
||||
watchdog.start();
|
||||
loop.exec();
|
||||
result.elapsedMs = clock.elapsed();
|
||||
return result;
|
||||
}
|
||||
|
||||
struct StressResult
|
||||
{
|
||||
PassResult a;
|
||||
PassResult b;
|
||||
int http429Count = 0;
|
||||
};
|
||||
|
||||
struct LogCounters
|
||||
{
|
||||
int http429 = 0;
|
||||
QMutex mutex;
|
||||
};
|
||||
|
||||
static LogCounters *s_activeCounters = nullptr;
|
||||
static QtMessageHandler s_previousMessageHandler = nullptr;
|
||||
|
||||
static void stressLogHandler(QtMsgType type, const QMessageLogContext &context, const QString &msg)
|
||||
{
|
||||
if (s_activeCounters && msg.contains(QStringLiteral("Too many requests from"))) {
|
||||
QMutexLocker locker(&s_activeCounters->mutex);
|
||||
++s_activeCounters->http429;
|
||||
}
|
||||
if (s_previousMessageHandler) {
|
||||
s_previousMessageHandler(type, context, msg);
|
||||
}
|
||||
}
|
||||
|
||||
static StressResult runStress(CardPictureLoaderWorker *workerA,
|
||||
CardPictureLoaderWorker *workerB,
|
||||
const QList<ExactCard> &cards,
|
||||
int timeoutMs)
|
||||
{
|
||||
StressResult result;
|
||||
result.a.enqueued = cards.size();
|
||||
result.b.enqueued = cards.size();
|
||||
|
||||
int completed = 0;
|
||||
QMutex completedMutex;
|
||||
|
||||
QEventLoop loop;
|
||||
QTimer watchdog;
|
||||
watchdog.setSingleShot(true);
|
||||
watchdog.setInterval(timeoutMs);
|
||||
QObject::connect(&watchdog, &QTimer::timeout, &loop, &QEventLoop::quit);
|
||||
|
||||
const auto finishOne = [&](PassResult &pass, const ExactCard &card, const QImage &image) {
|
||||
++pass.finished;
|
||||
if (image.isNull()) {
|
||||
++pass.failed;
|
||||
if (pass.failedCards.size() < 10) {
|
||||
pass.failedCards.append(card.getName());
|
||||
}
|
||||
}
|
||||
QMutexLocker locker(&completedMutex);
|
||||
++completed;
|
||||
if (completed >= result.a.enqueued + result.b.enqueued) {
|
||||
loop.quit();
|
||||
}
|
||||
};
|
||||
|
||||
QElapsedTimer clock;
|
||||
QObject::connect(workerA, &CardPictureLoaderWorker::imageLoaded, &loop,
|
||||
[&](const ExactCard &card, const QImage &image) { finishOne(result.a, card, image); });
|
||||
QObject::connect(workerB, &CardPictureLoaderWorker::imageLoaded, &loop,
|
||||
[&](const ExactCard &card, const QImage &image) { finishOne(result.b, card, image); });
|
||||
|
||||
// Count 429 responses as seen by the shared rate limiter.
|
||||
LogCounters counters;
|
||||
s_activeCounters = &counters;
|
||||
s_previousMessageHandler = qInstallMessageHandler(stressLogHandler);
|
||||
|
||||
clock.start();
|
||||
for (const ExactCard &card : cards) {
|
||||
workerA->enqueueImageLoad(card);
|
||||
workerB->enqueueImageLoad(card);
|
||||
}
|
||||
watchdog.start();
|
||||
loop.exec();
|
||||
const qint64 elapsedMs = clock.elapsed();
|
||||
result.a.elapsedMs = elapsedMs;
|
||||
result.b.elapsedMs = elapsedMs;
|
||||
|
||||
result.http429Count = counters.http429;
|
||||
s_activeCounters = nullptr;
|
||||
qInstallMessageHandler(s_previousMessageHandler);
|
||||
return result;
|
||||
}
|
||||
|
||||
static QString formatDuration(qint64 ms)
|
||||
{
|
||||
return QStringLiteral("%1.%2 s").arg(ms / 1000).arg((ms % 1000) / 100);
|
||||
}
|
||||
|
||||
static bool likelyRedirects(const QString &urlTemplate)
|
||||
{
|
||||
return urlTemplate.contains(QStringLiteral("api.scryfall.com"));
|
||||
}
|
||||
|
||||
static QString hostOf(const QString &urlTemplate)
|
||||
{
|
||||
return QUrl(urlTemplate).host();
|
||||
}
|
||||
|
||||
static void printUsage()
|
||||
{
|
||||
std::printf("usage: picture_loader_benchmark_test --carddb <path> [options]\n"
|
||||
"\n"
|
||||
"Loads card pictures from the real configured hosts (not a mock server) and\n"
|
||||
"verifies the picture loader's pacing / cache 429 behavior.\n"
|
||||
"\n"
|
||||
"options:\n"
|
||||
" --carddb <path> cards.xml to load card data from (required)\n"
|
||||
" --count <N> cards to load per template (default 300)\n"
|
||||
" --url <template> test only this URL template; repeatable\n"
|
||||
" --stress run the two-worker concurrency stress instead\n"
|
||||
" --stress-url <t> template used by --stress (default: scryfall uuid)\n"
|
||||
" --timeout-min <N> per-pass watchdog in minutes (default auto)\n"
|
||||
" --cache-dir <dir> reuse this directory as the sandbox root; non-empty\n"
|
||||
" cache skips the cold pacing lower bound\n"
|
||||
" --help show this help\n");
|
||||
}
|
||||
|
||||
int main(int argc, char **argv)
|
||||
{
|
||||
QString cardDbArg;
|
||||
QStringList explicitUrls;
|
||||
int count = 300;
|
||||
bool stress = false;
|
||||
QString stressUrl(QStringLiteral("https://api.scryfall.com/cards/!set:uuid!?format=image"));
|
||||
QString cacheDirArg;
|
||||
std::optional<int> timeoutMin;
|
||||
|
||||
QStringList args;
|
||||
for (int i = 1; i < argc; ++i) {
|
||||
args.append(QString::fromLocal8Bit(argv[i]));
|
||||
}
|
||||
for (int i = 0; i < args.size(); ++i) {
|
||||
const QString &arg = args.at(i);
|
||||
const auto value = [&]() -> QString { return i + 1 < args.size() ? args.at(++i) : QString(); };
|
||||
if (arg == QLatin1String("--carddb")) {
|
||||
cardDbArg = value();
|
||||
} else if (arg == QLatin1String("--count")) {
|
||||
count = value().toInt();
|
||||
} else if (arg == QLatin1String("--url")) {
|
||||
explicitUrls.append(value());
|
||||
} else if (arg == QLatin1String("--stress")) {
|
||||
stress = true;
|
||||
} else if (arg == QLatin1String("--stress-url")) {
|
||||
stressUrl = value();
|
||||
} else if (arg == QLatin1String("--timeout-min")) {
|
||||
timeoutMin = value().toInt();
|
||||
} else if (arg == QLatin1String("--cache-dir")) {
|
||||
cacheDirArg = value();
|
||||
} else if (arg == QLatin1String("--help") || arg == QLatin1String("-h")) {
|
||||
printUsage();
|
||||
return 0;
|
||||
} else {
|
||||
std::fprintf(stderr, "unknown argument: %s\n", qPrintable(arg));
|
||||
printUsage();
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
|
||||
if (cardDbArg.isEmpty()) {
|
||||
std::fprintf(stderr, "error: --carddb is required (a cards.xml generated by Oracle)\n");
|
||||
printUsage();
|
||||
return 2;
|
||||
}
|
||||
if (count <= 0) {
|
||||
std::fprintf(stderr, "error: --count must be positive\n");
|
||||
return 2;
|
||||
}
|
||||
|
||||
QCoreApplication app(argc, argv);
|
||||
app.setApplicationName(QStringLiteral("Cockatrice"));
|
||||
app.setOrganizationName(QStringLiteral("Cockatrice"));
|
||||
app.setApplicationVersion(QStringLiteral("9.0.0-benchmark"));
|
||||
|
||||
// The ExactCard argument of imageLoaded crosses threads via a queued connection.
|
||||
qRegisterMetaType<ExactCard>();
|
||||
|
||||
QTemporaryDir sandbox;
|
||||
const QString rootDir = cacheDirArg.isEmpty() ? sandbox.path() : cacheDirArg;
|
||||
QDir().mkpath(rootDir);
|
||||
QDir().mkpath(rootDir + "/config");
|
||||
QDir().mkpath(rootDir + "/data");
|
||||
QDir().mkpath(rootDir + "/cache");
|
||||
|
||||
#ifdef Q_OS_UNIX
|
||||
// Redirect every QStandardPaths lookup (and therefore SettingsCache paths)
|
||||
// into the sandbox so the benchmark never touches user config or caches.
|
||||
qputenv("XDG_CONFIG_HOME", (rootDir + "/config").toUtf8());
|
||||
qputenv("XDG_DATA_HOME", (rootDir + "/data").toUtf8());
|
||||
qputenv("XDG_CACHE_HOME", (rootDir + "/cache").toUtf8());
|
||||
#endif
|
||||
|
||||
const bool warmStart = QDir(rootDir + "/cache/Cockatrice/downloaded")
|
||||
.entryList(QDir::Files | QDir::AllDirs | QDir::NoDotAndDotDot, QDir::Name)
|
||||
.size() > 0;
|
||||
|
||||
// Copied into the sandbox so the loader's binary cache ("cards.xml.cache")
|
||||
// is written next to it instead of next to the user's file, and so a
|
||||
// --cache-dir rerun can pick it up again.
|
||||
const QString dataPath = rootDir + "/data/Cockatrice";
|
||||
QDir().mkpath(dataPath);
|
||||
const QString cardsXml = dataPath + "/cards.xml";
|
||||
if (!QFile::exists(cardsXml)) {
|
||||
if (!QFile::copy(cardDbArg, cardsXml)) {
|
||||
std::fprintf(stderr, "error: could not copy %s to sandbox\n", qPrintable(cardDbArg));
|
||||
return 2;
|
||||
}
|
||||
}
|
||||
const QString customSetsDir = dataPath + "/customsets";
|
||||
QDir().mkpath(customSetsDir);
|
||||
|
||||
auto *prefs = new NoopCardPreferenceProvider();
|
||||
auto *priorityController = new NoopCardSetPriorityController();
|
||||
auto *pathProvider = new BenchmarkCardDatabasePathProvider(cardsXml, customSetsDir);
|
||||
CardDatabase db(nullptr, prefs, pathProvider, priorityController);
|
||||
db.loadCardDatabases();
|
||||
if (db.getLoadStatus() != Ok || db.getCardList().isEmpty()) {
|
||||
std::fprintf(stderr, "error: failed to load card database from %s\n", qPrintable(cardsXml));
|
||||
return 1;
|
||||
}
|
||||
const int availableCards = db.getCardList().size();
|
||||
|
||||
const QStringList urlsToTest =
|
||||
explicitUrls.isEmpty() ? SettingsCache::instance().downloads().getAllURLs() : explicitUrls;
|
||||
|
||||
if (stress) {
|
||||
const QList<ExactCard> cards = selectCardsForTemplate(db, stressUrl, count);
|
||||
if (cards.isEmpty()) {
|
||||
std::fprintf(stderr, "error: no cards satisfy template %s\n", qPrintable(stressUrl));
|
||||
return 1;
|
||||
}
|
||||
SettingsCache::instance().downloads().setDownloadUrls({stressUrl});
|
||||
|
||||
std::printf("=== STRESS: 2 workers x %d cards, host %s ===\n", static_cast<int>(cards.size()),
|
||||
qPrintable(hostOf(stressUrl)));
|
||||
auto *workerA = new CardPictureLoaderWorker();
|
||||
auto *workerB = new CardPictureLoaderWorker();
|
||||
const int timeoutMs = (timeoutMin.has_value() ? timeoutMin.value() : 15) * 60 * 1000;
|
||||
const StressResult result = runStress(workerA, workerB, cards, timeoutMs);
|
||||
|
||||
const bool completeA = result.a.finished >= result.a.enqueued;
|
||||
const bool completeB = result.b.finished >= result.b.enqueued;
|
||||
const bool zeroFailures = result.a.failed == 0 && result.b.failed == 0;
|
||||
const bool pass = completeA && completeB && zeroFailures;
|
||||
|
||||
std::printf("worker A: %d/%d loaded, %d failed (%.2f images/s)\n", result.a.finished, result.a.enqueued,
|
||||
result.a.failed,
|
||||
result.a.elapsedMs > 0
|
||||
? static_cast<double>(result.a.finished) * 1000.0 / static_cast<double>(result.a.elapsedMs)
|
||||
: 0.0);
|
||||
std::printf("worker B: %d/%d loaded, %d failed (%.2f images/s)\n", result.b.finished, result.b.enqueued,
|
||||
result.b.failed,
|
||||
result.b.elapsedMs > 0
|
||||
? static_cast<double>(result.b.finished) * 1000.0 / static_cast<double>(result.b.elapsedMs)
|
||||
: 0.0);
|
||||
std::printf("429 responses observed: %d\n", result.http429Count);
|
||||
std::printf("elapsed: %s\n", qPrintable(formatDuration(result.a.elapsedMs)));
|
||||
if (result.a.failed > 0) {
|
||||
std::printf(" worker A failures: %s\n", qPrintable(result.a.failedCards.join(QStringLiteral(", "))));
|
||||
}
|
||||
if (result.b.failed > 0) {
|
||||
std::printf(" worker B failures: %s\n", qPrintable(result.b.failedCards.join(QStringLiteral(", "))));
|
||||
}
|
||||
std::printf("RESULT: %s\n", pass ? "PASS" : "FAIL");
|
||||
return pass ? 0 : 1;
|
||||
}
|
||||
|
||||
bool allPass = true;
|
||||
std::printf("=== PICTURE LOADER BENCHMARK (%d cards available, %d per template)%s ===\n", availableCards, count,
|
||||
warmStart ? ", WARM cache from previous run" : "");
|
||||
|
||||
auto *worker = new CardPictureLoaderWorker();
|
||||
for (const QString &urlTemplate : urlsToTest) {
|
||||
const QList<ExactCard> cards = selectCardsForTemplate(db, urlTemplate, count);
|
||||
if (cards.isEmpty()) {
|
||||
std::printf("SKIP %-22s %-52s (no cards satisfy the template)\n", qPrintable(hostOf(urlTemplate)),
|
||||
qPrintable(urlTemplate));
|
||||
continue;
|
||||
}
|
||||
SettingsCache::instance().downloads().setDownloadUrls({urlTemplate});
|
||||
|
||||
const bool redirects = likelyRedirects(urlTemplate);
|
||||
const qint64 perCardMs = redirects ? 200 : 100;
|
||||
const int timeoutMs =
|
||||
(timeoutMin.has_value() ? timeoutMin.value() : (cards.size() * perCardMs * 8 + 60000) / 60000) * 60 * 1000;
|
||||
const qint64 coldLowerMs = static_cast<qint64>(cards.size()) * perCardMs / 2;
|
||||
const qint64 cachedUpperMs = 8000;
|
||||
|
||||
const PassResult cold = runPass(worker, cards, timeoutMs);
|
||||
const PassResult cached = runPass(worker, cards, timeoutMs);
|
||||
|
||||
const bool coldComplete = cold.finished >= cold.enqueued;
|
||||
const bool coldZeroFailures = cold.failed == 0;
|
||||
const bool coldPaced = warmStart || cold.elapsedMs >= coldLowerMs;
|
||||
const bool cachedComplete = cached.finished >= cached.enqueued;
|
||||
const bool cachedZeroFailures = cached.failed == 0;
|
||||
const bool cachedFast = cached.elapsedMs < cachedUpperMs;
|
||||
|
||||
const bool pass =
|
||||
coldComplete && coldZeroFailures && coldPaced && cachedComplete && cachedZeroFailures && cachedFast;
|
||||
allPass = allPass && pass;
|
||||
|
||||
std::printf("%s %-22s %-52s cold %s (paced>=%s) cached %s (must be <%.0fs) | %d/%d %d/%d loaded, "
|
||||
"%d/%d failed\n",
|
||||
pass ? "PASS " : "FAIL ", qPrintable(hostOf(urlTemplate)), qPrintable(urlTemplate),
|
||||
qPrintable(formatDuration(cold.elapsedMs)), qPrintable(formatDuration(coldLowerMs)),
|
||||
qPrintable(formatDuration(cached.elapsedMs)), static_cast<double>(cachedUpperMs) / 1000.0,
|
||||
cold.finished, cold.enqueued, cached.finished, cached.enqueued, cold.failed, cached.failed);
|
||||
if (cold.failed > 0) {
|
||||
std::printf(" cold failures: %s\n", qPrintable(cold.failedCards.join(QStringLiteral(", "))));
|
||||
}
|
||||
if (cached.failed > 0) {
|
||||
std::printf(" cached failures: %s\n", qPrintable(cached.failedCards.join(QStringLiteral(", "))));
|
||||
}
|
||||
}
|
||||
|
||||
std::printf("cache root: %s%s\n", qPrintable(rootDir),
|
||||
cacheDirArg.isEmpty() ? " (reuse with --cache-dir to warm on the next run)" : "");
|
||||
std::printf("RESULT: %s\n", allPass ? "PASS" : "FAIL");
|
||||
return allPass ? 0 : 1;
|
||||
}
|
||||
200
tests/picture_loader_benchmark/settings_cache_mock.cpp
Normal file
200
tests/picture_loader_benchmark/settings_cache_mock.cpp
Normal file
|
|
@ -0,0 +1,200 @@
|
|||
/*
|
||||
* Minimal SettingsCache implementation for the picture loader benchmark.
|
||||
*
|
||||
* The picture loader code insists on reading every URL, cache and path via
|
||||
* SettingsCache::instance(). Compiling the real client SettingsCache would drag
|
||||
* in the network update-checker graph, so instead we provide the SettingsCache
|
||||
* member functions it actually uses, backed by the real libcockatrice_settings
|
||||
* manager classes pointed at a sandboxed settings directory.
|
||||
*
|
||||
* The mock must be named SettingsCache: PathsSettings declares
|
||||
* `friend class SettingsCache`, which is the only way to construct it.
|
||||
*
|
||||
* Paths resolved through QStandardPaths are redirected into the sandbox by
|
||||
* setting the XDG_* environment variables before the first instance() call.
|
||||
*/
|
||||
|
||||
#include "client/settings/cache_settings.h"
|
||||
#include "interface/card_picture_loader/card_picture_loader_cache_method.h"
|
||||
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QSettings>
|
||||
#include <QStandardPaths>
|
||||
#include <libcockatrice/settings/cache_storage_settings.h>
|
||||
#include <libcockatrice/settings/cards_display_settings.h>
|
||||
#include <libcockatrice/settings/download_settings.h>
|
||||
#include <libcockatrice/settings/paths_settings.h>
|
||||
#include <utility>
|
||||
|
||||
QString SettingsCache::getDataPath()
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
|
||||
}
|
||||
|
||||
QString SettingsCache::getSettingsPath()
|
||||
{
|
||||
return getDataPath() + "/settings/";
|
||||
}
|
||||
|
||||
QString SettingsCache::getCachePath() const
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::CacheLocation);
|
||||
}
|
||||
|
||||
QString SettingsCache::getNetworkCachePath() const
|
||||
{
|
||||
return getCachePath() + "/downloaded/";
|
||||
}
|
||||
|
||||
QString SettingsCache::getCustomCardDatabasePath() const
|
||||
{
|
||||
return paths().getCustomCardDatabasePath();
|
||||
}
|
||||
|
||||
QString SettingsCache::getCardDatabasePath() const
|
||||
{
|
||||
return paths().getCardDatabasePath();
|
||||
}
|
||||
|
||||
QString SettingsCache::getSpoilerCardDatabasePath() const
|
||||
{
|
||||
return paths().getSpoilerCardDatabasePath();
|
||||
}
|
||||
|
||||
QString SettingsCache::getTokenDatabasePath() const
|
||||
{
|
||||
return paths().getTokenDatabasePath();
|
||||
}
|
||||
|
||||
int SettingsCache::getKeepAlive() const
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
int SettingsCache::getTimeOut() const
|
||||
{
|
||||
return 0;
|
||||
}
|
||||
|
||||
bool SettingsCache::getNotifyAboutUpdates() const
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
void SettingsCache::setKnownMissingFeatures(const QString & /*_knownMissingFeatures*/)
|
||||
{
|
||||
}
|
||||
|
||||
QString SettingsCache::getKnownMissingFeatures()
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
|
||||
QString SettingsCache::getClientID()
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
|
||||
DownloadSettings &SettingsCache::downloads() const
|
||||
{
|
||||
return *downloadSettings;
|
||||
}
|
||||
|
||||
CacheStorageSettings &SettingsCache::cacheStorage() const
|
||||
{
|
||||
return *cacheStorageSettings;
|
||||
}
|
||||
|
||||
CardsDisplaySettings &SettingsCache::cardsDisplay() const
|
||||
{
|
||||
return *cardsDisplaySettings;
|
||||
}
|
||||
|
||||
PathsSettings &SettingsCache::paths() const
|
||||
{
|
||||
return *pathsSettings;
|
||||
}
|
||||
|
||||
SettingsCache::SettingsCache()
|
||||
{
|
||||
settings = nullptr;
|
||||
isPortableBuild = false;
|
||||
|
||||
const QString settingsPath = getSettingsPath();
|
||||
QDir().mkpath(settingsPath);
|
||||
|
||||
downloadSettings = new DownloadSettings(settingsPath, this);
|
||||
cacheStorageSettings = new CacheStorageSettings(settingsPath, this);
|
||||
cardsDisplaySettings = new CardsDisplaySettings(settingsPath, this);
|
||||
pathsSettings = new PathsSettings(settingsPath, this);
|
||||
|
||||
// Picture downloads enabled, default host list, network disk cache storage.
|
||||
downloadSettings->setPicDownload(true);
|
||||
downloadSettings->resetToDefaultURLs();
|
||||
cacheStorageSettings->setCardImageCacheMethod(
|
||||
static_cast<int>(CardPictureLoaderCacheMethod::CacheMethod::NETWORK_CACHE));
|
||||
|
||||
loadPaths();
|
||||
}
|
||||
|
||||
void SettingsCache::loadPaths()
|
||||
{
|
||||
QString dataPath = getDataPath();
|
||||
QSettings pathsIni(getSettingsPath() + "paths.ini", QSettings::IniFormat);
|
||||
|
||||
auto computePath = [&](const QString &key, const QString &defaultPath) -> QString {
|
||||
QString val = pathsIni.value("paths/" + key).toString();
|
||||
if (val.isEmpty() || !QDir(val).exists()) {
|
||||
if (!QDir().mkpath(defaultPath)) {
|
||||
qCInfo(SettingsCacheLog) << "[SettingsCache] Could not create folder:" << defaultPath;
|
||||
}
|
||||
val = defaultPath;
|
||||
pathsIni.setValue("paths/" + key, val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
auto computeFilePath = [&](const QString &key, const QString &defaultPath) -> QString {
|
||||
QString val = pathsIni.value("paths/" + key).toString();
|
||||
if (!QFile::exists(val) || val.isEmpty()) {
|
||||
val = defaultPath;
|
||||
pathsIni.setValue("paths/" + key, val);
|
||||
}
|
||||
return val;
|
||||
};
|
||||
|
||||
computePath("decks", dataPath + "/decks/");
|
||||
computePath("filters", dataPath + "/filters/");
|
||||
computePath("replays", dataPath + "/replays/");
|
||||
computePath("themes", dataPath + "/themes/");
|
||||
computePath("pics", dataPath + "/pics/");
|
||||
computePath("redirects", getCachePath() + "/redirects/");
|
||||
|
||||
// customPicsPath derived from picsPath
|
||||
QString picsPath = pathsIni.value("paths/pics").toString();
|
||||
if (picsPath.endsWith("/")) {
|
||||
computePath("customPics", picsPath + "CUSTOM/");
|
||||
} else {
|
||||
computePath("customPics", picsPath + "/CUSTOM/");
|
||||
}
|
||||
|
||||
computePath("customSets", dataPath + "/customsets/");
|
||||
computeFilePath("cardDatabase", dataPath + "/cards.xml");
|
||||
computeFilePath("tokenDatabase", dataPath + "/tokens.xml");
|
||||
computeFilePath("spoilerDatabase", dataPath + "/spoiler.xml");
|
||||
}
|
||||
|
||||
void SettingsCache::setThemeName(const QString &_themeName)
|
||||
{
|
||||
if (themeName != _themeName) {
|
||||
themeName = _themeName;
|
||||
emit themeChanged();
|
||||
}
|
||||
}
|
||||
|
||||
SettingsCache &SettingsCache::instance()
|
||||
{
|
||||
static SettingsCache settingsCache;
|
||||
return settingsCache;
|
||||
}
|
||||
|
|
@ -334,6 +334,38 @@ TEST_F(SettingsDefaultsTest, Download_DownloadSpoilersStatus_Default)
|
|||
ASSERT_EQ(s.getDownloadSpoilersStatus(), false);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_Default)
|
||||
{
|
||||
DownloadSettings s(settingsPath, nullptr);
|
||||
ASSERT_TRUE(s.getHostRequestLimits().isEmpty());
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_SetAndGet)
|
||||
{
|
||||
DownloadSettings s(settingsPath, nullptr);
|
||||
s.setHostRequestLimits({{"api.scryfall.com", 5}});
|
||||
const QHash<QString, int> limits = s.getHostRequestLimits();
|
||||
ASSERT_EQ(limits.size(), 1);
|
||||
ASSERT_EQ(limits.value("api.scryfall.com"), 5);
|
||||
}
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Download_HostRequestLimits_StackedHostCaps)
|
||||
{
|
||||
DownloadSettings s(settingsPath, nullptr);
|
||||
// The developer cap for the Scryfall API lowers the ceiling to 9; a user can
|
||||
// reduce it further but can never raise it above the cap.
|
||||
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 9), 9);
|
||||
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 20), 9);
|
||||
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 5), 5);
|
||||
ASSERT_EQ(s.clampHostRequestLimit("api.scryfall.com", 0), 1);
|
||||
// Hosts without a developer cap fall back to the global default ceiling.
|
||||
ASSERT_EQ(s.clampHostRequestLimit("gatherer.wizards.com", 10), 10);
|
||||
ASSERT_EQ(s.clampHostRequestLimit("gatherer.wizards.com", 20), 10);
|
||||
// The Scryfall CDN is unlocked: no upper bound (values are only floored).
|
||||
ASSERT_EQ(s.clampHostRequestLimit("cards.scryfall.io", 20), 20);
|
||||
ASSERT_EQ(s.clampHostRequestLimit("cards.scryfall.io", 0), 1);
|
||||
}
|
||||
|
||||
// --- AppearanceSettings ---
|
||||
|
||||
TEST_F(SettingsDefaultsTest, Appearance_ThemeName_Default)
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue