Refactored the Image Uploader feature. (#4971)
Co-authored-by: Rasmus Karlsson <rasmus.karlsson@pajlada.com>
This commit is contained in:
@@ -1,356 +0,0 @@
|
||||
#include "NuulsUploader.hpp"
|
||||
|
||||
#include "common/Env.hpp"
|
||||
#include "common/NetworkRequest.hpp"
|
||||
#include "common/NetworkResult.hpp"
|
||||
#include "common/QLogging.hpp"
|
||||
#include "providers/twitch/TwitchMessageBuilder.hpp"
|
||||
#include "singletons/Paths.hpp"
|
||||
#include "singletons/Settings.hpp"
|
||||
#include "util/CombinePath.hpp"
|
||||
#include "widgets/helper/ResizingTextEdit.hpp"
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QHttpMultiPart>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QMimeDatabase>
|
||||
#include <QMutex>
|
||||
#include <QSaveFile>
|
||||
|
||||
#define UPLOAD_DELAY 2000
|
||||
// Delay between uploads in milliseconds
|
||||
|
||||
namespace {
|
||||
|
||||
std::optional<QByteArray> convertToPng(const QImage &image)
|
||||
{
|
||||
QByteArray imageData;
|
||||
QBuffer buf(&imageData);
|
||||
buf.open(QIODevice::WriteOnly);
|
||||
bool success = image.save(&buf, "png");
|
||||
if (success)
|
||||
{
|
||||
return imageData;
|
||||
}
|
||||
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
} // namespace
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
// These variables are only used from the main thread.
|
||||
static auto uploadMutex = QMutex();
|
||||
static std::queue<RawImageData> uploadQueue;
|
||||
|
||||
// logging information on successful uploads to a json file
|
||||
void logToFile(const QString originalFilePath, QString imageLink,
|
||||
QString deletionLink, ChannelPtr channel)
|
||||
{
|
||||
const QString logFileName =
|
||||
combinePath((getSettings()->logPath.getValue().isEmpty()
|
||||
? getPaths()->messageLogDirectory
|
||||
: getSettings()->logPath),
|
||||
"ImageUploader.json");
|
||||
|
||||
//reading existing logs
|
||||
QFile logReadFile(logFileName);
|
||||
bool isLogFileOkay =
|
||||
logReadFile.open(QIODevice::ReadWrite | QIODevice::Text);
|
||||
if (!isLogFileOkay)
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Failed to open log file with links at ") + logFileName));
|
||||
return;
|
||||
}
|
||||
auto logs = logReadFile.readAll();
|
||||
if (logs.isEmpty())
|
||||
{
|
||||
logs = QJsonDocument(QJsonArray()).toJson();
|
||||
}
|
||||
logReadFile.close();
|
||||
|
||||
//writing new data to logs
|
||||
QJsonObject newLogEntry;
|
||||
newLogEntry["channelName"] = channel->getName();
|
||||
newLogEntry["deletionLink"] =
|
||||
deletionLink.isEmpty() ? QJsonValue(QJsonValue::Null) : deletionLink;
|
||||
newLogEntry["imageLink"] = imageLink;
|
||||
newLogEntry["localPath"] = originalFilePath.isEmpty()
|
||||
? QJsonValue(QJsonValue::Null)
|
||||
: originalFilePath;
|
||||
newLogEntry["timestamp"] = QDateTime::currentSecsSinceEpoch();
|
||||
// channel name
|
||||
// deletion link (can be empty)
|
||||
// image link
|
||||
// local path to an image (can be empty)
|
||||
// timestamp
|
||||
QSaveFile logSaveFile(logFileName);
|
||||
logSaveFile.open(QIODevice::WriteOnly | QIODevice::Text);
|
||||
QJsonArray entries = QJsonDocument::fromJson(logs).array();
|
||||
entries.push_back(newLogEntry);
|
||||
logSaveFile.write(QJsonDocument(entries).toJson());
|
||||
logSaveFile.commit();
|
||||
}
|
||||
|
||||
// extracting link to either image or its deletion from response body
|
||||
QString getJSONValue(QJsonValue responseJson, QString jsonPattern)
|
||||
{
|
||||
for (const QString &key : jsonPattern.split("."))
|
||||
{
|
||||
responseJson = responseJson[key];
|
||||
}
|
||||
return responseJson.toString();
|
||||
}
|
||||
|
||||
QString getLinkFromResponse(NetworkResult response, QString pattern)
|
||||
{
|
||||
QRegularExpression regExp("{(.+)}",
|
||||
QRegularExpression::InvertedGreedinessOption);
|
||||
auto match = regExp.match(pattern);
|
||||
|
||||
while (match.hasMatch())
|
||||
{
|
||||
pattern.replace(match.captured(0),
|
||||
getJSONValue(response.parseJson(), match.captured(1)));
|
||||
match = regExp.match(pattern);
|
||||
}
|
||||
return pattern;
|
||||
}
|
||||
|
||||
void uploadImageToNuuls(RawImageData imageData, ChannelPtr channel,
|
||||
ResizingTextEdit &textEdit)
|
||||
{
|
||||
const static char *const boundary = "thisistheboudaryasd";
|
||||
const static QString contentType =
|
||||
QString("multipart/form-data; boundary=%1").arg(boundary);
|
||||
QUrl url(getSettings()->imageUploaderUrl.getValue().isEmpty()
|
||||
? getSettings()->imageUploaderUrl.getDefaultValue()
|
||||
: getSettings()->imageUploaderUrl);
|
||||
QString formField(
|
||||
getSettings()->imageUploaderFormField.getValue().isEmpty()
|
||||
? getSettings()->imageUploaderFormField.getDefaultValue()
|
||||
: getSettings()->imageUploaderFormField);
|
||||
auto extraHeaders =
|
||||
parseHeaderList(getSettings()->imageUploaderHeaders.getValue());
|
||||
QString originalFilePath = imageData.filePath;
|
||||
|
||||
QHttpMultiPart *payload = new QHttpMultiPart(QHttpMultiPart::FormDataType);
|
||||
QHttpPart part = QHttpPart();
|
||||
part.setBody(imageData.data);
|
||||
part.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QString("image/%1").arg(imageData.format));
|
||||
part.setHeader(QNetworkRequest::ContentLengthHeader,
|
||||
QVariant(imageData.data.length()));
|
||||
part.setHeader(QNetworkRequest::ContentDispositionHeader,
|
||||
QString("form-data; name=\"%1\"; filename=\"control_v.%2\"")
|
||||
.arg(formField)
|
||||
.arg(imageData.format));
|
||||
payload->setBoundary(boundary);
|
||||
payload->append(part);
|
||||
|
||||
NetworkRequest(url, NetworkRequestType::Post)
|
||||
.header("Content-Type", contentType)
|
||||
.headerList(extraHeaders)
|
||||
.multiPart(payload)
|
||||
.onSuccess([&textEdit, channel,
|
||||
originalFilePath](NetworkResult result) {
|
||||
QString link = getSettings()->imageUploaderLink.getValue().isEmpty()
|
||||
? result.getData()
|
||||
: getLinkFromResponse(
|
||||
result, getSettings()->imageUploaderLink);
|
||||
QString deletionLink =
|
||||
getSettings()->imageUploaderDeletionLink.getValue().isEmpty()
|
||||
? ""
|
||||
: getLinkFromResponse(
|
||||
result, getSettings()->imageUploaderDeletionLink);
|
||||
qCDebug(chatterinoNuulsuploader) << link << deletionLink;
|
||||
textEdit.insertPlainText(link + " ");
|
||||
if (uploadQueue.empty())
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Your image has been uploaded to %1 %2.")
|
||||
.arg(link)
|
||||
.arg(deletionLink.isEmpty()
|
||||
? ""
|
||||
: QString("(Deletion link: %1 )")
|
||||
.arg(deletionLink))));
|
||||
uploadMutex.unlock();
|
||||
}
|
||||
else
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Your image has been uploaded to %1 %2. %3 left. "
|
||||
"Please wait until all of them are uploaded. "
|
||||
"About %4 seconds left.")
|
||||
.arg(link)
|
||||
.arg(deletionLink.isEmpty()
|
||||
? ""
|
||||
: QString("(Deletion link: %1 )")
|
||||
.arg(deletionLink))
|
||||
.arg(uploadQueue.size())
|
||||
.arg(uploadQueue.size() * (UPLOAD_DELAY / 1000 + 1))));
|
||||
// 2 seconds for the timer that's there not to spam the remote server
|
||||
// and 1 second of actual uploading.
|
||||
|
||||
QTimer::singleShot(UPLOAD_DELAY, [channel, &textEdit]() {
|
||||
uploadImageToNuuls(uploadQueue.front(), channel, textEdit);
|
||||
uploadQueue.pop();
|
||||
});
|
||||
}
|
||||
|
||||
logToFile(originalFilePath, link, deletionLink, channel);
|
||||
})
|
||||
.onError([channel](NetworkResult result) -> bool {
|
||||
auto errorMessage =
|
||||
QString("An error happened while uploading your image: %1")
|
||||
.arg(result.formatError());
|
||||
|
||||
// Try to read more information from the result body
|
||||
auto obj = result.parseJson();
|
||||
if (!obj.isEmpty())
|
||||
{
|
||||
auto apiCode = obj.value("code");
|
||||
if (!apiCode.isUndefined())
|
||||
{
|
||||
auto codeString = apiCode.toVariant().toString();
|
||||
codeString.truncate(20);
|
||||
errorMessage += QString(" - code: %1").arg(codeString);
|
||||
}
|
||||
|
||||
auto apiError = obj.value("error").toString();
|
||||
if (!apiError.isEmpty())
|
||||
{
|
||||
apiError.truncate(300);
|
||||
errorMessage +=
|
||||
QString(" - error: %1").arg(apiError.trimmed());
|
||||
}
|
||||
}
|
||||
|
||||
channel->addMessage(makeSystemMessage(errorMessage));
|
||||
uploadMutex.unlock();
|
||||
return true;
|
||||
})
|
||||
.execute();
|
||||
}
|
||||
|
||||
void upload(const QMimeData *source, ChannelPtr channel,
|
||||
ResizingTextEdit &outputTextEdit)
|
||||
{
|
||||
if (!uploadMutex.tryLock())
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Please wait until the upload finishes.")));
|
||||
return;
|
||||
}
|
||||
|
||||
channel->addMessage(makeSystemMessage(QString("Started upload...")));
|
||||
if (source->hasUrls())
|
||||
{
|
||||
auto mimeDb = QMimeDatabase();
|
||||
// This path gets chosen when files are copied from a file manager, like explorer.exe, caja.
|
||||
// Each entry in source->urls() is a QUrl pointing to a file that was copied.
|
||||
for (const QUrl &path : source->urls())
|
||||
{
|
||||
QString localPath = path.toLocalFile();
|
||||
QMimeType mime = mimeDb.mimeTypeForUrl(path);
|
||||
if (mime.name().startsWith("image") && !mime.inherits("image/gif"))
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Uploading image: %1").arg(localPath)));
|
||||
QImage img = QImage(localPath);
|
||||
if (img.isNull())
|
||||
{
|
||||
channel->addMessage(
|
||||
makeSystemMessage(QString("Couldn't load image :(")));
|
||||
uploadMutex.unlock();
|
||||
return;
|
||||
}
|
||||
|
||||
auto imageData = convertToPng(img);
|
||||
if (imageData)
|
||||
{
|
||||
RawImageData data = {*imageData, "png", localPath};
|
||||
uploadQueue.push(data);
|
||||
}
|
||||
else
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Cannot upload file: %1. Couldn't convert "
|
||||
"image to png.")
|
||||
.arg(localPath)));
|
||||
uploadMutex.unlock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
else if (mime.inherits("image/gif"))
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Uploading GIF: %1").arg(localPath)));
|
||||
QFile file(localPath);
|
||||
bool isOkay = file.open(QIODevice::ReadOnly);
|
||||
if (!isOkay)
|
||||
{
|
||||
channel->addMessage(
|
||||
makeSystemMessage(QString("Failed to open file. :(")));
|
||||
uploadMutex.unlock();
|
||||
return;
|
||||
}
|
||||
RawImageData data = {file.readAll(), "gif", localPath};
|
||||
uploadQueue.push(data);
|
||||
file.close();
|
||||
// file.readAll() => might be a bit big but it /should/ work
|
||||
}
|
||||
else
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Cannot upload file: %1. Not an image.")
|
||||
.arg(localPath)));
|
||||
uploadMutex.unlock();
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (!uploadQueue.empty())
|
||||
{
|
||||
uploadImageToNuuls(uploadQueue.front(), channel, outputTextEdit);
|
||||
uploadQueue.pop();
|
||||
}
|
||||
}
|
||||
else if (source->hasFormat("image/png"))
|
||||
{
|
||||
// the path to file is not present every time, thus the filePath is empty
|
||||
uploadImageToNuuls({source->data("image/png"), "png", ""}, channel,
|
||||
outputTextEdit);
|
||||
}
|
||||
else if (source->hasFormat("image/jpeg"))
|
||||
{
|
||||
uploadImageToNuuls({source->data("image/jpeg"), "jpeg", ""}, channel,
|
||||
outputTextEdit);
|
||||
}
|
||||
else if (source->hasFormat("image/gif"))
|
||||
{
|
||||
uploadImageToNuuls({source->data("image/gif"), "gif", ""}, channel,
|
||||
outputTextEdit);
|
||||
}
|
||||
|
||||
else
|
||||
{ // not PNG, try loading it into QImage and save it to a PNG.
|
||||
auto image = qvariant_cast<QImage>(source->imageData());
|
||||
auto imageData = convertToPng(image);
|
||||
if (imageData)
|
||||
{
|
||||
uploadImageToNuuls({*imageData, "png", ""}, channel,
|
||||
outputTextEdit);
|
||||
}
|
||||
else
|
||||
{
|
||||
channel->addMessage(makeSystemMessage(
|
||||
QString("Cannot upload file, failed to convert to png.")));
|
||||
uploadMutex.unlock();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
} // namespace chatterino
|
||||
@@ -1,27 +0,0 @@
|
||||
#pragma once
|
||||
|
||||
#include <QMimeData>
|
||||
#include <QString>
|
||||
|
||||
#include <memory>
|
||||
|
||||
namespace chatterino {
|
||||
|
||||
class ResizingTextEdit;
|
||||
class Channel;
|
||||
using ChannelPtr = std::shared_ptr<Channel>;
|
||||
|
||||
struct RawImageData {
|
||||
QByteArray data;
|
||||
QString format;
|
||||
QString filePath;
|
||||
};
|
||||
|
||||
void upload(QByteArray imageData, ChannelPtr channel,
|
||||
ResizingTextEdit &textEdit, std::string format);
|
||||
void upload(RawImageData imageData, ChannelPtr channel,
|
||||
ResizingTextEdit &textEdit);
|
||||
void upload(const QMimeData *source, ChannelPtr channel,
|
||||
ResizingTextEdit &outputTextEdit);
|
||||
|
||||
} // namespace chatterino
|
||||
Reference in New Issue
Block a user