chore: baseline usable version
This commit is contained in:
38
src/qEmbyCore/CMakeLists.txt
Normal file
38
src/qEmbyCore/CMakeLists.txt
Normal file
@@ -0,0 +1,38 @@
|
||||
# src/qEmbyCore/CMakeLists.txt
|
||||
|
||||
file(GLOB_RECURSE CORE_HEADERS CONFIGURE_DEPENDS "*.h")
|
||||
file(GLOB_RECURSE CORE_SOURCES CONFIGURE_DEPENDS "*.cpp")
|
||||
|
||||
add_library(qEmbyCore SHARED
|
||||
${CORE_HEADERS}
|
||||
${CORE_SOURCES}
|
||||
)
|
||||
|
||||
target_include_directories(qEmbyCore PUBLIC
|
||||
${CMAKE_CURRENT_SOURCE_DIR}
|
||||
)
|
||||
|
||||
target_link_libraries(qEmbyCore PUBLIC Qt${QT_VERSION_MAJOR}::Gui
|
||||
Qt${QT_VERSION_MAJOR}::Core
|
||||
Qt${QT_VERSION_MAJOR}::Network
|
||||
Qt${QT_VERSION_MAJOR}::Concurrent
|
||||
Qt${QT_VERSION_MAJOR}::WebSockets
|
||||
QCoro6::Core
|
||||
QCoro6::Network
|
||||
)
|
||||
|
||||
target_compile_definitions(qEmbyCore PRIVATE QEMBYCORE_LIBRARY)
|
||||
|
||||
include(GNUInstallDirs)
|
||||
|
||||
set(_qemby_core_library_install_dir "${CMAKE_INSTALL_LIBDIR}")
|
||||
if(UNIX AND NOT APPLE AND QEMBY_RUNTIME_LIB_SUBDIR)
|
||||
set(_qemby_core_library_install_dir
|
||||
"${CMAKE_INSTALL_LIBDIR}/${QEMBY_RUNTIME_LIB_SUBDIR}")
|
||||
endif()
|
||||
|
||||
install(TARGETS qEmbyCore
|
||||
LIBRARY DESTINATION ${_qemby_core_library_install_dir}
|
||||
RUNTIME DESTINATION ${CMAKE_INSTALL_BINDIR}
|
||||
ARCHIVE DESTINATION ${_qemby_core_library_install_dir}
|
||||
)
|
||||
77
src/qEmbyCore/api/apiclient.cpp
Normal file
77
src/qEmbyCore/api/apiclient.cpp
Normal file
@@ -0,0 +1,77 @@
|
||||
#include "apiclient.h"
|
||||
|
||||
#include <utility>
|
||||
|
||||
ApiClient::ApiClient(const ServerProfile& profile, NetworkManager* nm, QObject* parent)
|
||||
: QObject(parent), m_profile(profile), m_network(nm) {}
|
||||
|
||||
QMap<QString, QString> ApiClient::getAuthHeaders() const {
|
||||
QMap<QString, QString> headers;
|
||||
|
||||
|
||||
|
||||
QString auth = QString("MediaBrowser Client=\"qEmby\", Device=\"Desktop\", "
|
||||
"DeviceId=\"%1\", Version=\"0.1\"")
|
||||
.arg(m_profile.deviceId);
|
||||
|
||||
if (!m_profile.accessToken.isEmpty()) {
|
||||
auth += QString(", Token=\"%1\"").arg(m_profile.accessToken);
|
||||
}
|
||||
|
||||
|
||||
headers.insert("X-Emby-Authorization", auth);
|
||||
return headers;
|
||||
}
|
||||
|
||||
NetworkRequestOptions ApiClient::requestOptions() const {
|
||||
NetworkRequestOptions options;
|
||||
options.ignoreSslErrors = m_profile.ignoreSslVerification;
|
||||
return options;
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> ApiClient::get(const QString& path) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->get(fullUrl, getAuthHeaders(),
|
||||
requestOptions());
|
||||
}
|
||||
|
||||
QCoro::Task<QString> ApiClient::getText(const QString& path) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->getText(fullUrl, getAuthHeaders(),
|
||||
requestOptions());
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> ApiClient::post(const QString& path, const QJsonObject& payload) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->post(fullUrl, getAuthHeaders(), payload,
|
||||
requestOptions());
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> ApiClient::postArray(const QString& path, const QJsonArray& payload) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->postArray(fullUrl, getAuthHeaders(), payload,
|
||||
requestOptions());
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> ApiClient::postBytes(const QString& path,
|
||||
QByteArray payload,
|
||||
QString contentType) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->postBytes(fullUrl, getAuthHeaders(),
|
||||
std::move(payload),
|
||||
std::move(contentType),
|
||||
requestOptions());
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> ApiClient::postForm(const QString& path,
|
||||
const QUrlQuery& formData) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->postForm(fullUrl, getAuthHeaders(), formData,
|
||||
requestOptions());
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> ApiClient::deleteResource(const QString& path) {
|
||||
QString fullUrl = m_profile.url + path;
|
||||
co_return co_await m_network->deleteResource(fullUrl, getAuthHeaders(),
|
||||
requestOptions());
|
||||
}
|
||||
36
src/qEmbyCore/api/apiclient.h
Normal file
36
src/qEmbyCore/api/apiclient.h
Normal file
@@ -0,0 +1,36 @@
|
||||
#ifndef APICLIENT_H
|
||||
#define APICLIENT_H
|
||||
|
||||
#include "../qEmbyCore_global.h"
|
||||
#include "networkmanager.h"
|
||||
#include "../models/profile/serverprofile.h"
|
||||
#include <QByteArray>
|
||||
#include <qcorotask.h>
|
||||
#include <QJsonArray>
|
||||
#include <QUrlQuery>
|
||||
|
||||
class QEMBYCORE_EXPORT ApiClient : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ApiClient(const ServerProfile& profile, NetworkManager* nm, QObject* parent = nullptr);
|
||||
|
||||
|
||||
QCoro::Task<QJsonObject> get(const QString& path);
|
||||
QCoro::Task<QString> getText(const QString& path);
|
||||
QCoro::Task<QJsonObject> post(const QString& path, const QJsonObject& payload);
|
||||
QCoro::Task<QJsonObject> postArray(const QString& path, const QJsonArray& payload);
|
||||
QCoro::Task<QJsonObject> postBytes(const QString& path, QByteArray payload,
|
||||
QString contentType);
|
||||
QCoro::Task<QJsonObject> postForm(const QString& path, const QUrlQuery& formData);
|
||||
QCoro::Task<QJsonObject> deleteResource(const QString& path);
|
||||
|
||||
private:
|
||||
ServerProfile m_profile;
|
||||
NetworkManager* m_network;
|
||||
|
||||
|
||||
QMap<QString, QString> getAuthHeaders() const;
|
||||
NetworkRequestOptions requestOptions() const;
|
||||
};
|
||||
|
||||
#endif
|
||||
325
src/qEmbyCore/api/embywebsocket.cpp
Normal file
325
src/qEmbyCore/api/embywebsocket.cpp
Normal file
@@ -0,0 +1,325 @@
|
||||
#include "embywebsocket.h"
|
||||
#include "proxymanager.h"
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkProxy>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QDebug>
|
||||
|
||||
EmbyWebSocket::EmbyWebSocket(const ServerProfile& profile, QObject* parent)
|
||||
: QObject(parent)
|
||||
, m_profile(profile)
|
||||
, m_socket(new QWebSocket(QString(), QWebSocketProtocol::VersionLatest, this))
|
||||
, m_keepAliveTimer(new QTimer(this))
|
||||
, m_reconnectTimer(new QTimer(this))
|
||||
{
|
||||
|
||||
m_keepAliveTimer->setInterval(KeepAliveIntervalMs);
|
||||
connect(m_keepAliveTimer, &QTimer::timeout, this, &EmbyWebSocket::sendKeepAlive);
|
||||
|
||||
|
||||
m_reconnectTimer->setSingleShot(true);
|
||||
connect(m_reconnectTimer, &QTimer::timeout, this, &EmbyWebSocket::attemptReconnect);
|
||||
|
||||
|
||||
connect(m_socket, &QWebSocket::connected, this, &EmbyWebSocket::onConnected);
|
||||
connect(m_socket, &QWebSocket::disconnected, this, &EmbyWebSocket::onDisconnected);
|
||||
connect(m_socket, &QWebSocket::textMessageReceived,
|
||||
this, &EmbyWebSocket::onTextMessageReceived);
|
||||
connect(m_socket, &QWebSocket::errorOccurred, this, &EmbyWebSocket::onError);
|
||||
connect(m_socket, &QWebSocket::sslErrors, this, &EmbyWebSocket::onSslErrors);
|
||||
|
||||
|
||||
|
||||
connect(ProxyManager::instance(), &ProxyManager::proxyChanged, this,
|
||||
[this]() {
|
||||
if (m_socket->state() != QAbstractSocket::UnconnectedState) {
|
||||
qInfo() << "[EmbyWebSocket] proxy changed → reconnect"
|
||||
<< "| profileId:" << m_profile.id;
|
||||
|
||||
m_intentionalDisconnect = false;
|
||||
m_socket->close();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
EmbyWebSocket::~EmbyWebSocket()
|
||||
{
|
||||
m_intentionalDisconnect = true;
|
||||
m_keepAliveTimer->stop();
|
||||
m_reconnectTimer->stop();
|
||||
if (m_socket->state() != QAbstractSocket::UnconnectedState) {
|
||||
m_socket->close();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void EmbyWebSocket::connectToServer()
|
||||
{
|
||||
if (m_socket->state() != QAbstractSocket::UnconnectedState) {
|
||||
return;
|
||||
}
|
||||
|
||||
m_intentionalDisconnect = false;
|
||||
m_reconnectAttempts = 0;
|
||||
QString url = buildWebSocketUrl();
|
||||
|
||||
|
||||
|
||||
const QNetworkProxy proxy =
|
||||
ProxyManager::instance()->resolveForServer(m_profile);
|
||||
m_socket->setProxy(proxy);
|
||||
qDebug() << "[EmbyWebSocket] proxy set"
|
||||
<< "| profileId:" << m_profile.id
|
||||
<< "| proxyType:" << proxy.type()
|
||||
<< "| host:" << proxy.hostName()
|
||||
<< "| port:" << proxy.port();
|
||||
|
||||
qDebug() << "[EmbyWebSocket] Connecting to:" << url;
|
||||
if (m_profile.ignoreSslVerification &&
|
||||
url.startsWith("wss://", Qt::CaseInsensitive)) {
|
||||
qWarning() << "[EmbyWebSocket] SSL certificate verification is DISABLED"
|
||||
<< "| url:" << url;
|
||||
}
|
||||
m_socket->open(QUrl(url));
|
||||
}
|
||||
|
||||
void EmbyWebSocket::disconnectFromServer()
|
||||
{
|
||||
m_intentionalDisconnect = true;
|
||||
m_keepAliveTimer->stop();
|
||||
m_reconnectTimer->stop();
|
||||
m_reconnectAttempts = 0;
|
||||
|
||||
if (m_socket->state() != QAbstractSocket::UnconnectedState) {
|
||||
m_socket->close();
|
||||
}
|
||||
}
|
||||
|
||||
bool EmbyWebSocket::isConnected() const
|
||||
{
|
||||
return m_socket->state() == QAbstractSocket::ConnectedState;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void EmbyWebSocket::onConnected()
|
||||
{
|
||||
qDebug() << "[EmbyWebSocket] Connected successfully";
|
||||
m_reconnectAttempts = 0;
|
||||
m_keepAliveTimer->start();
|
||||
Q_EMIT connected();
|
||||
}
|
||||
|
||||
void EmbyWebSocket::onDisconnected()
|
||||
{
|
||||
qDebug() << "[EmbyWebSocket] Disconnected";
|
||||
m_keepAliveTimer->stop();
|
||||
Q_EMIT disconnected();
|
||||
|
||||
|
||||
if (!m_intentionalDisconnect && m_reconnectAttempts < MaxReconnectAttempts) {
|
||||
|
||||
int delayMs = qMin(5000 * (1 << m_reconnectAttempts), 30000);
|
||||
qDebug() << "[EmbyWebSocket] Reconnect in" << delayMs << "ms (attempt"
|
||||
<< m_reconnectAttempts + 1 << ")";
|
||||
m_reconnectTimer->start(delayMs);
|
||||
}
|
||||
}
|
||||
|
||||
void EmbyWebSocket::onError(QAbstractSocket::SocketError error)
|
||||
{
|
||||
Q_UNUSED(error)
|
||||
QString errMsg = m_socket->errorString();
|
||||
qDebug() << "[EmbyWebSocket] Error:" << errMsg;
|
||||
Q_EMIT connectionError(errMsg);
|
||||
}
|
||||
|
||||
void EmbyWebSocket::onSslErrors(const QList<QSslError>& errors)
|
||||
{
|
||||
QStringList errorMessages;
|
||||
for (const QSslError& error : errors) {
|
||||
const QString errorText = error.errorString().trimmed();
|
||||
if (!errorText.isEmpty() && !errorMessages.contains(errorText)) {
|
||||
errorMessages.append(errorText);
|
||||
}
|
||||
}
|
||||
|
||||
const QString summary = errorMessages.join("; ");
|
||||
qWarning() << "[EmbyWebSocket] TLS validation errors"
|
||||
<< "| url:" << buildWebSocketUrl()
|
||||
<< "| ignoreSslVerification:"
|
||||
<< m_profile.ignoreSslVerification
|
||||
<< "| errors:" << summary;
|
||||
|
||||
if (m_profile.ignoreSslVerification) {
|
||||
qWarning() << "[EmbyWebSocket] Ignoring TLS validation errors for "
|
||||
"trusted server";
|
||||
m_socket->ignoreSslErrors();
|
||||
}
|
||||
}
|
||||
|
||||
void EmbyWebSocket::onTextMessageReceived(const QString& message)
|
||||
{
|
||||
QJsonDocument doc = QJsonDocument::fromJson(message.toUtf8());
|
||||
if (!doc.isObject()) return;
|
||||
|
||||
QJsonObject obj = doc.object();
|
||||
QString messageType = obj["MessageType"].toString();
|
||||
QJsonValue data = obj["Data"];
|
||||
|
||||
|
||||
QString dataPreview;
|
||||
if (data.isArray()) {
|
||||
QJsonDocument dataDoc(data.toArray());
|
||||
dataPreview = QString::fromUtf8(dataDoc.toJson(QJsonDocument::Compact));
|
||||
} else if (data.isObject()) {
|
||||
QJsonDocument dataDoc(data.toObject());
|
||||
dataPreview = QString::fromUtf8(dataDoc.toJson(QJsonDocument::Compact));
|
||||
} else {
|
||||
dataPreview = data.toVariant().toString();
|
||||
}
|
||||
if (dataPreview.length() > 300) {
|
||||
dataPreview = dataPreview.left(300) + "...";
|
||||
}
|
||||
qDebug() << "[WS RAW] <<" << messageType << "| Data:" << dataPreview;
|
||||
|
||||
|
||||
processMessage(messageType, data);
|
||||
|
||||
|
||||
Q_EMIT messageReceived(messageType, data);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void EmbyWebSocket::sendKeepAlive()
|
||||
{
|
||||
if (!isConnected()) return;
|
||||
|
||||
QJsonObject msg;
|
||||
msg["MessageType"] = QStringLiteral("KeepAlive");
|
||||
m_socket->sendTextMessage(QJsonDocument(msg).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
|
||||
void EmbyWebSocket::attemptReconnect()
|
||||
{
|
||||
if (m_intentionalDisconnect) return;
|
||||
|
||||
m_reconnectAttempts++;
|
||||
qDebug() << "[EmbyWebSocket] Reconnect attempt" << m_reconnectAttempts;
|
||||
QString url = buildWebSocketUrl();
|
||||
|
||||
m_socket->setProxy(
|
||||
ProxyManager::instance()->resolveForServer(m_profile));
|
||||
m_socket->open(QUrl(url));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QString EmbyWebSocket::buildWebSocketUrl() const
|
||||
{
|
||||
|
||||
QString baseUrl = m_profile.url;
|
||||
if (baseUrl.startsWith("https://", Qt::CaseInsensitive)) {
|
||||
baseUrl.replace(0, 5, "wss");
|
||||
} else if (baseUrl.startsWith("http://", Qt::CaseInsensitive)) {
|
||||
baseUrl.replace(0, 4, "ws");
|
||||
}
|
||||
|
||||
|
||||
while (baseUrl.endsWith('/')) {
|
||||
baseUrl.chop(1);
|
||||
}
|
||||
|
||||
|
||||
QString path;
|
||||
if (m_profile.type == ServerProfile::Emby) {
|
||||
path = "/embywebsocket";
|
||||
} else {
|
||||
path = "/socket";
|
||||
}
|
||||
|
||||
return QString("%1%2?api_key=%3&deviceId=%4")
|
||||
.arg(baseUrl, path, m_profile.accessToken, m_profile.deviceId);
|
||||
}
|
||||
|
||||
void EmbyWebSocket::processMessage(const QString& messageType, const QJsonValue& data)
|
||||
{
|
||||
if (messageType == "RefreshProgress") {
|
||||
|
||||
QJsonObject obj = data.toObject();
|
||||
QString itemId = obj["ItemId"].toString();
|
||||
|
||||
QJsonValue progressVal = obj["Progress"];
|
||||
int progress;
|
||||
if (progressVal.isString()) {
|
||||
progress = static_cast<int>(progressVal.toString().toDouble());
|
||||
} else {
|
||||
progress = static_cast<int>(progressVal.toDouble());
|
||||
}
|
||||
qDebug() << "[EmbyWebSocket] RefreshProgress itemId=" << itemId << "progress=" << progress;
|
||||
Q_EMIT refreshProgress(itemId, progress);
|
||||
}
|
||||
else if (messageType == "ScheduledTasksInfo" || messageType == "ScheduledTasksInfoChanged") {
|
||||
|
||||
QJsonArray tasks = data.toArray();
|
||||
Q_EMIT scheduledTasksInfoChanged(tasks);
|
||||
|
||||
|
||||
for (const auto& val : tasks) {
|
||||
QJsonObject taskObj = val.toObject();
|
||||
QString category = taskObj["Category"].toString();
|
||||
QString state = taskObj["State"].toString();
|
||||
if (category == "Library" && state == "Running") {
|
||||
|
||||
|
||||
if (taskObj.contains("CurrentProgressPercentage")
|
||||
&& !taskObj["CurrentProgressPercentage"].isNull()) {
|
||||
double progressPct = taskObj["CurrentProgressPercentage"].toDouble();
|
||||
int progress = qBound(0, static_cast<int>(progressPct), 100);
|
||||
qDebug() << "[EmbyWebSocket] ScheduledTask Library running, progress=" << progress;
|
||||
Q_EMIT refreshProgress(QString(), progress);
|
||||
} else {
|
||||
qDebug() << "[EmbyWebSocket] ScheduledTask Library running, no progress yet";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (messageType == "ScheduledTaskEnded") {
|
||||
|
||||
QJsonObject taskObj = data.toObject();
|
||||
QString category;
|
||||
|
||||
if (taskObj.contains("Category")) {
|
||||
category = taskObj["Category"].toString();
|
||||
} else if (taskObj.contains("Key")) {
|
||||
category = taskObj["Key"].toString();
|
||||
}
|
||||
qDebug() << "[EmbyWebSocket] ScheduledTaskEnded category=" << category;
|
||||
Q_EMIT libraryChanged();
|
||||
}
|
||||
else if (messageType == "LibraryChanged") {
|
||||
qDebug() << "[EmbyWebSocket] LibraryChanged";
|
||||
Q_EMIT libraryChanged();
|
||||
}
|
||||
else if (messageType == "Sessions") {
|
||||
QJsonArray sessions = data.toArray();
|
||||
Q_EMIT sessionsUpdated(sessions);
|
||||
}
|
||||
else if (messageType == "UserDataChanged") {
|
||||
QJsonObject obj = data.toObject();
|
||||
Q_EMIT userDataChanged(obj);
|
||||
}
|
||||
|
||||
}
|
||||
92
src/qEmbyCore/api/embywebsocket.h
Normal file
92
src/qEmbyCore/api/embywebsocket.h
Normal file
@@ -0,0 +1,92 @@
|
||||
#ifndef EMBYWEBSOCKET_H
|
||||
#define EMBYWEBSOCKET_H
|
||||
|
||||
#include "../qEmbyCore_global.h"
|
||||
#include "../models/profile/serverprofile.h"
|
||||
#include <QObject>
|
||||
#include <QSslError>
|
||||
#include <QWebSocket>
|
||||
#include <QTimer>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class QEMBYCORE_EXPORT EmbyWebSocket : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit EmbyWebSocket(const ServerProfile& profile, QObject* parent = nullptr);
|
||||
~EmbyWebSocket() override;
|
||||
|
||||
|
||||
void connectToServer();
|
||||
|
||||
|
||||
void disconnectFromServer();
|
||||
|
||||
|
||||
bool isConnected() const;
|
||||
|
||||
Q_SIGNALS:
|
||||
|
||||
void connected();
|
||||
void disconnected();
|
||||
void connectionError(const QString& error);
|
||||
|
||||
|
||||
|
||||
|
||||
void refreshProgress(const QString& itemId, int progress);
|
||||
|
||||
|
||||
void libraryChanged();
|
||||
|
||||
|
||||
void scheduledTasksInfoChanged(const QJsonArray& tasks);
|
||||
|
||||
|
||||
void sessionsUpdated(const QJsonArray& sessions);
|
||||
|
||||
|
||||
void userDataChanged(const QJsonObject& data);
|
||||
|
||||
|
||||
void messageReceived(const QString& messageType, const QJsonValue& data);
|
||||
|
||||
private Q_SLOTS:
|
||||
void onConnected();
|
||||
void onDisconnected();
|
||||
void onTextMessageReceived(const QString& message);
|
||||
void onError(QAbstractSocket::SocketError error);
|
||||
void onSslErrors(const QList<QSslError>& errors);
|
||||
void sendKeepAlive();
|
||||
void attemptReconnect();
|
||||
|
||||
private:
|
||||
|
||||
QString buildWebSocketUrl() const;
|
||||
|
||||
|
||||
void processMessage(const QString& messageType, const QJsonValue& data);
|
||||
|
||||
ServerProfile m_profile;
|
||||
QWebSocket* m_socket;
|
||||
QTimer* m_keepAliveTimer;
|
||||
QTimer* m_reconnectTimer;
|
||||
int m_reconnectAttempts = 0;
|
||||
bool m_intentionalDisconnect = false;
|
||||
|
||||
static constexpr int KeepAliveIntervalMs = 30000;
|
||||
static constexpr int MaxReconnectAttempts = 10;
|
||||
};
|
||||
|
||||
#endif
|
||||
497
src/qEmbyCore/api/networkmanager.cpp
Normal file
497
src/qEmbyCore/api/networkmanager.cpp
Normal file
@@ -0,0 +1,497 @@
|
||||
#include "networkmanager.h"
|
||||
#include <QJsonArray>
|
||||
#include <QJsonParseError>
|
||||
#include <QJsonValue>
|
||||
#include <QNetworkRequest>
|
||||
#include <QRegularExpression>
|
||||
#include <QStringConverter>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <qcoronetwork.h>
|
||||
#include <limits>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
|
||||
QString extractCharsetName(const QString &contentType)
|
||||
{
|
||||
static const QRegularExpression charsetRe(
|
||||
QStringLiteral("charset\\s*=\\s*\"?([^;\"\\s]+)\"?"),
|
||||
QRegularExpression::CaseInsensitiveOption);
|
||||
const QRegularExpressionMatch match = charsetRe.match(contentType);
|
||||
return match.hasMatch() ? match.captured(1).trimmed() : QString();
|
||||
}
|
||||
|
||||
bool isUtf8Charset(const QString &charset)
|
||||
{
|
||||
const QString normalized = charset.trimmed().toLower();
|
||||
return normalized.isEmpty() || normalized == "utf-8" || normalized == "utf8";
|
||||
}
|
||||
|
||||
int countReplacementCharacters(const QJsonValue &value)
|
||||
{
|
||||
switch (value.type()) {
|
||||
case QJsonValue::String:
|
||||
return value.toString().count(QChar::ReplacementCharacter);
|
||||
case QJsonValue::Array: {
|
||||
int total = 0;
|
||||
const QJsonArray array = value.toArray();
|
||||
for (const QJsonValue &entry : array) {
|
||||
total += countReplacementCharacters(entry);
|
||||
}
|
||||
return total;
|
||||
}
|
||||
case QJsonValue::Object: {
|
||||
int total = 0;
|
||||
const QJsonObject object = value.toObject();
|
||||
for (auto it = object.constBegin(); it != object.constEnd(); ++it) {
|
||||
total += countReplacementCharacters(it.value());
|
||||
}
|
||||
return total;
|
||||
}
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
int countReplacementCharacters(const QJsonDocument &document)
|
||||
{
|
||||
if (document.isArray()) {
|
||||
return countReplacementCharacters(QJsonValue(document.array()));
|
||||
}
|
||||
return countReplacementCharacters(QJsonValue(document.object()));
|
||||
}
|
||||
|
||||
bool tryParseJsonWithEncoding(const QByteArray &responseData,
|
||||
const QString &encodingName,
|
||||
QJsonDocument &jsonDoc,
|
||||
QJsonParseError &parseError)
|
||||
{
|
||||
const auto encoding =
|
||||
QStringConverter::encodingForName(encodingName);
|
||||
if (!encoding.has_value()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QStringDecoder decoder(*encoding);
|
||||
const QString decodedText = decoder(responseData);
|
||||
const QByteArray utf8Data = decodedText.toUtf8();
|
||||
jsonDoc = QJsonDocument::fromJson(utf8Data, &parseError);
|
||||
return parseError.error == QJsonParseError::NoError;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
NetworkManager::NetworkManager(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
m_networkManager = new QNetworkAccessManager(this);
|
||||
}
|
||||
|
||||
NetworkManager::~NetworkManager() {}
|
||||
|
||||
void NetworkManager::applyHeaders(QNetworkRequest& request, const QMap<QString, QString>& headers) {
|
||||
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, "application/json");
|
||||
|
||||
|
||||
QMapIterator<QString, QString> i(headers);
|
||||
while (i.hasNext()) {
|
||||
i.next();
|
||||
request.setRawHeader(i.key().toUtf8(), i.value().toUtf8());
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkManager::applyRequestOptions(QNetworkRequest& request,
|
||||
const NetworkRequestOptions& options) {
|
||||
request.setAttribute(QNetworkRequest::RedirectPolicyAttribute,
|
||||
QNetworkRequest::NoLessSafeRedirectPolicy);
|
||||
|
||||
if (options.ignoreSslErrors &&
|
||||
request.url().scheme().compare(QStringLiteral("https"),
|
||||
Qt::CaseInsensitive) == 0) {
|
||||
qWarning() << "[NetworkManager] SSL certificate verification is DISABLED"
|
||||
<< "| url:" << request.url().toString();
|
||||
}
|
||||
}
|
||||
|
||||
void NetworkManager::attachReplyHandlers(QNetworkReply* reply,
|
||||
const NetworkRequestOptions& options,
|
||||
const QString& requestKind) {
|
||||
reply->setProperty("ignoreSslErrors", options.ignoreSslErrors);
|
||||
|
||||
connect(reply, &QNetworkReply::sslErrors, reply,
|
||||
[reply, options, requestKind](const QList<QSslError>& errors) {
|
||||
QStringList errorMessages;
|
||||
for (const QSslError& error : errors) {
|
||||
const QString errorText = error.errorString().trimmed();
|
||||
if (!errorText.isEmpty() &&
|
||||
!errorMessages.contains(errorText)) {
|
||||
errorMessages.append(errorText);
|
||||
}
|
||||
}
|
||||
|
||||
const QString summary = errorMessages.join("; ");
|
||||
reply->setProperty("sslErrorsSummary", summary);
|
||||
|
||||
qWarning() << "[NetworkManager]" << requestKind
|
||||
<< "TLS validation errors"
|
||||
<< "| url:" << reply->url().toString()
|
||||
<< "| ignoreSslErrors:" << options.ignoreSslErrors
|
||||
<< "| errors:" << summary;
|
||||
|
||||
if (options.ignoreSslErrors) {
|
||||
qWarning() << "[NetworkManager]" << requestKind
|
||||
<< "Ignoring TLS validation errors for trusted "
|
||||
"server"
|
||||
<< "| url:" << reply->url().toString();
|
||||
reply->ignoreSslErrors();
|
||||
reply->setProperty("sslErrorsIgnored", true);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
QString NetworkManager::buildReplyErrorMessage(QNetworkReply* reply,
|
||||
int httpStatus) {
|
||||
QString errorMsg =
|
||||
QString(NetworkManager::tr("请求失败(HTTP %1): %2"))
|
||||
.arg(httpStatus)
|
||||
.arg(reply->errorString());
|
||||
|
||||
const QString sslSummary = reply->property("sslErrorsSummary").toString();
|
||||
if (!sslSummary.isEmpty() &&
|
||||
!reply->property("sslErrorsIgnored").toBool()) {
|
||||
errorMsg +=
|
||||
QStringLiteral("\n") +
|
||||
NetworkManager::tr("TLS/SSL 证书错误: %1").arg(sslSummary);
|
||||
errorMsg += QStringLiteral("\n") +
|
||||
NetworkManager::tr("如果这是可信任的自签名 HTTPS 服务器,请启用“忽略 SSL "
|
||||
"证书验证”。");
|
||||
}
|
||||
|
||||
return errorMsg;
|
||||
}
|
||||
|
||||
QJsonObject NetworkManager::parseReply(QNetworkReply* reply) {
|
||||
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
QByteArray responseBody = reply->readAll();
|
||||
qWarning() << "[NetworkManager] Request FAILED"
|
||||
<< "| url:" << reply->url().toString()
|
||||
<< "| httpStatus:" << httpStatus
|
||||
<< "| qtError:" << reply->error()
|
||||
<< "| errorString:" << reply->errorString()
|
||||
<< "| ignoreSslErrors:"
|
||||
<< reply->property("ignoreSslErrors").toBool()
|
||||
<< "| sslErrors:"
|
||||
<< reply->property("sslErrorsSummary").toString()
|
||||
<< "| responseBody:" << responseBody.left(500);
|
||||
|
||||
QString errorMsg = buildReplyErrorMessage(reply, httpStatus);
|
||||
throw std::runtime_error(errorMsg.toStdString());
|
||||
}
|
||||
|
||||
QByteArray responseData = reply->readAll();
|
||||
const QString contentType =
|
||||
reply->header(QNetworkRequest::ContentTypeHeader).toString();
|
||||
const QString charsetName = extractCharsetName(contentType);
|
||||
|
||||
|
||||
|
||||
if (responseData.isEmpty()) {
|
||||
return QJsonObject();
|
||||
}
|
||||
|
||||
QJsonParseError parseError;
|
||||
QJsonDocument jsonDoc = QJsonDocument::fromJson(responseData, &parseError);
|
||||
bool hasValidJson = parseError.error == QJsonParseError::NoError;
|
||||
QString parseSource = QStringLiteral("raw-utf8");
|
||||
int replacementCount =
|
||||
hasValidJson ? countReplacementCharacters(jsonDoc)
|
||||
: std::numeric_limits<int>::max();
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
auto tryAdoptDecodedJson = [&](const QString &encodingName,
|
||||
const QString &sourceLabel) {
|
||||
if (encodingName.isEmpty()) {
|
||||
return;
|
||||
}
|
||||
|
||||
QJsonParseError decodedParseError;
|
||||
QJsonDocument decodedDoc;
|
||||
if (!tryParseJsonWithEncoding(responseData, encodingName, decodedDoc,
|
||||
decodedParseError)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const int decodedReplacementCount =
|
||||
countReplacementCharacters(decodedDoc);
|
||||
if (!hasValidJson || decodedReplacementCount < replacementCount) {
|
||||
jsonDoc = decodedDoc;
|
||||
parseError = decodedParseError;
|
||||
hasValidJson = true;
|
||||
replacementCount = decodedReplacementCount;
|
||||
parseSource = sourceLabel;
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
if (!hasValidJson) {
|
||||
if (!isUtf8Charset(charsetName)) {
|
||||
tryAdoptDecodedJson(charsetName,
|
||||
QStringLiteral("response-charset:%1").arg(charsetName));
|
||||
}
|
||||
tryAdoptDecodedJson(QStringLiteral("GB18030"),
|
||||
QStringLiteral("fallback:GB18030"));
|
||||
tryAdoptDecodedJson(QStringLiteral("GBK"),
|
||||
QStringLiteral("fallback:GBK"));
|
||||
}
|
||||
|
||||
if (!hasValidJson) {
|
||||
qWarning() << "[NetworkManager] JSON parse failed"
|
||||
<< "| url:" << reply->url().toString()
|
||||
<< "| contentType:" << contentType
|
||||
<< "| charset:" << charsetName
|
||||
<< "| parseError:" << parseError.errorString()
|
||||
<< "| responseBody:" << responseData.left(500);
|
||||
QString errorMsg = tr("JSON 解析失败: ") + parseError.errorString();
|
||||
throw std::runtime_error(errorMsg.toStdString());
|
||||
}
|
||||
|
||||
if (!charsetName.isEmpty() || parseSource != QStringLiteral("raw-utf8") ||
|
||||
replacementCount > 0) {
|
||||
qDebug() << "[NetworkManager] JSON decoded"
|
||||
<< "| url:" << reply->url().toString()
|
||||
<< "| contentType:" << contentType
|
||||
<< "| charset:" << charsetName
|
||||
<< "| source:" << parseSource
|
||||
<< "| replacementChars:" << replacementCount;
|
||||
}
|
||||
|
||||
|
||||
|
||||
if (jsonDoc.isArray()) {
|
||||
QJsonObject wrapper;
|
||||
wrapper["data"] = jsonDoc.array();
|
||||
return wrapper;
|
||||
}
|
||||
|
||||
return jsonDoc.object();
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> NetworkManager::get(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant());
|
||||
|
||||
QNetworkReply* reply = m_networkManager->get(request);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("GET"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReply(reply);
|
||||
}
|
||||
|
||||
|
||||
QCoro::Task<QString> NetworkManager::getText(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant());
|
||||
|
||||
QNetworkReply* reply = m_networkManager->get(request);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("GET_TEXT"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReplyAsText(reply);
|
||||
}
|
||||
|
||||
QString NetworkManager::parseReplyAsText(QNetworkReply* reply) {
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
qWarning() << "[NetworkManager] getText FAILED"
|
||||
<< "| url:" << reply->url().toString()
|
||||
<< "| httpStatus:" << httpStatus
|
||||
<< "| errorString:" << reply->errorString()
|
||||
<< "| ignoreSslErrors:"
|
||||
<< reply->property("ignoreSslErrors").toBool()
|
||||
<< "| sslErrors:"
|
||||
<< reply->property("sslErrorsSummary").toString();
|
||||
QString errorMsg = buildReplyErrorMessage(reply, httpStatus);
|
||||
throw std::runtime_error(errorMsg.toStdString());
|
||||
}
|
||||
|
||||
QByteArray responseData = reply->readAll();
|
||||
const QString contentType =
|
||||
reply->header(QNetworkRequest::ContentTypeHeader).toString();
|
||||
const QString charsetName = extractCharsetName(contentType);
|
||||
|
||||
|
||||
if (!charsetName.isEmpty() && !isUtf8Charset(charsetName)) {
|
||||
auto encoding = QStringConverter::encodingForName(charsetName);
|
||||
if (encoding.has_value()) {
|
||||
QStringDecoder decoder(*encoding);
|
||||
return decoder(responseData);
|
||||
}
|
||||
}
|
||||
|
||||
return QString::fromUtf8(responseData);
|
||||
}
|
||||
|
||||
QCoro::Task<QByteArray> NetworkManager::getBytes(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant());
|
||||
|
||||
QNetworkReply* reply = m_networkManager->get(request);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("GET_BYTES"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReplyAsBytes(reply, QStringLiteral("getBytes"));
|
||||
}
|
||||
|
||||
QByteArray NetworkManager::parseReplyAsBytes(QNetworkReply* reply,
|
||||
const QString& requestKind) {
|
||||
reply->deleteLater();
|
||||
|
||||
if (reply->error() != QNetworkReply::NoError) {
|
||||
int httpStatus = reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
qWarning() << "[NetworkManager]" << requestKind << "FAILED"
|
||||
<< "| url:" << reply->url().toString()
|
||||
<< "| httpStatus:" << httpStatus
|
||||
<< "| errorString:" << reply->errorString()
|
||||
<< "| ignoreSslErrors:"
|
||||
<< reply->property("ignoreSslErrors").toBool()
|
||||
<< "| sslErrors:"
|
||||
<< reply->property("sslErrorsSummary").toString();
|
||||
QString errorMsg = buildReplyErrorMessage(reply, httpStatus);
|
||||
throw std::runtime_error(errorMsg.toStdString());
|
||||
}
|
||||
|
||||
return reply->readAll();
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> NetworkManager::post(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const QJsonObject& payload, const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
|
||||
QByteArray data;
|
||||
|
||||
|
||||
|
||||
if (!payload.isEmpty()) {
|
||||
data = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
QNetworkReply* reply = m_networkManager->post(request, data);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("POST"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReply(reply);
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> NetworkManager::postArray(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const QJsonArray& payload, const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
|
||||
QByteArray data;
|
||||
if (!payload.isEmpty()) {
|
||||
data = QJsonDocument(payload).toJson(QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
QNetworkReply* reply = m_networkManager->post(request, data);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("POST_ARRAY"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReply(reply);
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> NetworkManager::postBytes(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
QByteArray payload, QString contentType,
|
||||
const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
|
||||
if (contentType.trimmed().isEmpty()) {
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant());
|
||||
} else {
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, contentType.trimmed());
|
||||
}
|
||||
|
||||
QNetworkReply* reply = m_networkManager->post(request, payload);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("POST_BYTES"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReply(reply);
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> NetworkManager::postForm(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const QUrlQuery& formData, const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
"application/x-www-form-urlencoded");
|
||||
|
||||
const QByteArray data = formData.toString(QUrl::FullyEncoded).toUtf8();
|
||||
QNetworkReply* reply = m_networkManager->post(request, data);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("POST_FORM"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReply(reply);
|
||||
}
|
||||
|
||||
QCoro::Task<QJsonObject> NetworkManager::deleteResource(
|
||||
const QString& url, const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options)
|
||||
{
|
||||
QNetworkRequest request((QUrl(url)));
|
||||
applyHeaders(request, headers);
|
||||
applyRequestOptions(request, options);
|
||||
|
||||
|
||||
|
||||
request.setHeader(QNetworkRequest::ContentTypeHeader, QVariant());
|
||||
|
||||
qDebug() << "[NetworkManager] DELETE encodedUrl:" << request.url().toEncoded();
|
||||
|
||||
QNetworkReply* reply = m_networkManager->deleteResource(request);
|
||||
attachReplyHandlers(reply, options, QStringLiteral("DELETE"));
|
||||
co_await reply;
|
||||
|
||||
co_return parseReply(reply);
|
||||
}
|
||||
87
src/qEmbyCore/api/networkmanager.h
Normal file
87
src/qEmbyCore/api/networkmanager.h
Normal file
@@ -0,0 +1,87 @@
|
||||
#ifndef NETWORKMANAGER_H
|
||||
#define NETWORKMANAGER_H
|
||||
|
||||
#include "../qEmbyCore_global.h"
|
||||
#include <QByteArray>
|
||||
#include <QObject>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkRequest>
|
||||
#include <QNetworkReply>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonDocument>
|
||||
#include <QMap>
|
||||
#include <QSslError>
|
||||
#include <QUrlQuery>
|
||||
#include <qcorotask.h>
|
||||
|
||||
struct NetworkRequestOptions {
|
||||
bool ignoreSslErrors = false;
|
||||
};
|
||||
|
||||
class QEMBYCORE_EXPORT NetworkManager : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit NetworkManager(QObject *parent = nullptr);
|
||||
~NetworkManager();
|
||||
|
||||
static void applyRequestOptions(QNetworkRequest& request,
|
||||
const NetworkRequestOptions& options);
|
||||
static void attachReplyHandlers(QNetworkReply* reply,
|
||||
const NetworkRequestOptions& options,
|
||||
const QString& requestKind);
|
||||
static QString buildReplyErrorMessage(QNetworkReply* reply,
|
||||
int httpStatus);
|
||||
|
||||
|
||||
QCoro::Task<QJsonObject> get(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options = {});
|
||||
|
||||
|
||||
QCoro::Task<QString> getText(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options = {});
|
||||
|
||||
|
||||
QCoro::Task<QByteArray> getBytes(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options = {});
|
||||
|
||||
|
||||
QCoro::Task<QJsonObject> post(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const QJsonObject& payload,
|
||||
const NetworkRequestOptions& options = {});
|
||||
QCoro::Task<QJsonObject> postArray(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const QJsonArray& payload,
|
||||
const NetworkRequestOptions& options = {});
|
||||
QCoro::Task<QJsonObject> postBytes(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
QByteArray payload,
|
||||
QString contentType,
|
||||
const NetworkRequestOptions& options = {});
|
||||
QCoro::Task<QJsonObject> postForm(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const QUrlQuery& formData,
|
||||
const NetworkRequestOptions& options = {});
|
||||
|
||||
|
||||
QCoro::Task<QJsonObject> deleteResource(const QString& url,
|
||||
const QMap<QString, QString>& headers,
|
||||
const NetworkRequestOptions& options = {});
|
||||
|
||||
private:
|
||||
QNetworkAccessManager* m_networkManager;
|
||||
|
||||
|
||||
void applyHeaders(QNetworkRequest& request, const QMap<QString, QString>& headers);
|
||||
|
||||
|
||||
QJsonObject parseReply(QNetworkReply* reply);
|
||||
QString parseReplyAsText(QNetworkReply* reply);
|
||||
QByteArray parseReplyAsBytes(QNetworkReply* reply, const QString& requestKind);
|
||||
};
|
||||
|
||||
#endif
|
||||
317
src/qEmbyCore/api/proxymanager.cpp
Normal file
317
src/qEmbyCore/api/proxymanager.cpp
Normal file
@@ -0,0 +1,317 @@
|
||||
#include "proxymanager.h"
|
||||
|
||||
#include "../config/config_keys.h"
|
||||
#include "../config/configstore.h"
|
||||
#include "../services/manager/servermanager.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
#include <QHostAddress>
|
||||
#include <QNetworkProxyFactory>
|
||||
#include <QNetworkProxyQuery>
|
||||
#include <QUrl>
|
||||
#include <QtGlobal>
|
||||
|
||||
namespace {
|
||||
|
||||
|
||||
|
||||
|
||||
class ProxyManagerFactory : public QNetworkProxyFactory {
|
||||
public:
|
||||
QList<QNetworkProxy> queryProxy(
|
||||
const QNetworkProxyQuery& query) override {
|
||||
if (auto* mgr = ProxyManager::instance()) {
|
||||
return mgr->queryProxy(query);
|
||||
}
|
||||
return {QNetworkProxy(QNetworkProxy::NoProxy)};
|
||||
}
|
||||
};
|
||||
|
||||
bool g_factoryInstalled = false;
|
||||
|
||||
}
|
||||
|
||||
ProxyManager* ProxyManager::instance() {
|
||||
|
||||
static ProxyManager* s_instance = new ProxyManager(qApp);
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
void ProxyManager::installApplicationFactory() {
|
||||
if (g_factoryInstalled) {
|
||||
return;
|
||||
}
|
||||
|
||||
(void)instance();
|
||||
QNetworkProxyFactory::setApplicationProxyFactory(new ProxyManagerFactory());
|
||||
g_factoryInstalled = true;
|
||||
qInfo() << "[ProxyManager] applicationProxyFactory installed";
|
||||
}
|
||||
|
||||
ProxyManager::ProxyManager(QObject* parent) : QObject(parent) {
|
||||
|
||||
auto* cfg = ConfigStore::instance();
|
||||
connect(cfg, &ConfigStore::valueChanged, this,
|
||||
[this](const QString& key, const QVariant&) {
|
||||
if (key.startsWith(QStringLiteral("network/"))) {
|
||||
onConfigStoreChanged(key);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
ProxyManager::~ProxyManager() = default;
|
||||
|
||||
void ProxyManager::attachServerManager(ServerManager* sm) {
|
||||
if (m_serverManager == sm) {
|
||||
return;
|
||||
}
|
||||
if (m_serverManager) {
|
||||
|
||||
disconnect(m_serverManager, nullptr, this, nullptr);
|
||||
}
|
||||
m_serverManager = sm;
|
||||
if (!m_serverManager) {
|
||||
m_serverCache.clear();
|
||||
return;
|
||||
}
|
||||
|
||||
m_serverCache = m_serverManager->servers();
|
||||
|
||||
connect(m_serverManager, &ServerManager::serversChanged, this, [this]() {
|
||||
m_serverCache = m_serverManager->servers();
|
||||
qDebug() << "[ProxyManager] server list refreshed | count:"
|
||||
<< m_serverCache.size();
|
||||
Q_EMIT proxyChanged();
|
||||
});
|
||||
connect(m_serverManager, &ServerManager::serverProxyChanged, this,
|
||||
[this](const QString& id) {
|
||||
m_serverCache = m_serverManager->servers();
|
||||
qDebug() << "[ProxyManager] server proxy changed | id:" << id;
|
||||
Q_EMIT proxyChanged();
|
||||
});
|
||||
|
||||
qInfo() << "[ProxyManager] attached to ServerManager"
|
||||
<< "| servers:" << m_serverCache.size();
|
||||
}
|
||||
|
||||
QNetworkProxy ProxyManager::resolveForUrl(const QUrl& url) const {
|
||||
const QString host = url.host(QUrl::FullyDecoded);
|
||||
|
||||
if (const ServerProfile* profile = findServerByHost(host)) {
|
||||
if (profile->useGlobalProxy) {
|
||||
const auto cfg = globalConfig();
|
||||
qDebug() << "[ProxyManager] resolved"
|
||||
<< "| host:" << host
|
||||
<< "| source: server-uses-global"
|
||||
<< "| serverId:" << profile->id
|
||||
<< "| proxy:" << cfg.summary();
|
||||
return cfg.toQNetworkProxy();
|
||||
}
|
||||
|
||||
if (profile->proxy.mode == ProxyConfig::Custom &&
|
||||
profile->proxy.bypassLocalhost && isLoopbackOrLocal(host)) {
|
||||
qDebug() << "[ProxyManager] bypass localhost"
|
||||
<< "| host:" << host
|
||||
<< "| serverId:" << profile->id;
|
||||
return QNetworkProxy(QNetworkProxy::NoProxy);
|
||||
}
|
||||
qDebug() << "[ProxyManager] resolved"
|
||||
<< "| host:" << host
|
||||
<< "| source: server"
|
||||
<< "| serverId:" << profile->id
|
||||
<< "| proxy:" << profile->proxy.summary();
|
||||
return profile->proxy.toQNetworkProxy();
|
||||
}
|
||||
|
||||
|
||||
const auto cfg = globalConfig();
|
||||
if (cfg.mode == ProxyConfig::Custom && cfg.bypassLocalhost &&
|
||||
isLoopbackOrLocal(host)) {
|
||||
qDebug() << "[ProxyManager] bypass localhost (global) | host:" << host;
|
||||
return QNetworkProxy(QNetworkProxy::NoProxy);
|
||||
}
|
||||
qDebug() << "[ProxyManager] resolved"
|
||||
<< "| host:" << host
|
||||
<< "| source: global"
|
||||
<< "| proxy:" << cfg.summary();
|
||||
return cfg.toQNetworkProxy();
|
||||
}
|
||||
|
||||
QNetworkProxy ProxyManager::resolveForServer(
|
||||
const ServerProfile& profile) const {
|
||||
if (profile.useGlobalProxy) {
|
||||
const auto cfg = globalConfig();
|
||||
qDebug() << "[ProxyManager] resolveForServer"
|
||||
<< "| id:" << profile.id
|
||||
<< "| source: server-uses-global"
|
||||
<< "| proxy:" << cfg.summary();
|
||||
return cfg.toQNetworkProxy();
|
||||
}
|
||||
qDebug() << "[ProxyManager] resolveForServer"
|
||||
<< "| id:" << profile.id
|
||||
<< "| source: server"
|
||||
<< "| proxy:" << profile.proxy.summary();
|
||||
return profile.proxy.toQNetworkProxy();
|
||||
}
|
||||
|
||||
QNetworkProxy ProxyManager::resolveForServerId(const QString& serverId) const {
|
||||
if (serverId.isEmpty()) {
|
||||
const auto cfg = globalConfig();
|
||||
qDebug() << "[ProxyManager] resolveForServerId"
|
||||
<< "| serverId: <empty>"
|
||||
<< "| source: global"
|
||||
<< "| proxy:" << cfg.summary();
|
||||
return cfg.toQNetworkProxy();
|
||||
}
|
||||
|
||||
for (const auto& profile : m_serverCache) {
|
||||
if (profile.id == serverId) {
|
||||
return resolveForServer(profile);
|
||||
}
|
||||
}
|
||||
|
||||
const auto cfg = globalConfig();
|
||||
qDebug() << "[ProxyManager] resolveForServerId"
|
||||
<< "| serverId:" << serverId
|
||||
<< "| source: global (server not found)"
|
||||
<< "| proxy:" << cfg.summary();
|
||||
return cfg.toQNetworkProxy();
|
||||
}
|
||||
|
||||
ProxyConfig ProxyManager::globalConfig() const {
|
||||
auto* cfg = ConfigStore::instance();
|
||||
ProxyConfig p;
|
||||
p.mode = static_cast<ProxyConfig::Mode>(
|
||||
cfg->get<int>(ConfigKeys::ProxyMode, ProxyConfig::None));
|
||||
p.type = static_cast<ProxyConfig::Type>(
|
||||
cfg->get<int>(ConfigKeys::ProxyType, ProxyConfig::Http));
|
||||
p.host = cfg->get<QString>(ConfigKeys::ProxyHost);
|
||||
const int parsedPort = cfg->get<int>(ConfigKeys::ProxyPort, 0);
|
||||
p.port = (parsedPort > 0 && parsedPort <= 65535)
|
||||
? static_cast<quint16>(parsedPort)
|
||||
: 0;
|
||||
p.username = cfg->get<QString>(ConfigKeys::ProxyUsername);
|
||||
p.password = cfg->get<QString>(ConfigKeys::ProxyPassword);
|
||||
p.bypassLocalhost =
|
||||
cfg->get<bool>(ConfigKeys::ProxyBypassLocalhost, true);
|
||||
return p;
|
||||
}
|
||||
|
||||
void ProxyManager::setGlobalConfig(const ProxyConfig& cfg) {
|
||||
auto* store = ConfigStore::instance();
|
||||
store->set(ConfigKeys::ProxyMode, static_cast<int>(cfg.mode));
|
||||
store->set(ConfigKeys::ProxyType, static_cast<int>(cfg.type));
|
||||
store->set(ConfigKeys::ProxyHost, cfg.host);
|
||||
store->set(ConfigKeys::ProxyPort, static_cast<int>(cfg.port));
|
||||
store->set(ConfigKeys::ProxyUsername, cfg.username);
|
||||
store->set(ConfigKeys::ProxyPassword, cfg.password);
|
||||
store->set(ConfigKeys::ProxyBypassLocalhost, cfg.bypassLocalhost);
|
||||
qInfo() << "[ProxyManager] global proxy updated | proxy:" << cfg.summary();
|
||||
|
||||
|
||||
}
|
||||
|
||||
QString ProxyManager::toMpvHttpProxy(const QNetworkProxy& proxy) {
|
||||
if (proxy.type() == QNetworkProxy::NoProxy) {
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
if (proxy.type() == QNetworkProxy::Socks5Proxy) {
|
||||
qWarning() << "[ProxyManager] SOCKS5 proxy cannot be used with MPV player"
|
||||
<< "| host:" << proxy.hostName()
|
||||
<< "| port:" << proxy.port()
|
||||
<< "| reason: MPV/FFmpeg limitation (http-proxy option only supports HTTP proxies)"
|
||||
<< "| fallback: direct connection";
|
||||
return {};
|
||||
}
|
||||
if (proxy.type() != QNetworkProxy::HttpProxy) {
|
||||
qWarning() << "[ProxyManager] unsupported proxy type for MPV"
|
||||
<< "| type:" << proxy.type()
|
||||
<< "| fallback: direct connection";
|
||||
return {};
|
||||
}
|
||||
const QString host = proxy.hostName().trimmed();
|
||||
if (host.isEmpty() || proxy.port() == 0) {
|
||||
qWarning() << "[ProxyManager] invalid HTTP proxy configuration for MPV"
|
||||
<< "| host:" << host
|
||||
<< "| port:" << proxy.port();
|
||||
return {};
|
||||
}
|
||||
QUrl proxyUrl;
|
||||
proxyUrl.setScheme(QStringLiteral("http"));
|
||||
proxyUrl.setHost(host);
|
||||
proxyUrl.setPort(proxy.port());
|
||||
if (!proxy.user().isEmpty()) {
|
||||
proxyUrl.setUserName(proxy.user());
|
||||
proxyUrl.setPassword(proxy.password());
|
||||
}
|
||||
return proxyUrl.toString(QUrl::FullyEncoded);
|
||||
}
|
||||
|
||||
QList<QNetworkProxy> ProxyManager::queryProxy(
|
||||
const QNetworkProxyQuery& query) const {
|
||||
const QUrl url = query.url();
|
||||
|
||||
if (!url.isValid() || url.host().isEmpty()) {
|
||||
const auto cfg = globalConfig();
|
||||
return {cfg.toQNetworkProxy()};
|
||||
}
|
||||
return {resolveForUrl(url)};
|
||||
}
|
||||
|
||||
const ServerProfile* ProxyManager::findServerByHost(const QString& host) const {
|
||||
if (host.isEmpty()) {
|
||||
return nullptr;
|
||||
}
|
||||
const QString needle = host.toLower();
|
||||
for (const auto& p : m_serverCache) {
|
||||
const QUrl u(p.url, QUrl::TolerantMode);
|
||||
if (!u.isValid()) {
|
||||
continue;
|
||||
}
|
||||
const QString serverHost = u.host(QUrl::FullyDecoded).toLower();
|
||||
if (!serverHost.isEmpty() && serverHost == needle) {
|
||||
return &p;
|
||||
}
|
||||
}
|
||||
return nullptr;
|
||||
}
|
||||
|
||||
bool ProxyManager::isLoopbackOrLocal(const QString& host) {
|
||||
if (host.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
const QString lower = host.toLower();
|
||||
if (lower == QStringLiteral("localhost") ||
|
||||
lower.endsWith(QStringLiteral(".localhost"))) {
|
||||
return true;
|
||||
}
|
||||
QHostAddress addr(host);
|
||||
if (addr.isNull()) {
|
||||
return false;
|
||||
}
|
||||
if (addr.isLoopback() || addr.isLinkLocal()) {
|
||||
return true;
|
||||
}
|
||||
|
||||
if (addr.protocol() == QAbstractSocket::IPv4Protocol) {
|
||||
const quint32 v4 = addr.toIPv4Address();
|
||||
if ((v4 & 0xff000000u) == 0x0a000000u) return true;
|
||||
if ((v4 & 0xfff00000u) == 0xac100000u) return true;
|
||||
if ((v4 & 0xffff0000u) == 0xc0a80000u) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
void ProxyManager::onConfigStoreChanged(const QString& key) {
|
||||
qDebug() << "[ProxyManager] config changed | key:" << key
|
||||
<< "| proxy:" << globalConfig().summary();
|
||||
Q_EMIT proxyChanged();
|
||||
}
|
||||
95
src/qEmbyCore/api/proxymanager.h
Normal file
95
src/qEmbyCore/api/proxymanager.h
Normal file
@@ -0,0 +1,95 @@
|
||||
#ifndef PROXYMANAGER_H
|
||||
#define PROXYMANAGER_H
|
||||
|
||||
#include "../qEmbyCore_global.h"
|
||||
#include "../models/profile/proxyconfig.h"
|
||||
#include "../models/profile/serverprofile.h"
|
||||
#include <QHostAddress>
|
||||
#include <QList>
|
||||
#include <QNetworkProxy>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QUrl>
|
||||
|
||||
class ServerManager;
|
||||
class QNetworkProxyQuery;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class QEMBYCORE_EXPORT ProxyManager : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
|
||||
static ProxyManager* instance();
|
||||
|
||||
|
||||
|
||||
static void installApplicationFactory();
|
||||
|
||||
|
||||
|
||||
void attachServerManager(ServerManager* sm);
|
||||
|
||||
|
||||
|
||||
QNetworkProxy resolveForUrl(const QUrl& url) const;
|
||||
|
||||
|
||||
QNetworkProxy resolveForServer(const ServerProfile& profile) const;
|
||||
|
||||
|
||||
|
||||
QNetworkProxy resolveForServerId(const QString& serverId) const;
|
||||
|
||||
|
||||
ProxyConfig globalConfig() const;
|
||||
void setGlobalConfig(const ProxyConfig& cfg);
|
||||
|
||||
|
||||
|
||||
|
||||
static QString toMpvHttpProxy(const QNetworkProxy& proxy);
|
||||
|
||||
|
||||
QList<QNetworkProxy> queryProxy(const QNetworkProxyQuery& query) const;
|
||||
|
||||
Q_SIGNALS:
|
||||
|
||||
|
||||
|
||||
void proxyChanged();
|
||||
|
||||
private:
|
||||
explicit ProxyManager(QObject* parent = nullptr);
|
||||
~ProxyManager() override;
|
||||
Q_DISABLE_COPY(ProxyManager)
|
||||
|
||||
|
||||
|
||||
const ServerProfile* findServerByHost(const QString& host) const;
|
||||
|
||||
static bool isLoopbackOrLocal(const QString& host);
|
||||
|
||||
void onConfigStoreChanged(const QString& key);
|
||||
|
||||
ServerManager* m_serverManager = nullptr;
|
||||
mutable QList<ServerProfile> m_serverCache;
|
||||
};
|
||||
|
||||
#endif
|
||||
856
src/qEmbyCore/api/webdav/webdavclient.cpp
Normal file
856
src/qEmbyCore/api/webdav/webdavclient.cpp
Normal file
@@ -0,0 +1,856 @@
|
||||
#include "webdavclient.h"
|
||||
|
||||
#include <algorithm>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
#include <QBuffer>
|
||||
#include <QByteArray>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QNetworkAccessManager>
|
||||
#include <QNetworkReply>
|
||||
#include <QNetworkRequest>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
#include <QXmlStreamReader>
|
||||
|
||||
#include <qcoronetwork.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr int kDepthListing = 1;
|
||||
constexpr int kDepthSelf = 0;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QString humanReadableHttpError(const QString &verb, int status,
|
||||
QNetworkReply::NetworkError qtErr,
|
||||
const QString &qtErrString,
|
||||
const QByteArray &body = QByteArray())
|
||||
{
|
||||
Q_UNUSED(qtErr);
|
||||
|
||||
QString reason;
|
||||
switch (status)
|
||||
{
|
||||
case 401:
|
||||
reason = WebdavClient::tr(
|
||||
"Authentication failed. Please check the username and password.");
|
||||
break;
|
||||
case 403:
|
||||
reason = WebdavClient::tr(
|
||||
"Access denied. The account does not have permission for this operation.");
|
||||
break;
|
||||
case 404:
|
||||
reason = WebdavClient::tr("Resource not found on the WebDAV server.");
|
||||
break;
|
||||
case 405:
|
||||
reason = WebdavClient::tr(
|
||||
"The WebDAV server does not allow this method on this resource.");
|
||||
break;
|
||||
case 409:
|
||||
reason = WebdavClient::tr(
|
||||
"Conflict. The parent directory may not exist on the WebDAV server.");
|
||||
break;
|
||||
case 412:
|
||||
reason = WebdavClient::tr("Precondition failed (HTTP 412).");
|
||||
break;
|
||||
case 423:
|
||||
reason = WebdavClient::tr("The resource is locked by another client.");
|
||||
break;
|
||||
case 500:
|
||||
case 502:
|
||||
case 503:
|
||||
case 504:
|
||||
reason = WebdavClient::tr(
|
||||
"WebDAV server is temporarily unavailable. Please try again later.");
|
||||
break;
|
||||
case 507:
|
||||
reason = WebdavClient::tr("Insufficient storage on the WebDAV server.");
|
||||
break;
|
||||
case 0:
|
||||
|
||||
reason = qtErrString.isEmpty()
|
||||
? WebdavClient::tr("Could not reach the WebDAV server.")
|
||||
: WebdavClient::tr("Network error: %1").arg(qtErrString);
|
||||
break;
|
||||
default:
|
||||
if (status >= 200 && status < 300)
|
||||
{
|
||||
|
||||
reason = QStringLiteral("Unexpected success state");
|
||||
}
|
||||
else if (!qtErrString.isEmpty())
|
||||
{
|
||||
reason = qtErrString;
|
||||
}
|
||||
else
|
||||
{
|
||||
reason = WebdavClient::tr("Unexpected response from the WebDAV server.");
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
QString full;
|
||||
if (status > 0)
|
||||
{
|
||||
full = WebdavClient::tr("%1 failed (HTTP %2): %3")
|
||||
.arg(verb)
|
||||
.arg(status)
|
||||
.arg(reason);
|
||||
}
|
||||
else
|
||||
{
|
||||
full = WebdavClient::tr("%1 failed: %2").arg(verb, reason);
|
||||
}
|
||||
|
||||
|
||||
Q_UNUSED(body);
|
||||
return full;
|
||||
}
|
||||
|
||||
const QByteArray &propfindBody()
|
||||
{
|
||||
static const QByteArray body = QByteArrayLiteral(
|
||||
"<?xml version=\"1.0\" encoding=\"utf-8\"?>"
|
||||
"<d:propfind xmlns:d=\"DAV:\">"
|
||||
"<d:prop>"
|
||||
"<d:displayname/>"
|
||||
"<d:resourcetype/>"
|
||||
"<d:getcontentlength/>"
|
||||
"<d:getcontenttype/>"
|
||||
"<d:getlastmodified/>"
|
||||
"<d:getetag/>"
|
||||
"</d:prop>"
|
||||
"</d:propfind>");
|
||||
return body;
|
||||
}
|
||||
|
||||
QString stripQuotes(QString s)
|
||||
{
|
||||
s = s.trimmed();
|
||||
if (s.size() >= 2 && s.startsWith(QLatin1Char('"')) && s.endsWith(QLatin1Char('"')))
|
||||
{
|
||||
return s.mid(1, s.size() - 2);
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
QString sanitizeRelPath(QString p)
|
||||
{
|
||||
QString t = p.trimmed();
|
||||
while (t.startsWith(QLatin1Char('/')))
|
||||
t.remove(0, 1);
|
||||
while (t.endsWith(QLatin1Char('/')))
|
||||
t.chop(1);
|
||||
return t;
|
||||
}
|
||||
|
||||
QString joinPathSegments(const QStringList &parts)
|
||||
{
|
||||
QStringList encoded;
|
||||
encoded.reserve(parts.size());
|
||||
for (const QString &p : parts)
|
||||
{
|
||||
if (p.isEmpty())
|
||||
continue;
|
||||
encoded.append(QString::fromUtf8(QUrl::toPercentEncoding(p)));
|
||||
}
|
||||
return encoded.join(QLatin1Char('/'));
|
||||
}
|
||||
|
||||
int httpStatusOf(QNetworkReply *reply)
|
||||
{
|
||||
return reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt();
|
||||
}
|
||||
|
||||
|
||||
|
||||
QDateTime parseHttpDate(QString s)
|
||||
{
|
||||
QString w = s.trimmed();
|
||||
if (w.endsWith(QStringLiteral("GMT"), Qt::CaseInsensitive))
|
||||
{
|
||||
w.chop(3);
|
||||
w = w.trimmed() + QStringLiteral(" +0000");
|
||||
}
|
||||
QDateTime dt = QDateTime::fromString(w, Qt::RFC2822Date);
|
||||
if (!dt.isValid())
|
||||
{
|
||||
dt = QDateTime::fromString(s.trimmed(), Qt::ISODate);
|
||||
}
|
||||
return dt;
|
||||
}
|
||||
|
||||
QString hrefBasename(QString href)
|
||||
{
|
||||
if (href.isEmpty())
|
||||
return QString();
|
||||
QString path = QUrl(href).path();
|
||||
while (path.endsWith(QLatin1Char('/')))
|
||||
path.chop(1);
|
||||
const qsizetype idx = path.lastIndexOf(QLatin1Char('/'));
|
||||
return idx >= 0 ? path.mid(idx + 1) : path;
|
||||
}
|
||||
|
||||
QString normalizePathForCompare(QString p)
|
||||
{
|
||||
while (p.endsWith(QLatin1Char('/')))
|
||||
p.chop(1);
|
||||
return p;
|
||||
}
|
||||
|
||||
|
||||
|
||||
QList<WebdavEntry> parseMultiStatusXml(const QByteArray &body, const QString &parentRelPath)
|
||||
{
|
||||
QList<WebdavEntry> entries;
|
||||
QXmlStreamReader xml(body);
|
||||
|
||||
WebdavEntry cur;
|
||||
bool inResponse = false;
|
||||
bool inProp = false;
|
||||
|
||||
while (!xml.atEnd() && !xml.hasError())
|
||||
{
|
||||
xml.readNext();
|
||||
|
||||
if (xml.isStartElement())
|
||||
{
|
||||
const QString name = xml.name().toString().toLower();
|
||||
if (name == QStringLiteral("response"))
|
||||
{
|
||||
inResponse = true;
|
||||
cur = WebdavEntry();
|
||||
cur.parentRelPath = parentRelPath;
|
||||
}
|
||||
else if (inResponse && name == QStringLiteral("href"))
|
||||
{
|
||||
const QString raw = xml.readElementText().trimmed();
|
||||
cur.href = QUrl::fromPercentEncoding(raw.toUtf8());
|
||||
}
|
||||
else if (inResponse && name == QStringLiteral("prop"))
|
||||
{
|
||||
inProp = true;
|
||||
}
|
||||
else if (inProp)
|
||||
{
|
||||
if (name == QStringLiteral("displayname"))
|
||||
{
|
||||
const QString v = xml.readElementText();
|
||||
if (!v.isEmpty())
|
||||
cur.displayName = v;
|
||||
}
|
||||
else if (name == QStringLiteral("getcontentlength"))
|
||||
{
|
||||
bool ok = false;
|
||||
const qint64 v = xml.readElementText().toLongLong(&ok);
|
||||
if (ok)
|
||||
cur.contentLength = v;
|
||||
}
|
||||
else if (name == QStringLiteral("getcontenttype"))
|
||||
{
|
||||
cur.contentType = xml.readElementText();
|
||||
}
|
||||
else if (name == QStringLiteral("getlastmodified"))
|
||||
{
|
||||
const QDateTime dt = parseHttpDate(xml.readElementText());
|
||||
if (dt.isValid())
|
||||
cur.lastModified = dt;
|
||||
}
|
||||
else if (name == QStringLiteral("getetag"))
|
||||
{
|
||||
cur.etag = stripQuotes(xml.readElementText());
|
||||
}
|
||||
else if (name == QStringLiteral("resourcetype"))
|
||||
{
|
||||
int depth = 1;
|
||||
while (!xml.atEnd() && depth > 0)
|
||||
{
|
||||
xml.readNext();
|
||||
if (xml.isStartElement())
|
||||
{
|
||||
++depth;
|
||||
if (xml.name().toString().toLower() == QStringLiteral("collection"))
|
||||
{
|
||||
cur.isCollection = true;
|
||||
}
|
||||
}
|
||||
else if (xml.isEndElement())
|
||||
{
|
||||
--depth;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
else if (xml.isEndElement())
|
||||
{
|
||||
const QString name = xml.name().toString().toLower();
|
||||
if (name == QStringLiteral("prop"))
|
||||
{
|
||||
inProp = false;
|
||||
}
|
||||
else if (name == QStringLiteral("response"))
|
||||
{
|
||||
inResponse = false;
|
||||
if (cur.displayName.isEmpty())
|
||||
{
|
||||
cur.displayName = hrefBasename(cur.href);
|
||||
}
|
||||
entries.append(cur);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (xml.hasError())
|
||||
{
|
||||
qWarning() << "[WebdavClient] parseMultiStatus XML error:" << xml.errorString()
|
||||
<< "| line:" << xml.lineNumber()
|
||||
<< "| col:" << xml.columnNumber();
|
||||
}
|
||||
|
||||
return entries;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
WebdavClient::WebdavClient(WebdavProfile profile, QObject *parent)
|
||||
: QObject(parent), m_profile(std::move(profile))
|
||||
{
|
||||
m_profile.normalize();
|
||||
m_nam = new QNetworkAccessManager(this);
|
||||
}
|
||||
|
||||
WebdavClient::~WebdavClient() = default;
|
||||
|
||||
void WebdavClient::setProfile(WebdavProfile profile)
|
||||
{
|
||||
m_profile = std::move(profile);
|
||||
m_profile.normalize();
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QUrl WebdavClient::absoluteUrl(QString relPath) const
|
||||
{
|
||||
const QStringList rootParts =
|
||||
sanitizeRelPath(m_profile.rootDir).split(QLatin1Char('/'), Qt::SkipEmptyParts);
|
||||
const QStringList relParts =
|
||||
sanitizeRelPath(relPath).split(QLatin1Char('/'), Qt::SkipEmptyParts);
|
||||
|
||||
QStringList allParts;
|
||||
allParts.reserve(rootParts.size() + relParts.size());
|
||||
allParts.append(rootParts);
|
||||
allParts.append(relParts);
|
||||
|
||||
return absoluteUrlForSegments(allParts);
|
||||
}
|
||||
|
||||
QUrl WebdavClient::absoluteUrlForSegments(const QStringList &segments) const
|
||||
{
|
||||
QUrl base(m_profile.baseUrl);
|
||||
if (!base.isValid())
|
||||
{
|
||||
qWarning() << "[WebdavClient] absoluteUrl: invalid baseUrl"
|
||||
<< "| baseUrl:" << m_profile.baseUrl;
|
||||
return base;
|
||||
}
|
||||
|
||||
QString basePath = base.path();
|
||||
while (basePath.endsWith(QLatin1Char('/')))
|
||||
basePath.chop(1);
|
||||
|
||||
const QString encoded = joinPathSegments(segments);
|
||||
QString fullPath = basePath;
|
||||
if (!encoded.isEmpty())
|
||||
{
|
||||
if (!fullPath.endsWith(QLatin1Char('/')))
|
||||
fullPath += QLatin1Char('/');
|
||||
fullPath += encoded;
|
||||
}
|
||||
base.setPath(fullPath, QUrl::StrictMode);
|
||||
return base;
|
||||
}
|
||||
|
||||
NetworkRequestOptions WebdavClient::networkOptions() const
|
||||
{
|
||||
NetworkRequestOptions options;
|
||||
options.ignoreSslErrors = m_profile.ignoreSsl;
|
||||
return options;
|
||||
}
|
||||
|
||||
void WebdavClient::applyAuthHeader(QNetworkRequest &request) const
|
||||
{
|
||||
const QByteArray cred =
|
||||
(m_profile.username + QLatin1Char(':') + m_profile.password).toUtf8();
|
||||
request.setRawHeader(QByteArrayLiteral("Authorization"),
|
||||
QByteArrayLiteral("Basic ") + cred.toBase64());
|
||||
request.setRawHeader(QByteArrayLiteral("User-Agent"),
|
||||
QByteArrayLiteral("qEmby-Webdav/1.0"));
|
||||
request.setRawHeader(QByteArrayLiteral("Accept"),
|
||||
QByteArrayLiteral("application/xml, text/xml, */*"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<bool> WebdavClient::testConnection()
|
||||
{
|
||||
const QUrl url = absoluteUrl(QString());
|
||||
qDebug() << "[WebdavClient] testConnection START"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| user:" << m_profile.username
|
||||
<< "| ignoreSsl:" << m_profile.ignoreSsl;
|
||||
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
req.setRawHeader(QByteArrayLiteral("Depth"), QByteArray::number(kDepthSelf));
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QStringLiteral("application/xml; charset=utf-8"));
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
auto *buffer = new QBuffer();
|
||||
buffer->setData(propfindBody());
|
||||
buffer->open(QIODevice::ReadOnly);
|
||||
|
||||
QNetworkReply *reply = m_nam->sendCustomRequest(req, "PROPFIND", buffer);
|
||||
buffer->setParent(reply);
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_PROPFIND_SELF"));
|
||||
co_await reply;
|
||||
|
||||
const int status = httpStatusOf(reply);
|
||||
const auto qtErr = reply->error();
|
||||
const QString errString = reply->errorString();
|
||||
const QByteArray body = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
const bool ok = (qtErr == QNetworkReply::NoError) && (status == 207 || status == 200);
|
||||
|
||||
qDebug() << "[WebdavClient] testConnection DONE"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| status:" << status
|
||||
<< "| ok:" << ok
|
||||
<< "| qtError:" << qtErr
|
||||
<< "| errString:" << errString
|
||||
<< "| bodyHead:" << body.left(200);
|
||||
|
||||
if (!ok && qtErr != QNetworkReply::NoError)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("PROPFIND"), status, qtErr, errString)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
co_return ok;
|
||||
}
|
||||
|
||||
QCoro::Task<bool> WebdavClient::ensureRootDir()
|
||||
{
|
||||
qDebug() << "[WebdavClient] ensureRootDir START"
|
||||
<< "| rootDir:" << m_profile.rootDir;
|
||||
|
||||
const QStringList rootParts =
|
||||
sanitizeRelPath(m_profile.rootDir).split(QLatin1Char('/'), Qt::SkipEmptyParts);
|
||||
|
||||
if (rootParts.isEmpty())
|
||||
{
|
||||
const bool ok = co_await ensureCollection(QStringList());
|
||||
co_return ok;
|
||||
}
|
||||
|
||||
QStringList currentParts;
|
||||
currentParts.reserve(rootParts.size());
|
||||
for (const QString &part : rootParts)
|
||||
{
|
||||
currentParts.append(part);
|
||||
co_await ensureCollection(currentParts);
|
||||
}
|
||||
|
||||
qDebug() << "[WebdavClient] ensureRootDir DONE"
|
||||
<< "| rootDir:" << m_profile.rootDir;
|
||||
co_return true;
|
||||
}
|
||||
|
||||
QCoro::Task<bool> WebdavClient::ensureCollection(QStringList segments)
|
||||
{
|
||||
const QUrl url = absoluteUrlForSegments(segments);
|
||||
const QString segmentPath = segments.join(QLatin1Char('/'));
|
||||
const QString pathLabel = segmentPath.isEmpty()
|
||||
? QStringLiteral("/")
|
||||
: QStringLiteral("/") + segmentPath;
|
||||
|
||||
qDebug() << "[WebdavClient] ensureCollection probe START"
|
||||
<< "| path:" << pathLabel
|
||||
<< "| url:" << url.toString();
|
||||
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
req.setRawHeader(QByteArrayLiteral("Depth"), QByteArray::number(kDepthSelf));
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QStringLiteral("application/xml; charset=utf-8"));
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
auto *buffer = new QBuffer();
|
||||
buffer->setData(propfindBody());
|
||||
buffer->open(QIODevice::ReadOnly);
|
||||
|
||||
QNetworkReply *reply = m_nam->sendCustomRequest(req, "PROPFIND", buffer);
|
||||
buffer->setParent(reply);
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_PROPFIND_PROBE"));
|
||||
co_await reply;
|
||||
|
||||
const int probeStatus = httpStatusOf(reply);
|
||||
const auto probeErr = reply->error();
|
||||
const QString probeErrString = reply->errorString();
|
||||
const QByteArray probeBody = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
qDebug() << "[WebdavClient] ensureCollection probe DONE"
|
||||
<< "| path:" << pathLabel
|
||||
<< "| status:" << probeStatus
|
||||
<< "| qtErr:" << probeErr
|
||||
<< "| bodyHead:" << probeBody.left(200);
|
||||
|
||||
if (probeErr == QNetworkReply::NoError && (probeStatus == 207 || probeStatus == 200))
|
||||
{
|
||||
qDebug() << "[WebdavClient] ensureCollection exists"
|
||||
<< "| path:" << pathLabel;
|
||||
co_return true;
|
||||
}
|
||||
|
||||
|
||||
if (probeStatus == 404 || probeStatus == 409 || probeStatus == 405)
|
||||
{
|
||||
qDebug() << "[WebdavClient] ensureCollection MKCOL START"
|
||||
<< "| path:" << pathLabel
|
||||
<< "| url:" << url.toString();
|
||||
|
||||
QNetworkRequest mkcolReq(url);
|
||||
applyAuthHeader(mkcolReq);
|
||||
NetworkManager::applyRequestOptions(mkcolReq, networkOptions());
|
||||
|
||||
QNetworkReply *mkcolReply = m_nam->sendCustomRequest(mkcolReq, "MKCOL");
|
||||
NetworkManager::attachReplyHandlers(mkcolReply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_MKCOL_ROOT_SEGMENT"));
|
||||
co_await mkcolReply;
|
||||
|
||||
const int mkcolStatus = httpStatusOf(mkcolReply);
|
||||
const auto mkcolErr = mkcolReply->error();
|
||||
const QString mkcolErrString = mkcolReply->errorString();
|
||||
const QByteArray mkcolBody = mkcolReply->readAll();
|
||||
mkcolReply->deleteLater();
|
||||
|
||||
const bool ok = mkcolStatus == 405 ||
|
||||
((mkcolErr == QNetworkReply::NoError) &&
|
||||
(mkcolStatus == 200 || mkcolStatus == 201 ||
|
||||
mkcolStatus == 204));
|
||||
|
||||
qDebug() << "[WebdavClient] ensureCollection MKCOL DONE"
|
||||
<< "| path:" << pathLabel
|
||||
<< "| status:" << mkcolStatus
|
||||
<< "| ok:" << ok
|
||||
<< "| bodyHead:" << mkcolBody.left(200);
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("MKCOL"), mkcolStatus, mkcolErr,
|
||||
mkcolErrString, mkcolBody)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
co_return true;
|
||||
}
|
||||
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("PROPFIND"), probeStatus, probeErr, probeErrString)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
QCoro::Task<QList<WebdavEntry>> WebdavClient::list(QString relPath)
|
||||
{
|
||||
const QString cleanRel = sanitizeRelPath(relPath);
|
||||
const QUrl url = absoluteUrl(cleanRel);
|
||||
|
||||
qDebug() << "[WebdavClient] list START"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| relPath:" << cleanRel;
|
||||
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
req.setRawHeader(QByteArrayLiteral("Depth"), QByteArray::number(kDepthListing));
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader,
|
||||
QStringLiteral("application/xml; charset=utf-8"));
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
auto *buffer = new QBuffer();
|
||||
buffer->setData(propfindBody());
|
||||
buffer->open(QIODevice::ReadOnly);
|
||||
|
||||
QNetworkReply *reply = m_nam->sendCustomRequest(req, "PROPFIND", buffer);
|
||||
buffer->setParent(reply);
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_PROPFIND_LIST"));
|
||||
co_await reply;
|
||||
|
||||
const int status = httpStatusOf(reply);
|
||||
const auto qtErr = reply->error();
|
||||
const QString errString = reply->errorString();
|
||||
const QByteArray body = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
qDebug() << "[WebdavClient] list response"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| status:" << status
|
||||
<< "| qtError:" << qtErr
|
||||
<< "| bodyLen:" << body.size();
|
||||
|
||||
if (qtErr != QNetworkReply::NoError || status < 200 || status >= 300)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("PROPFIND"), status, qtErr, errString, body)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
QList<WebdavEntry> result = parseMultiStatusXml(body, cleanRel);
|
||||
|
||||
|
||||
const QString parentPathNorm = normalizePathForCompare(url.path(QUrl::FullyDecoded));
|
||||
auto isSelf = [&parentPathNorm](const WebdavEntry &e)
|
||||
{
|
||||
QUrl entryUrl(e.href);
|
||||
const QString p = entryUrl.isValid() ? entryUrl.path(QUrl::FullyDecoded) : e.href;
|
||||
return normalizePathForCompare(p) == parentPathNorm;
|
||||
};
|
||||
result.erase(std::remove_if(result.begin(), result.end(), isSelf), result.end());
|
||||
|
||||
qDebug() << "[WebdavClient] list DONE"
|
||||
<< "| entries:" << result.size();
|
||||
co_return result;
|
||||
}
|
||||
|
||||
QCoro::Task<QByteArray> WebdavClient::getFile(QString relPath)
|
||||
{
|
||||
const QString cleanRel = sanitizeRelPath(relPath);
|
||||
if (cleanRel.isEmpty())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Cannot download: relative path is empty.").toUtf8().toStdString());
|
||||
}
|
||||
const QUrl url = absoluteUrl(cleanRel);
|
||||
|
||||
qDebug() << "[WebdavClient] getFile START | url:" << url.toString();
|
||||
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
QNetworkReply *reply = m_nam->get(req);
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_GET"));
|
||||
co_await reply;
|
||||
|
||||
const int status = httpStatusOf(reply);
|
||||
const auto qtErr = reply->error();
|
||||
const QString errString = reply->errorString();
|
||||
const QByteArray body = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
qDebug() << "[WebdavClient] getFile DONE"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| status:" << status
|
||||
<< "| size:" << body.size();
|
||||
|
||||
if (qtErr != QNetworkReply::NoError || status < 200 || status >= 300)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("GET"), status, qtErr, errString)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
co_return body;
|
||||
}
|
||||
|
||||
QCoro::Task<bool> WebdavClient::putFile(QString relPath, QByteArray bytes, QString contentType)
|
||||
{
|
||||
const QString cleanRel = sanitizeRelPath(relPath);
|
||||
if (cleanRel.isEmpty())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Cannot upload: relative path is empty.").toUtf8().toStdString());
|
||||
}
|
||||
const QUrl url = absoluteUrl(cleanRel);
|
||||
|
||||
qDebug() << "[WebdavClient] putFile START"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| size:" << bytes.size()
|
||||
<< "| contentType:" << contentType;
|
||||
|
||||
bool retried = false;
|
||||
|
||||
for (;;)
|
||||
{
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
if (!contentType.trimmed().isEmpty())
|
||||
{
|
||||
req.setHeader(QNetworkRequest::ContentTypeHeader, contentType.trimmed());
|
||||
}
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
QNetworkReply *reply = m_nam->put(req, bytes);
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_PUT"));
|
||||
co_await reply;
|
||||
|
||||
const int status = httpStatusOf(reply);
|
||||
const auto qtErr = reply->error();
|
||||
const QString errString = reply->errorString();
|
||||
const QByteArray respBody = reply->readAll();
|
||||
reply->deleteLater();
|
||||
|
||||
const bool ok = (qtErr == QNetworkReply::NoError) &&
|
||||
(status == 200 || status == 201 || status == 204);
|
||||
|
||||
if (ok)
|
||||
{
|
||||
qDebug() << "[WebdavClient] putFile DONE"
|
||||
<< "| url:" << url.toString()
|
||||
<< "| status:" << status
|
||||
<< "| retried:" << retried;
|
||||
co_return true;
|
||||
}
|
||||
|
||||
|
||||
if (!retried && (status == 404 || status == 409))
|
||||
{
|
||||
retried = true;
|
||||
qDebug() << "[WebdavClient] putFile received" << status
|
||||
<< ", forcing MKCOL on parent and retrying...";
|
||||
|
||||
const int lastSlash = cleanRel.lastIndexOf(QLatin1Char('/'));
|
||||
if (lastSlash >= 0)
|
||||
{
|
||||
co_await mkcol(cleanRel.left(lastSlash));
|
||||
}
|
||||
else
|
||||
{
|
||||
co_await mkcol(QString());
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("PUT"), status, qtErr, errString, respBody)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
}
|
||||
|
||||
QCoro::Task<bool> WebdavClient::remove(QString relPath)
|
||||
{
|
||||
const QString cleanRel = sanitizeRelPath(relPath);
|
||||
if (cleanRel.isEmpty())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Refused to delete the WebDAV root directory itself.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
const QUrl url = absoluteUrl(cleanRel);
|
||||
|
||||
qDebug() << "[WebdavClient] remove START | url:" << url.toString();
|
||||
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
QNetworkReply *reply = m_nam->deleteResource(req);
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_DELETE"));
|
||||
co_await reply;
|
||||
|
||||
const int status = httpStatusOf(reply);
|
||||
const auto qtErr = reply->error();
|
||||
const QString errString = reply->errorString();
|
||||
reply->deleteLater();
|
||||
|
||||
|
||||
const bool ok = (qtErr == QNetworkReply::NoError) &&
|
||||
(status == 200 || status == 204 || status == 404);
|
||||
|
||||
qDebug() << "[WebdavClient] remove DONE"
|
||||
<< "| status:" << status
|
||||
<< "| ok:" << ok;
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("DELETE"), status, qtErr, errString)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
co_return true;
|
||||
}
|
||||
|
||||
QCoro::Task<bool> WebdavClient::mkcol(QString relPath)
|
||||
{
|
||||
const QString cleanRel = sanitizeRelPath(relPath);
|
||||
const QUrl url = absoluteUrl(cleanRel);
|
||||
|
||||
qDebug() << "[WebdavClient] mkcol START | url:" << url.toString();
|
||||
|
||||
QNetworkRequest req(url);
|
||||
applyAuthHeader(req);
|
||||
NetworkManager::applyRequestOptions(req, networkOptions());
|
||||
|
||||
QNetworkReply *reply = m_nam->sendCustomRequest(req, "MKCOL");
|
||||
NetworkManager::attachReplyHandlers(reply, networkOptions(),
|
||||
QStringLiteral("WEBDAV_MKCOL"));
|
||||
co_await reply;
|
||||
|
||||
const int status = httpStatusOf(reply);
|
||||
const auto qtErr = reply->error();
|
||||
const QString errString = reply->errorString();
|
||||
reply->deleteLater();
|
||||
|
||||
|
||||
|
||||
const bool ok = status == 405 ||
|
||||
((qtErr == QNetworkReply::NoError) &&
|
||||
(status == 200 || status == 201));
|
||||
|
||||
qDebug() << "[WebdavClient] mkcol DONE"
|
||||
<< "| status:" << status
|
||||
<< "| ok:" << ok;
|
||||
|
||||
if (!ok)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
humanReadableHttpError(QStringLiteral("MKCOL"), status, qtErr, errString)
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
co_return true;
|
||||
}
|
||||
92
src/qEmbyCore/api/webdav/webdavclient.h
Normal file
92
src/qEmbyCore/api/webdav/webdavclient.h
Normal file
@@ -0,0 +1,92 @@
|
||||
#ifndef WEBDAVCLIENT_H
|
||||
#define WEBDAVCLIENT_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include "../networkmanager.h"
|
||||
#include "webdaventry.h"
|
||||
#include "webdavprofile.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QUrl>
|
||||
|
||||
#include <qcorotask.h>
|
||||
|
||||
class QNetworkAccessManager;
|
||||
class QNetworkRequest;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class QEMBYCORE_EXPORT WebdavClient : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit WebdavClient(WebdavProfile profile, QObject* parent = nullptr);
|
||||
~WebdavClient() override;
|
||||
|
||||
|
||||
void setProfile(WebdavProfile profile);
|
||||
const WebdavProfile& profile() const { return m_profile; }
|
||||
|
||||
|
||||
QCoro::Task<bool> testConnection();
|
||||
|
||||
|
||||
|
||||
QCoro::Task<bool> ensureRootDir();
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<WebdavEntry>> list(QString relPath = QString());
|
||||
|
||||
|
||||
QCoro::Task<QByteArray> getFile(QString relPath);
|
||||
|
||||
|
||||
QCoro::Task<bool> putFile(QString relPath, QByteArray bytes,
|
||||
QString contentType = QStringLiteral("application/json"));
|
||||
|
||||
|
||||
QCoro::Task<bool> remove(QString relPath);
|
||||
|
||||
|
||||
|
||||
QCoro::Task<bool> mkcol(QString relPath);
|
||||
|
||||
private:
|
||||
QNetworkAccessManager* m_nam = nullptr;
|
||||
WebdavProfile m_profile;
|
||||
|
||||
|
||||
|
||||
QUrl absoluteUrl(QString relPath) const;
|
||||
QUrl absoluteUrlForSegments(const QStringList &segments) const;
|
||||
|
||||
|
||||
QCoro::Task<bool> ensureCollection(QStringList segments);
|
||||
|
||||
NetworkRequestOptions networkOptions() const;
|
||||
|
||||
|
||||
void applyAuthHeader(QNetworkRequest& request) const;
|
||||
};
|
||||
|
||||
#endif
|
||||
46
src/qEmbyCore/api/webdav/webdaventry.h
Normal file
46
src/qEmbyCore/api/webdav/webdaventry.h
Normal file
@@ -0,0 +1,46 @@
|
||||
#ifndef WEBDAVENTRY_H
|
||||
#define WEBDAVENTRY_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT WebdavEntry
|
||||
{
|
||||
|
||||
|
||||
|
||||
QString href;
|
||||
|
||||
|
||||
QString displayName;
|
||||
|
||||
|
||||
|
||||
QString parentRelPath;
|
||||
|
||||
|
||||
|
||||
bool isCollection = false;
|
||||
|
||||
|
||||
qint64 contentLength = -1;
|
||||
|
||||
|
||||
QDateTime lastModified;
|
||||
|
||||
|
||||
QString contentType;
|
||||
|
||||
|
||||
QString etag;
|
||||
};
|
||||
|
||||
#endif
|
||||
130
src/qEmbyCore/api/webdav/webdavprofile.cpp
Normal file
130
src/qEmbyCore/api/webdav/webdavprofile.cpp
Normal file
@@ -0,0 +1,130 @@
|
||||
#include "webdavprofile.h"
|
||||
|
||||
#include <QUrl>
|
||||
#include <QUuid>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
WebdavProfile WebdavProfile::defaults()
|
||||
{
|
||||
WebdavProfile p;
|
||||
p.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
p.name.clear();
|
||||
p.baseUrl.clear();
|
||||
p.username.clear();
|
||||
p.password.clear();
|
||||
p.rootDir = QStringLiteral("/qEmby");
|
||||
p.ignoreSsl = false;
|
||||
p.lastSyncAt = 0;
|
||||
return p;
|
||||
}
|
||||
|
||||
bool WebdavProfile::isValid() const
|
||||
{
|
||||
if (baseUrl.trimmed().isEmpty() ||
|
||||
username.trimmed().isEmpty() ||
|
||||
password.isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QUrl url(baseUrl.trimmed());
|
||||
if (!url.isValid())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString scheme = url.scheme().toLower();
|
||||
if (scheme != QStringLiteral("http") && scheme != QStringLiteral("https"))
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (url.host().trimmed().isEmpty())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
void WebdavProfile::normalize()
|
||||
{
|
||||
if (id.trimmed().isEmpty())
|
||||
{
|
||||
id = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
}
|
||||
|
||||
name = name.trimmed();
|
||||
username = username.trimmed();
|
||||
|
||||
|
||||
|
||||
QString trimmed = baseUrl.trimmed();
|
||||
while (trimmed.endsWith('/') && !trimmed.endsWith(QStringLiteral("://")))
|
||||
{
|
||||
trimmed.chop(1);
|
||||
}
|
||||
baseUrl = trimmed;
|
||||
|
||||
|
||||
QString root = rootDir.trimmed();
|
||||
if (root.isEmpty())
|
||||
{
|
||||
root = QStringLiteral("/qEmby");
|
||||
}
|
||||
if (!root.startsWith('/'))
|
||||
{
|
||||
root.prepend('/');
|
||||
}
|
||||
while (root.size() > 1 && root.endsWith('/'))
|
||||
{
|
||||
root.chop(1);
|
||||
}
|
||||
rootDir = root;
|
||||
}
|
||||
|
||||
QJsonObject WebdavProfile::toJson() const
|
||||
{
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("id"), id);
|
||||
obj.insert(QStringLiteral("name"), name);
|
||||
obj.insert(QStringLiteral("baseUrl"), baseUrl);
|
||||
obj.insert(QStringLiteral("username"), username);
|
||||
obj.insert(QStringLiteral("password"), password);
|
||||
obj.insert(QStringLiteral("rootDir"), rootDir);
|
||||
obj.insert(QStringLiteral("ignoreSsl"), ignoreSsl);
|
||||
obj.insert(QStringLiteral("lastSyncAt"), static_cast<double>(lastSyncAt));
|
||||
return obj;
|
||||
}
|
||||
|
||||
WebdavProfile WebdavProfile::fromJson(const QJsonObject& obj)
|
||||
{
|
||||
WebdavProfile p = defaults();
|
||||
if (obj.contains(QStringLiteral("id")))
|
||||
{
|
||||
const QString stored = obj.value(QStringLiteral("id")).toString().trimmed();
|
||||
if (!stored.isEmpty())
|
||||
{
|
||||
p.id = stored;
|
||||
}
|
||||
}
|
||||
p.name = obj.value(QStringLiteral("name")).toString();
|
||||
p.baseUrl = obj.value(QStringLiteral("baseUrl")).toString();
|
||||
p.username = obj.value(QStringLiteral("username")).toString();
|
||||
p.password = obj.value(QStringLiteral("password")).toString();
|
||||
if (obj.contains(QStringLiteral("rootDir")))
|
||||
{
|
||||
const QString stored = obj.value(QStringLiteral("rootDir")).toString();
|
||||
if (!stored.trimmed().isEmpty())
|
||||
{
|
||||
p.rootDir = stored;
|
||||
}
|
||||
}
|
||||
p.ignoreSsl = obj.value(QStringLiteral("ignoreSsl")).toBool(false);
|
||||
p.lastSyncAt = static_cast<qint64>(obj.value(QStringLiteral("lastSyncAt")).toDouble(0));
|
||||
p.normalize();
|
||||
return p;
|
||||
}
|
||||
47
src/qEmbyCore/api/webdav/webdavprofile.h
Normal file
47
src/qEmbyCore/api/webdav/webdavprofile.h
Normal file
@@ -0,0 +1,47 @@
|
||||
#ifndef WEBDAVPROFILE_H
|
||||
#define WEBDAVPROFILE_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT WebdavProfile
|
||||
{
|
||||
QString id;
|
||||
QString name;
|
||||
QString baseUrl;
|
||||
QString username;
|
||||
QString password;
|
||||
QString rootDir;
|
||||
bool ignoreSsl = false;
|
||||
qint64 lastSyncAt = 0;
|
||||
|
||||
|
||||
bool isValid() const;
|
||||
|
||||
|
||||
|
||||
void normalize();
|
||||
|
||||
QJsonObject toJson() const;
|
||||
static WebdavProfile fromJson(const QJsonObject& obj);
|
||||
|
||||
|
||||
static WebdavProfile defaults();
|
||||
};
|
||||
|
||||
#endif
|
||||
203
src/qEmbyCore/config/config_keys.h
Normal file
203
src/qEmbyCore/config/config_keys.h
Normal file
@@ -0,0 +1,203 @@
|
||||
#ifndef CONFIG_KEYS_H
|
||||
#define CONFIG_KEYS_H
|
||||
|
||||
#include <QString>
|
||||
|
||||
namespace ConfigKeys {
|
||||
|
||||
|
||||
inline QString forServer(const QString& serverId, const char* baseKey) {
|
||||
return QStringLiteral("server/%1/%2").arg(serverId, baseKey);
|
||||
}
|
||||
|
||||
|
||||
inline QString forLibrary(const QString& serverId, const QString& libraryId, const char* baseKey) {
|
||||
return QStringLiteral("server/%1/library/%2/%3").arg(serverId, libraryId, baseKey);
|
||||
}
|
||||
|
||||
|
||||
inline QString forCategory(const QString& serverId, const QString& categoryId, const char* baseKey) {
|
||||
return QStringLiteral("server/%1/category/%2/%3").arg(serverId, categoryId, baseKey);
|
||||
}
|
||||
|
||||
|
||||
inline QString forServerMedia(const QString& serverId, const QString& mediaId, const char* baseKey) {
|
||||
return QStringLiteral("%1/%2/%3").arg(QString::fromLatin1(baseKey), serverId, mediaId);
|
||||
}
|
||||
|
||||
|
||||
|
||||
constexpr const char* Language = "general/language";
|
||||
constexpr const char* RememberServer = "general/remember_server";
|
||||
constexpr const char* LastSelectedServerId = "general/last_selected_server_id";
|
||||
constexpr const char* CloseToTray = "general/close_to_tray";
|
||||
constexpr const char* LogEnable = "general/log_enable";
|
||||
constexpr const char* ApiTimeout = "general/api_timeout";
|
||||
constexpr const char* ImageCacheLimit = "general/image_cache_limit";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* ProxyMode = "network/proxy_mode";
|
||||
constexpr const char* ProxyType = "network/proxy_type";
|
||||
constexpr const char* ProxyHost = "network/proxy_host";
|
||||
constexpr const char* ProxyPort = "network/proxy_port";
|
||||
constexpr const char* ProxyUsername = "network/proxy_username";
|
||||
constexpr const char* ProxyPassword = "network/proxy_password";
|
||||
constexpr const char* ProxyBypassLocalhost = "network/proxy_bypass_local";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* ThemeMode = "appearance/theme_mode";
|
||||
constexpr const char* DefaultLibraryView = "appearance/default_library_view";
|
||||
constexpr const char* FontSize = "appearance/font_size";
|
||||
constexpr const char* SidebarPosition = "appearance/sidebar_position";
|
||||
constexpr const char* SidebarPinned = "appearance/sidebar_pinned";
|
||||
constexpr const char* SidebarCustomEnabled = "appearance/sidebar_custom_enabled";
|
||||
constexpr const char* SidebarHideSearch = "appearance/sidebar_hide_search";
|
||||
constexpr const char* SidebarHideHome = "appearance/sidebar_hide_home";
|
||||
constexpr const char* SidebarHideFavorites = "appearance/sidebar_hide_favorites";
|
||||
constexpr const char* StartupWindowState = "appearance/startup_window_state";
|
||||
constexpr const char* UiAnimations = "appearance/ui_animations";
|
||||
constexpr const char* SnapshotNavigation = "appearance/snapshot_navigation";
|
||||
constexpr const char* SearchHistoryEnabled = "appearance/search_history_enabled";
|
||||
constexpr const char* SearchAutocompleteEnabled = "appearance/search_autocomplete_enabled";
|
||||
constexpr const char* ShowRecommended = "library/show_recommended";
|
||||
constexpr const char* ShowContinueWatching = "library/show_continue_watching";
|
||||
constexpr const char* ShowLatestAdded = "library/show_latest_added";
|
||||
constexpr const char* ShowCompletedWatching = "library/show_completed_watching";
|
||||
constexpr const char* ContinueWatchingRequestLimit =
|
||||
"library/continue_watching_request_limit";
|
||||
constexpr const char* LatestMediaRequestLimit =
|
||||
"library/latest_media_request_limit";
|
||||
constexpr const char* RecommendedRequestLimit =
|
||||
"library/recommended_request_limit";
|
||||
constexpr const char* CompletedWatchingRequestLimit =
|
||||
"library/completed_watching_request_limit";
|
||||
constexpr const char* ShowMediaLibraries = "library/show_media_libraries";
|
||||
constexpr const char* ShowEachLibrary = "library/show_each_library";
|
||||
constexpr const char* ShowFavoriteFolders = "library/show_favorite_folders";
|
||||
constexpr const char* CustomHomeSectionOrderEnabled =
|
||||
"library/custom_home_section_order_enabled";
|
||||
constexpr const char* HomeSectionOrder = "library/home_section_order";
|
||||
constexpr const char* ImageQuality = "library/image_quality";
|
||||
constexpr const char* ShimmerAnimation = "library/shimmer_animation";
|
||||
constexpr const char* AdaptiveImages = "library/adaptive_images";
|
||||
constexpr const char* ShowMediaTooltips = "library/show_media_tooltips";
|
||||
|
||||
|
||||
constexpr const char* LibrarySortIndex = "sort/index";
|
||||
constexpr const char* LibrarySortDescending = "sort/descending";
|
||||
constexpr const char* LibraryViewMode = "view/mode";
|
||||
|
||||
|
||||
constexpr const char* CategoryViewMode = "view/mode";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* DataCacheDuration = "cache/data_cache_duration";
|
||||
constexpr const char* ImageCacheDuration = "cache/image_cache_duration";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* PlayerHwDec = "player/hw_decoding";
|
||||
constexpr const char* PlayerVo = "player/video_output";
|
||||
constexpr const char* PlayerVideoSync = "player/video_sync";
|
||||
constexpr const char* PlayerDefaultScale = "player/default_scale";
|
||||
constexpr const char* PlayerAudioLang = "player/audio_lang";
|
||||
constexpr const char* PlayerSubLang = "player/sub_lang";
|
||||
constexpr const char* PlayerPreferredVersion = "player/preferred_version";
|
||||
constexpr const char* PlayerVolNormal = "player/vol_normalization";
|
||||
constexpr const char* PlayerContinuousPlay = "player/continuous_play";
|
||||
constexpr const char* PlayerSeekStep = "player/seek_step";
|
||||
constexpr const char* PlayerLongPressSeek = "player/long_press_seek";
|
||||
constexpr const char* PlayerLongPressMode = "player/long_press_mode";
|
||||
constexpr const char* PlayerLongPressTriggerMs = "player/long_press_trigger_ms";
|
||||
constexpr const char* PlayerMouseEdgeLongPress =
|
||||
"player/mouse_edge_long_press";
|
||||
constexpr const char* PlayerMediaSwitcherMode =
|
||||
"player/media_switcher_mode";
|
||||
constexpr const char* PlayerClickToPause = "player/click_to_pause";
|
||||
constexpr const char* PlayerSkipIntro = "player/skip_intro";
|
||||
constexpr const char* PlayerSkipOutro = "player/skip_outro";
|
||||
constexpr const char* PlayerIndependentWindow = "player/independent_window";
|
||||
constexpr const char* PlayerUseMpvConf = "player/use_mpv_conf";
|
||||
constexpr const char* PlayerVolumeLevel = "player/volume_level";
|
||||
constexpr const char* PlayerVolumeMuted = "player/volume_muted";
|
||||
constexpr const char* PlayerSubtitleFont = "player/subtitle_font";
|
||||
constexpr const char* PlayerSubtitleDelayMs = "player/subtitle_delay_ms";
|
||||
constexpr const char* PlayerSubtitleFontSize = "player/subtitle_font_size";
|
||||
constexpr const char* PlayerSubtitlePosition = "player/subtitle_position";
|
||||
constexpr const char* PlayerSubtitleOutlineSize =
|
||||
"player/subtitle_outline_size";
|
||||
constexpr const char* PlayerSubtitleShadowOffset =
|
||||
"player/subtitle_shadow_offset";
|
||||
constexpr const char* PlayerSubtitleScale = "player/subtitle_scale";
|
||||
constexpr const char* PlayerLastSubtitleDir = "player/last_subtitle_dir";
|
||||
|
||||
constexpr const char* PlayerExternalSubtitle = "player/external_subtitle";
|
||||
constexpr const char* PlayerDanmakuEnabled = "player/danmaku_enabled";
|
||||
constexpr const char* PlayerDanmakuRenderer = "player/danmaku_renderer";
|
||||
constexpr const char* PlayerDanmakuOpacity = "player/danmaku_opacity";
|
||||
constexpr const char* PlayerDanmakuFontScale = "player/danmaku_font_scale";
|
||||
constexpr const char* PlayerDanmakuFontWeight = "player/danmaku_font_weight";
|
||||
constexpr const char* PlayerDanmakuOutlineSize =
|
||||
"player/danmaku_outline_size";
|
||||
constexpr const char* PlayerDanmakuShadowOffset =
|
||||
"player/danmaku_shadow_offset";
|
||||
constexpr const char* PlayerDanmakuAreaPercent = "player/danmaku_area_percent";
|
||||
constexpr const char* PlayerDanmakuDensity = "player/danmaku_density";
|
||||
constexpr const char* PlayerDanmakuSpeedScale = "player/danmaku_speed_scale";
|
||||
constexpr const char* PlayerDanmakuOffsetMs = "player/danmaku_offset_ms";
|
||||
constexpr const char* PlayerDanmakuHideScroll = "player/danmaku_hide_scroll";
|
||||
constexpr const char* PlayerDanmakuHideTop = "player/danmaku_hide_top";
|
||||
constexpr const char* PlayerDanmakuHideBottom = "player/danmaku_hide_bottom";
|
||||
constexpr const char* PlayerDanmakuBlockedKeywords =
|
||||
"player/danmaku_blocked_keywords";
|
||||
constexpr const char* PlayerDanmakuDualSubtitle =
|
||||
"player/danmaku_dual_subtitle";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* DanmakuProvider = "danmaku/provider";
|
||||
constexpr const char* DanmakuProviderBaseUrl = "danmaku/provider_base_url";
|
||||
constexpr const char* DanmakuProviderAppId = "danmaku/provider_app_id";
|
||||
constexpr const char* DanmakuProviderAppSecret = "danmaku/provider_app_secret";
|
||||
constexpr const char* DanmakuServers = "danmaku/servers";
|
||||
constexpr const char* DanmakuSelectedServer = "danmaku/selected_server";
|
||||
constexpr const char* DanmakuSourceMode = "danmaku/source_mode";
|
||||
constexpr const char* DanmakuAutoLoad = "danmaku/auto_load";
|
||||
constexpr const char* DanmakuAutoMatch = "danmaku/auto_match";
|
||||
constexpr const char* DanmakuWithRelated = "danmaku/with_related";
|
||||
constexpr const char* DanmakuCacheHours = "danmaku/cache_hours";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* ExtPlayerEnable = "ext_player/enable";
|
||||
constexpr const char* ExtPlayerPath = "ext_player/path";
|
||||
constexpr const char* ExtPlayerArgs = "ext_player/arguments";
|
||||
constexpr const char* ExtPlayerDirectStream = "ext_player/direct_stream";
|
||||
constexpr const char* ExtPlayerQuickPlay = "ext_player/quick_play";
|
||||
constexpr const char* ExtPlayerUrlReplace = "ext_player/url_replace";
|
||||
constexpr const char* ExtPlayerDetectedList = "ext_player/detected_list";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* SearchHistoryRecords = "search/history_records";
|
||||
|
||||
|
||||
|
||||
|
||||
constexpr const char* DownloadDirectory = "download/directory";
|
||||
constexpr const char* DownloadHistoryRecords = "download/history_records";
|
||||
constexpr const char* DownloadDeleteFileWithRecord =
|
||||
"download/delete_file_with_record";
|
||||
}
|
||||
|
||||
#endif
|
||||
188
src/qEmbyCore/config/configstore.cpp
Normal file
188
src/qEmbyCore/config/configstore.cpp
Normal file
@@ -0,0 +1,188 @@
|
||||
#include "configstore.h"
|
||||
#include "config_keys.h"
|
||||
#include <QDir>
|
||||
#include <QStandardPaths>
|
||||
#include <QCoreApplication>
|
||||
#include <QDebug>
|
||||
|
||||
ConfigStore* ConfigStore::instance() {
|
||||
|
||||
static ConfigStore* s_instance = new ConfigStore(qApp);
|
||||
return s_instance;
|
||||
}
|
||||
|
||||
QString ConfigStore::canonicalStorageKey(const QString& key) {
|
||||
|
||||
|
||||
|
||||
if (key.startsWith(QStringLiteral("general/"))) {
|
||||
return QStringLiteral("app/") + key.mid(8);
|
||||
}
|
||||
return key;
|
||||
}
|
||||
|
||||
QStringList ConfigStore::legacyStorageKeys(const QString& key) {
|
||||
if (!key.startsWith(QStringLiteral("general/"))) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const QString suffix = key.mid(8);
|
||||
return {
|
||||
key,
|
||||
QStringLiteral("General/") + suffix,
|
||||
};
|
||||
}
|
||||
|
||||
ConfigStore::ConfigStore(QObject* parent) : QObject(parent) {
|
||||
|
||||
QString configPath = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
|
||||
QDir().mkpath(configPath);
|
||||
QString configFile = configPath + "/config.ini";
|
||||
|
||||
|
||||
m_settings = new QSettings(configFile, QSettings::IniFormat, this);
|
||||
qDebug() << "ConfigStore: using config file" << configFile;
|
||||
migrateLegacyGeneralSettings();
|
||||
|
||||
|
||||
if (!m_settings->contains(ConfigKeys::ShowLatestAdded)) {
|
||||
m_settings->setValue(ConfigKeys::ShowLatestAdded, true);
|
||||
}
|
||||
if (!m_settings->contains(ConfigKeys::ShowMediaLibraries)) {
|
||||
m_settings->setValue(ConfigKeys::ShowMediaLibraries, true);
|
||||
}
|
||||
if (!m_settings->contains(ConfigKeys::ShowEachLibrary)) {
|
||||
m_settings->setValue(ConfigKeys::ShowEachLibrary, true);
|
||||
}
|
||||
if (!m_settings->contains(ConfigKeys::ShowMediaTooltips)) {
|
||||
m_settings->setValue(ConfigKeys::ShowMediaTooltips, true);
|
||||
}
|
||||
if (!m_settings->contains(ConfigKeys::ImageQuality)) {
|
||||
m_settings->setValue(ConfigKeys::ImageQuality, "high");
|
||||
}
|
||||
if (!m_settings->contains(ConfigKeys::DataCacheDuration)) {
|
||||
m_settings->setValue(ConfigKeys::DataCacheDuration, "24");
|
||||
}
|
||||
if (!m_settings->contains(ConfigKeys::ImageCacheDuration)) {
|
||||
m_settings->setValue(ConfigKeys::ImageCacheDuration, "7");
|
||||
}
|
||||
}
|
||||
|
||||
ConfigStore::~ConfigStore() {
|
||||
if (m_settings) {
|
||||
m_settings->sync();
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigStore::migrateLegacyGeneralSettings() {
|
||||
const QStringList generalKeys = {
|
||||
ConfigKeys::Language,
|
||||
ConfigKeys::RememberServer,
|
||||
ConfigKeys::LastSelectedServerId,
|
||||
ConfigKeys::CloseToTray,
|
||||
ConfigKeys::LogEnable,
|
||||
ConfigKeys::ApiTimeout,
|
||||
ConfigKeys::ImageCacheLimit,
|
||||
};
|
||||
|
||||
bool migrated = false;
|
||||
for (const QString& key : generalKeys) {
|
||||
const QString storageKey = canonicalStorageKey(key);
|
||||
if (m_settings->contains(storageKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const QString& legacyKey : legacyStorageKeys(key)) {
|
||||
if (!m_settings->contains(legacyKey)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
m_settings->setValue(storageKey, m_settings->value(legacyKey));
|
||||
qDebug() << "ConfigStore: migrated legacy key" << legacyKey
|
||||
<< "to" << storageKey;
|
||||
migrated = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (migrated) {
|
||||
m_settings->sync();
|
||||
if (m_settings->status() != QSettings::NoError) {
|
||||
qWarning() << "ConfigStore: failed to sync migrated config"
|
||||
<< m_settings->fileName()
|
||||
<< "| status:" << m_settings->status();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ConfigStore::set(const QString& key, const QVariant& value) {
|
||||
const QString storageKey = canonicalStorageKey(key);
|
||||
m_mutex.lock();
|
||||
|
||||
|
||||
QVariant oldValue;
|
||||
if (m_cache.contains(key)) {
|
||||
oldValue = m_cache.value(key);
|
||||
} else if (m_cache.contains(storageKey)) {
|
||||
oldValue = m_cache.value(storageKey);
|
||||
} else {
|
||||
oldValue = m_settings->value(storageKey);
|
||||
if (!oldValue.isValid()) {
|
||||
for (const QString& legacyKey : legacyStorageKeys(key)) {
|
||||
oldValue = m_settings->value(legacyKey);
|
||||
if (oldValue.isValid()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (oldValue == value && oldValue.isValid()) {
|
||||
m_mutex.unlock();
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
m_cache.insert(key, value);
|
||||
m_cache.insert(storageKey, value);
|
||||
|
||||
m_settings->setValue(storageKey, value);
|
||||
m_settings->sync();
|
||||
if (m_settings->status() != QSettings::NoError) {
|
||||
qWarning() << "ConfigStore: failed to sync config"
|
||||
<< m_settings->fileName() << "| status:" << m_settings->status();
|
||||
}
|
||||
|
||||
|
||||
m_mutex.unlock();
|
||||
|
||||
|
||||
emit valueChanged(key, value);
|
||||
}
|
||||
|
||||
void ConfigStore::sync() {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
m_settings->sync();
|
||||
if (m_settings->status() != QSettings::NoError) {
|
||||
qWarning() << "ConfigStore: failed to sync config"
|
||||
<< m_settings->fileName() << "| status:" << m_settings->status();
|
||||
}
|
||||
}
|
||||
|
||||
QString ConfigStore::filePath() const {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
return m_settings->fileName();
|
||||
}
|
||||
|
||||
QStringList ConfigStore::allKeys() const {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
|
||||
QStringList keys = m_settings->allKeys();
|
||||
for (auto it = m_cache.constBegin(); it != m_cache.constEnd(); ++it) {
|
||||
if (!keys.contains(it.key())) {
|
||||
keys.append(it.key());
|
||||
}
|
||||
}
|
||||
return keys;
|
||||
}
|
||||
83
src/qEmbyCore/config/configstore.h
Normal file
83
src/qEmbyCore/config/configstore.h
Normal file
@@ -0,0 +1,83 @@
|
||||
#ifndef CONFIGSTORE_H
|
||||
#define CONFIGSTORE_H
|
||||
|
||||
#include <QObject>
|
||||
#include <QVariant>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QHash>
|
||||
#include <QSettings>
|
||||
#include <QMutex>
|
||||
#include <QMutexLocker>
|
||||
|
||||
|
||||
#include "../qEmbyCore_global.h"
|
||||
|
||||
class QEMBYCORE_EXPORT ConfigStore : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
|
||||
static ConfigStore* instance();
|
||||
|
||||
|
||||
template<typename T>
|
||||
T get(const QString& key, const T& defaultValue = T()) const {
|
||||
QMutexLocker locker(&m_mutex);
|
||||
const QString storageKey = canonicalStorageKey(key);
|
||||
const QStringList legacyKeys = legacyStorageKeys(key);
|
||||
|
||||
|
||||
if (m_cache.contains(key)) {
|
||||
return m_cache.value(key).value<T>();
|
||||
}
|
||||
if (m_cache.contains(storageKey)) {
|
||||
return m_cache.value(storageKey).value<T>();
|
||||
}
|
||||
for (const QString& legacyKey : legacyKeys) {
|
||||
if (m_cache.contains(legacyKey)) {
|
||||
return m_cache.value(legacyKey).value<T>();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
QVariant val = m_settings->value(storageKey);
|
||||
if (!val.isValid()) {
|
||||
for (const QString& legacyKey : legacyKeys) {
|
||||
val = m_settings->value(legacyKey);
|
||||
if (val.isValid()) {
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!val.isValid()) {
|
||||
val = QVariant::fromValue(defaultValue);
|
||||
}
|
||||
return val.value<T>();
|
||||
}
|
||||
|
||||
|
||||
void set(const QString& key, const QVariant& value);
|
||||
void sync();
|
||||
QString filePath() const;
|
||||
QStringList allKeys() const;
|
||||
|
||||
signals:
|
||||
|
||||
void valueChanged(const QString& key, const QVariant& newValue);
|
||||
|
||||
private:
|
||||
explicit ConfigStore(QObject* parent = nullptr);
|
||||
~ConfigStore() override;
|
||||
static QString canonicalStorageKey(const QString& key);
|
||||
static QStringList legacyStorageKeys(const QString& key);
|
||||
void migrateLegacyGeneralSettings();
|
||||
|
||||
|
||||
Q_DISABLE_COPY(ConfigStore)
|
||||
|
||||
QSettings* m_settings;
|
||||
mutable QMutex m_mutex;
|
||||
QHash<QString, QVariant> m_cache;
|
||||
};
|
||||
|
||||
#endif
|
||||
295
src/qEmbyCore/config/webdavprofilestore.cpp
Normal file
295
src/qEmbyCore/config/webdavprofilestore.cpp
Normal file
@@ -0,0 +1,295 @@
|
||||
#include "webdavprofilestore.h"
|
||||
|
||||
#include "../utils/securesecretbox.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr int kSchemaVersion = 1;
|
||||
|
||||
QString defaultFilePath()
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
|
||||
return QDir(dir).filePath(QStringLiteral("webdav-profile.json"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
QString encryptPasswordToCipherText(const QString &plainPassword)
|
||||
{
|
||||
if (plainPassword.isEmpty())
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
QByteArray pwBytes = plainPassword.toUtf8();
|
||||
const QByteArray cipher = SecureSecretBox::encryptLocalSecret(pwBytes);
|
||||
SecureSecretBox::secureZero(pwBytes);
|
||||
if (cipher.isEmpty())
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
return QString::fromLatin1(cipher.toBase64(QByteArray::Base64Encoding));
|
||||
}
|
||||
|
||||
|
||||
QString decryptCipherTextToPassword(const QString &cipherBase64)
|
||||
{
|
||||
if (cipherBase64.isEmpty())
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
const QByteArray cipher = QByteArray::fromBase64(cipherBase64.toLatin1(),
|
||||
QByteArray::Base64Encoding);
|
||||
if (cipher.isEmpty())
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
auto plain = SecureSecretBox::decryptLocalSecret(cipher);
|
||||
if (!plain.has_value())
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
return QString::fromUtf8(plain.value());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
WebdavProfileStore::WebdavProfileStore(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
}
|
||||
|
||||
WebdavProfileStore::~WebdavProfileStore() = default;
|
||||
|
||||
QString WebdavProfileStore::filePath()
|
||||
{
|
||||
return defaultFilePath();
|
||||
}
|
||||
|
||||
bool WebdavProfileStore::hasProfile() const
|
||||
{
|
||||
return m_hasProfile;
|
||||
}
|
||||
|
||||
WebdavProfile WebdavProfileStore::profile() const
|
||||
{
|
||||
return m_profile;
|
||||
}
|
||||
|
||||
void WebdavProfileStore::setProfile(WebdavProfile profile)
|
||||
{
|
||||
profile.normalize();
|
||||
m_profile = std::move(profile);
|
||||
m_hasProfile = m_profile.isValid();
|
||||
emit profileChanged();
|
||||
}
|
||||
|
||||
bool WebdavProfileStore::load()
|
||||
{
|
||||
const QString path = filePath();
|
||||
QFileInfo info(path);
|
||||
|
||||
if (!info.exists())
|
||||
{
|
||||
qDebug() << "[WebdavProfileStore] no profile file yet at" << path;
|
||||
m_profile = WebdavProfile::defaults();
|
||||
m_hasProfile = false;
|
||||
emit profileChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
QFile file(path);
|
||||
if (!file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] failed to open for read:" << path
|
||||
<< "|" << file.errorString();
|
||||
return false;
|
||||
}
|
||||
const QByteArray bytes = file.readAll();
|
||||
file.close();
|
||||
|
||||
QJsonParseError parseError{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &parseError);
|
||||
if (parseError.error != QJsonParseError::NoError || !doc.isObject())
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] JSON parse failed:" << parseError.errorString()
|
||||
<< "| path:" << path;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject root = doc.object();
|
||||
const int version = root.value(QStringLiteral("version")).toInt(0);
|
||||
if (version != kSchemaVersion)
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] unsupported schema version" << version
|
||||
<< "(expected" << kSchemaVersion << ")";
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonObject profileObj = root.value(QStringLiteral("profile")).toObject();
|
||||
if (profileObj.isEmpty())
|
||||
{
|
||||
qDebug() << "[WebdavProfileStore] empty profile section, treating as no profile";
|
||||
m_profile = WebdavProfile::defaults();
|
||||
m_hasProfile = false;
|
||||
emit profileChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
WebdavProfile loaded = WebdavProfile::fromJson(profileObj);
|
||||
|
||||
|
||||
const QString cipherBase64 =
|
||||
profileObj.value(QStringLiteral("passwordCipher")).toString();
|
||||
if (!cipherBase64.isEmpty())
|
||||
{
|
||||
const QString plain = decryptCipherTextToPassword(cipherBase64);
|
||||
if (plain.isEmpty())
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] failed to decrypt passwordCipher;"
|
||||
<< "profile loaded WITHOUT password";
|
||||
loaded.password.clear();
|
||||
}
|
||||
else
|
||||
{
|
||||
loaded.password = plain;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
|
||||
|
||||
}
|
||||
|
||||
loaded.normalize();
|
||||
m_profile = loaded;
|
||||
m_hasProfile = m_profile.isValid();
|
||||
|
||||
qDebug() << "[WebdavProfileStore] loaded profile"
|
||||
<< "| hasProfile:" << m_hasProfile
|
||||
<< "| baseUrl:" << m_profile.baseUrl
|
||||
<< "| user:" << m_profile.username
|
||||
<< "| rootDir:" << m_profile.rootDir;
|
||||
|
||||
emit profileChanged();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebdavProfileStore::save()
|
||||
{
|
||||
const QString path = filePath();
|
||||
const QString dirPath = QFileInfo(path).absolutePath();
|
||||
if (!QDir().mkpath(dirPath))
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] failed to create dir:" << dirPath;
|
||||
return false;
|
||||
}
|
||||
|
||||
if (!m_hasProfile && !m_profile.isValid())
|
||||
{
|
||||
|
||||
return clear();
|
||||
}
|
||||
|
||||
WebdavProfile snapshot = m_profile;
|
||||
snapshot.normalize();
|
||||
|
||||
QJsonObject profileObj = snapshot.toJson();
|
||||
profileObj.remove(QStringLiteral("password"));
|
||||
|
||||
const QString cipherBase64 = encryptPasswordToCipherText(snapshot.password);
|
||||
if (!cipherBase64.isEmpty())
|
||||
{
|
||||
profileObj.insert(QStringLiteral("passwordCipher"), cipherBase64);
|
||||
}
|
||||
else if (!snapshot.password.isEmpty())
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] password encryption failed; aborting save";
|
||||
return false;
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root.insert(QStringLiteral("version"), kSchemaVersion);
|
||||
root.insert(QStringLiteral("profile"), profileObj);
|
||||
|
||||
const QByteArray payload =
|
||||
QJsonDocument(root).toJson(QJsonDocument::Indented);
|
||||
|
||||
|
||||
const QString tmpPath = path + QStringLiteral(".tmp");
|
||||
{
|
||||
QFile out(tmpPath);
|
||||
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] failed to open for write:" << tmpPath
|
||||
<< "|" << out.errorString();
|
||||
return false;
|
||||
}
|
||||
if (out.write(payload) != payload.size())
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] short write:" << tmpPath;
|
||||
out.close();
|
||||
QFile::remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
out.close();
|
||||
}
|
||||
|
||||
QFile::remove(path);
|
||||
if (!QFile::rename(tmpPath, path))
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] rename" << tmpPath << "to" << path
|
||||
<< "failed; falling back to copy";
|
||||
if (!QFile::copy(tmpPath, path))
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] copy fallback failed too";
|
||||
return false;
|
||||
}
|
||||
QFile::remove(tmpPath);
|
||||
}
|
||||
|
||||
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
|
||||
qDebug() << "[WebdavProfileStore] saved profile to" << path
|
||||
<< "| size:" << payload.size();
|
||||
return true;
|
||||
}
|
||||
|
||||
bool WebdavProfileStore::clear()
|
||||
{
|
||||
const QString path = filePath();
|
||||
bool ok = true;
|
||||
if (QFile::exists(path))
|
||||
{
|
||||
if (!QFile::remove(path))
|
||||
{
|
||||
qWarning() << "[WebdavProfileStore] failed to remove" << path;
|
||||
ok = false;
|
||||
}
|
||||
}
|
||||
|
||||
m_profile = WebdavProfile::defaults();
|
||||
m_hasProfile = false;
|
||||
emit profileChanged();
|
||||
return ok;
|
||||
}
|
||||
|
||||
bool WebdavProfileStore::updateLastSyncAt(qint64 unixMs)
|
||||
{
|
||||
if (!m_hasProfile)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
m_profile.lastSyncAt = unixMs;
|
||||
return save();
|
||||
}
|
||||
80
src/qEmbyCore/config/webdavprofilestore.h
Normal file
80
src/qEmbyCore/config/webdavprofilestore.h
Normal file
@@ -0,0 +1,80 @@
|
||||
#ifndef WEBDAVPROFILESTORE_H
|
||||
#define WEBDAVPROFILESTORE_H
|
||||
|
||||
#include "../api/webdav/webdavprofile.h"
|
||||
#include "../qEmbyCore_global.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class QEMBYCORE_EXPORT WebdavProfileStore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit WebdavProfileStore(QObject *parent = nullptr);
|
||||
~WebdavProfileStore() override;
|
||||
|
||||
|
||||
|
||||
|
||||
bool load();
|
||||
|
||||
|
||||
bool save();
|
||||
|
||||
|
||||
bool clear();
|
||||
|
||||
bool hasProfile() const;
|
||||
|
||||
|
||||
WebdavProfile profile() const;
|
||||
|
||||
|
||||
void setProfile(WebdavProfile profile);
|
||||
|
||||
|
||||
bool updateLastSyncAt(qint64 unixMs);
|
||||
|
||||
|
||||
static QString filePath();
|
||||
|
||||
signals:
|
||||
|
||||
void profileChanged();
|
||||
|
||||
private:
|
||||
WebdavProfile m_profile;
|
||||
bool m_hasProfile = false;
|
||||
};
|
||||
|
||||
#endif
|
||||
29
src/qEmbyCore/fileutils.cpp
Normal file
29
src/qEmbyCore/fileutils.cpp
Normal file
@@ -0,0 +1,29 @@
|
||||
#include "fileutils.h"
|
||||
#include <QDir>
|
||||
#include <QDirIterator>
|
||||
#include <QFileInfo>
|
||||
|
||||
namespace FileUtils {
|
||||
|
||||
QString formatSize(qint64 bytes) {
|
||||
if (bytes < 1024)
|
||||
return QString("%1 B").arg(bytes);
|
||||
if (bytes < 1024 * 1024)
|
||||
return QString("%1 KB").arg(bytes / 1024.0, 0, 'f', 1);
|
||||
if (bytes < 1024LL * 1024 * 1024)
|
||||
return QString("%1 MB").arg(bytes / (1024.0 * 1024), 0, 'f', 1);
|
||||
return QString("%1 GB").arg(bytes / (1024.0 * 1024 * 1024), 0, 'f', 2);
|
||||
}
|
||||
|
||||
qint64 calcDirSize(const QString &path) {
|
||||
qint64 total = 0;
|
||||
QDirIterator it(path, QDir::Files | QDir::NoDotAndDotDot,
|
||||
QDirIterator::Subdirectories);
|
||||
while (it.hasNext()) {
|
||||
it.next();
|
||||
total += it.fileInfo().size();
|
||||
}
|
||||
return total;
|
||||
}
|
||||
|
||||
}
|
||||
19
src/qEmbyCore/fileutils.h
Normal file
19
src/qEmbyCore/fileutils.h
Normal file
@@ -0,0 +1,19 @@
|
||||
#ifndef FILE_UTILS_H
|
||||
#define FILE_UTILS_H
|
||||
|
||||
#include <QString>
|
||||
#include <QtGlobal>
|
||||
#include "qEmbyCore_global.h"
|
||||
|
||||
|
||||
namespace FileUtils {
|
||||
|
||||
|
||||
QEMBYCORE_EXPORT QString formatSize(qint64 bytes);
|
||||
|
||||
|
||||
QEMBYCORE_EXPORT qint64 calcDirSize(const QString &path);
|
||||
|
||||
}
|
||||
|
||||
#endif
|
||||
932
src/qEmbyCore/models/admin/adminmodels.h
Normal file
932
src/qEmbyCore/models/admin/adminmodels.h
Normal file
@@ -0,0 +1,932 @@
|
||||
#ifndef ADMINMODELS_H
|
||||
#define ADMINMODELS_H
|
||||
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QDateTime>
|
||||
#include <QMetaType>
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT SystemInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString serverName;
|
||||
QString version;
|
||||
QString operatingSystem;
|
||||
QString operatingSystemName;
|
||||
QString architecture;
|
||||
QString id;
|
||||
QString localAddress;
|
||||
QString wanAddress;
|
||||
bool hasUpdateAvailable = false;
|
||||
bool supportsLibraryMonitor = false;
|
||||
bool canSelfRestart = false;
|
||||
bool canSelfUpdate = false;
|
||||
|
||||
static SystemInfo fromJson(const QJsonObject& obj) {
|
||||
SystemInfo info;
|
||||
info.serverName = obj["ServerName"].toString();
|
||||
info.version = obj["Version"].toString();
|
||||
info.operatingSystem = obj["OperatingSystem"].toString();
|
||||
info.operatingSystemName = obj["OperatingSystemDisplayName"].toString();
|
||||
info.architecture = obj["SystemArchitecture"].toString();
|
||||
info.id = obj["Id"].toString();
|
||||
info.localAddress = obj["LocalAddress"].toString();
|
||||
info.wanAddress = obj["WanAddress"].toString();
|
||||
info.hasUpdateAvailable = obj["HasUpdateAvailable"].toBool();
|
||||
info.supportsLibraryMonitor = obj["SupportsLibraryMonitor"].toBool();
|
||||
info.canSelfRestart = obj["CanSelfRestart"].toBool();
|
||||
info.canSelfUpdate = obj["CanSelfUpdate"].toBool();
|
||||
return info;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(SystemInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT SessionInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString userId;
|
||||
QString userName;
|
||||
QString client;
|
||||
QString deviceName;
|
||||
QString deviceId;
|
||||
QString applicationVersion;
|
||||
QString remoteEndPoint;
|
||||
QString lastActivityDate;
|
||||
|
||||
|
||||
QString nowPlayingItemId;
|
||||
QString nowPlayingItemName;
|
||||
QString nowPlayingItemType;
|
||||
QString nowPlayingSeriesName;
|
||||
int nowPlayingParentIndexNumber = -1;
|
||||
int nowPlayingIndexNumber = -1;
|
||||
QString nowPlayingImageTag;
|
||||
QString nowPlayingThumbTag;
|
||||
QString nowPlayingBackdropTag;
|
||||
QString nowPlayingSeriesId;
|
||||
QString nowPlayingSeriesPrimaryTag;
|
||||
QString parentThumbItemId;
|
||||
QString parentThumbImageTag;
|
||||
QString parentBackdropItemId;
|
||||
QString parentBackdropImageTag;
|
||||
qint64 nowPlayingRunTimeTicks = 0;
|
||||
|
||||
|
||||
bool isPaused = false;
|
||||
qint64 positionTicks = 0;
|
||||
QString playMethod;
|
||||
|
||||
|
||||
QString transcodingContainer;
|
||||
QString transcodingVideoCodec;
|
||||
QString transcodingAudioCodec;
|
||||
int transcodingBitrate = 0;
|
||||
double transcodingProgress = 0.0;
|
||||
|
||||
|
||||
bool isNowPlaying() const { return !nowPlayingItemId.isEmpty(); }
|
||||
|
||||
QString seasonEpisodeLabel() const {
|
||||
if (nowPlayingParentIndexNumber >= 0 && nowPlayingIndexNumber >= 0) {
|
||||
return QString("S%1:E%2")
|
||||
.arg(nowPlayingParentIndexNumber)
|
||||
.arg(nowPlayingIndexNumber);
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
|
||||
static QString formatTicks(qint64 ticks) {
|
||||
qint64 totalSeconds = ticks / 10000000;
|
||||
int hours = totalSeconds / 3600;
|
||||
int minutes = (totalSeconds % 3600) / 60;
|
||||
int seconds = totalSeconds % 60;
|
||||
if (hours > 0)
|
||||
return QString("%1:%2:%3").arg(hours).arg(minutes, 2, 10, QChar('0')).arg(seconds, 2, 10, QChar('0'));
|
||||
return QString("%1:%2").arg(minutes).arg(seconds, 2, 10, QChar('0'));
|
||||
}
|
||||
|
||||
double playbackProgress() const {
|
||||
if (nowPlayingRunTimeTicks <= 0) return 0.0;
|
||||
return static_cast<double>(positionTicks) / nowPlayingRunTimeTicks;
|
||||
}
|
||||
|
||||
static SessionInfo fromJson(const QJsonObject& obj) {
|
||||
SessionInfo session;
|
||||
session.id = obj["Id"].toString();
|
||||
session.userId = obj["UserId"].toString();
|
||||
session.userName = obj["UserName"].toString();
|
||||
session.client = obj["Client"].toString();
|
||||
session.deviceName = obj["DeviceName"].toString();
|
||||
session.deviceId = obj["DeviceId"].toString();
|
||||
session.applicationVersion = obj["ApplicationVersion"].toString();
|
||||
session.remoteEndPoint = obj["RemoteEndPoint"].toString();
|
||||
session.lastActivityDate = obj["LastActivityDate"].toString();
|
||||
|
||||
|
||||
if (obj.contains("NowPlayingItem")) {
|
||||
QJsonObject np = obj["NowPlayingItem"].toObject();
|
||||
session.nowPlayingItemId = np["Id"].toString();
|
||||
session.nowPlayingItemName = np["Name"].toString();
|
||||
session.nowPlayingItemType = np["Type"].toString();
|
||||
session.nowPlayingSeriesName = np["SeriesName"].toString();
|
||||
session.nowPlayingParentIndexNumber = np["ParentIndexNumber"].toInt(-1);
|
||||
session.nowPlayingIndexNumber = np["IndexNumber"].toInt(-1);
|
||||
session.nowPlayingRunTimeTicks = np["RunTimeTicks"].toVariant().toLongLong();
|
||||
|
||||
|
||||
QJsonObject imgTags = np["ImageTags"].toObject();
|
||||
session.nowPlayingImageTag = imgTags["Primary"].toString();
|
||||
session.nowPlayingThumbTag = imgTags["Thumb"].toString();
|
||||
|
||||
|
||||
QJsonArray bdTags = np["BackdropImageTags"].toArray();
|
||||
if (!bdTags.isEmpty()) {
|
||||
session.nowPlayingBackdropTag = bdTags.first().toString();
|
||||
}
|
||||
|
||||
|
||||
session.nowPlayingSeriesId = np["SeriesId"].toString();
|
||||
session.nowPlayingSeriesPrimaryTag = np["SeriesPrimaryImageTag"].toString();
|
||||
|
||||
|
||||
session.parentThumbItemId = np["ParentThumbItemId"].toString();
|
||||
session.parentThumbImageTag = np["ParentThumbImageTag"].toString();
|
||||
session.parentBackdropItemId = np["ParentBackdropItemId"].toString();
|
||||
QJsonArray parentBdTags = np["ParentBackdropImageTags"].toArray();
|
||||
if (!parentBdTags.isEmpty()) {
|
||||
session.parentBackdropImageTag = parentBdTags.first().toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("PlayState")) {
|
||||
QJsonObject ps = obj["PlayState"].toObject();
|
||||
session.isPaused = ps["IsPaused"].toBool();
|
||||
session.positionTicks = ps["PositionTicks"].toVariant().toLongLong();
|
||||
session.playMethod = ps["PlayMethod"].toString();
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("TranscodingInfo")) {
|
||||
QJsonObject ti = obj["TranscodingInfo"].toObject();
|
||||
session.transcodingContainer = ti["Container"].toString();
|
||||
session.transcodingVideoCodec = ti["VideoCodec"].toString();
|
||||
session.transcodingAudioCodec = ti["AudioCodec"].toString();
|
||||
session.transcodingBitrate = ti["Bitrate"].toInt();
|
||||
session.transcodingProgress = ti["CompletionPercentage"].toDouble();
|
||||
}
|
||||
|
||||
return session;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(SessionInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT UserInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
QString serverIdRaw;
|
||||
bool isAdministrator = false;
|
||||
bool isDisabled = false;
|
||||
bool hasPassword = false;
|
||||
QString lastLoginDate;
|
||||
QString lastActivityDate;
|
||||
QString primaryImageTag;
|
||||
|
||||
|
||||
bool enableAllFolders = true;
|
||||
|
||||
static UserInfo fromJson(const QJsonObject& obj) {
|
||||
UserInfo user;
|
||||
user.id = obj["Id"].toString();
|
||||
user.name = obj["Name"].toString();
|
||||
user.serverIdRaw = obj["ServerId"].toString();
|
||||
user.hasPassword = obj["HasPassword"].toBool();
|
||||
user.lastLoginDate = obj["LastLoginDate"].toString();
|
||||
user.lastActivityDate = obj["LastActivityDate"].toString();
|
||||
user.primaryImageTag = obj["PrimaryImageTag"].toString();
|
||||
|
||||
|
||||
if (obj.contains("Policy")) {
|
||||
QJsonObject policy = obj["Policy"].toObject();
|
||||
user.isAdministrator = policy["IsAdministrator"].toBool();
|
||||
user.isDisabled = policy["IsDisabled"].toBool();
|
||||
user.enableAllFolders = policy["EnableAllFolders"].toBool();
|
||||
}
|
||||
|
||||
return user;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(UserInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminMediaSubFolderInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString guid;
|
||||
QString path;
|
||||
bool isUserAccessConfigurable = true;
|
||||
|
||||
static AdminMediaSubFolderInfo fromJson(const QJsonObject& obj) {
|
||||
AdminMediaSubFolderInfo folder;
|
||||
folder.id = obj["Id"].toString();
|
||||
folder.guid = obj["Guid"].toString();
|
||||
folder.path = obj["Path"].toString();
|
||||
folder.isUserAccessConfigurable =
|
||||
obj["IsUserAccessConfigurable"].toBool(true);
|
||||
return folder;
|
||||
}
|
||||
|
||||
QString effectiveId() const {
|
||||
return !guid.trimmed().isEmpty() ? guid.trimmed() : id.trimmed();
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminMediaSubFolderInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminMediaFolderInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
static AdminMediaFolderInfo fromJson(const QJsonObject& obj) {
|
||||
AdminMediaFolderInfo folder;
|
||||
folder.id = obj["Guid"].toString();
|
||||
if (folder.id.isEmpty()) {
|
||||
folder.id = obj["Id"].toString();
|
||||
}
|
||||
folder.name = obj["Name"].toString();
|
||||
return folder;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminMediaFolderInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT SelectableMediaFolderInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
bool isUserAccessConfigurable = true;
|
||||
QList<AdminMediaSubFolderInfo> subFolders;
|
||||
|
||||
static SelectableMediaFolderInfo fromJson(const QJsonObject& obj) {
|
||||
SelectableMediaFolderInfo folder;
|
||||
folder.id = obj["Guid"].toString();
|
||||
if (folder.id.isEmpty()) {
|
||||
folder.id = obj["Id"].toString();
|
||||
}
|
||||
folder.name = obj["Name"].toString();
|
||||
folder.isUserAccessConfigurable =
|
||||
obj["IsUserAccessConfigurable"].toBool(true);
|
||||
const QJsonArray subFolderArray = obj["SubFolders"].toArray();
|
||||
for (const auto& value : subFolderArray) {
|
||||
folder.subFolders.append(
|
||||
AdminMediaSubFolderInfo::fromJson(value.toObject()));
|
||||
}
|
||||
return folder;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(SelectableMediaFolderInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminChannelInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
static AdminChannelInfo fromJson(const QJsonObject& obj) {
|
||||
AdminChannelInfo channel;
|
||||
channel.id = obj["Guid"].toString();
|
||||
if (channel.id.isEmpty()) {
|
||||
channel.id = obj["Id"].toString();
|
||||
}
|
||||
channel.name = obj["Name"].toString();
|
||||
return channel;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminChannelInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminDeviceInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
QString appName;
|
||||
|
||||
static AdminDeviceInfo fromJson(const QJsonObject& obj) {
|
||||
AdminDeviceInfo device;
|
||||
device.id = obj["ReportedDeviceId"].toString();
|
||||
if (device.id.isEmpty()) {
|
||||
device.id = obj["Id"].toString();
|
||||
}
|
||||
device.name = obj["Name"].toString();
|
||||
if (device.name.isEmpty()) {
|
||||
device.name = obj["DeviceName"].toString();
|
||||
}
|
||||
device.appName = obj["AppName"].toString();
|
||||
return device;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminDeviceInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminAuthProviderInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
static AdminAuthProviderInfo fromJson(const QJsonObject& obj) {
|
||||
AdminAuthProviderInfo provider;
|
||||
provider.id = obj["Id"].toString();
|
||||
provider.name = obj["Name"].toString();
|
||||
return provider;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminAuthProviderInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminFeatureInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
static AdminFeatureInfo fromJson(const QJsonObject& obj) {
|
||||
AdminFeatureInfo feature;
|
||||
feature.id = obj["Id"].toString();
|
||||
feature.name = obj["Name"].toString();
|
||||
return feature;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminFeatureInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT AdminParentalRatingInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString name;
|
||||
int value = 0;
|
||||
|
||||
static AdminParentalRatingInfo fromJson(const QJsonObject& obj) {
|
||||
AdminParentalRatingInfo rating;
|
||||
rating.name = obj["Name"].toString();
|
||||
rating.value = obj["Value"].toInt();
|
||||
return rating;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(AdminParentalRatingInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT LocalizationCulture {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString name;
|
||||
QString displayName;
|
||||
QString twoLetterISOLanguageName;
|
||||
QString threeLetterISOLanguageName;
|
||||
QStringList threeLetterISOLanguageNames;
|
||||
QStringList twoLetterISOLanguageNames;
|
||||
|
||||
static LocalizationCulture fromJson(const QJsonObject& obj) {
|
||||
LocalizationCulture culture;
|
||||
culture.name = obj["Name"].toString();
|
||||
culture.displayName = obj["DisplayName"].toString();
|
||||
culture.twoLetterISOLanguageName =
|
||||
obj["TwoLetterISOLanguageName"].toString();
|
||||
culture.threeLetterISOLanguageName =
|
||||
obj["ThreeLetterISOLanguageName"].toString();
|
||||
|
||||
const QJsonArray threeLetterNames =
|
||||
obj["ThreeLetterISOLanguageNames"].toArray();
|
||||
for (const auto& value : threeLetterNames) {
|
||||
const QString text = value.toString().trimmed();
|
||||
if (!text.isEmpty() &&
|
||||
!culture.threeLetterISOLanguageNames.contains(text)) {
|
||||
culture.threeLetterISOLanguageNames.append(text);
|
||||
}
|
||||
}
|
||||
|
||||
const QJsonArray twoLetterNames =
|
||||
obj["TwoLetterISOLanguageNames"].toArray();
|
||||
for (const auto& value : twoLetterNames) {
|
||||
const QString text = value.toString().trimmed();
|
||||
if (!text.isEmpty() &&
|
||||
!culture.twoLetterISOLanguageNames.contains(text)) {
|
||||
culture.twoLetterISOLanguageNames.append(text);
|
||||
}
|
||||
}
|
||||
|
||||
return culture;
|
||||
}
|
||||
|
||||
QString canonicalCode() const {
|
||||
if (!twoLetterISOLanguageName.trimmed().isEmpty()) {
|
||||
return twoLetterISOLanguageName.trimmed();
|
||||
}
|
||||
if (!twoLetterISOLanguageNames.isEmpty()) {
|
||||
return twoLetterISOLanguageNames.first().trimmed();
|
||||
}
|
||||
if (!threeLetterISOLanguageName.trimmed().isEmpty()) {
|
||||
return threeLetterISOLanguageName.trimmed();
|
||||
}
|
||||
if (!threeLetterISOLanguageNames.isEmpty()) {
|
||||
return threeLetterISOLanguageNames.first().trimmed();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString visibleName() const {
|
||||
if (!displayName.trimmed().isEmpty()) {
|
||||
return displayName.trimmed();
|
||||
}
|
||||
return name.trimmed();
|
||||
}
|
||||
|
||||
QStringList allCodes() const {
|
||||
QStringList codes;
|
||||
auto appendUnique = [&codes](const QString& value) {
|
||||
const QString trimmed = value.trimmed();
|
||||
if (!trimmed.isEmpty() && !codes.contains(trimmed, Qt::CaseInsensitive)) {
|
||||
codes.append(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
appendUnique(twoLetterISOLanguageName);
|
||||
appendUnique(threeLetterISOLanguageName);
|
||||
for (const QString& value : twoLetterISOLanguageNames) {
|
||||
appendUnique(value);
|
||||
}
|
||||
for (const QString& value : threeLetterISOLanguageNames) {
|
||||
appendUnique(value);
|
||||
}
|
||||
return codes;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(LocalizationCulture)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT LocalizationCountry {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString name;
|
||||
QString displayName;
|
||||
QString englishName;
|
||||
QString twoLetterISORegionName;
|
||||
QString threeLetterISORegionName;
|
||||
|
||||
static LocalizationCountry fromJson(const QJsonObject& obj) {
|
||||
LocalizationCountry country;
|
||||
country.name = obj["Name"].toString();
|
||||
country.displayName = obj["DisplayName"].toString();
|
||||
country.englishName = obj["EnglishName"].toString();
|
||||
country.twoLetterISORegionName =
|
||||
obj["TwoLetterISORegionName"].toString();
|
||||
country.threeLetterISORegionName =
|
||||
obj["ThreeLetterISORegionName"].toString();
|
||||
return country;
|
||||
}
|
||||
|
||||
QString canonicalCode() const {
|
||||
if (!twoLetterISORegionName.trimmed().isEmpty()) {
|
||||
return twoLetterISORegionName.trimmed();
|
||||
}
|
||||
if (!name.trimmed().isEmpty()) {
|
||||
return name.trimmed();
|
||||
}
|
||||
if (!threeLetterISORegionName.trimmed().isEmpty()) {
|
||||
return threeLetterISORegionName.trimmed();
|
||||
}
|
||||
return {};
|
||||
}
|
||||
|
||||
QString visibleName() const {
|
||||
if (!displayName.trimmed().isEmpty()) {
|
||||
return displayName.trimmed();
|
||||
}
|
||||
if (!englishName.trimmed().isEmpty()) {
|
||||
return englishName.trimmed();
|
||||
}
|
||||
return name.trimmed();
|
||||
}
|
||||
|
||||
QStringList allCodes() const {
|
||||
QStringList codes;
|
||||
auto appendUnique = [&codes](const QString& value) {
|
||||
const QString trimmed = value.trimmed();
|
||||
if (!trimmed.isEmpty() && !codes.contains(trimmed, Qt::CaseInsensitive)) {
|
||||
codes.append(trimmed);
|
||||
}
|
||||
};
|
||||
|
||||
appendUnique(name);
|
||||
appendUnique(twoLetterISORegionName);
|
||||
appendUnique(threeLetterISORegionName);
|
||||
return codes;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(LocalizationCountry)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT VirtualFolder {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
QString itemId;
|
||||
QString guid;
|
||||
QString collectionType;
|
||||
QStringList locations;
|
||||
QString primaryImageItemId;
|
||||
QJsonObject libraryOptions;
|
||||
|
||||
static VirtualFolder fromJson(const QJsonObject& obj) {
|
||||
VirtualFolder folder;
|
||||
folder.id = obj["Id"].toString();
|
||||
folder.name = obj["Name"].toString();
|
||||
folder.itemId = obj["ItemId"].toString();
|
||||
folder.guid = obj["Guid"].toString();
|
||||
folder.collectionType = obj["CollectionType"].toString();
|
||||
folder.primaryImageItemId = obj["PrimaryImageItemId"].toString();
|
||||
folder.libraryOptions = obj["LibraryOptions"].toObject();
|
||||
|
||||
if (folder.id.isEmpty()) {
|
||||
folder.id = folder.itemId;
|
||||
}
|
||||
if (folder.itemId.isEmpty()) {
|
||||
folder.itemId = folder.id;
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("Locations")) {
|
||||
QJsonArray locs = obj["Locations"].toArray();
|
||||
for (const auto& loc : locs) {
|
||||
folder.locations.append(loc.toString());
|
||||
}
|
||||
}
|
||||
|
||||
return folder;
|
||||
}
|
||||
|
||||
QString virtualFolderId() const {
|
||||
return id.isEmpty() ? itemId : id;
|
||||
}
|
||||
|
||||
|
||||
QString collectionTypeDisplayName() const {
|
||||
if (collectionType == "movies") return QObject::tr("Movies");
|
||||
if (collectionType == "tvshows") return QObject::tr("TV Shows");
|
||||
if (collectionType == "music") return QObject::tr("Music");
|
||||
if (collectionType == "homevideos") return QObject::tr("Home Videos");
|
||||
if (collectionType == "audiobooks" || collectionType == "books")
|
||||
return QObject::tr("Books");
|
||||
if (collectionType == "musicvideos") return QObject::tr("Music Videos");
|
||||
if (collectionType == "boxsets") return QObject::tr("Collections");
|
||||
if (collectionType == "playlists") return QObject::tr("Playlists");
|
||||
if (collectionType.isEmpty()) return QObject::tr("Mixed");
|
||||
return collectionType;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(VirtualFolder)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT ItemImageInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString imageType;
|
||||
int imageIndex = -1;
|
||||
QString path;
|
||||
QString fileName;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
qint64 size = 0;
|
||||
|
||||
static ItemImageInfo fromJson(const QJsonObject& obj) {
|
||||
ItemImageInfo info;
|
||||
info.imageType = obj.value(QStringLiteral("ImageType")).toString();
|
||||
if (info.imageType.isEmpty()) {
|
||||
info.imageType = obj.value(QStringLiteral("Type")).toString();
|
||||
}
|
||||
info.imageIndex = obj.value(QStringLiteral("ImageIndex")).toInt(-1);
|
||||
info.path = obj.value(QStringLiteral("Path")).toString();
|
||||
info.fileName = obj.value(QStringLiteral("Filename")).toString();
|
||||
info.width = obj.value(QStringLiteral("Width")).toInt();
|
||||
info.height = obj.value(QStringLiteral("Height")).toInt();
|
||||
info.size = obj.value(QStringLiteral("Size")).toVariant().toLongLong();
|
||||
return info;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(ItemImageInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT RemoteSearchResult {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString name;
|
||||
int productionYear = 0;
|
||||
int indexNumber = -1;
|
||||
int indexNumberEnd = -1;
|
||||
int parentIndexNumber = -1;
|
||||
QString premiereDate;
|
||||
QString imageUrl;
|
||||
QString searchProviderName;
|
||||
QString overview;
|
||||
QString albumArtistName;
|
||||
QStringList artistNames;
|
||||
QJsonObject providerIds;
|
||||
QJsonObject rawData;
|
||||
|
||||
QString providerIdSummary() const {
|
||||
QStringList pairs;
|
||||
const QStringList keys = providerIds.keys();
|
||||
for (const QString& key : keys) {
|
||||
const QString value = providerIds.value(key).toString().trimmed();
|
||||
if (!key.trimmed().isEmpty() && !value.isEmpty()) {
|
||||
pairs.append(QStringLiteral("%1=%2").arg(key.trimmed(), value));
|
||||
}
|
||||
}
|
||||
return pairs.join(QStringLiteral(", "));
|
||||
}
|
||||
|
||||
QString secondaryText() const {
|
||||
QStringList parts;
|
||||
|
||||
if (productionYear > 0) {
|
||||
parts.append(QString::number(productionYear));
|
||||
}
|
||||
|
||||
if (parentIndexNumber >= 0 && indexNumber >= 0) {
|
||||
parts.append(QStringLiteral("S%1E%2")
|
||||
.arg(parentIndexNumber, 2, 10, QChar('0'))
|
||||
.arg(indexNumber, 2, 10, QChar('0')));
|
||||
} else if (indexNumber >= 0) {
|
||||
parts.append(QStringLiteral("E%1").arg(indexNumber));
|
||||
}
|
||||
|
||||
if (!searchProviderName.trimmed().isEmpty()) {
|
||||
parts.append(searchProviderName.trimmed());
|
||||
}
|
||||
|
||||
return parts.join(QStringLiteral(" | "));
|
||||
}
|
||||
|
||||
QJsonObject toJson() const {
|
||||
if (!rawData.isEmpty()) {
|
||||
return rawData;
|
||||
}
|
||||
|
||||
QJsonObject obj;
|
||||
obj.insert(QStringLiteral("Name"), name);
|
||||
obj.insert(QStringLiteral("ProductionYear"), productionYear);
|
||||
obj.insert(QStringLiteral("IndexNumber"), indexNumber);
|
||||
obj.insert(QStringLiteral("IndexNumberEnd"), indexNumberEnd);
|
||||
obj.insert(QStringLiteral("ParentIndexNumber"), parentIndexNumber);
|
||||
obj.insert(QStringLiteral("PremiereDate"), premiereDate);
|
||||
obj.insert(QStringLiteral("ImageUrl"), imageUrl);
|
||||
obj.insert(QStringLiteral("SearchProviderName"), searchProviderName);
|
||||
obj.insert(QStringLiteral("Overview"), overview);
|
||||
obj.insert(QStringLiteral("ProviderIds"), providerIds);
|
||||
if (!albumArtistName.trimmed().isEmpty()) {
|
||||
QJsonObject albumArtist;
|
||||
albumArtist.insert(QStringLiteral("Name"), albumArtistName.trimmed());
|
||||
obj.insert(QStringLiteral("AlbumArtist"), albumArtist);
|
||||
}
|
||||
if (!artistNames.isEmpty()) {
|
||||
QJsonArray artists;
|
||||
for (const QString& artistName : artistNames) {
|
||||
if (artistName.trimmed().isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QJsonObject artist;
|
||||
artist.insert(QStringLiteral("Name"), artistName.trimmed());
|
||||
artists.append(artist);
|
||||
}
|
||||
obj.insert(QStringLiteral("Artists"), artists);
|
||||
}
|
||||
return obj;
|
||||
}
|
||||
|
||||
static RemoteSearchResult fromJson(const QJsonObject& obj) {
|
||||
RemoteSearchResult result;
|
||||
result.name = obj.value(QStringLiteral("Name")).toString();
|
||||
result.productionYear =
|
||||
obj.value(QStringLiteral("ProductionYear")).toInt();
|
||||
result.indexNumber = obj.value(QStringLiteral("IndexNumber")).toInt(-1);
|
||||
result.indexNumberEnd =
|
||||
obj.value(QStringLiteral("IndexNumberEnd")).toInt(-1);
|
||||
result.parentIndexNumber =
|
||||
obj.value(QStringLiteral("ParentIndexNumber")).toInt(-1);
|
||||
result.premiereDate = obj.value(QStringLiteral("PremiereDate")).toString();
|
||||
result.imageUrl = obj.value(QStringLiteral("ImageUrl")).toString();
|
||||
result.searchProviderName =
|
||||
obj.value(QStringLiteral("SearchProviderName")).toString();
|
||||
result.overview = obj.value(QStringLiteral("Overview")).toString();
|
||||
result.providerIds = obj.value(QStringLiteral("ProviderIds")).toObject();
|
||||
result.rawData = obj;
|
||||
|
||||
const QJsonValue albumArtistValue = obj.value(QStringLiteral("AlbumArtist"));
|
||||
if (albumArtistValue.isObject()) {
|
||||
result.albumArtistName =
|
||||
albumArtistValue.toObject().value(QStringLiteral("Name")).toString();
|
||||
} else if (albumArtistValue.isString()) {
|
||||
result.albumArtistName = albumArtistValue.toString();
|
||||
}
|
||||
|
||||
const QJsonArray artists = obj.value(QStringLiteral("Artists")).toArray();
|
||||
for (const QJsonValue& artistValue : artists) {
|
||||
if (artistValue.isObject()) {
|
||||
const QString artistName =
|
||||
artistValue.toObject().value(QStringLiteral("Name")).toString().trimmed();
|
||||
if (!artistName.isEmpty()) {
|
||||
result.artistNames.append(artistName);
|
||||
}
|
||||
} else if (artistValue.isString()) {
|
||||
const QString artistName = artistValue.toString().trimmed();
|
||||
if (!artistName.isEmpty()) {
|
||||
result.artistNames.append(artistName);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(RemoteSearchResult)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT ScheduledTaskInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
QString description;
|
||||
QString category;
|
||||
QString state;
|
||||
double currentProgressPercentage = 0.0;
|
||||
QJsonArray triggers;
|
||||
bool isHidden = false;
|
||||
QString key;
|
||||
|
||||
|
||||
QString lastExecutionResult;
|
||||
QString lastStartTime;
|
||||
QString lastEndTime;
|
||||
|
||||
bool isRunning() const { return state == "Running"; }
|
||||
bool hasTriggers() const { return !triggers.isEmpty(); }
|
||||
|
||||
static ScheduledTaskInfo fromJson(const QJsonObject& obj) {
|
||||
ScheduledTaskInfo task;
|
||||
task.id = obj["Id"].toString();
|
||||
task.name = obj["Name"].toString();
|
||||
task.description = obj["Description"].toString();
|
||||
task.category = obj["Category"].toString();
|
||||
task.state = obj["State"].toString();
|
||||
task.currentProgressPercentage = obj["CurrentProgressPercentage"].toDouble();
|
||||
task.triggers = obj["Triggers"].toArray();
|
||||
task.isHidden = obj["IsHidden"].toBool(false);
|
||||
task.key = obj["Key"].toString();
|
||||
|
||||
|
||||
if (obj.contains("LastExecutionResult")) {
|
||||
QJsonObject result = obj["LastExecutionResult"].toObject();
|
||||
task.lastExecutionResult = result["Status"].toString();
|
||||
task.lastStartTime = result["StartTimeUtc"].toString();
|
||||
task.lastEndTime = result["EndTimeUtc"].toString();
|
||||
}
|
||||
|
||||
return task;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(ScheduledTaskInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT LogFileInfo {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
QString name;
|
||||
QString dateCreated;
|
||||
QString dateModified;
|
||||
long long size = 0;
|
||||
|
||||
|
||||
QString readableSize() const {
|
||||
if (size < 1024) return QString("%1 B").arg(size);
|
||||
if (size < 1024 * 1024) return QString("%1 KB").arg(size / 1024.0, 0, 'f', 1);
|
||||
return QString("%1 MB").arg(size / (1024.0 * 1024.0), 0, 'f', 1);
|
||||
}
|
||||
|
||||
static LogFileInfo fromJson(const QJsonObject& obj) {
|
||||
LogFileInfo log;
|
||||
log.name = obj["Name"].toString();
|
||||
log.dateCreated = obj["DateCreated"].toString();
|
||||
log.dateModified = obj["DateModified"].toString();
|
||||
log.size = obj["Size"].toVariant().toLongLong();
|
||||
return log;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(LogFileInfo)
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT ActivityLogEntry {
|
||||
Q_GADGET
|
||||
|
||||
public:
|
||||
qint64 id = 0;
|
||||
QString name;
|
||||
QString type;
|
||||
QString overview;
|
||||
QString shortOverview;
|
||||
QString date;
|
||||
QString userId;
|
||||
QString severity;
|
||||
QString itemId;
|
||||
|
||||
static ActivityLogEntry fromJson(const QJsonObject& obj) {
|
||||
ActivityLogEntry entry;
|
||||
entry.id = obj["Id"].toVariant().toLongLong();
|
||||
entry.name = obj["Name"].toString();
|
||||
entry.type = obj["Type"].toString();
|
||||
entry.overview = obj["Overview"].toString();
|
||||
entry.shortOverview = obj["ShortOverview"].toString();
|
||||
entry.date = obj["Date"].toString();
|
||||
entry.userId = obj["UserId"].toString();
|
||||
entry.severity = obj["Severity"].toString();
|
||||
entry.itemId = obj["ItemId"].toString();
|
||||
return entry;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(ActivityLogEntry)
|
||||
|
||||
#endif
|
||||
194
src/qEmbyCore/models/danmaku/danmakumodels.h
Normal file
194
src/qEmbyCore/models/danmaku/danmakumodels.h
Normal file
@@ -0,0 +1,194 @@
|
||||
#ifndef DANMAKUMODELS_H
|
||||
#define DANMAKUMODELS_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
#include <QColor>
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
#include <QVariantMap>
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuServerDefinition {
|
||||
QString id;
|
||||
QString name;
|
||||
QString provider = QStringLiteral("dandanplay");
|
||||
QString baseUrl = QStringLiteral("https://api.dandanplay.net");
|
||||
QString description;
|
||||
QString appId;
|
||||
QString appSecret;
|
||||
|
||||
QString contentScope;
|
||||
bool builtIn = false;
|
||||
bool enabled = true;
|
||||
|
||||
bool isValid() const {
|
||||
return !id.trimmed().isEmpty() && !name.trimmed().isEmpty() &&
|
||||
!baseUrl.trimmed().isEmpty();
|
||||
}
|
||||
|
||||
QString displayName() const {
|
||||
return name.trimmed().isEmpty() ? baseUrl.trimmed() : name.trimmed();
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuServerDefinition)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuProviderConfig {
|
||||
QString provider = QStringLiteral("dandanplay");
|
||||
QString baseUrl = QStringLiteral("https://api.dandanplay.net");
|
||||
QString endpointId;
|
||||
QString endpointName;
|
||||
QString appId;
|
||||
QString appSecret;
|
||||
|
||||
QString contentScope;
|
||||
bool enabled = true;
|
||||
bool withRelated = true;
|
||||
int cacheHours = 24;
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuProviderConfig)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuMediaContext {
|
||||
QString serverId;
|
||||
QString mediaId;
|
||||
QString mediaSourceId;
|
||||
QString itemType;
|
||||
QString title;
|
||||
QString originalTitle;
|
||||
QString seriesName;
|
||||
int productionYear = 0;
|
||||
int seasonNumber = -1;
|
||||
int episodeNumber = -1;
|
||||
qint64 durationMs = 0;
|
||||
QString path;
|
||||
QVariantMap providerIds;
|
||||
|
||||
QString cacheKey() const {
|
||||
return mediaSourceId.isEmpty() ? mediaId
|
||||
: QStringLiteral("%1:%2")
|
||||
.arg(mediaId, mediaSourceId);
|
||||
}
|
||||
|
||||
bool isEpisode() const {
|
||||
return itemType.compare(QStringLiteral("Episode"),
|
||||
Qt::CaseInsensitive) == 0;
|
||||
}
|
||||
|
||||
QString displayTitle() const {
|
||||
if (isEpisode() && !seriesName.isEmpty()) {
|
||||
return QStringLiteral("%1 S%2E%3")
|
||||
.arg(seriesName)
|
||||
.arg(seasonNumber, 2, 10, QChar('0'))
|
||||
.arg(episodeNumber, 2, 10, QChar('0'));
|
||||
}
|
||||
return title;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuMediaContext)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuMatchCandidate {
|
||||
QString provider;
|
||||
QString cacheScope;
|
||||
QString endpointId;
|
||||
QString endpointName;
|
||||
QString targetId;
|
||||
QString title;
|
||||
QString subtitle;
|
||||
int seasonNumber = -1;
|
||||
int episodeNumber = -1;
|
||||
qint64 durationMs = 0;
|
||||
double score = 0.0;
|
||||
QString matchReason;
|
||||
int commentCount = 0;
|
||||
|
||||
bool isValid() const {
|
||||
return !provider.isEmpty() && !targetId.isEmpty();
|
||||
}
|
||||
|
||||
QString displayText() const {
|
||||
return subtitle.isEmpty() ? title
|
||||
: QStringLiteral("%1 - %2")
|
||||
.arg(subtitle, title);
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuMatchCandidate)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuMatchResult {
|
||||
bool matched = false;
|
||||
bool cacheHit = false;
|
||||
bool manualOverride = false;
|
||||
DanmakuMatchCandidate selected;
|
||||
QList<DanmakuMatchCandidate> candidates;
|
||||
|
||||
bool needManualMatch() const {
|
||||
return !matched && !candidates.isEmpty();
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuMatchResult)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuComment {
|
||||
qint64 timeMs = 0;
|
||||
int mode = 1;
|
||||
QColor color = QColor(Qt::white);
|
||||
int fontLevel = 25;
|
||||
QString sender;
|
||||
QString text;
|
||||
QDateTime createdAt;
|
||||
|
||||
bool isValid() const {
|
||||
return !text.trimmed().isEmpty() && timeMs >= 0;
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuComment)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuRenderOptions {
|
||||
bool enabled = true;
|
||||
double opacity = 0.72;
|
||||
double fontScale = 1.0;
|
||||
int fontWeight = 400;
|
||||
double outlineSize = 3.0;
|
||||
double shadowOffset = 1.0;
|
||||
int areaPercent = 70;
|
||||
int density = 100;
|
||||
double speedScale = 0.5;
|
||||
int offsetMs = 0;
|
||||
bool hideScroll = false;
|
||||
bool hideTop = false;
|
||||
bool hideBottom = false;
|
||||
bool dualSubtitle = true;
|
||||
QStringList blockedKeywords;
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuRenderOptions)
|
||||
|
||||
struct QEMBYCORE_EXPORT DanmakuLoadResult {
|
||||
bool success = false;
|
||||
QString assFilePath;
|
||||
int commentCount = 0;
|
||||
QString provider;
|
||||
QString sourceServerId;
|
||||
QString sourceServerName;
|
||||
QString sourceTitle;
|
||||
bool needManualMatch = false;
|
||||
QList<DanmakuComment> comments;
|
||||
DanmakuMatchResult matchResult;
|
||||
|
||||
bool hasComments() const {
|
||||
return commentCount > 0 && hasRenderableContent();
|
||||
}
|
||||
|
||||
bool hasCommentPayload() const {
|
||||
return !comments.isEmpty();
|
||||
}
|
||||
|
||||
bool hasRenderableTrack() const {
|
||||
return success && !assFilePath.isEmpty();
|
||||
}
|
||||
|
||||
bool hasRenderableContent() const {
|
||||
return hasRenderableTrack() || hasCommentPayload();
|
||||
}
|
||||
};
|
||||
Q_DECLARE_METATYPE(DanmakuLoadResult)
|
||||
|
||||
#endif
|
||||
277
src/qEmbyCore/models/media/mediaitem.cpp
Normal file
277
src/qEmbyCore/models/media/mediaitem.cpp
Normal file
@@ -0,0 +1,277 @@
|
||||
#include "mediaitem.h"
|
||||
#include <QDateTime>
|
||||
|
||||
MediaPersonInfo MediaPersonInfo::fromJson(const QJsonObject& obj) {
|
||||
MediaPersonInfo person;
|
||||
person.id = obj["Id"].toString();
|
||||
person.name = obj["Name"].toString();
|
||||
person.role = obj["Role"].toString();
|
||||
person.type = obj["Type"].toString();
|
||||
if (obj.contains("PrimaryImageTag")) {
|
||||
person.primaryImageTag = obj["PrimaryImageTag"].toString();
|
||||
}
|
||||
return person;
|
||||
}
|
||||
|
||||
MediaStudioInfo MediaStudioInfo::fromJson(const QJsonObject& obj) {
|
||||
MediaStudioInfo studio;
|
||||
studio.name = obj["Name"].toString();
|
||||
|
||||
studio.id = obj["Id"].toVariant().toString();
|
||||
return studio;
|
||||
}
|
||||
|
||||
|
||||
MediaExternalUrlInfo MediaExternalUrlInfo::fromJson(const QJsonObject& obj) {
|
||||
MediaExternalUrlInfo urlInfo;
|
||||
urlInfo.name = obj["Name"].toString();
|
||||
urlInfo.url = obj["Url"].toString();
|
||||
return urlInfo;
|
||||
}
|
||||
|
||||
|
||||
|
||||
QPair<QString, QString> MediaImageInfo::bestPoster() const {
|
||||
if (!primaryTag.isEmpty()) return {primaryTag, QStringLiteral("Primary")};
|
||||
if (!thumbTag.isEmpty()) return {thumbTag, QStringLiteral("Thumb")};
|
||||
if (!backdropTag.isEmpty()) return {backdropTag, QStringLiteral("Backdrop")};
|
||||
|
||||
if (!parentPrimaryTag.isEmpty()) return {parentPrimaryTag, QStringLiteral("Primary")};
|
||||
if (!parentBackdropTag.isEmpty()) return {parentBackdropTag, QStringLiteral("Backdrop")};
|
||||
return {{}, {}};
|
||||
}
|
||||
|
||||
QPair<QString, QString> MediaImageInfo::bestThumb() const {
|
||||
if (!thumbTag.isEmpty()) return {thumbTag, QStringLiteral("Thumb")};
|
||||
if (!backdropTag.isEmpty()) return {backdropTag, QStringLiteral("Backdrop")};
|
||||
if (!primaryTag.isEmpty()) return {primaryTag, QStringLiteral("Primary")};
|
||||
|
||||
if (!parentBackdropTag.isEmpty()) return {parentBackdropTag, QStringLiteral("Backdrop")};
|
||||
if (!parentThumbTag.isEmpty()) return {parentThumbTag, QStringLiteral("Thumb")};
|
||||
if (!parentPrimaryTag.isEmpty()) return {parentPrimaryTag, QStringLiteral("Primary")};
|
||||
return {{}, {}};
|
||||
}
|
||||
|
||||
|
||||
QPair<QString, QString> MediaImageInfo::bestBackdrop() const {
|
||||
if (!backdropTag.isEmpty()) return {backdropTag, QStringLiteral("Backdrop")};
|
||||
if (!thumbTag.isEmpty()) return {thumbTag, QStringLiteral("Thumb")};
|
||||
if (!primaryTag.isEmpty()) return {primaryTag, QStringLiteral("Primary")};
|
||||
|
||||
if (!parentBackdropTag.isEmpty()) return {parentBackdropTag, QStringLiteral("Backdrop")};
|
||||
if (!parentThumbTag.isEmpty()) return {parentThumbTag, QStringLiteral("Thumb")};
|
||||
return {{}, {}};
|
||||
}
|
||||
|
||||
|
||||
bool MediaImageInfo::isParentTag(const QString& tag) const {
|
||||
if (tag.isEmpty()) return false;
|
||||
return (tag == parentPrimaryTag || tag == parentBackdropTag || tag == parentThumbTag);
|
||||
}
|
||||
|
||||
MediaImageInfo MediaImageInfo::fromJson(const QJsonObject& obj) {
|
||||
MediaImageInfo info;
|
||||
info.primaryImageAspectRatio = obj["PrimaryImageAspectRatio"].toDouble();
|
||||
|
||||
|
||||
if (obj.contains("ImageTags")) {
|
||||
QJsonObject tagsObj = obj["ImageTags"].toObject();
|
||||
if (tagsObj.contains("Primary")) info.primaryTag = tagsObj["Primary"].toString();
|
||||
if (tagsObj.contains("Thumb")) info.thumbTag = tagsObj["Thumb"].toString();
|
||||
if (tagsObj.contains("Logo")) info.logoTag = tagsObj["Logo"].toString();
|
||||
if (tagsObj.contains("Backdrop")) info.backdropTag = tagsObj["Backdrop"].toString();
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("PrimaryImageItemId")) {
|
||||
info.primaryImageItemId = obj["PrimaryImageItemId"].toString();
|
||||
}
|
||||
|
||||
if (info.primaryTag.isEmpty() && obj.contains("PrimaryImageTag")) {
|
||||
|
||||
info.primaryTag = obj["PrimaryImageTag"].toString();
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("BackdropImageTags")) {
|
||||
QJsonArray backdropTags = obj["BackdropImageTags"].toArray();
|
||||
if (!backdropTags.isEmpty()) {
|
||||
info.backdropTag = backdropTags[0].toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("SeriesPrimaryImageTag")) {
|
||||
info.parentPrimaryTag = obj["SeriesPrimaryImageTag"].toString();
|
||||
}
|
||||
if (obj.contains("ParentThumbImageTag")) {
|
||||
info.parentThumbTag = obj["ParentThumbImageTag"].toString();
|
||||
}
|
||||
if (obj.contains("ParentBackdropImageTags")) {
|
||||
QJsonArray parentBackdrops = obj["ParentBackdropImageTags"].toArray();
|
||||
if (!parentBackdrops.isEmpty()) {
|
||||
info.parentBackdropTag = parentBackdrops[0].toString();
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("SeriesId")) {
|
||||
info.parentImageItemId = obj["SeriesId"].toString();
|
||||
}
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
MediaUserDataInfo MediaUserDataInfo::fromJson(const QJsonObject& obj) {
|
||||
MediaUserDataInfo ud;
|
||||
ud.isFavorite = obj["IsFavorite"].toBool();
|
||||
ud.playedPercentage = obj["PlayedPercentage"].toDouble();
|
||||
ud.playbackPositionTicks = obj["PlaybackPositionTicks"].toVariant().toLongLong();
|
||||
ud.played = obj["Played"].toBool();
|
||||
ud.playCount = obj["PlayCount"].toInt();
|
||||
ud.lastPlayedDate = obj["LastPlayedDate"].toString();
|
||||
return ud;
|
||||
}
|
||||
|
||||
MediaItem MediaItem::fromJson(const QJsonObject& obj) {
|
||||
MediaItem item;
|
||||
|
||||
|
||||
item.id = obj["Id"].toString();
|
||||
item.name = obj["Name"].toString();
|
||||
item.originalTitle = obj["OriginalTitle"].toString();
|
||||
item.sortName = obj["SortName"].toString();
|
||||
item.type = obj["Type"].toString();
|
||||
item.mediaType = obj["MediaType"].toString();
|
||||
item.collectionType = obj["CollectionType"].toString();
|
||||
|
||||
|
||||
item.collectionId = obj["CollectionId"].toString();
|
||||
item.collectionName = obj["CollectionName"].toString();
|
||||
item.playlistItemId = obj["PlaylistItemId"].toString();
|
||||
|
||||
|
||||
item.seriesId = obj["SeriesId"].toString();
|
||||
item.seriesName = obj["SeriesName"].toString();
|
||||
item.parentIndexNumber = obj.contains("ParentIndexNumber") ? obj["ParentIndexNumber"].toInt(-1) : -1;
|
||||
item.indexNumber = obj.contains("IndexNumber") ? obj["IndexNumber"].toInt(-1) : -1;
|
||||
if (obj.contains("ProviderIds")) {
|
||||
const QJsonObject providerIdsObj = obj["ProviderIds"].toObject();
|
||||
for (auto it = providerIdsObj.constBegin(); it != providerIdsObj.constEnd(); ++it) {
|
||||
item.providerIds.insert(it.key(), it.value().toVariant());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
item.productionYear = obj["ProductionYear"].toInt();
|
||||
item.overview = obj["Overview"].toString();
|
||||
item.officialRating = obj["OfficialRating"].toString();
|
||||
item.criticRating = obj["CriticRating"].toInt();
|
||||
item.runTimeTicks = obj["RunTimeTicks"].toVariant().toLongLong();
|
||||
item.communityRating = obj["CommunityRating"].toDouble();
|
||||
item.canDownload = obj["CanDownload"].toBool(false);
|
||||
|
||||
|
||||
if (obj.contains("PremiereDate")) {
|
||||
item.premiereDate = obj["PremiereDate"].toString();
|
||||
int tIdx = item.premiereDate.indexOf('T');
|
||||
if (tIdx > 0) item.premiereDate = item.premiereDate.left(tIdx);
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("DateCreated")) {
|
||||
QString rawDate = obj["DateCreated"].toString();
|
||||
item.dateCreatedRaw = rawDate;
|
||||
QDateTime dt = QDateTime::fromString(rawDate, Qt::ISODate);
|
||||
if (dt.isValid()) {
|
||||
item.dateCreated = dt.toLocalTime().toString("yyyy-MM-dd HH:mm");
|
||||
} else {
|
||||
|
||||
item.dateCreated = rawDate;
|
||||
int tIdx = item.dateCreated.indexOf('T');
|
||||
if (tIdx > 0 && item.dateCreated.length() >= tIdx + 6) {
|
||||
item.dateCreated = item.dateCreated.left(tIdx + 6).replace('T', ' ');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
item.container = obj["Container"].toString();
|
||||
item.path = obj["Path"].toString();
|
||||
item.size = obj["Size"].toVariant().toLongLong();
|
||||
item.bitrate = obj["Bitrate"].toVariant().toLongLong();
|
||||
item.width = obj["Width"].toInt();
|
||||
item.height = obj["Height"].toInt();
|
||||
item.childCount = obj["ChildCount"].toInt();
|
||||
item.recursiveItemCount = obj["RecursiveItemCount"].toInt();
|
||||
|
||||
|
||||
if (obj.contains("UserData")) {
|
||||
item.userData = MediaUserDataInfo::fromJson(obj["UserData"].toObject());
|
||||
}
|
||||
|
||||
|
||||
|
||||
item.images = MediaImageInfo::fromJson(obj);
|
||||
|
||||
|
||||
|
||||
|
||||
if (obj.contains("GenreItems")) {
|
||||
QJsonArray arr = obj["GenreItems"].toArray();
|
||||
for (const auto& val : arr) {
|
||||
QString name = val.toObject()["Name"].toString();
|
||||
if (!name.isEmpty()) item.genres.append(name);
|
||||
}
|
||||
}
|
||||
if (item.genres.isEmpty() && obj.contains("Genres")) {
|
||||
QJsonArray arr = obj["Genres"].toArray();
|
||||
for (const auto& val : arr) item.genres.append(val.toString());
|
||||
}
|
||||
|
||||
if (obj.contains("Taglines")) {
|
||||
QJsonArray arr = obj["Taglines"].toArray();
|
||||
for (const auto& val : arr) item.taglines.append(val.toString());
|
||||
}
|
||||
|
||||
if (obj.contains("TagItems")) {
|
||||
QJsonArray arr = obj["TagItems"].toArray();
|
||||
for (const auto& val : arr) item.tags.append(val.toObject()["Name"].toString());
|
||||
}
|
||||
|
||||
if (obj.contains("RemoteTrailers")) {
|
||||
QJsonArray arr = obj["RemoteTrailers"].toArray();
|
||||
for (const auto& val : arr) item.remoteTrailers.append(val.toObject()["Url"].toString());
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("People")) {
|
||||
QJsonArray arr = obj["People"].toArray();
|
||||
for (const auto& val : arr) {
|
||||
item.people.append(MediaPersonInfo::fromJson(val.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("Studios")) {
|
||||
QJsonArray arr = obj["Studios"].toArray();
|
||||
for (const auto& val : arr) {
|
||||
item.studios.append(MediaStudioInfo::fromJson(val.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
if (obj.contains("ExternalUrls")) {
|
||||
QJsonArray arr = obj["ExternalUrls"].toArray();
|
||||
for (const auto& val : arr) {
|
||||
item.externalUrls.append(MediaExternalUrlInfo::fromJson(val.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
if (obj.contains("MediaSources")) {
|
||||
QJsonArray arr = obj["MediaSources"].toArray();
|
||||
for (const auto& val : arr) {
|
||||
item.mediaSources.append(MediaSourceInfo::fromJson(val.toObject()));
|
||||
}
|
||||
}
|
||||
|
||||
return item;
|
||||
}
|
||||
249
src/qEmbyCore/models/media/mediaitem.h
Normal file
249
src/qEmbyCore/models/media/mediaitem.h
Normal file
@@ -0,0 +1,249 @@
|
||||
#ifndef MEDIAITEM_H
|
||||
#define MEDIAITEM_H
|
||||
|
||||
#include <QString>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonArray>
|
||||
#include <QStringList>
|
||||
#include <QList>
|
||||
#include <QVariant>
|
||||
#include <QMetaType>
|
||||
#include <QObject>
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
|
||||
#include "playbackinfo.h"
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaPersonInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString id MEMBER id)
|
||||
Q_PROPERTY(QString name MEMBER name)
|
||||
Q_PROPERTY(QString role MEMBER role)
|
||||
Q_PROPERTY(QString type MEMBER type)
|
||||
Q_PROPERTY(QString primaryImageTag MEMBER primaryImageTag)
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
QString role;
|
||||
QString type;
|
||||
QString primaryImageTag;
|
||||
|
||||
static MediaPersonInfo fromJson(const QJsonObject& obj);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaPersonInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaStudioInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString id MEMBER id)
|
||||
Q_PROPERTY(QString name MEMBER name)
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
|
||||
static MediaStudioInfo fromJson(const QJsonObject& obj);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaStudioInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaExternalUrlInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString name MEMBER name)
|
||||
Q_PROPERTY(QString url MEMBER url)
|
||||
|
||||
public:
|
||||
QString name;
|
||||
QString url;
|
||||
|
||||
static MediaExternalUrlInfo fromJson(const QJsonObject& obj);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaExternalUrlInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaImageInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString primaryTag MEMBER primaryTag)
|
||||
Q_PROPERTY(QString thumbTag MEMBER thumbTag)
|
||||
Q_PROPERTY(QString backdropTag MEMBER backdropTag)
|
||||
Q_PROPERTY(QString logoTag MEMBER logoTag)
|
||||
Q_PROPERTY(double primaryImageAspectRatio MEMBER primaryImageAspectRatio)
|
||||
Q_PROPERTY(QString primaryImageItemId MEMBER primaryImageItemId)
|
||||
|
||||
public:
|
||||
QString primaryTag;
|
||||
QString thumbTag;
|
||||
QString backdropTag;
|
||||
QString logoTag;
|
||||
double primaryImageAspectRatio = 0.0;
|
||||
QString primaryImageItemId;
|
||||
|
||||
|
||||
QString parentPrimaryTag;
|
||||
QString parentBackdropTag;
|
||||
QString parentThumbTag;
|
||||
QString parentImageItemId;
|
||||
|
||||
|
||||
|
||||
QPair<QString, QString> bestPoster() const;
|
||||
|
||||
|
||||
|
||||
QPair<QString, QString> bestThumb() const;
|
||||
|
||||
|
||||
|
||||
QPair<QString, QString> bestBackdrop() const;
|
||||
|
||||
|
||||
bool isParentTag(const QString& tag) const;
|
||||
|
||||
static MediaImageInfo fromJson(const QJsonObject& obj);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaImageInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaUserDataInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(bool isFavorite MEMBER isFavorite)
|
||||
Q_PROPERTY(double playedPercentage MEMBER playedPercentage)
|
||||
Q_PROPERTY(long long playbackPositionTicks MEMBER playbackPositionTicks)
|
||||
Q_PROPERTY(bool played MEMBER played)
|
||||
Q_PROPERTY(int playCount MEMBER playCount)
|
||||
Q_PROPERTY(QString lastPlayedDate MEMBER lastPlayedDate)
|
||||
|
||||
public:
|
||||
bool isFavorite = false;
|
||||
double playedPercentage = 0.0;
|
||||
long long playbackPositionTicks = 0;
|
||||
bool played = false;
|
||||
int playCount = 0;
|
||||
QString lastPlayedDate;
|
||||
|
||||
static MediaUserDataInfo fromJson(const QJsonObject& obj);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaUserDataInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaItem {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString id MEMBER id)
|
||||
Q_PROPERTY(QString name MEMBER name)
|
||||
Q_PROPERTY(QString originalTitle MEMBER originalTitle)
|
||||
Q_PROPERTY(QString sortName MEMBER sortName)
|
||||
Q_PROPERTY(QString type MEMBER type)
|
||||
Q_PROPERTY(QString mediaType MEMBER mediaType)
|
||||
Q_PROPERTY(QString collectionType MEMBER collectionType)
|
||||
Q_PROPERTY(QString collectionId MEMBER collectionId)
|
||||
Q_PROPERTY(QString collectionName MEMBER collectionName)
|
||||
Q_PROPERTY(QString playlistId MEMBER playlistId)
|
||||
Q_PROPERTY(QString playlistItemId MEMBER playlistItemId)
|
||||
Q_PROPERTY(QString seriesId MEMBER seriesId)
|
||||
Q_PROPERTY(QString seriesName MEMBER seriesName)
|
||||
Q_PROPERTY(QVariantMap providerIds MEMBER providerIds)
|
||||
Q_PROPERTY(int productionYear MEMBER productionYear)
|
||||
Q_PROPERTY(QString overview MEMBER overview)
|
||||
Q_PROPERTY(QString officialRating MEMBER officialRating)
|
||||
Q_PROPERTY(QString premiereDate MEMBER premiereDate)
|
||||
Q_PROPERTY(QString dateCreated MEMBER dateCreated)
|
||||
Q_PROPERTY(QString dateCreatedRaw MEMBER dateCreatedRaw)
|
||||
Q_PROPERTY(int criticRating MEMBER criticRating)
|
||||
Q_PROPERTY(bool canDownload MEMBER canDownload)
|
||||
|
||||
|
||||
Q_PROPERTY(QString container MEMBER container)
|
||||
Q_PROPERTY(QString path MEMBER path)
|
||||
Q_PROPERTY(long long size MEMBER size)
|
||||
Q_PROPERTY(long long bitrate MEMBER bitrate)
|
||||
Q_PROPERTY(int width MEMBER width)
|
||||
Q_PROPERTY(int height MEMBER height)
|
||||
Q_PROPERTY(int partCount MEMBER partCount)
|
||||
Q_PROPERTY(int childCount MEMBER childCount)
|
||||
Q_PROPERTY(int recursiveItemCount MEMBER recursiveItemCount)
|
||||
Q_PROPERTY(int parentIndexNumber MEMBER parentIndexNumber)
|
||||
Q_PROPERTY(int indexNumber MEMBER indexNumber)
|
||||
|
||||
public:
|
||||
|
||||
QString id;
|
||||
QString name;
|
||||
QString originalTitle;
|
||||
QString sortName;
|
||||
QString type;
|
||||
QString mediaType;
|
||||
QString collectionType;
|
||||
QString collectionId;
|
||||
QString collectionName;
|
||||
QString playlistId;
|
||||
QString playlistItemId;
|
||||
QString seriesId;
|
||||
QString seriesName;
|
||||
QVariantMap providerIds;
|
||||
|
||||
|
||||
int productionYear = 0;
|
||||
QString overview;
|
||||
QString officialRating;
|
||||
QString premiereDate;
|
||||
QString dateCreated;
|
||||
QString dateCreatedRaw;
|
||||
long long runTimeTicks = 0;
|
||||
double communityRating = 0.0;
|
||||
int criticRating = 0;
|
||||
bool canDownload = false;
|
||||
|
||||
|
||||
QString container;
|
||||
QString path;
|
||||
long long size = 0;
|
||||
long long bitrate = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
int partCount = 1;
|
||||
int childCount = 0;
|
||||
int recursiveItemCount = 0;
|
||||
int parentIndexNumber = -1;
|
||||
int indexNumber = -1;
|
||||
|
||||
|
||||
QStringList genres;
|
||||
QStringList tags;
|
||||
QStringList taglines;
|
||||
QStringList remoteTrailers;
|
||||
|
||||
|
||||
MediaImageInfo images;
|
||||
MediaUserDataInfo userData;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QString resumeItemId;
|
||||
MediaUserDataInfo resumeUserData;
|
||||
bool hasResumeContext = false;
|
||||
|
||||
QList<MediaPersonInfo> people;
|
||||
QList<MediaStudioInfo> studios;
|
||||
QList<MediaExternalUrlInfo> externalUrls;
|
||||
QList<MediaSourceInfo> mediaSources;
|
||||
|
||||
|
||||
bool isFavorite() const { return userData.isFavorite; }
|
||||
QString primaryImageTag() const { return images.primaryTag; }
|
||||
QString backdropImageTag() const { return images.backdropTag; }
|
||||
|
||||
|
||||
|
||||
QString getPrimaryImageId() const {
|
||||
return images.primaryImageItemId.isEmpty() ? id : images.primaryImageItemId;
|
||||
}
|
||||
|
||||
static MediaItem fromJson(const QJsonObject& obj);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaItem)
|
||||
|
||||
#endif
|
||||
119
src/qEmbyCore/models/media/playbackinfo.cpp
Normal file
119
src/qEmbyCore/models/media/playbackinfo.cpp
Normal file
@@ -0,0 +1,119 @@
|
||||
#include "playbackinfo.h"
|
||||
#include <QJsonArray>
|
||||
#include <QStringList>
|
||||
#include <QVariant>
|
||||
|
||||
namespace {
|
||||
|
||||
QDateTime parseOptionalDateTime(const QJsonObject& json, const QStringList& keys)
|
||||
{
|
||||
for (const QString& key : keys) {
|
||||
const QString rawValue = json.value(key).toString().trimmed();
|
||||
if (rawValue.isEmpty()) {
|
||||
continue;
|
||||
}
|
||||
|
||||
QDateTime parsed = QDateTime::fromString(rawValue, Qt::ISODateWithMs);
|
||||
if (!parsed.isValid()) {
|
||||
parsed = QDateTime::fromString(rawValue, Qt::ISODate);
|
||||
}
|
||||
if (parsed.isValid()) {
|
||||
return parsed;
|
||||
}
|
||||
}
|
||||
|
||||
return {};
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
MediaStreamInfo MediaStreamInfo::fromJson(const QJsonObject& json) {
|
||||
MediaStreamInfo info;
|
||||
info.type = json["Type"].toString();
|
||||
info.codec = json["Codec"].toString();
|
||||
info.codecTag = json["CodecTag"].toString();
|
||||
info.title = json["Title"].toString();
|
||||
info.displayTitle = json["DisplayTitle"].toString();
|
||||
info.profile = json["Profile"].toString();
|
||||
info.level = json["Level"].toInt();
|
||||
info.width = json["Width"].toInt();
|
||||
info.height = json["Height"].toInt();
|
||||
info.aspectRatio = json["AspectRatio"].toString();
|
||||
info.isInterlaced = json["IsInterlaced"].toBool();
|
||||
info.realFrameRate = json["RealFrameRate"].toDouble();
|
||||
info.bitRate = json["BitRate"].toVariant().toLongLong();
|
||||
info.bitDepth = json["BitDepth"].toInt();
|
||||
info.pixelFormat = json["PixelFormat"].toString();
|
||||
info.refFrames = json["RefFrames"].toInt();
|
||||
info.language = json["Language"].toString();
|
||||
info.channelLayout = json["ChannelLayout"].toString();
|
||||
info.channels = json["Channels"].toInt();
|
||||
info.sampleRate = json["SampleRate"].toInt();
|
||||
info.isDefault = json["IsDefault"].toBool();
|
||||
|
||||
|
||||
info.displayLanguage = json["DisplayLanguage"].toString();
|
||||
info.isForced = json["IsForced"].toBool();
|
||||
info.isHearingImpaired = json["IsHearingImpaired"].toBool();
|
||||
|
||||
|
||||
info.index = json.contains("Index") ? json["Index"].toInt() : -1;
|
||||
|
||||
info.isExternal = json["IsExternal"].toBool();
|
||||
info.deliveryMethod = json["DeliveryMethod"].toString();
|
||||
info.deliveryUrl = json["DeliveryUrl"].toString();
|
||||
info.isExternalUrl = json["IsExternalUrl"].toBool();
|
||||
info.isTextSubtitleStream = json["IsTextSubtitleStream"].toBool();
|
||||
info.supportsExternalStream = json["SupportsExternalStream"].toBool();
|
||||
info.path = json["Path"].toString();
|
||||
info.protocol = json["Protocol"].toString();
|
||||
info.extendedVideoType = json["ExtendedVideoType"].toString();
|
||||
info.extendedVideoSubType = json["ExtendedVideoSubType"].toString();
|
||||
info.attachmentSize = json["AttachmentSize"].toVariant().toLongLong();
|
||||
|
||||
return info;
|
||||
}
|
||||
|
||||
MediaSourceInfo MediaSourceInfo::fromJson(const QJsonObject& json) {
|
||||
MediaSourceInfo info;
|
||||
info.id = json["Id"].toString();
|
||||
info.name = json["Name"].toString();
|
||||
info.path = json["Path"].toString();
|
||||
info.container = json["Container"].toString();
|
||||
info.directStreamUrl = json["DirectStreamUrl"].toString();
|
||||
|
||||
if (json.contains("Size")) {
|
||||
info.size = json["Size"].toVariant().toLongLong();
|
||||
}
|
||||
|
||||
if (json.contains("RunTimeTicks")) {
|
||||
info.runTimeTicks = json["RunTimeTicks"].toVariant().toLongLong();
|
||||
}
|
||||
|
||||
info.dateCreated = parseOptionalDateTime(
|
||||
json, {QStringLiteral("DateCreated"), QStringLiteral("CreatedAt")});
|
||||
info.dateModified = parseOptionalDateTime(
|
||||
json, {QStringLiteral("DateModified"), QStringLiteral("ModifiedAt"),
|
||||
QStringLiteral("LastModified"), QStringLiteral("UpdatedAt")});
|
||||
|
||||
if (json.contains("MediaStreams")) {
|
||||
QJsonArray streamsArr = json["MediaStreams"].toArray();
|
||||
for (const auto& val : streamsArr) {
|
||||
info.mediaStreams.append(MediaStreamInfo::fromJson(val.toObject()));
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
|
||||
PlaybackInfo PlaybackInfo::fromJson(const QJsonObject& json) {
|
||||
PlaybackInfo info;
|
||||
info.playSessionId = json["PlaySessionId"].toString();
|
||||
|
||||
if (json.contains("MediaSources")) {
|
||||
QJsonArray sourcesArr = json["MediaSources"].toArray();
|
||||
for (const auto& val : sourcesArr) {
|
||||
info.mediaSources.append(MediaSourceInfo::fromJson(val.toObject()));
|
||||
}
|
||||
}
|
||||
return info;
|
||||
}
|
||||
140
src/qEmbyCore/models/media/playbackinfo.h
Normal file
140
src/qEmbyCore/models/media/playbackinfo.h
Normal file
@@ -0,0 +1,140 @@
|
||||
#ifndef PLAYBACKINFO_H
|
||||
#define PLAYBACKINFO_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include <QDateTime>
|
||||
#include <QString>
|
||||
#include <QList>
|
||||
#include <QJsonObject>
|
||||
#include <QMetaType>
|
||||
#include <QObject>
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaStreamInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString type MEMBER type)
|
||||
Q_PROPERTY(QString codec MEMBER codec)
|
||||
Q_PROPERTY(QString codecTag MEMBER codecTag)
|
||||
Q_PROPERTY(QString title MEMBER title)
|
||||
Q_PROPERTY(QString displayTitle MEMBER displayTitle)
|
||||
Q_PROPERTY(QString profile MEMBER profile)
|
||||
Q_PROPERTY(int level MEMBER level)
|
||||
Q_PROPERTY(int width MEMBER width)
|
||||
Q_PROPERTY(int height MEMBER height)
|
||||
Q_PROPERTY(QString aspectRatio MEMBER aspectRatio)
|
||||
Q_PROPERTY(bool isInterlaced MEMBER isInterlaced)
|
||||
Q_PROPERTY(float realFrameRate MEMBER realFrameRate)
|
||||
Q_PROPERTY(long long bitRate MEMBER bitRate)
|
||||
Q_PROPERTY(int bitDepth MEMBER bitDepth)
|
||||
Q_PROPERTY(QString pixelFormat MEMBER pixelFormat)
|
||||
Q_PROPERTY(int refFrames MEMBER refFrames)
|
||||
Q_PROPERTY(QString language MEMBER language)
|
||||
Q_PROPERTY(QString channelLayout MEMBER channelLayout)
|
||||
Q_PROPERTY(int channels MEMBER channels)
|
||||
Q_PROPERTY(int sampleRate MEMBER sampleRate)
|
||||
Q_PROPERTY(bool isDefault MEMBER isDefault)
|
||||
|
||||
|
||||
Q_PROPERTY(QString displayLanguage MEMBER displayLanguage)
|
||||
Q_PROPERTY(bool isForced MEMBER isForced)
|
||||
Q_PROPERTY(bool isHearingImpaired MEMBER isHearingImpaired)
|
||||
Q_PROPERTY(int index MEMBER index)
|
||||
Q_PROPERTY(bool isExternal MEMBER isExternal)
|
||||
Q_PROPERTY(QString deliveryMethod MEMBER deliveryMethod)
|
||||
Q_PROPERTY(QString deliveryUrl MEMBER deliveryUrl)
|
||||
Q_PROPERTY(bool isExternalUrl MEMBER isExternalUrl)
|
||||
Q_PROPERTY(bool isTextSubtitleStream MEMBER isTextSubtitleStream)
|
||||
Q_PROPERTY(bool supportsExternalStream MEMBER supportsExternalStream)
|
||||
Q_PROPERTY(QString path MEMBER path)
|
||||
Q_PROPERTY(QString protocol MEMBER protocol)
|
||||
Q_PROPERTY(QString extendedVideoType MEMBER extendedVideoType)
|
||||
Q_PROPERTY(QString extendedVideoSubType MEMBER extendedVideoSubType)
|
||||
Q_PROPERTY(long long attachmentSize MEMBER attachmentSize)
|
||||
|
||||
public:
|
||||
QString type;
|
||||
QString codec;
|
||||
QString codecTag;
|
||||
QString title;
|
||||
QString displayTitle;
|
||||
QString profile;
|
||||
int level = 0;
|
||||
int width = 0;
|
||||
int height = 0;
|
||||
QString aspectRatio;
|
||||
bool isInterlaced = false;
|
||||
float realFrameRate = 0.0f;
|
||||
long long bitRate = 0;
|
||||
int bitDepth = 0;
|
||||
QString pixelFormat;
|
||||
int refFrames = 0;
|
||||
QString language;
|
||||
QString channelLayout;
|
||||
int channels = 0;
|
||||
int sampleRate = 0;
|
||||
bool isDefault = false;
|
||||
|
||||
|
||||
QString displayLanguage;
|
||||
bool isForced = false;
|
||||
bool isHearingImpaired = false;
|
||||
int index = -1;
|
||||
bool isExternal = false;
|
||||
QString deliveryMethod;
|
||||
QString deliveryUrl;
|
||||
bool isExternalUrl = false;
|
||||
bool isTextSubtitleStream = false;
|
||||
bool supportsExternalStream = false;
|
||||
QString path;
|
||||
QString protocol;
|
||||
QString extendedVideoType;
|
||||
QString extendedVideoSubType;
|
||||
long long attachmentSize = 0;
|
||||
|
||||
static MediaStreamInfo fromJson(const QJsonObject& json);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaStreamInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaSourceInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString id MEMBER id)
|
||||
Q_PROPERTY(QString name MEMBER name)
|
||||
Q_PROPERTY(QString path MEMBER path)
|
||||
Q_PROPERTY(QString container MEMBER container)
|
||||
Q_PROPERTY(long long size MEMBER size)
|
||||
Q_PROPERTY(QString directStreamUrl MEMBER directStreamUrl)
|
||||
Q_PROPERTY(long long runTimeTicks MEMBER runTimeTicks)
|
||||
Q_PROPERTY(QDateTime dateCreated MEMBER dateCreated)
|
||||
Q_PROPERTY(QDateTime dateModified MEMBER dateModified)
|
||||
|
||||
public:
|
||||
QString id;
|
||||
QString name;
|
||||
QString path;
|
||||
QString container;
|
||||
long long size = 0;
|
||||
QString directStreamUrl;
|
||||
long long runTimeTicks = 0;
|
||||
QDateTime dateCreated;
|
||||
QDateTime dateModified;
|
||||
QList<MediaStreamInfo> mediaStreams;
|
||||
|
||||
static MediaSourceInfo fromJson(const QJsonObject& json);
|
||||
};
|
||||
Q_DECLARE_METATYPE(MediaSourceInfo)
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT PlaybackInfo {
|
||||
Q_GADGET
|
||||
Q_PROPERTY(QString playSessionId MEMBER playSessionId)
|
||||
|
||||
public:
|
||||
QString playSessionId;
|
||||
QList<MediaSourceInfo> mediaSources;
|
||||
|
||||
static PlaybackInfo fromJson(const QJsonObject& json);
|
||||
};
|
||||
Q_DECLARE_METATYPE(PlaybackInfo)
|
||||
|
||||
#endif
|
||||
13
src/qEmbyCore/models/media/playerlaunchcontext.h
Normal file
13
src/qEmbyCore/models/media/playerlaunchcontext.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef PLAYERLAUNCHCONTEXT_H
|
||||
#define PLAYERLAUNCHCONTEXT_H
|
||||
|
||||
#include "mediaitem.h"
|
||||
#include "playbackinfo.h"
|
||||
|
||||
struct PlayerLaunchContext {
|
||||
MediaItem mediaItem;
|
||||
MediaSourceInfo selectedSource;
|
||||
};
|
||||
Q_DECLARE_METATYPE(PlayerLaunchContext)
|
||||
|
||||
#endif
|
||||
92
src/qEmbyCore/models/profile/proxyconfig.cpp
Normal file
92
src/qEmbyCore/models/profile/proxyconfig.cpp
Normal file
@@ -0,0 +1,92 @@
|
||||
#include "proxyconfig.h"
|
||||
|
||||
#include <QNetworkProxyFactory>
|
||||
#include <QNetworkProxyQuery>
|
||||
#include <QString>
|
||||
|
||||
QNetworkProxy ProxyConfig::toQNetworkProxy() const {
|
||||
switch (mode) {
|
||||
case None:
|
||||
return QNetworkProxy(QNetworkProxy::NoProxy);
|
||||
|
||||
case System: {
|
||||
|
||||
const auto proxies =
|
||||
QNetworkProxyFactory::systemProxyForQuery(QNetworkProxyQuery());
|
||||
if (proxies.isEmpty()) {
|
||||
return QNetworkProxy(QNetworkProxy::NoProxy);
|
||||
}
|
||||
return proxies.first();
|
||||
}
|
||||
|
||||
case Custom: {
|
||||
if (host.trimmed().isEmpty() || port == 0) {
|
||||
return QNetworkProxy(QNetworkProxy::NoProxy);
|
||||
}
|
||||
const QNetworkProxy::ProxyType qtType =
|
||||
(type == Socks5) ? QNetworkProxy::Socks5Proxy
|
||||
: QNetworkProxy::HttpProxy;
|
||||
return QNetworkProxy(qtType, host.trimmed(), port,
|
||||
username, password);
|
||||
}
|
||||
}
|
||||
return QNetworkProxy(QNetworkProxy::NoProxy);
|
||||
}
|
||||
|
||||
QJsonObject ProxyConfig::toJson() const {
|
||||
QJsonObject obj;
|
||||
obj["mode"] = static_cast<int>(mode);
|
||||
obj["type"] = static_cast<int>(type);
|
||||
obj["host"] = host;
|
||||
obj["port"] = static_cast<int>(port);
|
||||
obj["username"] = username;
|
||||
obj["password"] = password;
|
||||
obj["bypassLocalhost"] = bypassLocalhost;
|
||||
return obj;
|
||||
}
|
||||
|
||||
ProxyConfig ProxyConfig::fromJson(const QJsonObject& obj) {
|
||||
ProxyConfig cfg;
|
||||
if (obj.isEmpty()) {
|
||||
return cfg;
|
||||
}
|
||||
cfg.mode = static_cast<Mode>(obj.value("mode").toInt(0));
|
||||
cfg.type = static_cast<Type>(obj.value("type").toInt(0));
|
||||
cfg.host = obj.value("host").toString();
|
||||
const int parsedPort = obj.value("port").toInt(0);
|
||||
cfg.port = (parsedPort > 0 && parsedPort <= 65535)
|
||||
? static_cast<quint16>(parsedPort)
|
||||
: 0;
|
||||
cfg.username = obj.value("username").toString();
|
||||
cfg.password = obj.value("password").toString();
|
||||
cfg.bypassLocalhost = obj.value("bypassLocalhost").toBool(true);
|
||||
return cfg;
|
||||
}
|
||||
|
||||
QString ProxyConfig::summary() const {
|
||||
switch (mode) {
|
||||
case None:
|
||||
return QStringLiteral("none");
|
||||
case System:
|
||||
return QStringLiteral("system");
|
||||
case Custom: {
|
||||
const QString typeLabel =
|
||||
(type == Socks5) ? QStringLiteral("socks5") : QStringLiteral("http");
|
||||
const QString credLabel =
|
||||
username.isEmpty() ? QString() : QStringLiteral(" auth=***");
|
||||
return QStringLiteral("custom %1://%2:%3%4")
|
||||
.arg(typeLabel,
|
||||
host.trimmed(),
|
||||
QString::number(port),
|
||||
credLabel);
|
||||
}
|
||||
}
|
||||
return QStringLiteral("unknown");
|
||||
}
|
||||
|
||||
bool ProxyConfig::operator==(const ProxyConfig& other) const {
|
||||
return mode == other.mode && type == other.type && host == other.host &&
|
||||
port == other.port && username == other.username &&
|
||||
password == other.password &&
|
||||
bypassLocalhost == other.bypassLocalhost;
|
||||
}
|
||||
59
src/qEmbyCore/models/profile/proxyconfig.h
Normal file
59
src/qEmbyCore/models/profile/proxyconfig.h
Normal file
@@ -0,0 +1,59 @@
|
||||
#ifndef PROXYCONFIG_H
|
||||
#define PROXYCONFIG_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include <QJsonObject>
|
||||
#include <QNetworkProxy>
|
||||
#include <QString>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
struct QEMBYCORE_EXPORT ProxyConfig {
|
||||
enum Mode {
|
||||
None = 0,
|
||||
System = 1,
|
||||
Custom = 2,
|
||||
};
|
||||
|
||||
enum Type {
|
||||
Http = 0,
|
||||
Socks5 = 1,
|
||||
};
|
||||
|
||||
Mode mode = None;
|
||||
Type type = Http;
|
||||
QString host;
|
||||
quint16 port = 0;
|
||||
QString username;
|
||||
QString password;
|
||||
bool bypassLocalhost = true;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QNetworkProxy toQNetworkProxy() const;
|
||||
|
||||
|
||||
QJsonObject toJson() const;
|
||||
static ProxyConfig fromJson(const QJsonObject& obj);
|
||||
|
||||
|
||||
bool isExplicit() const { return mode != None; }
|
||||
|
||||
|
||||
QString summary() const;
|
||||
|
||||
bool operator==(const ProxyConfig& other) const;
|
||||
bool operator!=(const ProxyConfig& other) const { return !(*this == other); }
|
||||
};
|
||||
|
||||
#endif
|
||||
35
src/qEmbyCore/models/profile/serverprofile.h
Normal file
35
src/qEmbyCore/models/profile/serverprofile.h
Normal file
@@ -0,0 +1,35 @@
|
||||
#ifndef SERVERPROFILE_H
|
||||
#define SERVERPROFILE_H
|
||||
|
||||
#include "proxyconfig.h"
|
||||
#include <QString>
|
||||
#include <QUuid>
|
||||
|
||||
struct ServerProfile {
|
||||
enum ServerType { Emby, Jellyfin };
|
||||
|
||||
QString id = QUuid::createUuid().toString();
|
||||
QString name;
|
||||
QString url;
|
||||
ServerType type = Emby;
|
||||
bool ignoreSslVerification = false;
|
||||
|
||||
QString userId;
|
||||
QString userName;
|
||||
QString accessToken;
|
||||
QString deviceId;
|
||||
bool isAdmin = false;
|
||||
bool canDownloadMedia = false;
|
||||
|
||||
QString iconBase64;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bool useGlobalProxy = false;
|
||||
ProxyConfig proxy;
|
||||
|
||||
bool isValid() const { return !accessToken.isEmpty(); }
|
||||
};
|
||||
#endif
|
||||
370
src/qEmbyCore/models/sync/configbundle.cpp
Normal file
370
src/qEmbyCore/models/sync/configbundle.cpp
Normal file
@@ -0,0 +1,370 @@
|
||||
#include "configbundle.h"
|
||||
|
||||
#include "../../config/configstore.h"
|
||||
|
||||
#include <QCoreApplication>
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonValue>
|
||||
#include <QStandardPaths>
|
||||
#include <QSysInfo>
|
||||
#include <QVariant>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
|
||||
constexpr char kKSchemaVersion[] = "schemaVersion";
|
||||
constexpr char kKAppVersion[] = "appVersion";
|
||||
constexpr char kKDeviceName[] = "deviceName";
|
||||
constexpr char kKOsName[] = "osName";
|
||||
constexpr char kKOsPretty[] = "osPretty";
|
||||
constexpr char kKExportedAt[] = "exportedAt";
|
||||
|
||||
|
||||
constexpr char kKMetadata[] = "metadata";
|
||||
constexpr char kKServers[] = "servers";
|
||||
constexpr char kKConfigEntries[] = "configEntries";
|
||||
|
||||
|
||||
QString serversJsonPath()
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
return QDir(dir).filePath(QStringLiteral("servers.json"));
|
||||
}
|
||||
|
||||
QString compactProductType(QString type)
|
||||
{
|
||||
type = type.trimmed().toLower();
|
||||
if (type.isEmpty())
|
||||
{
|
||||
return QStringLiteral("OS");
|
||||
}
|
||||
if (type == QStringLiteral("ubuntu"))
|
||||
return QStringLiteral("Ubuntu");
|
||||
if (type == QStringLiteral("debian"))
|
||||
return QStringLiteral("Debian");
|
||||
if (type == QStringLiteral("arch"))
|
||||
return QStringLiteral("Arch");
|
||||
if (type == QStringLiteral("fedora"))
|
||||
return QStringLiteral("Fedora");
|
||||
if (type == QStringLiteral("centos"))
|
||||
return QStringLiteral("CentOS");
|
||||
if (type == QStringLiteral("opensuse"))
|
||||
return QStringLiteral("openSUSE");
|
||||
if (type == QStringLiteral("manjaro"))
|
||||
return QStringLiteral("Manjaro");
|
||||
if (type == QStringLiteral("rhel"))
|
||||
return QStringLiteral("RHEL");
|
||||
if (type == QStringLiteral("linux"))
|
||||
return QStringLiteral("Linux");
|
||||
type[0] = type.at(0).toUpper();
|
||||
return type;
|
||||
}
|
||||
|
||||
int windowsBuildNumber()
|
||||
{
|
||||
const QStringList parts = QSysInfo::kernelVersion().split(QLatin1Char('.'));
|
||||
bool ok = false;
|
||||
const int build = parts.size() >= 3 ? parts.at(2).toInt(&ok) : 0;
|
||||
return ok ? build : 0;
|
||||
}
|
||||
|
||||
|
||||
QString readableOsName()
|
||||
{
|
||||
const QString type = QSysInfo::productType().toLower();
|
||||
const QString version = QSysInfo::productVersion().trimmed();
|
||||
if (type == QStringLiteral("windows"))
|
||||
{
|
||||
const int build = windowsBuildNumber();
|
||||
if (build >= 22000)
|
||||
return QStringLiteral("Win11");
|
||||
if (build > 0)
|
||||
return QStringLiteral("Win10");
|
||||
return version.isEmpty() ? QStringLiteral("Windows")
|
||||
: QStringLiteral("Windows%1").arg(version);
|
||||
}
|
||||
if (type == QStringLiteral("macos") || type == QStringLiteral("osx"))
|
||||
return version.isEmpty() ? QStringLiteral("macOS")
|
||||
: QStringLiteral("macOS%1").arg(version);
|
||||
|
||||
const QString name = compactProductType(type);
|
||||
return version.isEmpty() ? name : name + version;
|
||||
}
|
||||
|
||||
|
||||
|
||||
QJsonValue variantToJson(const QVariant &v)
|
||||
{
|
||||
if (!v.isValid() || v.isNull()) {
|
||||
return QJsonValue::Null;
|
||||
}
|
||||
|
||||
switch (static_cast<QMetaType::Type>(v.userType())) {
|
||||
case QMetaType::Bool:
|
||||
return v.toBool();
|
||||
case QMetaType::Int:
|
||||
case QMetaType::UInt:
|
||||
case QMetaType::LongLong:
|
||||
case QMetaType::ULongLong:
|
||||
return v.toLongLong();
|
||||
case QMetaType::Double:
|
||||
case QMetaType::Float:
|
||||
return v.toDouble();
|
||||
case QMetaType::QStringList: {
|
||||
QJsonArray arr;
|
||||
for (const QString &s : v.toStringList()) {
|
||||
arr.append(s);
|
||||
}
|
||||
return arr;
|
||||
}
|
||||
case QMetaType::QByteArray:
|
||||
|
||||
{
|
||||
const QByteArray ba = v.toByteArray();
|
||||
const QJsonDocument tryDoc = QJsonDocument::fromJson(ba);
|
||||
if (!tryDoc.isNull()) {
|
||||
if (tryDoc.isObject()) return tryDoc.object();
|
||||
if (tryDoc.isArray()) return tryDoc.array();
|
||||
}
|
||||
return QString::fromUtf8(ba);
|
||||
}
|
||||
case QMetaType::QString:
|
||||
default:
|
||||
return v.toString();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
QVariant jsonToVariant(const QJsonValue &v)
|
||||
{
|
||||
switch (v.type()) {
|
||||
case QJsonValue::Bool:
|
||||
return v.toBool();
|
||||
case QJsonValue::Double: {
|
||||
const double d = v.toDouble();
|
||||
const qlonglong i = static_cast<qlonglong>(d);
|
||||
if (static_cast<double>(i) == d) {
|
||||
return QVariant::fromValue(i);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
case QJsonValue::String:
|
||||
return v.toString();
|
||||
case QJsonValue::Array:
|
||||
return QString::fromUtf8(
|
||||
QJsonDocument(v.toArray()).toJson(QJsonDocument::Compact));
|
||||
case QJsonValue::Object:
|
||||
return QString::fromUtf8(
|
||||
QJsonDocument(v.toObject()).toJson(QJsonDocument::Compact));
|
||||
case QJsonValue::Null:
|
||||
case QJsonValue::Undefined:
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QJsonObject ConfigBundleMetadata::toJson() const
|
||||
{
|
||||
QJsonObject obj;
|
||||
obj.insert(QString::fromLatin1(kKSchemaVersion), schemaVersion);
|
||||
obj.insert(QString::fromLatin1(kKAppVersion), appVersion);
|
||||
obj.insert(QString::fromLatin1(kKDeviceName), deviceName);
|
||||
obj.insert(QString::fromLatin1(kKOsName), osName);
|
||||
obj.insert(QString::fromLatin1(kKOsPretty), osPretty);
|
||||
obj.insert(QString::fromLatin1(kKExportedAt), exportedAt);
|
||||
return obj;
|
||||
}
|
||||
|
||||
ConfigBundleMetadata ConfigBundleMetadata::fromJson(const QJsonObject &obj)
|
||||
{
|
||||
ConfigBundleMetadata m;
|
||||
m.schemaVersion = obj.value(QString::fromLatin1(kKSchemaVersion)).toInt(1);
|
||||
m.appVersion = obj.value(QString::fromLatin1(kKAppVersion)).toString();
|
||||
m.deviceName = obj.value(QString::fromLatin1(kKDeviceName)).toString();
|
||||
m.osName = obj.value(QString::fromLatin1(kKOsName)).toString();
|
||||
m.osPretty = obj.value(QString::fromLatin1(kKOsPretty)).toString();
|
||||
m.exportedAt = static_cast<qint64>(obj.value(QString::fromLatin1(kKExportedAt)).toDouble(0.0));
|
||||
return m;
|
||||
}
|
||||
|
||||
ConfigBundleMetadata ConfigBundleMetadata::current()
|
||||
{
|
||||
ConfigBundleMetadata m;
|
||||
m.schemaVersion = 1;
|
||||
m.appVersion = QCoreApplication::applicationVersion();
|
||||
if (m.appVersion.isEmpty()) {
|
||||
m.appVersion = QStringLiteral("dev");
|
||||
}
|
||||
m.deviceName = QSysInfo::machineHostName();
|
||||
m.osName = readableOsName();
|
||||
m.osPretty = QSysInfo::prettyProductName();
|
||||
m.exportedAt = QDateTime::currentMSecsSinceEpoch();
|
||||
return m;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
const QStringList &ConfigBundle::includedNamespaces()
|
||||
{
|
||||
static const QStringList list = {
|
||||
QStringLiteral("general/"),
|
||||
QStringLiteral("network/"),
|
||||
QStringLiteral("appearance/"),
|
||||
QStringLiteral("library/"),
|
||||
QStringLiteral("player/"),
|
||||
QStringLiteral("danmaku/"),
|
||||
QStringLiteral("ext_player/"),
|
||||
QStringLiteral("search/"),
|
||||
QStringLiteral("sort/"),
|
||||
QStringLiteral("view/"),
|
||||
QStringLiteral("server/"),
|
||||
|
||||
|
||||
QStringLiteral("app/"),
|
||||
};
|
||||
return list;
|
||||
}
|
||||
|
||||
bool ConfigBundle::shouldExcludeKey(const QString &key)
|
||||
{
|
||||
|
||||
static const QStringList exactBlacklist = {
|
||||
QStringLiteral("general/last_selected_server_id"),
|
||||
QStringLiteral("app/last_selected_server_id"),
|
||||
QStringLiteral("appearance/startup_window_state"),
|
||||
QStringLiteral("ext_player/detected_list"),
|
||||
QStringLiteral("player/last_subtitle_dir"),
|
||||
};
|
||||
if (exactBlacklist.contains(key)) {
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
static const QStringList prefixBlacklist = {
|
||||
QStringLiteral("cache/"),
|
||||
QStringLiteral("player/external_subtitle/"),
|
||||
QStringLiteral("download/"),
|
||||
};
|
||||
for (const QString &prefix : prefixBlacklist) {
|
||||
if (key.startsWith(prefix)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QJsonObject ConfigBundle::toJson() const
|
||||
{
|
||||
QJsonObject root;
|
||||
root.insert(QString::fromLatin1(kKMetadata), metadata.toJson());
|
||||
root.insert(QString::fromLatin1(kKServers), servers);
|
||||
root.insert(QString::fromLatin1(kKConfigEntries), configEntries);
|
||||
return root;
|
||||
}
|
||||
|
||||
QByteArray ConfigBundle::serialize(bool indented) const
|
||||
{
|
||||
return QJsonDocument(toJson()).toJson(indented ? QJsonDocument::Indented
|
||||
: QJsonDocument::Compact);
|
||||
}
|
||||
|
||||
ConfigBundle ConfigBundle::fromJson(const QJsonObject &obj)
|
||||
{
|
||||
ConfigBundle b;
|
||||
b.metadata = ConfigBundleMetadata::fromJson(
|
||||
obj.value(QString::fromLatin1(kKMetadata)).toObject());
|
||||
b.servers = obj.value(QString::fromLatin1(kKServers)).toArray();
|
||||
b.configEntries = obj.value(QString::fromLatin1(kKConfigEntries)).toObject();
|
||||
return b;
|
||||
}
|
||||
|
||||
std::optional<ConfigBundle> ConfigBundle::deserialize(const QByteArray &bytes)
|
||||
{
|
||||
QJsonParseError err{};
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(bytes, &err);
|
||||
if (err.error != QJsonParseError::NoError || !doc.isObject()) {
|
||||
qWarning() << "[ConfigBundle] deserialize parse failed:" << err.errorString();
|
||||
return std::nullopt;
|
||||
}
|
||||
return fromJson(doc.object());
|
||||
}
|
||||
|
||||
|
||||
|
||||
ConfigBundle ConfigBundle::collectFromLocal()
|
||||
{
|
||||
ConfigBundle b;
|
||||
b.metadata = ConfigBundleMetadata::current();
|
||||
|
||||
|
||||
{
|
||||
QFile f(serversJsonPath());
|
||||
if (f.open(QIODevice::ReadOnly)) {
|
||||
const QByteArray bytes = f.readAll();
|
||||
f.close();
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(bytes);
|
||||
if (doc.isArray()) {
|
||||
b.servers = doc.array();
|
||||
} else {
|
||||
qWarning() << "[ConfigBundle] servers.json is not array, skipped";
|
||||
}
|
||||
} else {
|
||||
qDebug() << "[ConfigBundle] servers.json not present at"
|
||||
<< serversJsonPath();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
ConfigStore *store = ConfigStore::instance();
|
||||
if (store) {
|
||||
const QStringList allKeys = store->allKeys();
|
||||
const QStringList &nsList = includedNamespaces();
|
||||
|
||||
for (const QString &key : allKeys) {
|
||||
|
||||
bool inWhitelist = false;
|
||||
for (const QString &ns : nsList) {
|
||||
if (key.startsWith(ns)) {
|
||||
inWhitelist = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!inWhitelist) {
|
||||
continue;
|
||||
}
|
||||
if (shouldExcludeKey(key)) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const QVariant val = store->get<QVariant>(key);
|
||||
b.configEntries.insert(key, variantToJson(val));
|
||||
}
|
||||
}
|
||||
|
||||
qDebug() << "[ConfigBundle] collectFromLocal"
|
||||
<< "| servers:" << b.servers.size()
|
||||
<< "| entries:" << b.configEntries.size()
|
||||
<< "| host:" << b.metadata.deviceName
|
||||
<< "| os:" << b.metadata.osPretty
|
||||
<< "| app:" << b.metadata.appVersion;
|
||||
return b;
|
||||
}
|
||||
80
src/qEmbyCore/models/sync/configbundle.h
Normal file
80
src/qEmbyCore/models/sync/configbundle.h
Normal file
@@ -0,0 +1,80 @@
|
||||
#ifndef CONFIGBUNDLE_H
|
||||
#define CONFIGBUNDLE_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonObject>
|
||||
#include <QString>
|
||||
#include <QStringList>
|
||||
|
||||
#include <optional>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
enum class MergeStrategy
|
||||
{
|
||||
Replace = 0,
|
||||
Merge = 1,
|
||||
LocalWins = 2,
|
||||
};
|
||||
|
||||
struct QEMBYCORE_EXPORT ConfigBundleMetadata
|
||||
{
|
||||
int schemaVersion = 1;
|
||||
QString appVersion;
|
||||
QString deviceName;
|
||||
QString osName;
|
||||
QString osPretty;
|
||||
qint64 exportedAt = 0;
|
||||
|
||||
QJsonObject toJson() const;
|
||||
static ConfigBundleMetadata fromJson(const QJsonObject &obj);
|
||||
|
||||
|
||||
static ConfigBundleMetadata current();
|
||||
};
|
||||
|
||||
class QEMBYCORE_EXPORT ConfigBundle
|
||||
{
|
||||
public:
|
||||
ConfigBundleMetadata metadata;
|
||||
QJsonArray servers;
|
||||
QJsonObject configEntries;
|
||||
|
||||
|
||||
QJsonObject toJson() const;
|
||||
QByteArray serialize(bool indented = false) const;
|
||||
|
||||
static ConfigBundle fromJson(const QJsonObject &obj);
|
||||
static std::optional<ConfigBundle> deserialize(const QByteArray &bytes);
|
||||
|
||||
|
||||
|
||||
|
||||
static ConfigBundle collectFromLocal();
|
||||
|
||||
|
||||
|
||||
static const QStringList &includedNamespaces();
|
||||
|
||||
|
||||
static bool shouldExcludeKey(const QString &key);
|
||||
};
|
||||
|
||||
#endif
|
||||
12
src/qEmbyCore/qEmbyCore_global.h
Normal file
12
src/qEmbyCore/qEmbyCore_global.h
Normal file
@@ -0,0 +1,12 @@
|
||||
#ifndef QEMBYCORE_GLOBAL_H
|
||||
#define QEMBYCORE_GLOBAL_H
|
||||
|
||||
#include <QtCore/qglobal.h>
|
||||
|
||||
#if defined(QEMBYCORE_LIBRARY)
|
||||
#define QEMBYCORE_EXPORT Q_DECL_EXPORT
|
||||
#else
|
||||
#define QEMBYCORE_EXPORT Q_DECL_IMPORT
|
||||
#endif
|
||||
|
||||
#endif
|
||||
79
src/qEmbyCore/qembycore.cpp
Normal file
79
src/qEmbyCore/qembycore.cpp
Normal file
@@ -0,0 +1,79 @@
|
||||
#include "qembycore.h"
|
||||
#include "api/networkmanager.h"
|
||||
#include "api/proxymanager.h"
|
||||
#include "services/manager/servermanager.h"
|
||||
#include "services/auth/authservice.h"
|
||||
#include "services/media/mediaservice.h"
|
||||
#include "services/admin/adminservice.h"
|
||||
#include "services/danmaku/danmakuservice.h"
|
||||
#include "services/introdb/introdbservice.h"
|
||||
|
||||
QEmbyCore::QEmbyCore(QObject *parent)
|
||||
: QObject(parent)
|
||||
{
|
||||
|
||||
ProxyManager::installApplicationFactory();
|
||||
|
||||
|
||||
m_networkManager = new NetworkManager(this);
|
||||
|
||||
|
||||
m_serverManager = new ServerManager(m_networkManager, this);
|
||||
|
||||
|
||||
|
||||
|
||||
ProxyManager::instance()->attachServerManager(m_serverManager);
|
||||
|
||||
|
||||
m_authService = new AuthService(m_networkManager, m_serverManager, this);
|
||||
|
||||
|
||||
m_mediaService = new MediaService(m_serverManager, this);
|
||||
|
||||
|
||||
m_adminService = new AdminService(m_serverManager, this);
|
||||
|
||||
|
||||
m_danmakuService = new DanmakuService(m_networkManager, m_serverManager, this);
|
||||
|
||||
|
||||
m_introDBService = new IntroDBService(m_networkManager, this);
|
||||
}
|
||||
|
||||
QEmbyCore::~QEmbyCore()
|
||||
{
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
ServerManager* QEmbyCore::serverManager() const
|
||||
{
|
||||
return m_serverManager;
|
||||
}
|
||||
|
||||
AuthService* QEmbyCore::authService() const
|
||||
{
|
||||
return m_authService;
|
||||
}
|
||||
|
||||
MediaService* QEmbyCore::mediaService() const
|
||||
{
|
||||
return m_mediaService;
|
||||
}
|
||||
|
||||
AdminService* QEmbyCore::adminService() const
|
||||
{
|
||||
return m_adminService;
|
||||
}
|
||||
|
||||
DanmakuService* QEmbyCore::danmakuService() const
|
||||
{
|
||||
return m_danmakuService;
|
||||
}
|
||||
|
||||
IntroDBService* QEmbyCore::introDBService() const
|
||||
{
|
||||
return m_introDBService;
|
||||
}
|
||||
42
src/qEmbyCore/qembycore.h
Normal file
42
src/qEmbyCore/qembycore.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#ifndef QEMBYCORE_H
|
||||
#define QEMBYCORE_H
|
||||
|
||||
#include "qEmbyCore_global.h"
|
||||
#include <QObject>
|
||||
|
||||
|
||||
class NetworkManager;
|
||||
class ServerManager;
|
||||
class AuthService;
|
||||
class MediaService;
|
||||
class AdminService;
|
||||
class DanmakuService;
|
||||
class IntroDBService;
|
||||
|
||||
class QEMBYCORE_EXPORT QEmbyCore : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit QEmbyCore(QObject *parent = nullptr);
|
||||
~QEmbyCore() override;
|
||||
|
||||
|
||||
ServerManager* serverManager() const;
|
||||
AuthService* authService() const;
|
||||
MediaService* mediaService() const;
|
||||
AdminService* adminService() const;
|
||||
DanmakuService* danmakuService() const;
|
||||
IntroDBService* introDBService() const;
|
||||
|
||||
private:
|
||||
|
||||
NetworkManager* m_networkManager;
|
||||
ServerManager* m_serverManager;
|
||||
AuthService* m_authService;
|
||||
MediaService* m_mediaService;
|
||||
AdminService* m_adminService;
|
||||
DanmakuService* m_danmakuService;
|
||||
IntroDBService* m_introDBService;
|
||||
};
|
||||
|
||||
#endif
|
||||
2086
src/qEmbyCore/services/admin/adminservice.cpp
Normal file
2086
src/qEmbyCore/services/admin/adminservice.cpp
Normal file
File diff suppressed because it is too large
Load Diff
204
src/qEmbyCore/services/admin/adminservice.h
Normal file
204
src/qEmbyCore/services/admin/adminservice.h
Normal file
@@ -0,0 +1,204 @@
|
||||
#ifndef ADMINSERVICE_H
|
||||
#define ADMINSERVICE_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include "../../models/admin/adminmodels.h"
|
||||
#include <QByteArray>
|
||||
#include <QJsonArray>
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QJsonObject>
|
||||
#include <qcorotask.h>
|
||||
|
||||
class ServerManager;
|
||||
|
||||
class QEMBYCORE_EXPORT AdminService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AdminService(ServerManager* serverManager, QObject *parent = nullptr);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<SystemInfo> getSystemInfo();
|
||||
QCoro::Task<void> restartServer();
|
||||
QCoro::Task<void> shutdownServer();
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<SessionInfo>> getActiveSessions();
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<UserInfo>> getUsers();
|
||||
QCoro::Task<QJsonObject> getUserById(const QString& userId);
|
||||
QCoro::Task<QJsonObject> createUser(const QString& name,
|
||||
const QString& copyFromUserId = {},
|
||||
const QStringList& userCopyOptions = {});
|
||||
QCoro::Task<QList<AdminMediaFolderInfo>> getMediaFolders(
|
||||
bool includeHidden = false);
|
||||
QCoro::Task<QList<SelectableMediaFolderInfo>> getSelectableMediaFolders(
|
||||
bool includeHidden = false);
|
||||
QCoro::Task<QList<AdminChannelInfo>> getChannels(
|
||||
bool supportsMediaDeletion = false);
|
||||
QCoro::Task<QList<AdminDeviceInfo>> getDevices();
|
||||
QCoro::Task<QList<AdminAuthProviderInfo>> getAuthProviders();
|
||||
QCoro::Task<QList<AdminFeatureInfo>> getUserFeatures();
|
||||
QCoro::Task<QList<AdminParentalRatingInfo>> getParentalRatings();
|
||||
QCoro::Task<void> updateUser(QString userId, QJsonObject userData);
|
||||
QCoro::Task<void> setUserPolicy(const QString& userId, const QJsonObject& policy);
|
||||
QCoro::Task<void> updateUserConfiguration(const QString& userId,
|
||||
const QJsonObject& configuration);
|
||||
QCoro::Task<void> deleteUser(const QString& userId);
|
||||
QCoro::Task<void> updateUserPassword(const QString& userId,
|
||||
const QString& currentPassword,
|
||||
const QString& newPassword);
|
||||
QCoro::Task<void> updateEasyPassword(const QString& userId,
|
||||
const QString& newPassword,
|
||||
bool resetPassword = false);
|
||||
QCoro::Task<void> updateConnectLink(const QString& userId,
|
||||
const QString& connectUserName);
|
||||
QCoro::Task<void> removeConnectLink(const QString& userId);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
struct LibraryProviders {
|
||||
QStringList metadataFetchers;
|
||||
QStringList imageFetchers;
|
||||
QStringList subtitleDownloaders;
|
||||
QStringList lyricsFetchers;
|
||||
};
|
||||
|
||||
struct LocalizationOptions {
|
||||
QList<LocalizationCulture> cultures;
|
||||
QList<LocalizationCountry> countries;
|
||||
};
|
||||
|
||||
|
||||
|
||||
QCoro::Task<LibraryProviders> getAvailableProviders();
|
||||
QCoro::Task<LocalizationOptions> getLocalizationOptions();
|
||||
QCoro::Task<QList<LocalizationCulture>> getLocalizationCultures();
|
||||
QCoro::Task<QList<LocalizationCountry>> getLocalizationCountries();
|
||||
|
||||
QCoro::Task<QList<VirtualFolder>> getVirtualFolders();
|
||||
QCoro::Task<void> addVirtualFolder(const QString& name, const QString& collectionType,
|
||||
const QStringList& paths,
|
||||
const QJsonObject& libraryOptions = {});
|
||||
QCoro::Task<void> removeVirtualFolder(const VirtualFolder& folder);
|
||||
QCoro::Task<void> renameVirtualFolder(const QString& oldName, const QString& newName);
|
||||
QCoro::Task<void> addMediaPath(const QString& libraryId,
|
||||
const QString& libraryName,
|
||||
const QString& path);
|
||||
QCoro::Task<void> removeMediaPath(const QString& libraryId,
|
||||
const QString& libraryName,
|
||||
const QString& path);
|
||||
QCoro::Task<void> updateVirtualFolderOptions(const QString& folderId,
|
||||
const QJsonObject& libraryOptions,
|
||||
const QStringList& paths = {});
|
||||
QCoro::Task<void> refreshLibrary();
|
||||
|
||||
|
||||
|
||||
|
||||
struct DriveInfo {
|
||||
QString path;
|
||||
QString name;
|
||||
};
|
||||
QCoro::Task<QList<DriveInfo>> getServerDrives();
|
||||
QCoro::Task<QStringList> getDirectoryContents(const QString& path);
|
||||
QCoro::Task<QStringList> getLibraryOrder();
|
||||
QCoro::Task<void> updateLibraryOrder(QStringList orderedIds);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QJsonArray> getUserPlaylists();
|
||||
QCoro::Task<void> updatePlaylistOrderByDateCreated(QStringList orderedIds);
|
||||
QCoro::Task<void> updatePlaylistDateCreated(QString itemId, QString dateCreatedText);
|
||||
QCoro::Task<QString> createPlaylist(const QString& name, const QString& mediaType = "Video");
|
||||
QCoro::Task<QJsonObject> getPlaylistItems(QString playlistId);
|
||||
QCoro::Task<void> addToPlaylist(QString playlistId, QStringList itemIds);
|
||||
QCoro::Task<void> removeFromPlaylist(QString playlistId, QStringList entryIds);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QJsonArray> getUserCollections();
|
||||
QCoro::Task<QString> createCollection(const QString& name, const QStringList& itemIds = {});
|
||||
QCoro::Task<void> addToCollection(const QString& collectionId, const QStringList& itemIds);
|
||||
QCoro::Task<void> removeFromCollection(const QString& collectionId, const QStringList& itemIds);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<RemoteSearchResult>> searchRemoteMetadata(
|
||||
QString remoteSearchType, QString itemId, QJsonObject searchInfo,
|
||||
QString searchProviderName = QString(),
|
||||
bool includeDisabledProviders = false);
|
||||
QCoro::Task<QJsonObject> getItemMetadata(QString itemId);
|
||||
QCoro::Task<QList<ItemImageInfo>> getItemImages(QString itemId);
|
||||
QCoro::Task<void> applyRemoteSearchResult(QString itemId,
|
||||
RemoteSearchResult result);
|
||||
QCoro::Task<void> updateItemMetadata(const QString& itemId, const QJsonObject& itemData);
|
||||
QCoro::Task<void> refreshItemMetadata(const QString& itemId,
|
||||
bool replaceAllMetadata = false,
|
||||
bool replaceAllImages = false);
|
||||
QCoro::Task<void> uploadItemImage(QString itemId, QString imageType,
|
||||
QByteArray imageData, QString mimeType,
|
||||
int imageIndex = -1);
|
||||
QCoro::Task<void> deleteItemImage(QString itemId, QString imageType,
|
||||
int imageIndex = -1);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<void> deleteItem(const QString& itemId);
|
||||
QCoro::Task<void> renameItem(const QString& itemId, const QString& newName);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QJsonObject> getEncodingConfiguration();
|
||||
QCoro::Task<void> updateEncodingConfiguration(QJsonObject config);
|
||||
QCoro::Task<QJsonArray> getVideoCodecInformation();
|
||||
QCoro::Task<QJsonArray> getDefaultCodecConfigurations();
|
||||
QCoro::Task<QJsonObject> getCodecParameters(QString codecId,
|
||||
QString parameterContext = QStringLiteral("Playback"));
|
||||
QCoro::Task<void> updateCodecParameters(QString codecId,
|
||||
QJsonObject parameters,
|
||||
QString parameterContext = QStringLiteral("Playback"));
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<ScheduledTaskInfo>> getScheduledTasks();
|
||||
QCoro::Task<QJsonObject> getScheduledTaskById(const QString& taskId);
|
||||
QCoro::Task<void> runScheduledTask(const QString& taskId);
|
||||
QCoro::Task<void> stopScheduledTask(const QString& taskId);
|
||||
QCoro::Task<void> updateTaskTriggers(const QString& taskId, const QJsonArray& triggers);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<LogFileInfo>> getLogFiles();
|
||||
QCoro::Task<QString> getLogFileContent(const QString& logName);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<ActivityLogEntry>> getActivityLog(int limit = 30);
|
||||
|
||||
private:
|
||||
ServerManager* m_serverManager;
|
||||
|
||||
void ensureValidProfile() const;
|
||||
};
|
||||
|
||||
#endif
|
||||
209
src/qEmbyCore/services/auth/authservice.cpp
Normal file
209
src/qEmbyCore/services/auth/authservice.cpp
Normal file
@@ -0,0 +1,209 @@
|
||||
#include "authservice.h"
|
||||
#include <QJsonObject>
|
||||
#include <QUuid>
|
||||
#include <QUrl>
|
||||
#include <qcoronetwork.h>
|
||||
#include <stdexcept>
|
||||
|
||||
namespace {
|
||||
|
||||
bool resolveContentDownloadPermission(const QJsonObject& policyObj)
|
||||
{
|
||||
return policyObj["EnableContentDownloading"].toBool(
|
||||
policyObj["EnableMediaDownloading"].toBool(true));
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
AuthService::AuthService(NetworkManager* networkManager, ServerManager* serverManager, QObject *parent)
|
||||
: QObject(parent), m_networkManager(networkManager), m_serverManager(serverManager)
|
||||
{
|
||||
}
|
||||
|
||||
QCoro::Task<ServerProfile> AuthService::login(const QString& serverUrl,
|
||||
const QString& username,
|
||||
const QString& password,
|
||||
bool ignoreSslVerification)
|
||||
{
|
||||
QString cleanUrl = serverUrl;
|
||||
if (cleanUrl.endsWith('/')) {
|
||||
cleanUrl.chop(1);
|
||||
}
|
||||
|
||||
NetworkRequestOptions requestOptions;
|
||||
requestOptions.ignoreSslErrors = ignoreSslVerification;
|
||||
|
||||
qDebug() << "[AuthService] Login start"
|
||||
<< "| url:" << cleanUrl
|
||||
<< "| ignoreSslVerification:" << ignoreSslVerification;
|
||||
|
||||
|
||||
|
||||
QJsonObject infoResp = co_await m_networkManager->get(
|
||||
cleanUrl + "/System/Info/Public", QMap<QString, QString>(),
|
||||
requestOptions);
|
||||
|
||||
|
||||
ServerProfile tempProfile;
|
||||
tempProfile.url = cleanUrl;
|
||||
tempProfile.deviceId = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
tempProfile.ignoreSslVerification = ignoreSslVerification;
|
||||
|
||||
|
||||
QString productName = infoResp["ProductName"].toString();
|
||||
if (productName.contains("Jellyfin", Qt::CaseInsensitive)) {
|
||||
tempProfile.type = ServerProfile::Jellyfin;
|
||||
} else {
|
||||
tempProfile.type = ServerProfile::Emby;
|
||||
}
|
||||
|
||||
|
||||
QString realName = infoResp["ServerName"].toString();
|
||||
if (!realName.isEmpty()) {
|
||||
tempProfile.name = realName;
|
||||
} else {
|
||||
QUrl parsedUrl(cleanUrl);
|
||||
tempProfile.name = parsedUrl.host() + (parsedUrl.port() != -1 ? ":" + QString::number(parsedUrl.port()) : "");
|
||||
}
|
||||
|
||||
|
||||
ApiClient tempClient(tempProfile, m_networkManager);
|
||||
QJsonObject payload;
|
||||
payload["Username"] = username;
|
||||
payload["Pw"] = password;
|
||||
|
||||
|
||||
QJsonObject authResp = co_await tempClient.post("/Users/AuthenticateByName", payload);
|
||||
|
||||
|
||||
tempProfile.accessToken = authResp["AccessToken"].toString();
|
||||
|
||||
QJsonObject userObj = authResp["User"].toObject();
|
||||
tempProfile.userId = userObj["Id"].toString();
|
||||
tempProfile.userName = userObj["Name"].toString();
|
||||
|
||||
QJsonObject policyObj = userObj["Policy"].toObject();
|
||||
tempProfile.isAdmin = policyObj["IsAdministrator"].toBool();
|
||||
tempProfile.canDownloadMedia = resolveContentDownloadPermission(policyObj);
|
||||
|
||||
|
||||
|
||||
{
|
||||
QStringList iconCandidates;
|
||||
if (tempProfile.type == ServerProfile::Jellyfin) {
|
||||
iconCandidates = {
|
||||
QStringLiteral("/web/icon-transparent.png"),
|
||||
QStringLiteral("/web/touchicon.png"),
|
||||
QStringLiteral("/web/favicon.png"),
|
||||
QStringLiteral("/web/favicon.ico"),
|
||||
};
|
||||
} else {
|
||||
iconCandidates = {
|
||||
QStringLiteral("/web/touchicon.png"),
|
||||
QStringLiteral("/web/touchicon144.png"),
|
||||
QStringLiteral("/web/favicon.png"),
|
||||
QStringLiteral("/web/favicon.ico"),
|
||||
QStringLiteral("/emby/web/touchicon.png"),
|
||||
QStringLiteral("/emby/web/favicon.png"),
|
||||
};
|
||||
}
|
||||
|
||||
qDebug() << "[AuthService] Attempting to fetch server icon"
|
||||
<< "| type:" << (tempProfile.type == ServerProfile::Jellyfin ? "Jellyfin" : "Emby")
|
||||
<< "| candidates:" << iconCandidates.size();
|
||||
|
||||
for (const QString &iconPath : iconCandidates) {
|
||||
try {
|
||||
const QByteArray iconBytes = co_await m_networkManager->getBytes(
|
||||
tempProfile.url + iconPath, QMap<QString, QString>(),
|
||||
requestOptions);
|
||||
if (!iconBytes.isEmpty()) {
|
||||
tempProfile.iconBase64 =
|
||||
QString::fromUtf8(iconBytes.toBase64());
|
||||
qDebug() << "[AuthService] Server icon fetched successfully"
|
||||
<< "| path:" << iconPath
|
||||
<< "| size:" << iconBytes.size() << "bytes";
|
||||
break;
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
qDebug() << "[AuthService] Icon fetch failed, trying next"
|
||||
<< "| path:" << iconPath
|
||||
<< "| error:" << e.what();
|
||||
} catch (...) {
|
||||
qDebug() << "[AuthService] Icon fetch failed (unknown error)"
|
||||
<< "| path:" << iconPath;
|
||||
}
|
||||
}
|
||||
|
||||
if (tempProfile.iconBase64.isEmpty()) {
|
||||
qWarning() << "[AuthService] All icon fetch attempts failed"
|
||||
<< "| url:" << tempProfile.url
|
||||
<< "| type:" << (tempProfile.type == ServerProfile::Jellyfin ? "Jellyfin" : "Emby");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
m_serverManager->addServer(tempProfile);
|
||||
m_serverManager->setActiveServer(tempProfile.id);
|
||||
|
||||
|
||||
co_return tempProfile;
|
||||
}
|
||||
|
||||
void AuthService::logout()
|
||||
{
|
||||
|
||||
m_serverManager->clearActiveSession();
|
||||
|
||||
|
||||
Q_EMIT userLoggedOut();
|
||||
}
|
||||
|
||||
QCoro::Task<ServerProfile> AuthService::validateSession(const QString& serverId)
|
||||
{
|
||||
|
||||
QList<ServerProfile> servers = m_serverManager->servers();
|
||||
ServerProfile targetProfile;
|
||||
bool found = false;
|
||||
for (const auto& p : servers) {
|
||||
if (p.id == serverId) {
|
||||
targetProfile = p;
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
throw std::runtime_error(tr("本地存储中找不到该服务器配置。").toStdString());
|
||||
}
|
||||
|
||||
|
||||
ApiClient tempClient(targetProfile, m_networkManager);
|
||||
|
||||
|
||||
|
||||
co_await tempClient.get("/System/Info");
|
||||
|
||||
try {
|
||||
const QJsonObject userResp =
|
||||
co_await tempClient.get(QStringLiteral("/Users/%1").arg(targetProfile.userId));
|
||||
const QJsonObject policyObj = userResp.value(QStringLiteral("Policy")).toObject();
|
||||
targetProfile.userName = userResp.value(QStringLiteral("Name")).toString(
|
||||
targetProfile.userName);
|
||||
targetProfile.isAdmin = policyObj.value(QStringLiteral("IsAdministrator"))
|
||||
.toBool(targetProfile.isAdmin);
|
||||
targetProfile.canDownloadMedia =
|
||||
resolveContentDownloadPermission(policyObj);
|
||||
m_serverManager->addServer(targetProfile);
|
||||
} catch (const std::exception& e) {
|
||||
qWarning() << "[AuthService] Failed to refresh current user policy during"
|
||||
" session validation"
|
||||
<< "| userId:" << targetProfile.userId
|
||||
<< "| error:" << e.what();
|
||||
}
|
||||
|
||||
|
||||
m_serverManager->setActiveServer(targetProfile.id);
|
||||
|
||||
co_return targetProfile;
|
||||
}
|
||||
44
src/qEmbyCore/services/auth/authservice.h
Normal file
44
src/qEmbyCore/services/auth/authservice.h
Normal file
@@ -0,0 +1,44 @@
|
||||
#ifndef AUTHSERVICE_H
|
||||
#define AUTHSERVICE_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include "../../api/networkmanager.h"
|
||||
#include "../../api/apiclient.h"
|
||||
#include "../manager/servermanager.h"
|
||||
#include "../../models/profile/serverprofile.h"
|
||||
#include <QObject>
|
||||
#include <QString>
|
||||
#include <qcorotask.h>
|
||||
|
||||
class QEMBYCORE_EXPORT AuthService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit AuthService(NetworkManager* networkManager, ServerManager* serverManager, QObject *parent = nullptr);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<ServerProfile> login(const QString& serverUrl,
|
||||
const QString& username,
|
||||
const QString& password,
|
||||
bool ignoreSslVerification);
|
||||
|
||||
|
||||
QCoro::Task<ServerProfile> validateSession(const QString& serverId);
|
||||
|
||||
|
||||
void logout();
|
||||
|
||||
Q_SIGNALS:
|
||||
|
||||
void userLoggedOut();
|
||||
|
||||
private:
|
||||
NetworkManager* m_networkManager;
|
||||
ServerManager* m_serverManager;
|
||||
};
|
||||
|
||||
#endif
|
||||
1053
src/qEmbyCore/services/danmaku/dandanplayprovider.cpp
Normal file
1053
src/qEmbyCore/services/danmaku/dandanplayprovider.cpp
Normal file
File diff suppressed because it is too large
Load Diff
25
src/qEmbyCore/services/danmaku/dandanplayprovider.h
Normal file
25
src/qEmbyCore/services/danmaku/dandanplayprovider.h
Normal file
@@ -0,0 +1,25 @@
|
||||
#ifndef DANDANPLAYPROVIDER_H
|
||||
#define DANDANPLAYPROVIDER_H
|
||||
|
||||
#include "../../api/networkmanager.h"
|
||||
#include "../../models/danmaku/danmakumodels.h"
|
||||
|
||||
class DandanplayProvider
|
||||
{
|
||||
public:
|
||||
explicit DandanplayProvider(NetworkManager *networkManager);
|
||||
|
||||
QCoro::Task<QList<DanmakuMatchCandidate>> searchCandidates(
|
||||
DanmakuMediaContext context,
|
||||
DanmakuProviderConfig config,
|
||||
QString manualKeyword = QString()) const;
|
||||
|
||||
QCoro::Task<QList<DanmakuComment>> fetchComments(
|
||||
DanmakuMatchCandidate candidate,
|
||||
DanmakuProviderConfig config) const;
|
||||
|
||||
private:
|
||||
NetworkManager *m_networkManager;
|
||||
};
|
||||
|
||||
#endif
|
||||
502
src/qEmbyCore/services/danmaku/danmakuasscomposer.cpp
Normal file
502
src/qEmbyCore/services/danmaku/danmakuasscomposer.cpp
Normal file
@@ -0,0 +1,502 @@
|
||||
#include "danmakuasscomposer.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QFont>
|
||||
#include <QFontDatabase>
|
||||
#include <QFontMetricsF>
|
||||
#include <QHash>
|
||||
#include <QTextStream>
|
||||
#include <QVector>
|
||||
#include <algorithm>
|
||||
#include <cmath>
|
||||
#include <utility>
|
||||
|
||||
namespace {
|
||||
|
||||
constexpr int kAssCoordScale = 3;
|
||||
constexpr int kPlayResX = 1920 * kAssCoordScale;
|
||||
constexpr int kPlayResY = 1080 * kAssCoordScale;
|
||||
constexpr int kTopMargin = 24 * kAssCoordScale;
|
||||
constexpr int kBottomMargin = 48 * kAssCoordScale;
|
||||
constexpr int kScrollStartPadding = 32 * kAssCoordScale;
|
||||
constexpr int kScrollEndPadding = 48 * kAssCoordScale;
|
||||
constexpr int kScrollLaneGapPadding = 96 * kAssCoordScale;
|
||||
constexpr double kScrollReferenceWidthFactor = 8.0;
|
||||
constexpr double kScrollSpeedEaseExponent = 0.55;
|
||||
constexpr double kMaxScrollPixelsPerMs = 1.12;
|
||||
constexpr double kScrollOutlineRatio = 0.72;
|
||||
constexpr double kScrollOutlineCap = 2.4;
|
||||
constexpr double kScrollShadowRatio = 0.25;
|
||||
constexpr double kScrollShadowCap = 0.45;
|
||||
constexpr qint64 kMinScrollDurationMs = 2200;
|
||||
constexpr qint64 kMaxScrollDurationMs = 22000;
|
||||
constexpr double kMaxScrollQueueDelayRatio = 0.42;
|
||||
constexpr qint64 kMaxStaticQueueDelayMs = 1800;
|
||||
|
||||
|
||||
|
||||
|
||||
QString findCjkFontFamily()
|
||||
{
|
||||
QFontDatabase db;
|
||||
const QStringList families = db.families();
|
||||
const QStringList preferredCjk = {
|
||||
QStringLiteral("PingFang SC"),
|
||||
QStringLiteral("PingFang TC"),
|
||||
QStringLiteral("Heiti SC"),
|
||||
QStringLiteral("STHeiti"),
|
||||
QStringLiteral("Microsoft YaHei UI"),
|
||||
QStringLiteral("Microsoft YaHei"),
|
||||
QStringLiteral("SimHei"),
|
||||
QStringLiteral("Noto Sans CJK SC"),
|
||||
QStringLiteral("Noto Sans CJK TC"),
|
||||
QStringLiteral("WenQuanYi Micro Hei"),
|
||||
QStringLiteral("WenQuanYi Zen Hei"),
|
||||
};
|
||||
for (const QString &family : preferredCjk) {
|
||||
if (families.contains(family)) {
|
||||
return family;
|
||||
}
|
||||
}
|
||||
return QStringLiteral("sans-serif");
|
||||
}
|
||||
|
||||
QString assTime(qint64 milliseconds)
|
||||
{
|
||||
const qint64 totalCentiseconds = std::max<qint64>(
|
||||
0, static_cast<qint64>(std::llround(milliseconds / 10.0)));
|
||||
const qint64 cs = totalCentiseconds % 100;
|
||||
const qint64 totalSeconds = totalCentiseconds / 100;
|
||||
const qint64 s = totalSeconds % 60;
|
||||
const qint64 totalMinutes = totalSeconds / 60;
|
||||
const qint64 m = totalMinutes % 60;
|
||||
const qint64 h = totalMinutes / 60;
|
||||
return QStringLiteral("%1:%2:%3.%4")
|
||||
.arg(h)
|
||||
.arg(m, 2, 10, QChar('0'))
|
||||
.arg(s, 2, 10, QChar('0'))
|
||||
.arg(cs, 2, 10, QChar('0'));
|
||||
}
|
||||
|
||||
QString assColor(const QColor &color)
|
||||
{
|
||||
return QStringLiteral("&H%1%2%3&")
|
||||
.arg(color.blue(), 2, 16, QChar('0'))
|
||||
.arg(color.green(), 2, 16, QChar('0'))
|
||||
.arg(color.red(), 2, 16, QChar('0'))
|
||||
.toUpper();
|
||||
}
|
||||
|
||||
QString assAlpha(double opacity)
|
||||
{
|
||||
const int alpha = qBound(
|
||||
0, static_cast<int>(std::lround((1.0 - opacity) * 255.0)), 255);
|
||||
return QStringLiteral("&H%1&")
|
||||
.arg(alpha, 2, 16, QChar('0'))
|
||||
.toUpper();
|
||||
}
|
||||
|
||||
QString escapeAssText(QString text)
|
||||
{
|
||||
text.replace(QStringLiteral("\r"), QString());
|
||||
text.replace(QStringLiteral("\n"), QStringLiteral("\\N"));
|
||||
text.replace(QStringLiteral("{"), QStringLiteral("\\{"));
|
||||
text.replace(QStringLiteral("}"), QStringLiteral("\\}"));
|
||||
return text;
|
||||
}
|
||||
|
||||
QString stripTrailingZeros(QString text)
|
||||
{
|
||||
if (!text.contains(QLatin1Char('.'))) {
|
||||
return text;
|
||||
}
|
||||
|
||||
while (text.endsWith(QLatin1Char('0'))) {
|
||||
text.chop(1);
|
||||
}
|
||||
if (text.endsWith(QLatin1Char('.'))) {
|
||||
text.chop(1);
|
||||
}
|
||||
return text;
|
||||
}
|
||||
|
||||
QString assNumber(double value)
|
||||
{
|
||||
return stripTrailingZeros(QString::number(value, 'f', 2));
|
||||
}
|
||||
|
||||
QStringList normalizeBlockedKeywords(const QStringList &keywords)
|
||||
{
|
||||
QStringList result;
|
||||
for (const QString &keyword : keywords) {
|
||||
const QString trimmed = keyword.trimmed();
|
||||
if (!trimmed.isEmpty()) {
|
||||
result.append(trimmed);
|
||||
}
|
||||
}
|
||||
result.removeDuplicates();
|
||||
return result;
|
||||
}
|
||||
|
||||
bool isBlocked(const QString &text, const QStringList &blockedKeywords)
|
||||
{
|
||||
for (const QString &keyword : blockedKeywords) {
|
||||
if (text.contains(keyword, Qt::CaseInsensitive)) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
double commentScale(int fontLevel)
|
||||
{
|
||||
if (fontLevel <= 0) {
|
||||
return 1.0;
|
||||
}
|
||||
return qBound(0.72, fontLevel / 25.0, 1.65);
|
||||
}
|
||||
|
||||
double effectiveScrollSpeedScale(double configuredSpeedScale)
|
||||
{
|
||||
if (configuredSpeedScale <= 1.0) {
|
||||
return configuredSpeedScale;
|
||||
}
|
||||
return std::pow(configuredSpeedScale, kScrollSpeedEaseExponent);
|
||||
}
|
||||
|
||||
double scrollOutlineSizeForMotion(double outlineSize)
|
||||
{
|
||||
if (outlineSize <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
const double preferred =
|
||||
qBound(0.85, outlineSize * kScrollOutlineRatio, kScrollOutlineCap);
|
||||
return std::min(outlineSize, preferred);
|
||||
}
|
||||
|
||||
double scrollShadowOffsetForMotion(double shadowOffset)
|
||||
{
|
||||
if (shadowOffset <= 0.0) {
|
||||
return 0.0;
|
||||
}
|
||||
const double preferred =
|
||||
qBound(0.0, shadowOffset * kScrollShadowRatio, kScrollShadowCap);
|
||||
return std::min(shadowOffset, preferred);
|
||||
}
|
||||
|
||||
QString eventForScroll(qint64 startMs,
|
||||
qint64 endMs,
|
||||
int y,
|
||||
int startX,
|
||||
int endX,
|
||||
int fontSize,
|
||||
int fontWeight,
|
||||
const QColor &color,
|
||||
const QString &alpha,
|
||||
const QString &text)
|
||||
{
|
||||
return QStringLiteral(
|
||||
"Dialogue: 2,%1,%2,DanmakuScroll,,0,0,0,,{\\move(%3,%4,%5,%4)\\fs%6\\b%7\\blur0\\c%8\\alpha%9}%10")
|
||||
.arg(assTime(startMs), assTime(endMs))
|
||||
.arg(startX)
|
||||
.arg(y)
|
||||
.arg(endX)
|
||||
.arg(fontSize)
|
||||
.arg(fontWeight)
|
||||
.arg(assColor(color))
|
||||
.arg(alpha)
|
||||
.arg(text);
|
||||
}
|
||||
|
||||
QString eventForStatic(const QString &styleName,
|
||||
qint64 startMs,
|
||||
qint64 endMs,
|
||||
int y,
|
||||
int fontSize,
|
||||
int fontWeight,
|
||||
const QColor &color,
|
||||
const QString &alpha,
|
||||
const QString &text)
|
||||
{
|
||||
return QStringLiteral(
|
||||
"Dialogue: 2,%1,%2,%3,,0,0,0,,{\\pos(%4,%5)\\fs%6\\b%7\\c%8\\alpha%9}%10")
|
||||
.arg(assTime(startMs), assTime(endMs), styleName)
|
||||
.arg(kPlayResX / 2)
|
||||
.arg(y)
|
||||
.arg(fontSize)
|
||||
.arg(fontWeight)
|
||||
.arg(assColor(color))
|
||||
.arg(alpha)
|
||||
.arg(text);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
QString DanmakuAssComposer::composeAss(const QList<DanmakuComment> &comments,
|
||||
const DanmakuRenderOptions &options)
|
||||
{
|
||||
QList<DanmakuComment> sortedComments = comments;
|
||||
std::sort(sortedComments.begin(), sortedComments.end(),
|
||||
[](const DanmakuComment &lhs, const DanmakuComment &rhs) {
|
||||
if (lhs.timeMs == rhs.timeMs) {
|
||||
return lhs.text < rhs.text;
|
||||
}
|
||||
return lhs.timeMs < rhs.timeMs;
|
||||
});
|
||||
|
||||
const QStringList blockedKeywords =
|
||||
normalizeBlockedKeywords(options.blockedKeywords);
|
||||
const double opacity = qBound(0.05, options.opacity, 1.0);
|
||||
const double fontScale = qBound(0.6, options.fontScale, 2.4);
|
||||
const int fontWeight = qBound(100, options.fontWeight, 900);
|
||||
const double outlineSize = qBound(0.0, options.outlineSize, 6.0);
|
||||
const double shadowOffset = qBound(0.0, options.shadowOffset, 4.0);
|
||||
const double scrollOutlineSize = scrollOutlineSizeForMotion(outlineSize);
|
||||
const double scrollShadowOffset = scrollShadowOffsetForMotion(shadowOffset);
|
||||
const QString outlineAss = assNumber(outlineSize * kAssCoordScale);
|
||||
const QString shadowAss = assNumber(shadowOffset * kAssCoordScale);
|
||||
const QString scrollOutlineAss =
|
||||
assNumber(scrollOutlineSize * kAssCoordScale);
|
||||
const QString scrollShadowAss =
|
||||
assNumber(scrollShadowOffset * kAssCoordScale);
|
||||
const double speedScale = qBound(0.5, options.speedScale, 3.0);
|
||||
const double effectiveSpeedScale =
|
||||
effectiveScrollSpeedScale(speedScale);
|
||||
const int areaPercent = qBound(10, options.areaPercent, 100);
|
||||
const int densityPercent = qBound(20, options.density, 100);
|
||||
const int baseFontSize =
|
||||
qBound(24 * kAssCoordScale,
|
||||
static_cast<int>(std::lround(44.0 * fontScale * kAssCoordScale)),
|
||||
88 * kAssCoordScale);
|
||||
const int lineHeight = static_cast<int>(std::ceil(baseFontSize * 1.28));
|
||||
const int maxLanes = std::max(
|
||||
1, static_cast<int>(std::floor((kPlayResY * (areaPercent / 100.0)) /
|
||||
static_cast<double>(lineHeight))));
|
||||
const int laneCount = std::max(
|
||||
1, static_cast<int>(std::round(maxLanes * (densityPercent / 100.0))));
|
||||
const QString alpha = assAlpha(opacity);
|
||||
const qint64 referenceDurationMs = static_cast<qint64>(
|
||||
std::lround(9000.0 / effectiveSpeedScale));
|
||||
const double referenceTextWidth = std::clamp(
|
||||
baseFontSize * kScrollReferenceWidthFactor,
|
||||
220.0 * kAssCoordScale,
|
||||
1800.0 * kAssCoordScale);
|
||||
const double referenceDistance =
|
||||
kPlayResX + kScrollStartPadding + kScrollEndPadding + referenceTextWidth;
|
||||
const double scrollPixelsPerMs = std::min(
|
||||
kMaxScrollPixelsPerMs,
|
||||
std::max(0.01, referenceDistance /
|
||||
std::max<qint64>(1, referenceDurationMs)));
|
||||
|
||||
QFont font;
|
||||
font.setFamily(findCjkFontFamily());
|
||||
font.setPixelSize(baseFontSize);
|
||||
font.setWeight(static_cast<QFont::Weight>(fontWeight));
|
||||
QHash<int, QFontMetricsF> fontMetricsCache;
|
||||
auto metricsForSize = [&font, &fontMetricsCache](int fontSize) {
|
||||
const auto it = fontMetricsCache.constFind(fontSize);
|
||||
if (it != fontMetricsCache.constEnd()) {
|
||||
return it.value();
|
||||
}
|
||||
|
||||
QFont sizedFont(font);
|
||||
sizedFont.setPixelSize(fontSize);
|
||||
const QFontMetricsF metrics(sizedFont);
|
||||
fontMetricsCache.insert(fontSize, metrics);
|
||||
return metrics;
|
||||
};
|
||||
|
||||
QVector<qint64> scrollLaneAvailable(laneCount, 0);
|
||||
QVector<qint64> topLaneAvailable(laneCount, 0);
|
||||
QVector<qint64> bottomLaneAvailable(laneCount, 0);
|
||||
|
||||
QStringList dialogueLines;
|
||||
dialogueLines.reserve(sortedComments.size());
|
||||
int skippedInvalid = 0;
|
||||
int skippedBlocked = 0;
|
||||
int skippedByMode = 0;
|
||||
int skippedByOverload = 0;
|
||||
int renderedScroll = 0;
|
||||
int renderedTop = 0;
|
||||
int renderedBottom = 0;
|
||||
|
||||
for (const DanmakuComment &comment : std::as_const(sortedComments)) {
|
||||
if (!comment.isValid()) {
|
||||
++skippedInvalid;
|
||||
continue;
|
||||
}
|
||||
|
||||
if (isBlocked(comment.text, blockedKeywords)) {
|
||||
++skippedBlocked;
|
||||
continue;
|
||||
}
|
||||
|
||||
if ((comment.mode == 1 && options.hideScroll) ||
|
||||
(comment.mode == 5 && options.hideTop) ||
|
||||
(comment.mode == 4 && options.hideBottom)) {
|
||||
++skippedByMode;
|
||||
continue;
|
||||
}
|
||||
|
||||
const QString assText = escapeAssText(comment.text);
|
||||
const double widthScale = commentScale(comment.fontLevel);
|
||||
const int fontSize = std::max(
|
||||
18, static_cast<int>(std::lround(baseFontSize * widthScale)));
|
||||
const QFontMetricsF metrics = metricsForSize(fontSize);
|
||||
const double textWidth = std::max(metrics.horizontalAdvance(comment.text),
|
||||
static_cast<double>(fontSize));
|
||||
const qint64 startMs = std::max<qint64>(0, comment.timeMs + options.offsetMs);
|
||||
|
||||
if (comment.mode == 5 || comment.mode == 4) {
|
||||
QVector<qint64> &lanes = (comment.mode == 5) ? topLaneAvailable
|
||||
: bottomLaneAvailable;
|
||||
const qint64 durationMs = 4200;
|
||||
int laneIndex = 0;
|
||||
qint64 bestAvailable = lanes[0];
|
||||
for (int i = 0; i < lanes.size(); ++i) {
|
||||
if (lanes[i] <= startMs) {
|
||||
laneIndex = i;
|
||||
break;
|
||||
}
|
||||
if (lanes[i] < bestAvailable) {
|
||||
bestAvailable = lanes[i];
|
||||
laneIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const qint64 actualStartMs = std::max(startMs, lanes[laneIndex]);
|
||||
if (actualStartMs - startMs > kMaxStaticQueueDelayMs) {
|
||||
++skippedByOverload;
|
||||
continue;
|
||||
}
|
||||
const qint64 endMs = actualStartMs + durationMs;
|
||||
lanes[laneIndex] = endMs;
|
||||
|
||||
const int y = (comment.mode == 5)
|
||||
? (kTopMargin + laneIndex * lineHeight)
|
||||
: (kPlayResY - kBottomMargin -
|
||||
laneIndex * lineHeight);
|
||||
dialogueLines.append(eventForStatic(
|
||||
comment.mode == 5 ? QStringLiteral("DanmakuTop")
|
||||
: QStringLiteral("DanmakuBottom"),
|
||||
actualStartMs, endMs, y, fontSize, fontWeight, comment.color,
|
||||
alpha,
|
||||
assText));
|
||||
if (comment.mode == 5) {
|
||||
++renderedTop;
|
||||
} else {
|
||||
++renderedBottom;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
const int textWidthPixels =
|
||||
std::max(1, static_cast<int>(std::ceil(textWidth)));
|
||||
const int startX = kPlayResX + kScrollStartPadding;
|
||||
const int endX = -textWidthPixels - kScrollEndPadding;
|
||||
const double totalDistance = static_cast<double>(startX - endX);
|
||||
const qint64 durationMs = qBound<qint64>(
|
||||
kMinScrollDurationMs,
|
||||
static_cast<qint64>(std::llround(totalDistance / scrollPixelsPerMs)),
|
||||
kMaxScrollDurationMs);
|
||||
const qint64 laneGapMs =
|
||||
static_cast<qint64>(std::ceil((textWidthPixels + kScrollLaneGapPadding) /
|
||||
scrollPixelsPerMs));
|
||||
|
||||
int laneIndex = 0;
|
||||
qint64 bestAvailable = scrollLaneAvailable[0];
|
||||
for (int i = 0; i < scrollLaneAvailable.size(); ++i) {
|
||||
if (scrollLaneAvailable[i] <= startMs) {
|
||||
laneIndex = i;
|
||||
break;
|
||||
}
|
||||
if (scrollLaneAvailable[i] < bestAvailable) {
|
||||
bestAvailable = scrollLaneAvailable[i];
|
||||
laneIndex = i;
|
||||
}
|
||||
}
|
||||
|
||||
const qint64 actualStartMs = std::max(startMs, scrollLaneAvailable[laneIndex]);
|
||||
const qint64 maxQueueDelayMs = std::max<qint64>(
|
||||
300,
|
||||
static_cast<qint64>(std::lround(durationMs * kMaxScrollQueueDelayRatio)));
|
||||
if (actualStartMs - startMs > maxQueueDelayMs) {
|
||||
++skippedByOverload;
|
||||
continue;
|
||||
}
|
||||
const qint64 endMs = actualStartMs + durationMs;
|
||||
scrollLaneAvailable[laneIndex] = actualStartMs + laneGapMs;
|
||||
|
||||
const int y = kTopMargin + laneIndex * lineHeight;
|
||||
dialogueLines.append(eventForScroll(actualStartMs, endMs, y, startX, endX,
|
||||
fontSize, fontWeight, comment.color,
|
||||
alpha,
|
||||
assText));
|
||||
++renderedScroll;
|
||||
}
|
||||
|
||||
QString ass;
|
||||
QTextStream stream(&ass);
|
||||
stream << "[Script Info]\n";
|
||||
stream << "ScriptType: v4.00+\n";
|
||||
stream << "PlayResX: " << kPlayResX << '\n';
|
||||
stream << "PlayResY: " << kPlayResY << '\n';
|
||||
stream << "WrapStyle: 2\n";
|
||||
stream << "ScaledBorderAndShadow: yes\n";
|
||||
stream << "YCbCr Matrix: TV.601\n\n";
|
||||
|
||||
stream << "[V4+ Styles]\n";
|
||||
stream << "Format: Name, Fontname, Fontsize, PrimaryColour, SecondaryColour, "
|
||||
"OutlineColour, BackColour, Bold, Italic, Underline, StrikeOut, "
|
||||
"ScaleX, ScaleY, Spacing, Angle, BorderStyle, Outline, Shadow, "
|
||||
"Alignment, MarginL, MarginR, MarginV, Encoding\n";
|
||||
|
||||
stream << "Style: DanmakuScroll," << font.family()
|
||||
<< ',' << baseFontSize
|
||||
<< ",&H00FFFFFF,&H00FFFFFF,&H32000000,&H82000000,0,0,0,0,100,100,0,0,1,"
|
||||
<< scrollOutlineAss << ',' << scrollShadowAss
|
||||
<< ",7,0,0,0,1\n";
|
||||
stream << "Style: DanmakuTop," << font.family()
|
||||
<< ',' << baseFontSize
|
||||
<< ",&H00FFFFFF,&H00FFFFFF,&H32000000,&H82000000,0,0,0,0,100,100,0,0,1,"
|
||||
<< outlineAss << ',' << shadowAss
|
||||
<< ",8,0,0,0,1\n";
|
||||
stream << "Style: DanmakuBottom," << font.family()
|
||||
<< ',' << baseFontSize
|
||||
<< ",&H00FFFFFF,&H00FFFFFF,&H32000000,&H82000000,0,0,0,0,100,100,0,0,1,"
|
||||
<< outlineAss << ',' << shadowAss
|
||||
<< ",2,0,0,0,1\n\n";
|
||||
|
||||
stream << "[Events]\n";
|
||||
stream << "Format: Layer, Start, End, Style, Name, MarginL, MarginR, MarginV, Effect, Text\n";
|
||||
for (const QString &line : std::as_const(dialogueLines)) {
|
||||
stream << line << '\n';
|
||||
}
|
||||
|
||||
const int renderedTotal = renderedScroll + renderedTop + renderedBottom;
|
||||
qDebug().noquote()
|
||||
<< "[Danmaku][ASS] Compose summary"
|
||||
<< "| inputCount:" << sortedComments.size()
|
||||
<< "| renderedCount:" << renderedTotal
|
||||
<< "| scroll:" << renderedScroll
|
||||
<< "| top:" << renderedTop
|
||||
<< "| bottom:" << renderedBottom
|
||||
<< "| droppedInvalid:" << skippedInvalid
|
||||
<< "| droppedBlocked:" << skippedBlocked
|
||||
<< "| droppedModeFiltered:" << skippedByMode
|
||||
<< "| droppedQueueOverflow:" << skippedByOverload
|
||||
<< "| laneCount:" << laneCount
|
||||
<< "| coordScale:" << kAssCoordScale
|
||||
<< "| baseFontSize:" << baseFontSize
|
||||
<< "| lineHeight:" << lineHeight
|
||||
<< "| scrollPixelsPerMs:" << scrollPixelsPerMs
|
||||
<< "| fontWeight:" << fontWeight
|
||||
<< "| effectiveSpeedScale:" << effectiveSpeedScale
|
||||
<< "| scrollOutline:" << scrollOutlineAss
|
||||
<< "| scrollShadow:" << scrollShadowAss
|
||||
<< "| outline:" << outlineAss
|
||||
<< "| shadow:" << shadowAss
|
||||
<< "| speedScale:" << speedScale;
|
||||
|
||||
return ass;
|
||||
}
|
||||
13
src/qEmbyCore/services/danmaku/danmakuasscomposer.h
Normal file
13
src/qEmbyCore/services/danmaku/danmakuasscomposer.h
Normal file
@@ -0,0 +1,13 @@
|
||||
#ifndef DANMAKUASSCOMPOSER_H
|
||||
#define DANMAKUASSCOMPOSER_H
|
||||
|
||||
#include "../../models/danmaku/danmakumodels.h"
|
||||
|
||||
class DanmakuAssComposer
|
||||
{
|
||||
public:
|
||||
static QString composeAss(const QList<DanmakuComment> &comments,
|
||||
const DanmakuRenderOptions &options);
|
||||
};
|
||||
|
||||
#endif
|
||||
279
src/qEmbyCore/services/danmaku/danmakucachestore.cpp
Normal file
279
src/qEmbyCore/services/danmaku/danmakucachestore.cpp
Normal file
@@ -0,0 +1,279 @@
|
||||
#include "danmakucachestore.h"
|
||||
|
||||
#include <QCryptographicHash>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QStandardPaths>
|
||||
|
||||
namespace {
|
||||
|
||||
QJsonObject candidateToJson(const DanmakuMatchCandidate &candidate)
|
||||
{
|
||||
QJsonObject obj;
|
||||
obj["provider"] = candidate.provider;
|
||||
obj["cacheScope"] = candidate.cacheScope;
|
||||
obj["endpointId"] = candidate.endpointId;
|
||||
obj["endpointName"] = candidate.endpointName;
|
||||
obj["targetId"] = candidate.targetId;
|
||||
obj["title"] = candidate.title;
|
||||
obj["subtitle"] = candidate.subtitle;
|
||||
obj["seasonNumber"] = candidate.seasonNumber;
|
||||
obj["episodeNumber"] = candidate.episodeNumber;
|
||||
obj["durationMs"] = QString::number(candidate.durationMs);
|
||||
obj["score"] = candidate.score;
|
||||
obj["matchReason"] = candidate.matchReason;
|
||||
obj["commentCount"] = candidate.commentCount;
|
||||
return obj;
|
||||
}
|
||||
|
||||
DanmakuMatchCandidate candidateFromJson(const QJsonObject &obj)
|
||||
{
|
||||
DanmakuMatchCandidate candidate;
|
||||
candidate.provider = obj["provider"].toString();
|
||||
candidate.cacheScope = obj["cacheScope"].toString();
|
||||
candidate.endpointId = obj["endpointId"].toString();
|
||||
candidate.endpointName = obj["endpointName"].toString();
|
||||
candidate.targetId = obj["targetId"].toString();
|
||||
candidate.title = obj["title"].toString();
|
||||
candidate.subtitle = obj["subtitle"].toString();
|
||||
candidate.seasonNumber = obj["seasonNumber"].toInt(-1);
|
||||
candidate.episodeNumber = obj["episodeNumber"].toInt(-1);
|
||||
candidate.durationMs = obj["durationMs"].toVariant().toLongLong();
|
||||
candidate.score = obj["score"].toDouble();
|
||||
candidate.matchReason = obj["matchReason"].toString();
|
||||
candidate.commentCount = obj["commentCount"].toInt();
|
||||
return candidate;
|
||||
}
|
||||
|
||||
QJsonObject commentToJson(const DanmakuComment &comment)
|
||||
{
|
||||
QJsonObject obj;
|
||||
obj["timeMs"] = QString::number(comment.timeMs);
|
||||
obj["mode"] = comment.mode;
|
||||
obj["color"] = comment.color.name(QColor::HexRgb);
|
||||
obj["fontLevel"] = comment.fontLevel;
|
||||
obj["sender"] = comment.sender;
|
||||
obj["text"] = comment.text;
|
||||
obj["createdAt"] = comment.createdAt.toString(Qt::ISODate);
|
||||
return obj;
|
||||
}
|
||||
|
||||
DanmakuComment commentFromJson(const QJsonObject &obj)
|
||||
{
|
||||
DanmakuComment comment;
|
||||
comment.timeMs = obj["timeMs"].toVariant().toLongLong();
|
||||
comment.mode = obj["mode"].toInt(1);
|
||||
comment.color = QColor(obj["color"].toString(QStringLiteral("#FFFFFF")));
|
||||
comment.fontLevel = obj["fontLevel"].toInt(25);
|
||||
comment.sender = obj["sender"].toString();
|
||||
comment.text = obj["text"].toString();
|
||||
comment.createdAt =
|
||||
QDateTime::fromString(obj["createdAt"].toString(), Qt::ISODate);
|
||||
return comment;
|
||||
}
|
||||
|
||||
bool ensureParentDir(const QString &path)
|
||||
{
|
||||
return QDir().mkpath(QFileInfo(path).absolutePath());
|
||||
}
|
||||
|
||||
QString commentCacheKey(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId)
|
||||
{
|
||||
const QByteArray rawKey =
|
||||
QStringLiteral("%1|%2|%3").arg(provider, cacheScope, targetId).toUtf8();
|
||||
return QString::fromLatin1(
|
||||
QCryptographicHash::hash(rawKey, QCryptographicHash::Sha1).toHex());
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
bool DanmakuCacheStore::loadMatch(const DanmakuMediaContext &context,
|
||||
DanmakuMatchCandidate *candidate,
|
||||
bool *manualOverride) const
|
||||
{
|
||||
if (!candidate) {
|
||||
return false;
|
||||
}
|
||||
|
||||
QFile file(matchesFilePath(context.serverId));
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
const QJsonObject root = doc.object();
|
||||
const QJsonObject entry = root.value(context.cacheKey()).toObject();
|
||||
if (entry.isEmpty()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*candidate = candidateFromJson(entry.value("candidate").toObject());
|
||||
if (manualOverride) {
|
||||
*manualOverride = entry.value("manualOverride").toBool(false);
|
||||
}
|
||||
return candidate->isValid();
|
||||
}
|
||||
|
||||
void DanmakuCacheStore::saveMatch(const DanmakuMediaContext &context,
|
||||
const DanmakuMatchCandidate &candidate,
|
||||
bool manualOverride) const
|
||||
{
|
||||
const QString filePath = matchesFilePath(context.serverId);
|
||||
ensureParentDir(filePath);
|
||||
|
||||
QJsonObject root;
|
||||
QFile file(filePath);
|
||||
if (file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
root = QJsonDocument::fromJson(file.readAll()).object();
|
||||
file.close();
|
||||
}
|
||||
|
||||
QJsonObject entry;
|
||||
entry["candidate"] = candidateToJson(candidate);
|
||||
entry["manualOverride"] = manualOverride;
|
||||
entry["updatedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
root[context.cacheKey()] = entry;
|
||||
|
||||
if (file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Indented));
|
||||
}
|
||||
}
|
||||
|
||||
QList<DanmakuComment> DanmakuCacheStore::loadComments(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId,
|
||||
int maxAgeHours) const
|
||||
{
|
||||
QList<DanmakuComment> comments;
|
||||
QFile file(commentsFilePath(provider, cacheScope, targetId));
|
||||
if (!file.open(QIODevice::ReadOnly | QIODevice::Text)) {
|
||||
return comments;
|
||||
}
|
||||
|
||||
const QJsonDocument doc = QJsonDocument::fromJson(file.readAll());
|
||||
const QJsonObject root = doc.object();
|
||||
const QDateTime fetchedAt = QDateTime::fromString(
|
||||
root.value("fetchedAt").toString(), Qt::ISODate);
|
||||
if (!fetchedAt.isValid() ||
|
||||
fetchedAt.secsTo(QDateTime::currentDateTimeUtc()) >
|
||||
static_cast<qint64>(maxAgeHours) * 3600) {
|
||||
return comments;
|
||||
}
|
||||
|
||||
const QJsonArray arr = root.value("comments").toArray();
|
||||
comments.reserve(arr.size());
|
||||
for (const QJsonValue &value : arr) {
|
||||
const DanmakuComment comment = commentFromJson(value.toObject());
|
||||
if (comment.isValid()) {
|
||||
comments.append(comment);
|
||||
}
|
||||
}
|
||||
return comments;
|
||||
}
|
||||
|
||||
void DanmakuCacheStore::saveComments(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId,
|
||||
const QString &sourceTitle,
|
||||
const QList<DanmakuComment> &comments) const
|
||||
{
|
||||
const QString filePath = commentsFilePath(provider, cacheScope, targetId);
|
||||
ensureParentDir(filePath);
|
||||
|
||||
QJsonArray arr;
|
||||
for (const DanmakuComment &comment : comments) {
|
||||
arr.append(commentToJson(comment));
|
||||
}
|
||||
|
||||
QJsonObject root;
|
||||
root["provider"] = provider;
|
||||
root["cacheScope"] = cacheScope;
|
||||
root["targetId"] = targetId;
|
||||
root["sourceTitle"] = sourceTitle;
|
||||
root["fetchedAt"] = QDateTime::currentDateTimeUtc().toString(Qt::ISODate);
|
||||
root["comments"] = arr;
|
||||
|
||||
QFile file(filePath);
|
||||
if (file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
file.write(QJsonDocument(root).toJson(QJsonDocument::Compact));
|
||||
}
|
||||
}
|
||||
|
||||
bool DanmakuCacheStore::loadAssPath(const QString &assKey,
|
||||
QString *path,
|
||||
int maxAgeHours) const
|
||||
{
|
||||
if (!path) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const QString filePath = assFilePath(assKey);
|
||||
QFileInfo info(filePath);
|
||||
if (!info.exists()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const qint64 ageSeconds = info.lastModified().secsTo(QDateTime::currentDateTime());
|
||||
if (ageSeconds > static_cast<qint64>(maxAgeHours) * 3600) {
|
||||
return false;
|
||||
}
|
||||
|
||||
*path = filePath;
|
||||
return true;
|
||||
}
|
||||
|
||||
QString DanmakuCacheStore::saveAssFile(const QString &assKey,
|
||||
const QString &content) const
|
||||
{
|
||||
const QString filePath = assFilePath(assKey);
|
||||
if (!ensureParentDir(filePath)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
QFile file(filePath);
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate | QIODevice::Text)) {
|
||||
return {};
|
||||
}
|
||||
|
||||
file.write(content.toUtf8());
|
||||
return filePath;
|
||||
}
|
||||
|
||||
void DanmakuCacheStore::clearAll() const
|
||||
{
|
||||
QDir dir(baseDirPath());
|
||||
if (dir.exists()) {
|
||||
dir.removeRecursively();
|
||||
}
|
||||
}
|
||||
|
||||
QString DanmakuCacheStore::baseDirPath() const
|
||||
{
|
||||
return QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation) +
|
||||
QStringLiteral("/danmaku");
|
||||
}
|
||||
|
||||
QString DanmakuCacheStore::matchesFilePath(const QString &serverId) const
|
||||
{
|
||||
return baseDirPath() + QStringLiteral("/matches/%1.json").arg(serverId);
|
||||
}
|
||||
|
||||
QString DanmakuCacheStore::commentsFilePath(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId) const
|
||||
{
|
||||
const QString hashedKey = commentCacheKey(provider, cacheScope, targetId);
|
||||
return baseDirPath() +
|
||||
QStringLiteral("/comments/%1/%2.json").arg(provider, hashedKey);
|
||||
}
|
||||
|
||||
QString DanmakuCacheStore::assFilePath(const QString &assKey) const
|
||||
{
|
||||
return baseDirPath() + QStringLiteral("/ass/%1.ass").arg(assKey);
|
||||
}
|
||||
46
src/qEmbyCore/services/danmaku/danmakucachestore.h
Normal file
46
src/qEmbyCore/services/danmaku/danmakucachestore.h
Normal file
@@ -0,0 +1,46 @@
|
||||
#ifndef DANMAKUCACHESTORE_H
|
||||
#define DANMAKUCACHESTORE_H
|
||||
|
||||
#include "../../models/danmaku/danmakumodels.h"
|
||||
|
||||
#include <QString>
|
||||
|
||||
class DanmakuCacheStore
|
||||
{
|
||||
public:
|
||||
DanmakuCacheStore() = default;
|
||||
|
||||
bool loadMatch(const DanmakuMediaContext &context,
|
||||
DanmakuMatchCandidate *candidate,
|
||||
bool *manualOverride) const;
|
||||
void saveMatch(const DanmakuMediaContext &context,
|
||||
const DanmakuMatchCandidate &candidate,
|
||||
bool manualOverride) const;
|
||||
|
||||
QList<DanmakuComment> loadComments(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId,
|
||||
int maxAgeHours) const;
|
||||
void saveComments(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId,
|
||||
const QString &sourceTitle,
|
||||
const QList<DanmakuComment> &comments) const;
|
||||
|
||||
bool loadAssPath(const QString &assKey,
|
||||
QString *path,
|
||||
int maxAgeHours) const;
|
||||
QString saveAssFile(const QString &assKey, const QString &content) const;
|
||||
|
||||
void clearAll() const;
|
||||
|
||||
private:
|
||||
QString baseDirPath() const;
|
||||
QString matchesFilePath(const QString &serverId) const;
|
||||
QString commentsFilePath(const QString &provider,
|
||||
const QString &cacheScope,
|
||||
const QString &targetId) const;
|
||||
QString assFilePath(const QString &assKey) const;
|
||||
};
|
||||
|
||||
#endif
|
||||
1750
src/qEmbyCore/services/danmaku/danmakuservice.cpp
Normal file
1750
src/qEmbyCore/services/danmaku/danmakuservice.cpp
Normal file
File diff suppressed because it is too large
Load Diff
74
src/qEmbyCore/services/danmaku/danmakuservice.h
Normal file
74
src/qEmbyCore/services/danmaku/danmakuservice.h
Normal file
@@ -0,0 +1,74 @@
|
||||
#ifndef DANMAKUSERVICE_H
|
||||
#define DANMAKUSERVICE_H
|
||||
|
||||
#include "../../models/danmaku/danmakumodels.h"
|
||||
|
||||
#include <QObject>
|
||||
#include <qcorotask.h>
|
||||
|
||||
class NetworkManager;
|
||||
class ServerManager;
|
||||
class DandanplayProvider;
|
||||
class DanmakuCacheStore;
|
||||
|
||||
class QEMBYCORE_EXPORT DanmakuService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit DanmakuService(NetworkManager *networkManager,
|
||||
ServerManager *serverManager,
|
||||
QObject *parent = nullptr);
|
||||
~DanmakuService() override;
|
||||
|
||||
static DanmakuMatchCandidate createLocalFileCandidate(
|
||||
QString filePath);
|
||||
static QString localDanmakuDirectoryPath(
|
||||
QString serverId = QString());
|
||||
static bool ensureLocalDanmakuDirectory(
|
||||
QString serverId = QString());
|
||||
|
||||
DanmakuRenderOptions renderOptions() const;
|
||||
DanmakuProviderConfig providerConfig(
|
||||
QString serverId = QString()) const;
|
||||
|
||||
QCoro::Task<DanmakuLoadResult> prepareDanmaku(
|
||||
DanmakuMediaContext context,
|
||||
QString manualKeyword = QString());
|
||||
QCoro::Task<DanmakuLoadResult> prepareDanmakuForCandidate(
|
||||
DanmakuMediaContext context,
|
||||
DanmakuMatchCandidate candidate);
|
||||
QCoro::Task<QList<DanmakuMatchCandidate>> searchCandidates(
|
||||
DanmakuMediaContext context,
|
||||
QString manualKeyword = QString());
|
||||
QCoro::Task<QList<DanmakuMatchCandidate>> searchAllCandidates(
|
||||
DanmakuMediaContext context,
|
||||
QString manualKeyword = QString());
|
||||
QCoro::Task<DanmakuMatchResult> resolveMatch(
|
||||
DanmakuMediaContext context,
|
||||
QString manualKeyword = QString());
|
||||
|
||||
void saveManualMatch(const DanmakuMediaContext &context,
|
||||
const DanmakuMatchCandidate &candidate);
|
||||
void clearCache();
|
||||
|
||||
private:
|
||||
bool autoMatchEnabled(const QString &serverId) const;
|
||||
QList<DanmakuProviderConfig> enabledProviderConfigs(
|
||||
QString serverId = QString()) const;
|
||||
DanmakuProviderConfig providerConfigForCandidate(
|
||||
QString serverId,
|
||||
const DanmakuMatchCandidate &candidate) const;
|
||||
QCoro::Task<QList<DanmakuMatchCandidate>> searchCandidatesForConfig(
|
||||
DanmakuMediaContext context,
|
||||
DanmakuProviderConfig config,
|
||||
QString manualKeyword = QString());
|
||||
QString assCacheKey(const DanmakuMatchCandidate &candidate,
|
||||
const DanmakuRenderOptions &options) const;
|
||||
|
||||
NetworkManager *m_networkManager;
|
||||
ServerManager *m_serverManager;
|
||||
DandanplayProvider *m_dandanplayProvider;
|
||||
DanmakuCacheStore *m_cacheStore;
|
||||
};
|
||||
|
||||
#endif
|
||||
460
src/qEmbyCore/services/danmaku/danmakusettings.cpp
Normal file
460
src/qEmbyCore/services/danmaku/danmakusettings.cpp
Normal file
@@ -0,0 +1,460 @@
|
||||
#include "danmakusettings.h"
|
||||
|
||||
#include "../../config/config_keys.h"
|
||||
#include "../../config/configstore.h"
|
||||
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QSet>
|
||||
#include <QUrl>
|
||||
#include <QUuid>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr auto kOfficialDandanplayServerId = "default";
|
||||
constexpr auto kOfficialDandanplayProvider = "dandanplay";
|
||||
constexpr auto kOfficialDandanplayName = "DandanPlay Open API";
|
||||
constexpr auto kOfficialDandanplayBaseUrl = "https://api.dandanplay.net";
|
||||
constexpr auto kOfficialDandanplayContentScope = "anime";
|
||||
|
||||
|
||||
constexpr auto kBuiltInOfficialDandanplayAppId = "wyptw278x3";
|
||||
constexpr auto kBuiltInOfficialDandanplayAppSecret = "ApQZbydpeafcDTe8pBX3EJxifAuhfkSn";
|
||||
|
||||
QString settingKey(const QString &serverId, const char *baseKey)
|
||||
{
|
||||
return ConfigKeys::forServer(serverId.trimmed(), baseKey);
|
||||
}
|
||||
|
||||
QString normalizeBaseUrl(QString baseUrl)
|
||||
{
|
||||
baseUrl = baseUrl.trimmed();
|
||||
while (baseUrl.endsWith('/') && !baseUrl.endsWith(QStringLiteral("://")))
|
||||
{
|
||||
baseUrl.chop(1);
|
||||
}
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
QString normalizedHost(const QString &baseUrl)
|
||||
{
|
||||
const QUrl url = QUrl::fromUserInput(baseUrl.trimmed());
|
||||
return url.host().trimmed().toLower();
|
||||
}
|
||||
|
||||
bool isOfficialDandanplayEndpoint(const QString &baseUrl)
|
||||
{
|
||||
return normalizedHost(baseUrl) == QStringLiteral("api.dandanplay.net");
|
||||
}
|
||||
|
||||
bool isBuiltInOfficialDandanplayServer(const DanmakuServerDefinition &server)
|
||||
{
|
||||
return server.builtIn ||
|
||||
(server.id == QLatin1String(kOfficialDandanplayServerId) && isOfficialDandanplayEndpoint(server.baseUrl));
|
||||
}
|
||||
|
||||
QString defaultServerName(const QString &baseUrl)
|
||||
{
|
||||
const QString normalized = normalizeBaseUrl(baseUrl);
|
||||
if (normalized.isEmpty() || normalized == QString::fromLatin1(kOfficialDandanplayBaseUrl))
|
||||
{
|
||||
return QString::fromLatin1(kOfficialDandanplayName);
|
||||
}
|
||||
|
||||
const QUrl url = QUrl::fromUserInput(normalized);
|
||||
if (!url.host().trimmed().isEmpty())
|
||||
{
|
||||
return url.host().trimmed();
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
DanmakuServerDefinition makeBuiltInOfficialDandanplayServer()
|
||||
{
|
||||
DanmakuServerDefinition server;
|
||||
server.id = QString::fromLatin1(kOfficialDandanplayServerId);
|
||||
server.name = QString::fromLatin1(kOfficialDandanplayName);
|
||||
server.provider = QString::fromLatin1(kOfficialDandanplayProvider);
|
||||
server.baseUrl = QString::fromLatin1(kOfficialDandanplayBaseUrl);
|
||||
server.appId = QString::fromLatin1(kBuiltInOfficialDandanplayAppId);
|
||||
server.appSecret = QString::fromLatin1(kBuiltInOfficialDandanplayAppSecret);
|
||||
server.contentScope = QString::fromLatin1(kOfficialDandanplayContentScope);
|
||||
server.builtIn = true;
|
||||
server.enabled = true;
|
||||
return server;
|
||||
}
|
||||
|
||||
void applyBuiltInOfficialDandanplayDefaults(DanmakuServerDefinition *server)
|
||||
{
|
||||
if (!server || !isBuiltInOfficialDandanplayServer(*server))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
const DanmakuServerDefinition builtIn = makeBuiltInOfficialDandanplayServer();
|
||||
server->builtIn = true;
|
||||
server->id = builtIn.id;
|
||||
server->baseUrl = builtIn.baseUrl;
|
||||
if (server->provider.trimmed().isEmpty())
|
||||
{
|
||||
server->provider = builtIn.provider;
|
||||
}
|
||||
if (server->name.trimmed().isEmpty())
|
||||
{
|
||||
server->name = builtIn.name;
|
||||
}
|
||||
if (server->appId.trimmed().isEmpty())
|
||||
{
|
||||
server->appId = builtIn.appId;
|
||||
}
|
||||
if (server->appSecret.trimmed().isEmpty())
|
||||
{
|
||||
server->appSecret = builtIn.appSecret;
|
||||
}
|
||||
if (server->contentScope.trimmed().isEmpty())
|
||||
{
|
||||
server->contentScope = builtIn.contentScope;
|
||||
}
|
||||
}
|
||||
|
||||
DanmakuServerDefinition legacyServerDefinition(const QString &serverId)
|
||||
{
|
||||
auto *store = ConfigStore::instance();
|
||||
|
||||
DanmakuServerDefinition server = makeBuiltInOfficialDandanplayServer();
|
||||
server.provider = store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuProvider), server.provider);
|
||||
server.baseUrl =
|
||||
normalizeBaseUrl(store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuProviderBaseUrl), server.baseUrl));
|
||||
const QString legacyAppId = store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuProviderAppId));
|
||||
const QString legacyAppSecret = store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuProviderAppSecret));
|
||||
const bool hasLegacyAppId = !legacyAppId.trimmed().isEmpty();
|
||||
const bool hasLegacyAppSecret = !legacyAppSecret.trimmed().isEmpty();
|
||||
const bool officialEndpoint = isOfficialDandanplayEndpoint(server.baseUrl);
|
||||
if (!officialEndpoint && hasLegacyAppId)
|
||||
{
|
||||
server.appId = legacyAppId.trimmed();
|
||||
}
|
||||
if (!officialEndpoint && hasLegacyAppSecret)
|
||||
{
|
||||
server.appSecret = legacyAppSecret.trimmed();
|
||||
}
|
||||
server.name = defaultServerName(server.baseUrl);
|
||||
if (!officialEndpoint)
|
||||
{
|
||||
server.contentScope.clear();
|
||||
if (!hasLegacyAppId)
|
||||
{
|
||||
server.appId.clear();
|
||||
}
|
||||
if (!hasLegacyAppSecret)
|
||||
{
|
||||
server.appSecret.clear();
|
||||
}
|
||||
server.builtIn = false;
|
||||
}
|
||||
applyBuiltInOfficialDandanplayDefaults(&server);
|
||||
server.enabled = true;
|
||||
return server;
|
||||
}
|
||||
|
||||
int firstEnabledServerIndex(const QList<DanmakuServerDefinition> &servers)
|
||||
{
|
||||
for (int index = 0; index < servers.size(); ++index)
|
||||
{
|
||||
if (servers.at(index).enabled)
|
||||
{
|
||||
return index;
|
||||
}
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
QList<DanmakuServerDefinition> normalizedServers(QList<DanmakuServerDefinition> servers)
|
||||
{
|
||||
QSet<QString> usedIds;
|
||||
QList<DanmakuServerDefinition> normalized;
|
||||
normalized.reserve(servers.size());
|
||||
|
||||
for (DanmakuServerDefinition &server : servers)
|
||||
{
|
||||
server.id = server.id.trimmed();
|
||||
server.name = server.name.trimmed();
|
||||
server.provider = server.provider.trimmed();
|
||||
server.baseUrl = normalizeBaseUrl(server.baseUrl);
|
||||
server.description = server.description.trimmed();
|
||||
server.appId = server.appId.trimmed();
|
||||
server.appSecret = server.appSecret.trimmed();
|
||||
server.contentScope = server.contentScope.trimmed().toLower();
|
||||
server.builtIn = isBuiltInOfficialDandanplayServer(server);
|
||||
|
||||
if (server.provider.isEmpty())
|
||||
{
|
||||
server.provider = QString::fromLatin1(kOfficialDandanplayProvider);
|
||||
}
|
||||
if (server.id.isEmpty())
|
||||
{
|
||||
server.id = QUuid::createUuid().toString(QUuid::WithoutBraces);
|
||||
}
|
||||
applyBuiltInOfficialDandanplayDefaults(&server);
|
||||
if (server.name.isEmpty())
|
||||
{
|
||||
server.name = defaultServerName(server.baseUrl);
|
||||
}
|
||||
if (server.baseUrl.isEmpty() || usedIds.contains(server.id))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
usedIds.insert(server.id);
|
||||
normalized.append(server);
|
||||
}
|
||||
|
||||
bool hasBuiltInOfficialServer = false;
|
||||
for (const DanmakuServerDefinition &server : std::as_const(normalized))
|
||||
{
|
||||
if (isBuiltInOfficialDandanplayServer(server))
|
||||
{
|
||||
hasBuiltInOfficialServer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
if (!hasBuiltInOfficialServer)
|
||||
{
|
||||
normalized.prepend(makeBuiltInOfficialDandanplayServer());
|
||||
}
|
||||
return normalized;
|
||||
}
|
||||
|
||||
QList<DanmakuServerDefinition> parseServers(const QString &json)
|
||||
{
|
||||
QList<DanmakuServerDefinition> servers;
|
||||
const QJsonDocument document = QJsonDocument::fromJson(json.toUtf8());
|
||||
if (!document.isArray())
|
||||
{
|
||||
return servers;
|
||||
}
|
||||
|
||||
const QJsonArray array = document.array();
|
||||
servers.reserve(array.size());
|
||||
for (const QJsonValue &value : array)
|
||||
{
|
||||
const QJsonObject object = value.toObject();
|
||||
DanmakuServerDefinition server;
|
||||
server.id = object.value(QStringLiteral("id")).toString().trimmed();
|
||||
server.name = object.value(QStringLiteral("name")).toString().trimmed();
|
||||
server.provider = object.value(QStringLiteral("provider")).toString().trimmed();
|
||||
server.baseUrl = object.value(QStringLiteral("baseUrl")).toString().trimmed();
|
||||
server.description = object.value(QStringLiteral("description")).toString().trimmed();
|
||||
server.appId = object.value(QStringLiteral("appId")).toString().trimmed();
|
||||
server.appSecret = object.value(QStringLiteral("appSecret")).toString().trimmed();
|
||||
server.contentScope = object.value(QStringLiteral("contentScope")).toString().trimmed();
|
||||
server.builtIn = object.value(QStringLiteral("builtIn")).toBool(false);
|
||||
server.enabled = object.value(QStringLiteral("enabled")).toBool(true);
|
||||
servers.append(server);
|
||||
}
|
||||
return normalizedServers(servers);
|
||||
}
|
||||
|
||||
QJsonArray toJsonArray(const QList<DanmakuServerDefinition> &servers)
|
||||
{
|
||||
QJsonArray array;
|
||||
for (const DanmakuServerDefinition &server : servers)
|
||||
{
|
||||
DanmakuServerDefinition savedServer = server;
|
||||
if (isBuiltInOfficialDandanplayServer(savedServer))
|
||||
{
|
||||
savedServer.builtIn = true;
|
||||
savedServer.description.clear();
|
||||
savedServer.appId.clear();
|
||||
savedServer.appSecret.clear();
|
||||
}
|
||||
|
||||
QJsonObject object;
|
||||
object.insert(QStringLiteral("id"), savedServer.id);
|
||||
object.insert(QStringLiteral("name"), savedServer.name);
|
||||
object.insert(QStringLiteral("provider"), savedServer.provider);
|
||||
object.insert(QStringLiteral("baseUrl"), savedServer.baseUrl);
|
||||
object.insert(QStringLiteral("description"), savedServer.description);
|
||||
object.insert(QStringLiteral("appId"), savedServer.appId);
|
||||
object.insert(QStringLiteral("appSecret"), savedServer.appSecret);
|
||||
object.insert(QStringLiteral("contentScope"), savedServer.contentScope);
|
||||
object.insert(QStringLiteral("builtIn"), savedServer.builtIn);
|
||||
object.insert(QStringLiteral("enabled"), savedServer.enabled);
|
||||
array.append(object);
|
||||
}
|
||||
return array;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
DanmakuServerDefinition DanmakuSettings::builtInOfficialDandanplayServer()
|
||||
{
|
||||
return makeBuiltInOfficialDandanplayServer();
|
||||
}
|
||||
|
||||
QList<DanmakuServerDefinition> DanmakuSettings::loadServers(QString serverId)
|
||||
{
|
||||
serverId = serverId.trimmed();
|
||||
if (serverId.isEmpty())
|
||||
{
|
||||
return {builtInOfficialDandanplayServer()};
|
||||
}
|
||||
|
||||
const QString storedJson = ConfigStore::instance()->get<QString>(settingKey(serverId, ConfigKeys::DanmakuServers));
|
||||
if (!storedJson.trimmed().isEmpty())
|
||||
{
|
||||
QList<DanmakuServerDefinition> servers = parseServers(storedJson);
|
||||
if (!servers.isEmpty())
|
||||
{
|
||||
auto *store = ConfigStore::instance();
|
||||
const QString legacySelectedId =
|
||||
store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuSelectedServer));
|
||||
const QString legacyAppId = store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuProviderAppId));
|
||||
const QString legacyAppSecret =
|
||||
store->get<QString>(settingKey(serverId, ConfigKeys::DanmakuProviderAppSecret));
|
||||
|
||||
if (!legacyAppId.trimmed().isEmpty() || !legacyAppSecret.trimmed().isEmpty())
|
||||
{
|
||||
int selectedIndex = 0;
|
||||
for (int index = 0; index < servers.size(); ++index)
|
||||
{
|
||||
if (servers.at(index).id == legacySelectedId)
|
||||
{
|
||||
selectedIndex = index;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
DanmakuServerDefinition &selectedServer = servers[selectedIndex];
|
||||
if (!selectedServer.builtIn && selectedServer.appId.trimmed().isEmpty())
|
||||
{
|
||||
selectedServer.appId = legacyAppId.trimmed();
|
||||
}
|
||||
if (!selectedServer.builtIn && selectedServer.appSecret.trimmed().isEmpty())
|
||||
{
|
||||
selectedServer.appSecret = legacyAppSecret.trimmed();
|
||||
}
|
||||
}
|
||||
return servers;
|
||||
}
|
||||
}
|
||||
|
||||
return normalizedServers({legacyServerDefinition(serverId)});
|
||||
}
|
||||
|
||||
DanmakuServerDefinition DanmakuSettings::selectedServer(QString serverId)
|
||||
{
|
||||
const QList<DanmakuServerDefinition> servers = loadServers(serverId);
|
||||
const QString selectedId = selectedServerId(serverId);
|
||||
for (const DanmakuServerDefinition &server : servers)
|
||||
{
|
||||
if (server.id == selectedId)
|
||||
{
|
||||
return server;
|
||||
}
|
||||
}
|
||||
|
||||
const int enabledIndex = firstEnabledServerIndex(servers);
|
||||
if (enabledIndex >= 0)
|
||||
{
|
||||
return servers.at(enabledIndex);
|
||||
}
|
||||
return servers.isEmpty() ? builtInOfficialDandanplayServer() : servers.first();
|
||||
}
|
||||
|
||||
QString DanmakuSettings::selectedServerId(QString serverId)
|
||||
{
|
||||
const QList<DanmakuServerDefinition> servers = loadServers(serverId);
|
||||
if (servers.isEmpty())
|
||||
{
|
||||
return QString();
|
||||
}
|
||||
|
||||
serverId = serverId.trimmed();
|
||||
if (serverId.isEmpty())
|
||||
{
|
||||
const int enabledIndex = firstEnabledServerIndex(servers);
|
||||
return enabledIndex >= 0 ? servers.at(enabledIndex).id : QString();
|
||||
}
|
||||
|
||||
const QString selectedId =
|
||||
ConfigStore::instance()->get<QString>(settingKey(serverId, ConfigKeys::DanmakuSelectedServer));
|
||||
for (const DanmakuServerDefinition &server : servers)
|
||||
{
|
||||
if (server.id == selectedId && server.enabled)
|
||||
{
|
||||
return selectedId;
|
||||
}
|
||||
}
|
||||
|
||||
const int enabledIndex = firstEnabledServerIndex(servers);
|
||||
if (enabledIndex >= 0)
|
||||
{
|
||||
return servers.at(enabledIndex).id;
|
||||
}
|
||||
return QString();
|
||||
}
|
||||
|
||||
void DanmakuSettings::saveServers(QString serverId, QList<DanmakuServerDefinition> servers, QString selectedServerId)
|
||||
{
|
||||
serverId = serverId.trimmed();
|
||||
if (serverId.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
servers = normalizedServers(std::move(servers));
|
||||
if (selectedServerId.trimmed().isEmpty())
|
||||
{
|
||||
const int enabledIndex = firstEnabledServerIndex(servers);
|
||||
selectedServerId = enabledIndex >= 0 ? servers.at(enabledIndex).id : QString();
|
||||
}
|
||||
|
||||
bool hasSelectedServer = false;
|
||||
if (!selectedServerId.trimmed().isEmpty())
|
||||
{
|
||||
for (const DanmakuServerDefinition &server : servers)
|
||||
{
|
||||
if (server.id == selectedServerId && server.enabled)
|
||||
{
|
||||
hasSelectedServer = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (!hasSelectedServer)
|
||||
{
|
||||
const int enabledIndex = firstEnabledServerIndex(servers);
|
||||
selectedServerId = enabledIndex >= 0 ? servers.at(enabledIndex).id : QString();
|
||||
}
|
||||
|
||||
auto *store = ConfigStore::instance();
|
||||
store->set(settingKey(serverId, ConfigKeys::DanmakuServers),
|
||||
QString::fromUtf8(QJsonDocument(toJsonArray(servers)).toJson(QJsonDocument::Compact)));
|
||||
store->set(settingKey(serverId, ConfigKeys::DanmakuSelectedServer), selectedServerId);
|
||||
|
||||
DanmakuServerDefinition selected = servers.isEmpty() ? makeBuiltInOfficialDandanplayServer() : servers.first();
|
||||
if (!selectedServerId.trimmed().isEmpty())
|
||||
{
|
||||
for (const DanmakuServerDefinition &server : std::as_const(servers))
|
||||
{
|
||||
if (server.id == selectedServerId)
|
||||
{
|
||||
selected = server;
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
if (isBuiltInOfficialDandanplayServer(selected))
|
||||
{
|
||||
selected.appId.clear();
|
||||
selected.appSecret.clear();
|
||||
}
|
||||
store->set(settingKey(serverId, ConfigKeys::DanmakuProvider), selected.provider);
|
||||
store->set(settingKey(serverId, ConfigKeys::DanmakuProviderBaseUrl), selected.baseUrl);
|
||||
store->set(settingKey(serverId, ConfigKeys::DanmakuProviderAppId), selected.appId);
|
||||
store->set(settingKey(serverId, ConfigKeys::DanmakuProviderAppSecret), selected.appSecret);
|
||||
}
|
||||
21
src/qEmbyCore/services/danmaku/danmakusettings.h
Normal file
21
src/qEmbyCore/services/danmaku/danmakusettings.h
Normal file
@@ -0,0 +1,21 @@
|
||||
#ifndef DANMAKUSETTINGS_H
|
||||
#define DANMAKUSETTINGS_H
|
||||
|
||||
#include "../../models/danmaku/danmakumodels.h"
|
||||
|
||||
#include <QList>
|
||||
#include <QString>
|
||||
|
||||
class QEMBYCORE_EXPORT DanmakuSettings final
|
||||
{
|
||||
public:
|
||||
static DanmakuServerDefinition builtInOfficialDandanplayServer();
|
||||
static QList<DanmakuServerDefinition> loadServers(QString serverId);
|
||||
static DanmakuServerDefinition selectedServer(QString serverId);
|
||||
static QString selectedServerId(QString serverId);
|
||||
static void saveServers(QString serverId,
|
||||
QList<DanmakuServerDefinition> servers,
|
||||
QString selectedServerId);
|
||||
};
|
||||
|
||||
#endif
|
||||
99
src/qEmbyCore/services/introdb/introdbservice.cpp
Normal file
99
src/qEmbyCore/services/introdb/introdbservice.cpp
Normal file
@@ -0,0 +1,99 @@
|
||||
#include "introdbservice.h"
|
||||
#include "api/networkmanager.h"
|
||||
|
||||
#include <QDebug>
|
||||
#include <QJsonObject>
|
||||
#include <exception>
|
||||
|
||||
IntroDBService::IntroDBService(NetworkManager *nm, QObject *parent)
|
||||
: QObject(parent), m_nm(nm) {}
|
||||
|
||||
QString IntroDBService::cacheKey(const QString &imdbId, int season, int episode) {
|
||||
return QStringLiteral("%1_s%2_e%3").arg(imdbId).arg(season).arg(episode);
|
||||
}
|
||||
|
||||
static IntroDBService::SegmentInfo parseSegment(const QJsonObject &seg) {
|
||||
IntroDBService::SegmentInfo s;
|
||||
if (seg.contains("start_sec")) {
|
||||
s.startSec = seg["start_sec"].toDouble();
|
||||
} else if (seg.contains("start_ms")) {
|
||||
s.startSec = seg["start_ms"].toDouble() / 1000.0;
|
||||
}
|
||||
if (seg.contains("end_sec")) {
|
||||
s.endSec = seg["end_sec"].toDouble();
|
||||
} else if (seg.contains("end_ms")) {
|
||||
s.endSec = seg["end_ms"].toDouble() / 1000.0;
|
||||
}
|
||||
return s;
|
||||
}
|
||||
|
||||
QCoro::Task<IntroDBService::EpisodeSegments>
|
||||
IntroDBService::fetchSegments(QString imdbId, int season, int episode) {
|
||||
imdbId = imdbId.trimmed();
|
||||
if (imdbId.isEmpty() || season <= 0 || episode <= 0 || !m_nm) {
|
||||
qWarning("IntroDB: invalid request imdb=%s S%02dE%02d networkReady=%d",
|
||||
qPrintable(imdbId), season, episode, !m_nm.isNull());
|
||||
co_return {};
|
||||
}
|
||||
|
||||
const QString key = cacheKey(imdbId, season, episode);
|
||||
if (m_cache.contains(key)) {
|
||||
const EpisodeSegments cached = m_cache.value(key);
|
||||
qInfo("IntroDB: cache hit imdb=%s S%02dE%02d fetched=%d notFound=%d",
|
||||
qPrintable(imdbId), season, episode, cached.fetched,
|
||||
cached.notFound);
|
||||
co_return m_cache[key];
|
||||
}
|
||||
|
||||
QString url = QStringLiteral(
|
||||
"https://api.introdb.app/segments"
|
||||
"?imdb_id=%1&season=%2&episode=%3")
|
||||
.arg(imdbId)
|
||||
.arg(season)
|
||||
.arg(episode);
|
||||
|
||||
qInfo("IntroDB: fetching %s S%02dE%02d",
|
||||
qPrintable(imdbId), season, episode);
|
||||
|
||||
QPointer<IntroDBService> safeThis(this);
|
||||
QPointer<NetworkManager> network = m_nm;
|
||||
EpisodeSegments segments;
|
||||
bool cacheable = false;
|
||||
try {
|
||||
if (!network) {
|
||||
co_return segments;
|
||||
}
|
||||
QJsonObject root = co_await network->get(url, {});
|
||||
if (!safeThis) {
|
||||
co_return segments;
|
||||
}
|
||||
if (root.contains("error")) {
|
||||
segments.notFound = true;
|
||||
cacheable = true;
|
||||
} else {
|
||||
if (root.contains("intro") && !root["intro"].isNull()) {
|
||||
segments.intro = parseSegment(root["intro"].toObject());
|
||||
}
|
||||
if (root.contains("outro") && !root["outro"].isNull()) {
|
||||
segments.outro = parseSegment(root["outro"].toObject());
|
||||
}
|
||||
segments.fetched = true;
|
||||
cacheable = true;
|
||||
}
|
||||
} catch (const std::exception &e) {
|
||||
const QString error = QString::fromUtf8(e.what());
|
||||
const bool isNotFound = error.contains(QStringLiteral("HTTP 404"));
|
||||
qWarning("IntroDB: request failed: %s", e.what());
|
||||
segments.notFound = isNotFound;
|
||||
cacheable = isNotFound;
|
||||
}
|
||||
|
||||
if (cacheable && safeThis) {
|
||||
safeThis->m_cache[key] = segments;
|
||||
}
|
||||
co_return segments;
|
||||
}
|
||||
|
||||
void IntroDBService::clearCache() {
|
||||
m_cache.clear();
|
||||
}
|
||||
42
src/qEmbyCore/services/introdb/introdbservice.h
Normal file
42
src/qEmbyCore/services/introdb/introdbservice.h
Normal file
@@ -0,0 +1,42 @@
|
||||
#ifndef INTRODBSERVICE_H
|
||||
#define INTRODBSERVICE_H
|
||||
|
||||
#include "qEmbyCore_global.h"
|
||||
#include "api/networkmanager.h"
|
||||
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
#include <qcorotask.h>
|
||||
|
||||
class QEMBYCORE_EXPORT IntroDBService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
struct SegmentInfo {
|
||||
double startSec = -1;
|
||||
double endSec = -1;
|
||||
};
|
||||
|
||||
struct EpisodeSegments {
|
||||
SegmentInfo intro;
|
||||
SegmentInfo outro;
|
||||
bool fetched = false;
|
||||
bool notFound = false;
|
||||
};
|
||||
|
||||
explicit IntroDBService(NetworkManager *nm, QObject *parent = nullptr);
|
||||
|
||||
QCoro::Task<EpisodeSegments> fetchSegments(QString imdbId,
|
||||
int season, int episode);
|
||||
void clearCache();
|
||||
|
||||
private:
|
||||
QPointer<NetworkManager> m_nm;
|
||||
QHash<QString, EpisodeSegments> m_cache;
|
||||
|
||||
static QString cacheKey(const QString &imdbId, int season, int episode);
|
||||
};
|
||||
|
||||
#endif
|
||||
232
src/qEmbyCore/services/manager/servermanager.cpp
Normal file
232
src/qEmbyCore/services/manager/servermanager.cpp
Normal file
@@ -0,0 +1,232 @@
|
||||
#include "servermanager.h"
|
||||
#include "../../api/embywebsocket.h"
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QFile>
|
||||
#include <QStandardPaths>
|
||||
#include <QDir>
|
||||
|
||||
ServerManager::ServerManager(NetworkManager* nm, QObject* parent)
|
||||
: QObject(parent), m_network(nm) {
|
||||
loadSettings();
|
||||
}
|
||||
|
||||
void ServerManager::addServer(const ServerProfile& profile) {
|
||||
|
||||
for (int i = 0; i < m_servers.size(); ++i) {
|
||||
if (m_servers[i].url == profile.url && m_servers[i].userId == profile.userId) {
|
||||
m_servers[i] = profile;
|
||||
saveSettings();
|
||||
Q_EMIT serversChanged();
|
||||
return;
|
||||
}
|
||||
}
|
||||
m_servers.append(profile);
|
||||
saveSettings();
|
||||
Q_EMIT serversChanged();
|
||||
}
|
||||
|
||||
void ServerManager::removeServer(const QString& id) {
|
||||
for (int i = 0; i < m_servers.size(); ++i) {
|
||||
if (m_servers[i].id == id) {
|
||||
|
||||
m_servers.removeAt(i);
|
||||
|
||||
|
||||
saveSettings();
|
||||
Q_EMIT serversChanged();
|
||||
|
||||
|
||||
if (m_activeProfile.id == id) {
|
||||
|
||||
disconnectWebSocket();
|
||||
|
||||
m_activeProfile = ServerProfile();
|
||||
m_activeClient.reset();
|
||||
|
||||
|
||||
if (!m_servers.isEmpty()) {
|
||||
setActiveServer(m_servers.first().id);
|
||||
} else {
|
||||
|
||||
Q_EMIT activeServerChanged(m_activeProfile);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServerManager::setActiveServer(const QString& id) {
|
||||
for (const auto& profile : m_servers) {
|
||||
if (profile.id == id) {
|
||||
m_activeProfile = profile;
|
||||
|
||||
m_activeClient = QSharedPointer<ApiClient>::create(profile, m_network);
|
||||
Q_EMIT activeServerChanged(m_activeProfile);
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
void ServerManager::updateServerProxy(const QString& id,
|
||||
const ProxyConfig& proxy,
|
||||
bool useGlobalProxy) {
|
||||
bool found = false;
|
||||
bool isActive = false;
|
||||
for (int i = 0; i < m_servers.size(); ++i) {
|
||||
if (m_servers[i].id != id) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (m_servers[i].proxy == proxy &&
|
||||
m_servers[i].useGlobalProxy == useGlobalProxy) {
|
||||
qDebug() << "[ServerManager] updateServerProxy skipped: no change"
|
||||
<< "| id:" << id;
|
||||
return;
|
||||
}
|
||||
m_servers[i].proxy = proxy;
|
||||
m_servers[i].useGlobalProxy = useGlobalProxy;
|
||||
|
||||
|
||||
|
||||
if (m_activeProfile.id == id) {
|
||||
m_activeProfile = m_servers[i];
|
||||
isActive = true;
|
||||
}
|
||||
found = true;
|
||||
break;
|
||||
}
|
||||
|
||||
if (!found) {
|
||||
qWarning() << "[ServerManager] updateServerProxy: server not found"
|
||||
<< "| id:" << id;
|
||||
return;
|
||||
}
|
||||
|
||||
saveSettings();
|
||||
qInfo() << "[ServerManager] proxy updated"
|
||||
<< "| id:" << id
|
||||
<< "| useGlobalProxy:" << useGlobalProxy
|
||||
<< "| proxy:" << proxy.summary();
|
||||
|
||||
Q_EMIT serversChanged();
|
||||
Q_EMIT serverProxyChanged(id);
|
||||
if (isActive) {
|
||||
Q_EMIT activeServerChanged(m_activeProfile);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void ServerManager::connectWebSocket()
|
||||
{
|
||||
|
||||
if (m_activeProfile.url.isEmpty() || m_activeProfile.accessToken.isEmpty()) return;
|
||||
|
||||
|
||||
if (m_activeWebSocket && m_activeWebSocket->isConnected()) return;
|
||||
|
||||
|
||||
if (m_activeWebSocket) {
|
||||
m_activeWebSocket->disconnectFromServer();
|
||||
m_activeWebSocket->deleteLater();
|
||||
m_activeWebSocket = nullptr;
|
||||
}
|
||||
|
||||
|
||||
m_activeWebSocket = new EmbyWebSocket(m_activeProfile, this);
|
||||
m_activeWebSocket->connectToServer();
|
||||
}
|
||||
|
||||
void ServerManager::disconnectWebSocket()
|
||||
{
|
||||
if (m_activeWebSocket) {
|
||||
m_activeWebSocket->disconnectFromServer();
|
||||
m_activeWebSocket->deleteLater();
|
||||
m_activeWebSocket = nullptr;
|
||||
}
|
||||
}
|
||||
|
||||
EmbyWebSocket* ServerManager::activeWebSocket() const
|
||||
{
|
||||
return m_activeWebSocket;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void ServerManager::saveSettings() {
|
||||
|
||||
QJsonArray array;
|
||||
for (const auto& p : m_servers) {
|
||||
QJsonObject obj;
|
||||
obj["id"] = p.id;
|
||||
obj["name"] = p.name;
|
||||
obj["url"] = p.url;
|
||||
obj["type"] = (p.type == ServerProfile::Emby ? "Emby" : "Jellyfin");
|
||||
obj["ignoreSslVerification"] = p.ignoreSslVerification;
|
||||
obj["userId"] = p.userId;
|
||||
obj["userName"] = p.userName;
|
||||
obj["accessToken"] = p.accessToken;
|
||||
obj["deviceId"] = p.deviceId;
|
||||
obj["isAdmin"] = p.isAdmin;
|
||||
obj["canDownloadMedia"] = p.canDownloadMedia;
|
||||
obj["iconBase64"] = p.iconBase64;
|
||||
obj["useGlobalProxy"] = p.useGlobalProxy;
|
||||
obj["proxy"] = p.proxy.toJson();
|
||||
array.append(obj);
|
||||
}
|
||||
|
||||
QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
QDir().mkpath(path);
|
||||
QFile file(path + "/servers.json");
|
||||
if (file.open(QIODevice::WriteOnly)) {
|
||||
file.write(QJsonDocument(array).toJson());
|
||||
}
|
||||
}
|
||||
|
||||
void ServerManager::loadSettings() {
|
||||
QString path = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
QFile file(path + "/servers.json");
|
||||
if (!file.open(QIODevice::ReadOnly)) return;
|
||||
|
||||
QJsonArray array = QJsonDocument::fromJson(file.readAll()).array();
|
||||
m_servers.clear();
|
||||
for (auto val : array) {
|
||||
QJsonObject obj = val.toObject();
|
||||
ServerProfile p;
|
||||
p.id = obj["id"].toString();
|
||||
p.name = obj["name"].toString();
|
||||
p.url = obj["url"].toString();
|
||||
p.type = (obj["type"].toString() == "Emby" ? ServerProfile::Emby : ServerProfile::Jellyfin);
|
||||
p.ignoreSslVerification = obj["ignoreSslVerification"].toBool(false);
|
||||
p.userId = obj["userId"].toString();
|
||||
p.userName = obj["userName"].toString();
|
||||
p.accessToken = obj["accessToken"].toString();
|
||||
p.deviceId = obj["deviceId"].toString();
|
||||
p.isAdmin = obj["isAdmin"].toBool();
|
||||
p.canDownloadMedia = obj["canDownloadMedia"].toBool(false);
|
||||
p.iconBase64 = obj["iconBase64"].toString();
|
||||
p.useGlobalProxy = obj["useGlobalProxy"].toBool(false);
|
||||
p.proxy = ProxyConfig::fromJson(obj["proxy"].toObject());
|
||||
m_servers.append(p);
|
||||
}
|
||||
|
||||
|
||||
if (!m_servers.isEmpty()) {
|
||||
setActiveServer(m_servers.first().id);
|
||||
}
|
||||
}
|
||||
|
||||
void ServerManager::clearActiveSession()
|
||||
{
|
||||
disconnectWebSocket();
|
||||
m_activeProfile = ServerProfile();
|
||||
m_activeClient.reset();
|
||||
Q_EMIT activeServerChanged(m_activeProfile);
|
||||
}
|
||||
|
||||
61
src/qEmbyCore/services/manager/servermanager.h
Normal file
61
src/qEmbyCore/services/manager/servermanager.h
Normal file
@@ -0,0 +1,61 @@
|
||||
#ifndef SERVERMANAGER_H
|
||||
#define SERVERMANAGER_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include "../../models/profile/serverprofile.h"
|
||||
#include "../../api/apiclient.h"
|
||||
#include "../../api/networkmanager.h"
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QSharedPointer>
|
||||
|
||||
class EmbyWebSocket;
|
||||
|
||||
class QEMBYCORE_EXPORT ServerManager : public QObject {
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit ServerManager(NetworkManager* nm, QObject* parent = nullptr);
|
||||
|
||||
|
||||
void addServer(const ServerProfile& profile);
|
||||
void removeServer(const QString& id);
|
||||
void setActiveServer(const QString& id);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
void updateServerProxy(const QString& id, const ProxyConfig& proxy,
|
||||
bool useGlobalProxy);
|
||||
|
||||
|
||||
QList<ServerProfile> servers() const { return m_servers; }
|
||||
ServerProfile activeProfile() const { return m_activeProfile; }
|
||||
ApiClient* activeClient() const { return m_activeClient.data(); }
|
||||
|
||||
|
||||
void connectWebSocket();
|
||||
void disconnectWebSocket();
|
||||
EmbyWebSocket* activeWebSocket() const;
|
||||
|
||||
|
||||
void loadSettings();
|
||||
void saveSettings();
|
||||
|
||||
void clearActiveSession();
|
||||
Q_SIGNALS:
|
||||
void serversChanged();
|
||||
void activeServerChanged(const ServerProfile& profile);
|
||||
|
||||
|
||||
void serverProxyChanged(const QString& serverId);
|
||||
|
||||
private:
|
||||
NetworkManager* m_network;
|
||||
QList<ServerProfile> m_servers;
|
||||
ServerProfile m_activeProfile;
|
||||
QSharedPointer<ApiClient> m_activeClient;
|
||||
EmbyWebSocket* m_activeWebSocket = nullptr;
|
||||
};
|
||||
|
||||
#endif
|
||||
2128
src/qEmbyCore/services/media/mediaservice.cpp
Normal file
2128
src/qEmbyCore/services/media/mediaservice.cpp
Normal file
File diff suppressed because it is too large
Load Diff
205
src/qEmbyCore/services/media/mediaservice.h
Normal file
205
src/qEmbyCore/services/media/mediaservice.h
Normal file
@@ -0,0 +1,205 @@
|
||||
#ifndef MEDIASERVICE_H
|
||||
#define MEDIASERVICE_H
|
||||
|
||||
#include "../../qEmbyCore_global.h"
|
||||
#include "../../models/media/mediaitem.h"
|
||||
#include "../../models/media/playbackinfo.h"
|
||||
#include <QHash>
|
||||
#include <QObject>
|
||||
#include <QList>
|
||||
#include <QPixmap>
|
||||
#include <QDateTime>
|
||||
#include <qcorotask.h>
|
||||
|
||||
|
||||
struct RecommendCache {
|
||||
QList<MediaItem> items;
|
||||
QDateTime fetchTime;
|
||||
QString userId;
|
||||
QString serverId;
|
||||
int requestLimit = 0;
|
||||
|
||||
bool isValid(const QString& currentServerId, const QString& currentUserId,
|
||||
int hours = 24, int requiredLimit = -1) const {
|
||||
bool limitSatisfied = requiredLimit < 0;
|
||||
if (!limitSatisfied) {
|
||||
limitSatisfied = requiredLimit == 0
|
||||
? requestLimit == 0
|
||||
: (requestLimit == 0 ||
|
||||
requestLimit >= requiredLimit);
|
||||
}
|
||||
|
||||
return !items.isEmpty()
|
||||
&& serverId == currentServerId
|
||||
&& userId == currentUserId
|
||||
&& limitSatisfied
|
||||
&& fetchTime.secsTo(QDateTime::currentDateTime()) < hours * 3600;
|
||||
}
|
||||
|
||||
void clear();
|
||||
void saveToDisk() const;
|
||||
bool loadFromDisk(const QString& currentServerId, const QString& currentUserId, int cacheDurationHours = 24);
|
||||
|
||||
static QString cacheFilePath(const QString& serverId);
|
||||
};
|
||||
|
||||
struct UserViewsCache {
|
||||
QList<MediaItem> views;
|
||||
QString userId;
|
||||
QString serverId;
|
||||
bool includesHidden = false;
|
||||
|
||||
bool isValid(const QString& currentServerId, const QString& currentUserId,
|
||||
bool requireHidden = false) const {
|
||||
return serverId == currentServerId
|
||||
&& userId == currentUserId
|
||||
&& (!requireHidden || includesHidden);
|
||||
}
|
||||
|
||||
void clear() {
|
||||
views.clear();
|
||||
userId.clear();
|
||||
serverId.clear();
|
||||
includesHidden = false;
|
||||
}
|
||||
};
|
||||
|
||||
struct QEMBYCORE_EXPORT DownloadedImageData {
|
||||
QByteArray data;
|
||||
QString mimeType;
|
||||
|
||||
bool isValid() const {
|
||||
return !data.isEmpty();
|
||||
}
|
||||
};
|
||||
|
||||
struct QEMBYCORE_EXPORT MediaQueryPage {
|
||||
QList<MediaItem> items;
|
||||
int totalRecordCount = 0;
|
||||
int startIndex = 0;
|
||||
int limit = 0;
|
||||
bool hasTotalRecordCount = false;
|
||||
|
||||
bool hasMore() const {
|
||||
return hasTotalRecordCount &&
|
||||
startIndex + items.size() < totalRecordCount;
|
||||
}
|
||||
};
|
||||
|
||||
class ServerManager;
|
||||
class QNetworkAccessManager;
|
||||
|
||||
class QEMBYCORE_EXPORT MediaService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit MediaService(ServerManager* serverManager, QObject *parent = nullptr);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getUserViews(bool includeHidden = false);
|
||||
void clearUserViewsCache();
|
||||
|
||||
QCoro::Task<MediaQueryPage> getLibraryItemsPage(const QString& parentId, const QString& sortBy = "IsFolder,SortName", const QString& sortOrder = "Ascending", const QString& filters = "", const QString& includeItemTypes = "", int startIndex = 0, int limit = 50, bool recursive = false, bool includeChildCount = false);
|
||||
QCoro::Task<QList<MediaItem>> getLibraryItems(const QString& parentId, const QString& sortBy = "IsFolder,SortName", const QString& sortOrder = "Ascending", const QString& filters = "", const QString& includeItemTypes = "", int startIndex = 0, int limit = 50, bool recursive = false, bool includeChildCount = false);
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getResumeItems(int limit = 0, const QString& sortBy = "", const QString& sortOrder = "");
|
||||
QCoro::Task<QList<MediaItem>> getLatestItems(int limit = 1000, const QString& sortBy = "DateCreated", const QString& sortOrder = "Descending");
|
||||
QCoro::Task<QList<MediaItem>> getPlayedItems(int limit = 0, const QString& sortBy = "DatePlayed", const QString& sortOrder = "Descending");
|
||||
QCoro::Task<QList<MediaItem>> getRecommendedMovies(int limit = 1000, const QString& sortBy = "Random", const QString& sortOrder = "Ascending");
|
||||
QCoro::Task<MediaQueryPage> getResumeItemsPage(const QString& sortBy = "", const QString& sortOrder = "", int startIndex = 0, int limit = 50);
|
||||
QCoro::Task<MediaQueryPage> getLatestItemsPage(const QString& sortBy = "DateCreated", const QString& sortOrder = "Descending", int startIndex = 0, int limit = 50);
|
||||
QCoro::Task<MediaQueryPage> getPlayedItemsPage(const QString& sortBy = "DatePlayed", const QString& sortOrder = "Descending", int startIndex = 0, int limit = 50);
|
||||
QCoro::Task<MediaQueryPage> getRecommendedMoviesPage(const QString& sortBy = "Random", const QString& sortOrder = "Ascending", int startIndex = 0, int limit = 50);
|
||||
|
||||
QCoro::Task<QList<MediaItem>> searchMedia(const QString& searchTerm, const QString& includeItemTypes = "Movie,Series,BoxSet,Person", const QString& sortBy = "", const QString& sortOrder = "Ascending", int limit = 50);
|
||||
|
||||
QCoro::Task<MediaItem> getItemDetail(const QString& itemId);
|
||||
|
||||
QCoro::Task<PlaybackInfo> getPlaybackInfo(const QString& itemId);
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getSeasons(const QString& seriesId);
|
||||
QCoro::Task<QList<MediaItem>> getEpisodes(const QString& seriesId, const QString& seasonId, const QString& sortBy = "ParentIndexNumber,IndexNumber", const QString& sortOrder = "Ascending");
|
||||
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getNextUp(const QString& seriesId = "");
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getAdditionalParts(const QString& itemId);
|
||||
|
||||
|
||||
QCoro::Task<bool> toggleFavorite(const QString& itemId, bool isFavorite);
|
||||
|
||||
|
||||
QCoro::Task<void> markAsPlayed(const QString& itemId);
|
||||
QCoro::Task<void> markAsUnplayed(const QString& itemId);
|
||||
QCoro::Task<void> removeFromResume(const QString& itemId);
|
||||
|
||||
|
||||
void clearRecommendCache();
|
||||
void removeRecommendCacheItem(const QString& itemId);
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getSimilarItems(const QString& itemId, int limit = 15);
|
||||
QCoro::Task<QList<MediaItem>> getItemCollections(const QString& itemId);
|
||||
QCoro::Task<QList<MediaItem>> getCollectionItems(const QString& collectionId);
|
||||
QCoro::Task<MediaQueryPage> getItemsByPersonPage(const QString& personId, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending", int startIndex = 0, int limit = 0);
|
||||
QCoro::Task<QList<MediaItem>> getItemsByPerson(const QString& personId, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
QCoro::Task<MediaQueryPage> getItemsByFilterPage(const QString& genreFilter = "", const QString& tagFilter = "", const QString& studioFilter = "", const QString& sortBy = "SortName", const QString& sortOrder = "Ascending", int startIndex = 0, int limit = 0);
|
||||
QCoro::Task<QList<MediaItem>> getItemsByFilter(const QString& genreFilter = "", const QString& tagFilter = "", const QString& studioFilter = "", const QString& sortBy = "SortName", const QString& sortOrder = "Ascending", int limit = 0);
|
||||
|
||||
QCoro::Task<QPixmap> fetchImage(const QString& itemId, const QString& imageType,
|
||||
const QString& imageTag, int maxWidth,
|
||||
int imageIndex = -1);
|
||||
QCoro::Task<QPixmap> fetchImageByUrl(QString imageUrl);
|
||||
QCoro::Task<DownloadedImageData> downloadImageByUrl(QString imageUrl);
|
||||
void invalidateImageCache(QString itemId, QString imageType,
|
||||
int imageIndex = -1);
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getFavoriteMovies(int limit = 50, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
QCoro::Task<QList<MediaItem>> getFavoriteSeries(int limit = 50, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
QCoro::Task<QList<MediaItem>> getFavoriteCollections(int limit = 50, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getFavoritePlaylists(int limit = 50, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
QCoro::Task<QList<MediaItem>> getFavoriteFolders(int limit = 50, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
|
||||
QCoro::Task<QList<MediaItem>> getFavoritePeople(int limit = 50, const QString& sortBy = "SortName", const QString& sortOrder = "Ascending");
|
||||
|
||||
QString getStreamUrl(const QString& itemId, const QString& mediaSourceId) const;
|
||||
QString getStreamUrl(const QString& itemId, const MediaSourceInfo& sourceInfo) const;
|
||||
|
||||
QCoro::Task<QString> reportPlaybackStart(QString itemId, QString mediaSourceId, long long positionTicks);
|
||||
QCoro::Task<void> reportPlaybackProgress(QString itemId, QString mediaSourceId, long long positionTicks, bool isPaused, QString playSessionId);
|
||||
QCoro::Task<void> reportPlaybackStopped(QString itemId, QString mediaSourceId, long long positionTicks, QString playSessionId);
|
||||
|
||||
Q_SIGNALS:
|
||||
|
||||
void recommendCacheCleared();
|
||||
|
||||
private:
|
||||
ServerManager* m_serverManager;
|
||||
QNetworkAccessManager* m_imageManager;
|
||||
RecommendCache m_recommendCache;
|
||||
UserViewsCache m_userViewsCache;
|
||||
QHash<QString, quint64> m_invalidatedImageRequestVersions;
|
||||
quint64 m_nextImageRequestVersion = 0;
|
||||
|
||||
|
||||
void ensureValidProfile() const;
|
||||
QCoro::Task<MediaQueryPage> fetchItemPage(QString basePath,
|
||||
int startIndex, int limit,
|
||||
QString context);
|
||||
QCoro::Task<QList<MediaItem>> fetchPagedItemList(QString basePath,
|
||||
int requestedLimit,
|
||||
QString context,
|
||||
bool deduplicateItems = false);
|
||||
void updateUserViewsCache(QList<MediaItem> views, QString serverId,
|
||||
QString userId, bool includesHidden);
|
||||
void updateUserViewsCache(MediaItem view, QString serverId,
|
||||
QString userId);
|
||||
};
|
||||
|
||||
#endif
|
||||
697
src/qEmbyCore/services/sync/webdavsyncservice.cpp
Normal file
697
src/qEmbyCore/services/sync/webdavsyncservice.cpp
Normal file
@@ -0,0 +1,697 @@
|
||||
#include "webdavsyncservice.h"
|
||||
|
||||
#include "../../api/webdav/webdavclient.h"
|
||||
#include "../../config/configstore.h"
|
||||
#include "../../utils/securesecretbox.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QJsonArray>
|
||||
#include <QJsonDocument>
|
||||
#include <QJsonObject>
|
||||
#include <QJsonValue>
|
||||
#include <QRegularExpression>
|
||||
#include <QSet>
|
||||
#include <QStandardPaths>
|
||||
#include <QTimeZone>
|
||||
|
||||
#include <algorithm>
|
||||
#include <memory>
|
||||
#include <stdexcept>
|
||||
#include <utility>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
constexpr char kLegacySnapshotPrefix[] = "qemby-config-";
|
||||
constexpr char kLegacySnapshotSuffix[] = ".bin";
|
||||
constexpr char kSnapshotPrefix[] = "qEmby-";
|
||||
constexpr char kSnapshotSuffix[] = "-config.bin";
|
||||
constexpr char kDefaultSnapshotTag[] = "snapshot";
|
||||
|
||||
QString sanitizeNameSegment(const QString &s, const QString &fallback)
|
||||
{
|
||||
const QString trimmed = s.trimmed();
|
||||
QString out;
|
||||
out.reserve(trimmed.size());
|
||||
for (QChar c : trimmed)
|
||||
{
|
||||
if (c.isLetterOrNumber() || c == QLatin1Char('_') ||
|
||||
c == QLatin1Char('-') || c == QLatin1Char('.'))
|
||||
{
|
||||
out.append(c);
|
||||
}
|
||||
else if (c.isSpace())
|
||||
{
|
||||
out.append(QLatin1Char('_'));
|
||||
}
|
||||
else
|
||||
{
|
||||
out.append(QLatin1Char('_'));
|
||||
}
|
||||
}
|
||||
if (out.isEmpty())
|
||||
{
|
||||
out = fallback;
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
bool looksEncryptedPayload(const QByteArray &bytes)
|
||||
{
|
||||
return bytes.size() >= 4 && bytes.left(4) == QByteArrayLiteral("QSB1");
|
||||
}
|
||||
|
||||
|
||||
QString serversJsonPath()
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppDataLocation);
|
||||
return QDir(dir).filePath(QStringLiteral("servers.json"));
|
||||
}
|
||||
|
||||
QJsonArray readServersJson()
|
||||
{
|
||||
QFile f(serversJsonPath());
|
||||
if (!f.open(QIODevice::ReadOnly))
|
||||
{
|
||||
return QJsonArray();
|
||||
}
|
||||
const QByteArray bytes = f.readAll();
|
||||
f.close();
|
||||
return QJsonDocument::fromJson(bytes).array();
|
||||
}
|
||||
|
||||
bool writeServersJson(const QJsonArray &arr)
|
||||
{
|
||||
const QString path = serversJsonPath();
|
||||
const QString dir = QFileInfo(path).absolutePath();
|
||||
if (!QDir().mkpath(dir))
|
||||
{
|
||||
qWarning() << "[WebdavSyncService] failed to create dir for servers.json:" << dir;
|
||||
return false;
|
||||
}
|
||||
|
||||
const QByteArray payload = QJsonDocument(arr).toJson(QJsonDocument::Indented);
|
||||
|
||||
const QString tmpPath = path + QStringLiteral(".tmp");
|
||||
{
|
||||
QFile out(tmpPath);
|
||||
if (!out.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
{
|
||||
qWarning() << "[WebdavSyncService] failed to open" << tmpPath
|
||||
<< "|" << out.errorString();
|
||||
return false;
|
||||
}
|
||||
if (out.write(payload) != payload.size())
|
||||
{
|
||||
out.close();
|
||||
QFile::remove(tmpPath);
|
||||
return false;
|
||||
}
|
||||
out.close();
|
||||
}
|
||||
|
||||
QFile::remove(path);
|
||||
if (!QFile::rename(tmpPath, path))
|
||||
{
|
||||
if (!QFile::copy(tmpPath, path))
|
||||
{
|
||||
qWarning() << "[WebdavSyncService] writeServersJson rename/copy failed";
|
||||
return false;
|
||||
}
|
||||
QFile::remove(tmpPath);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
QSet<QString> idSetOfArray(const QJsonArray &arr)
|
||||
{
|
||||
QSet<QString> ids;
|
||||
for (const QJsonValue &v : arr)
|
||||
{
|
||||
const QString id = v.toObject().value(QStringLiteral("id")).toString();
|
||||
if (!id.isEmpty())
|
||||
{
|
||||
ids.insert(id);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
|
||||
|
||||
QVariant jsonValueToVariant(const QJsonValue &v)
|
||||
{
|
||||
switch (v.type())
|
||||
{
|
||||
case QJsonValue::Bool:
|
||||
return v.toBool();
|
||||
case QJsonValue::Double: {
|
||||
const double d = v.toDouble();
|
||||
const qlonglong i = static_cast<qlonglong>(d);
|
||||
if (static_cast<double>(i) == d)
|
||||
{
|
||||
return QVariant::fromValue(i);
|
||||
}
|
||||
return d;
|
||||
}
|
||||
case QJsonValue::String:
|
||||
return v.toString();
|
||||
case QJsonValue::Array:
|
||||
return QString::fromUtf8(QJsonDocument(v.toArray()).toJson(QJsonDocument::Compact));
|
||||
case QJsonValue::Object:
|
||||
return QString::fromUtf8(QJsonDocument(v.toObject()).toJson(QJsonDocument::Compact));
|
||||
case QJsonValue::Null:
|
||||
case QJsonValue::Undefined:
|
||||
default:
|
||||
return QVariant();
|
||||
}
|
||||
}
|
||||
|
||||
bool keyMatchesIncludedNamespace(const QString &key)
|
||||
{
|
||||
for (const QString &ns : ConfigBundle::includedNamespaces())
|
||||
{
|
||||
if (key.startsWith(ns))
|
||||
{
|
||||
return true;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
WebdavSyncService::WebdavSyncService(WebdavProfileStore *store, QObject *parent)
|
||||
: QObject(parent), m_store(store)
|
||||
{
|
||||
}
|
||||
|
||||
WebdavSyncService::~WebdavSyncService() = default;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QString WebdavSyncService::proposedSnapshotFileName(const ConfigBundleMetadata &metadata,
|
||||
QString customTag)
|
||||
{
|
||||
const qint64 exportedAt = metadata.exportedAt > 0
|
||||
? metadata.exportedAt
|
||||
: QDateTime::currentMSecsSinceEpoch();
|
||||
const QString version = sanitizeNameSegment(metadata.appVersion, QStringLiteral("dev"));
|
||||
const QString osName = sanitizeNameSegment(metadata.osName, QStringLiteral("OS"));
|
||||
const QString device = sanitizeNameSegment(metadata.deviceName, QStringLiteral("device"));
|
||||
const QString tag = sanitizeNameSegment(customTag, QString::fromLatin1(kDefaultSnapshotTag));
|
||||
const QString ts = QDateTime::fromMSecsSinceEpoch(exportedAt, Qt::LocalTime)
|
||||
.toString(QStringLiteral("yyyy-MM-dd-HH-mm-ss"));
|
||||
|
||||
return QString::fromLatin1(kSnapshotPrefix) + version + QLatin1Char('-') + osName +
|
||||
QLatin1Char('-') + device + QLatin1Char('-') + ts + QLatin1Char('-') + tag +
|
||||
QString::fromLatin1(kSnapshotSuffix);
|
||||
}
|
||||
|
||||
bool WebdavSyncService::isQEmbySnapshotName(const QString &fileName)
|
||||
{
|
||||
const bool isNewName = fileName.startsWith(QString::fromLatin1(kSnapshotPrefix)) &&
|
||||
fileName.endsWith(QString::fromLatin1(kSnapshotSuffix));
|
||||
const bool isLegacyName =
|
||||
fileName.startsWith(QString::fromLatin1(kLegacySnapshotPrefix)) &&
|
||||
fileName.endsWith(QString::fromLatin1(kLegacySnapshotSuffix));
|
||||
return isNewName || isLegacyName;
|
||||
}
|
||||
|
||||
void WebdavSyncService::parseSnapshotName(const QString &fileName,
|
||||
QString &appVersionOut,
|
||||
QString &osNameOut,
|
||||
QString &deviceHintOut,
|
||||
QString &customTagOut,
|
||||
QDateTime &createdAtOut)
|
||||
{
|
||||
appVersionOut.clear();
|
||||
osNameOut.clear();
|
||||
deviceHintOut.clear();
|
||||
customTagOut.clear();
|
||||
createdAtOut = QDateTime();
|
||||
|
||||
if (!isQEmbySnapshotName(fileName))
|
||||
{
|
||||
return;
|
||||
}
|
||||
|
||||
if (fileName.startsWith(QString::fromLatin1(kSnapshotPrefix)) &&
|
||||
fileName.endsWith(QString::fromLatin1(kSnapshotSuffix)))
|
||||
{
|
||||
const int prefixLen = static_cast<int>(sizeof(kSnapshotPrefix)) - 1;
|
||||
const int suffixLen = static_cast<int>(sizeof(kSnapshotSuffix)) - 1;
|
||||
const QString core = fileName.mid(prefixLen, fileName.size() - prefixLen - suffixLen);
|
||||
|
||||
static const QRegularExpression tsPattern(QStringLiteral(
|
||||
"-(\\d{4}-\\d{2}-\\d{2}(?: \\d{2}:\\d{2}:\\d{2}|-\\d{2}-\\d{2}-\\d{2}))-"));
|
||||
const QRegularExpressionMatch match = tsPattern.match(core);
|
||||
if (!match.hasMatch())
|
||||
{
|
||||
deviceHintOut = core;
|
||||
return;
|
||||
}
|
||||
|
||||
const QString beforeTs = core.left(match.capturedStart(0));
|
||||
customTagOut = core.mid(match.capturedEnd(0));
|
||||
const int firstDash = beforeTs.indexOf(QLatin1Char('-'));
|
||||
const int secondDash = firstDash >= 0
|
||||
? beforeTs.indexOf(QLatin1Char('-'), firstDash + 1)
|
||||
: -1;
|
||||
if (firstDash >= 0)
|
||||
{
|
||||
appVersionOut = beforeTs.left(firstDash);
|
||||
}
|
||||
if (secondDash >= 0)
|
||||
{
|
||||
osNameOut = beforeTs.mid(firstDash + 1, secondDash - firstDash - 1);
|
||||
deviceHintOut = beforeTs.mid(secondDash + 1);
|
||||
}
|
||||
else
|
||||
{
|
||||
deviceHintOut = beforeTs;
|
||||
}
|
||||
|
||||
const QString timestampText = match.captured(1);
|
||||
const QString timestampFormat = timestampText.contains(QLatin1Char(':'))
|
||||
? QStringLiteral("yyyy-MM-dd HH:mm:ss")
|
||||
: QStringLiteral("yyyy-MM-dd-HH-mm-ss");
|
||||
const QDateTime parsed = QDateTime::fromString(timestampText, timestampFormat);
|
||||
if (parsed.isValid())
|
||||
{
|
||||
createdAtOut = QDateTime(parsed.date(), parsed.time(),
|
||||
QTimeZone::systemTimeZone());
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const int prefixLen = static_cast<int>(sizeof(kLegacySnapshotPrefix)) - 1;
|
||||
const int suffixLen = static_cast<int>(sizeof(kLegacySnapshotSuffix)) - 1;
|
||||
const QString core = fileName.mid(prefixLen, fileName.size() - prefixLen - suffixLen);
|
||||
|
||||
|
||||
const int lastDash = core.lastIndexOf(QLatin1Char('-'));
|
||||
const int secondLastDash = lastDash >= 0
|
||||
? core.lastIndexOf(QLatin1Char('-'), lastDash - 1)
|
||||
: -1;
|
||||
if (secondLastDash < 0)
|
||||
{
|
||||
deviceHintOut = core;
|
||||
return;
|
||||
}
|
||||
|
||||
const QString tsPart = core.mid(secondLastDash + 1);
|
||||
deviceHintOut = core.left(secondLastDash);
|
||||
|
||||
QDateTime parsed = QDateTime::fromString(tsPart, QStringLiteral("yyyyMMdd-HHmmss"));
|
||||
if (parsed.isValid())
|
||||
{
|
||||
parsed.setTimeZone(QTimeZone(QTimeZone::UTC));
|
||||
createdAtOut = parsed;
|
||||
}
|
||||
}
|
||||
|
||||
WebdavClient *WebdavSyncService::buildClient()
|
||||
{
|
||||
if (!m_store)
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Internal error: WebDAV profile store is unavailable.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
if (!m_store->hasProfile())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("WebDAV profile is not configured. Please save a profile first.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
WebdavProfile profile = m_store->profile();
|
||||
if (!profile.isValid())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("WebDAV profile is incomplete. Please fill in URL, username and password.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
|
||||
return new WebdavClient(profile, nullptr);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<bool> WebdavSyncService::testConnection()
|
||||
{
|
||||
qDebug() << "[WebdavSyncService] testConnection START";
|
||||
std::unique_ptr<WebdavClient> client(buildClient());
|
||||
|
||||
|
||||
const bool ok = co_await client->ensureRootDir();
|
||||
qDebug() << "[WebdavSyncService] testConnection DONE | ok:" << ok;
|
||||
co_return ok;
|
||||
}
|
||||
|
||||
QCoro::Task<QString> WebdavSyncService::uploadSnapshot(QString customTag, bool encrypt,
|
||||
QString passphrase)
|
||||
{
|
||||
qDebug() << "[WebdavSyncService] uploadSnapshot START"
|
||||
<< "| encrypt:" << encrypt
|
||||
<< "| customTag:" << customTag;
|
||||
if (encrypt && passphrase.isEmpty())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Passphrase cannot be empty.").toUtf8().toStdString());
|
||||
}
|
||||
|
||||
std::unique_ptr<WebdavClient> client(buildClient());
|
||||
co_await client->ensureRootDir();
|
||||
|
||||
ConfigBundle bundle = ConfigBundle::collectFromLocal();
|
||||
QByteArray plain = bundle.serialize(false);
|
||||
if (plain.isEmpty())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("There is no local configuration to upload.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
QByteArray payload;
|
||||
if (encrypt)
|
||||
{
|
||||
payload = SecureSecretBox::encryptWithPassphrase(plain, passphrase);
|
||||
SecureSecretBox::secureZero(plain);
|
||||
if (payload.isEmpty())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Failed to encrypt the snapshot.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
payload = std::move(plain);
|
||||
}
|
||||
|
||||
const QString fileName = proposedSnapshotFileName(bundle.metadata, customTag);
|
||||
qDebug() << "[WebdavSyncService] uploadSnapshot uploading"
|
||||
<< "| fileName:" << fileName
|
||||
<< "| encrypted:" << encrypt
|
||||
<< "| size:" << payload.size();
|
||||
|
||||
co_await client->putFile(fileName, payload,
|
||||
QStringLiteral("application/octet-stream"));
|
||||
SecureSecretBox::secureZero(payload);
|
||||
|
||||
if (m_store)
|
||||
{
|
||||
m_store->updateLastSyncAt(QDateTime::currentMSecsSinceEpoch());
|
||||
}
|
||||
emit snapshotListChanged();
|
||||
|
||||
qDebug() << "[WebdavSyncService] uploadSnapshot DONE | fileName:" << fileName;
|
||||
co_return fileName;
|
||||
}
|
||||
|
||||
QCoro::Task<QList<WebdavSnapshot>> WebdavSyncService::listSnapshots()
|
||||
{
|
||||
qDebug() << "[WebdavSyncService] listSnapshots START";
|
||||
std::unique_ptr<WebdavClient> client(buildClient());
|
||||
co_await client->ensureRootDir();
|
||||
|
||||
const QList<WebdavEntry> entries = co_await client->list(QString());
|
||||
|
||||
QList<WebdavSnapshot> snapshots;
|
||||
for (const WebdavEntry &e : entries)
|
||||
{
|
||||
if (e.isCollection)
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (!isQEmbySnapshotName(e.displayName))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
WebdavSnapshot s;
|
||||
s.fileName = e.displayName;
|
||||
s.mtime = e.lastModified;
|
||||
s.size = e.contentLength;
|
||||
parseSnapshotName(s.fileName, s.appVersion, s.osName, s.deviceHint,
|
||||
s.customTag, s.createdAt);
|
||||
if (!s.createdAt.isValid())
|
||||
{
|
||||
s.createdAt = s.mtime;
|
||||
}
|
||||
snapshots.append(s);
|
||||
}
|
||||
|
||||
std::sort(snapshots.begin(), snapshots.end(),
|
||||
[](const WebdavSnapshot &a, const WebdavSnapshot &b)
|
||||
{ return a.createdAt > b.createdAt; });
|
||||
|
||||
qDebug() << "[WebdavSyncService] listSnapshots DONE"
|
||||
<< "| total:" << entries.size()
|
||||
<< "| matched:" << snapshots.size();
|
||||
co_return snapshots;
|
||||
}
|
||||
|
||||
QCoro::Task<ConfigBundle> WebdavSyncService::downloadSnapshot(QString fileName, QString passphrase)
|
||||
{
|
||||
qDebug() << "[WebdavSyncService] downloadSnapshot START | fileName:" << fileName;
|
||||
if (!isQEmbySnapshotName(fileName))
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Selected file is not a valid qEmby snapshot.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
std::unique_ptr<WebdavClient> client(buildClient());
|
||||
QByteArray payload = co_await client->getFile(fileName);
|
||||
|
||||
if (!looksEncryptedPayload(payload))
|
||||
{
|
||||
auto plainBundleOpt = ConfigBundle::deserialize(payload);
|
||||
if (!plainBundleOpt.has_value())
|
||||
{
|
||||
SecureSecretBox::secureZero(payload);
|
||||
throw std::runtime_error(
|
||||
tr("Snapshot is not a valid qEmby configuration bundle.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
SecureSecretBox::secureZero(payload);
|
||||
qDebug() << "[WebdavSyncService] downloadSnapshot DONE"
|
||||
<< "| encrypted:" << false
|
||||
<< "| servers:" << plainBundleOpt->servers.size()
|
||||
<< "| entries:" << plainBundleOpt->configEntries.size()
|
||||
<< "| fromHost:" << plainBundleOpt->metadata.deviceName
|
||||
<< "| fromOs:" << plainBundleOpt->metadata.osPretty
|
||||
<< "| fromApp:" << plainBundleOpt->metadata.appVersion;
|
||||
co_return plainBundleOpt.value();
|
||||
}
|
||||
|
||||
if (passphrase.isEmpty())
|
||||
{
|
||||
SecureSecretBox::secureZero(payload);
|
||||
throw WebdavPassphraseRequiredError(
|
||||
tr("Passphrase is required for encrypted snapshots.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
auto plainOpt = SecureSecretBox::decryptWithPassphrase(payload, passphrase);
|
||||
SecureSecretBox::secureZero(payload);
|
||||
if (!plainOpt.has_value())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Failed to decrypt the snapshot. The passphrase may be wrong or the file is corrupted.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
QByteArray plain = plainOpt.value();
|
||||
auto bundleOpt = ConfigBundle::deserialize(plain);
|
||||
SecureSecretBox::secureZero(plain);
|
||||
if (!bundleOpt.has_value())
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Snapshot is not a valid qEmby configuration bundle.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
qDebug() << "[WebdavSyncService] downloadSnapshot DONE"
|
||||
<< "| encrypted:" << true
|
||||
<< "| servers:" << bundleOpt->servers.size()
|
||||
<< "| entries:" << bundleOpt->configEntries.size()
|
||||
<< "| fromHost:" << bundleOpt->metadata.deviceName
|
||||
<< "| fromOs:" << bundleOpt->metadata.osPretty
|
||||
<< "| fromApp:" << bundleOpt->metadata.appVersion;
|
||||
co_return bundleOpt.value();
|
||||
}
|
||||
|
||||
QCoro::Task<bool> WebdavSyncService::deleteSnapshot(QString fileName)
|
||||
{
|
||||
qDebug() << "[WebdavSyncService] deleteSnapshot START | fileName:" << fileName;
|
||||
if (!isQEmbySnapshotName(fileName))
|
||||
{
|
||||
throw std::runtime_error(
|
||||
tr("Refused to delete a file that is not a qEmby snapshot.")
|
||||
.toUtf8()
|
||||
.toStdString());
|
||||
}
|
||||
|
||||
std::unique_ptr<WebdavClient> client(buildClient());
|
||||
const bool ok = co_await client->remove(fileName);
|
||||
if (ok)
|
||||
{
|
||||
emit snapshotListChanged();
|
||||
}
|
||||
qDebug() << "[WebdavSyncService] deleteSnapshot DONE | ok:" << ok;
|
||||
co_return ok;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bool WebdavSyncService::applyBundle(const ConfigBundle &bundle, MergeStrategy strategy)
|
||||
{
|
||||
qDebug() << "[WebdavSyncService] applyBundle START"
|
||||
<< "| strategy:" << static_cast<int>(strategy)
|
||||
<< "| bundleServers:" << bundle.servers.size()
|
||||
<< "| bundleEntries:" << bundle.configEntries.size()
|
||||
<< "| fromHost:" << bundle.metadata.deviceName;
|
||||
|
||||
|
||||
QJsonArray finalServers;
|
||||
switch (strategy)
|
||||
{
|
||||
case MergeStrategy::Replace:
|
||||
finalServers = bundle.servers;
|
||||
break;
|
||||
case MergeStrategy::Merge: {
|
||||
const QJsonArray local = readServersJson();
|
||||
const QSet<QString> bundleIds = idSetOfArray(bundle.servers);
|
||||
finalServers = bundle.servers;
|
||||
for (const QJsonValue &v : local)
|
||||
{
|
||||
const QString id = v.toObject().value(QStringLiteral("id")).toString();
|
||||
if (id.isEmpty() || !bundleIds.contains(id))
|
||||
{
|
||||
finalServers.append(v);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
case MergeStrategy::LocalWins: {
|
||||
const QJsonArray local = readServersJson();
|
||||
const QSet<QString> localIds = idSetOfArray(local);
|
||||
finalServers = local;
|
||||
for (const QJsonValue &v : bundle.servers)
|
||||
{
|
||||
const QString id = v.toObject().value(QStringLiteral("id")).toString();
|
||||
if (!id.isEmpty() && !localIds.contains(id))
|
||||
{
|
||||
finalServers.append(v);
|
||||
}
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if (!writeServersJson(finalServers))
|
||||
{
|
||||
qWarning() << "[WebdavSyncService] applyBundle: writeServersJson failed";
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
ConfigStore *store = ConfigStore::instance();
|
||||
if (!store)
|
||||
{
|
||||
qWarning() << "[WebdavSyncService] applyBundle: ConfigStore singleton missing";
|
||||
return false;
|
||||
}
|
||||
|
||||
if (strategy == MergeStrategy::Replace)
|
||||
{
|
||||
|
||||
const QStringList localKeys = store->allKeys();
|
||||
for (const QString &key : localKeys)
|
||||
{
|
||||
if (!keyMatchesIncludedNamespace(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
if (ConfigBundle::shouldExcludeKey(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (bundle.configEntries.contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
store->set(key, QVariant());
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
QStringList localKeysCache;
|
||||
if (strategy == MergeStrategy::LocalWins)
|
||||
{
|
||||
localKeysCache = store->allKeys();
|
||||
}
|
||||
|
||||
for (auto it = bundle.configEntries.constBegin();
|
||||
it != bundle.configEntries.constEnd(); ++it)
|
||||
{
|
||||
const QString &key = it.key();
|
||||
if (ConfigBundle::shouldExcludeKey(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
|
||||
if (strategy == MergeStrategy::LocalWins)
|
||||
{
|
||||
if (localKeysCache.contains(key))
|
||||
{
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
store->set(key, jsonValueToVariant(it.value()));
|
||||
}
|
||||
|
||||
|
||||
if (m_store)
|
||||
{
|
||||
m_store->updateLastSyncAt(QDateTime::currentMSecsSinceEpoch());
|
||||
}
|
||||
|
||||
emit appliedBundle(strategy);
|
||||
qDebug() << "[WebdavSyncService] applyBundle DONE"
|
||||
<< "| finalServers:" << finalServers.size();
|
||||
return true;
|
||||
}
|
||||
132
src/qEmbyCore/services/sync/webdavsyncservice.h
Normal file
132
src/qEmbyCore/services/sync/webdavsyncservice.h
Normal file
@@ -0,0 +1,132 @@
|
||||
#ifndef WEBDAVSYNCSERVICE_H
|
||||
#define WEBDAVSYNCSERVICE_H
|
||||
|
||||
#include "../../api/webdav/webdaventry.h"
|
||||
#include "../../config/webdavprofilestore.h"
|
||||
#include "../../models/sync/configbundle.h"
|
||||
#include "../../qEmbyCore_global.h"
|
||||
|
||||
#include <QDateTime>
|
||||
#include <QList>
|
||||
#include <QObject>
|
||||
#include <QPointer>
|
||||
#include <QString>
|
||||
|
||||
#include <stdexcept>
|
||||
|
||||
#include <qcoro/qcorotask.h>
|
||||
|
||||
class WebdavClient;
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
struct WebdavSnapshot
|
||||
{
|
||||
QString fileName;
|
||||
QString appVersion;
|
||||
QString osName;
|
||||
QString deviceHint;
|
||||
QString customTag;
|
||||
QDateTime createdAt;
|
||||
QDateTime mtime;
|
||||
qint64 size = 0;
|
||||
};
|
||||
|
||||
class QEMBYCORE_EXPORT WebdavPassphraseRequiredError : public std::runtime_error
|
||||
{
|
||||
public:
|
||||
using std::runtime_error::runtime_error;
|
||||
};
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class QEMBYCORE_EXPORT WebdavSyncService : public QObject
|
||||
{
|
||||
Q_OBJECT
|
||||
public:
|
||||
explicit WebdavSyncService(WebdavProfileStore *store, QObject *parent = nullptr);
|
||||
~WebdavSyncService() override;
|
||||
|
||||
|
||||
|
||||
|
||||
QCoro::Task<bool> testConnection();
|
||||
|
||||
|
||||
|
||||
QCoro::Task<QString> uploadSnapshot(QString customTag, bool encrypt, QString passphrase);
|
||||
|
||||
|
||||
QCoro::Task<QList<WebdavSnapshot>> listSnapshots();
|
||||
|
||||
|
||||
|
||||
QCoro::Task<ConfigBundle> downloadSnapshot(QString fileName, QString passphrase = QString());
|
||||
|
||||
|
||||
QCoro::Task<bool> deleteSnapshot(QString fileName);
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
bool applyBundle(const ConfigBundle &bundle, MergeStrategy strategy);
|
||||
|
||||
|
||||
static QString proposedSnapshotFileName(const ConfigBundleMetadata &metadata,
|
||||
QString customTag);
|
||||
|
||||
|
||||
static bool isQEmbySnapshotName(const QString &fileName);
|
||||
|
||||
signals:
|
||||
|
||||
void snapshotListChanged();
|
||||
|
||||
|
||||
void appliedBundle(MergeStrategy strategy);
|
||||
|
||||
private:
|
||||
|
||||
WebdavClient *buildClient();
|
||||
|
||||
|
||||
static void parseSnapshotName(const QString &fileName,
|
||||
QString &appVersionOut,
|
||||
QString &osNameOut,
|
||||
QString &deviceHintOut,
|
||||
QString &customTagOut,
|
||||
QDateTime &createdAtOut);
|
||||
|
||||
QPointer<WebdavProfileStore> m_store;
|
||||
};
|
||||
|
||||
#endif
|
||||
269
src/qEmbyCore/utils/aes_lite.cpp
Normal file
269
src/qEmbyCore/utils/aes_lite.cpp
Normal file
@@ -0,0 +1,269 @@
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#include "aes_lite.h"
|
||||
|
||||
#include <string.h>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
|
||||
|
||||
constexpr int Nb = 4;
|
||||
constexpr int Nk = 8;
|
||||
constexpr int Nr = 14;
|
||||
|
||||
using state_t = uint8_t[4][4];
|
||||
|
||||
|
||||
static const uint8_t sbox[256] = {
|
||||
0x63, 0x7c, 0x77, 0x7b, 0xf2, 0x6b, 0x6f, 0xc5, 0x30, 0x01, 0x67, 0x2b, 0xfe, 0xd7, 0xab, 0x76,
|
||||
0xca, 0x82, 0xc9, 0x7d, 0xfa, 0x59, 0x47, 0xf0, 0xad, 0xd4, 0xa2, 0xaf, 0x9c, 0xa4, 0x72, 0xc0,
|
||||
0xb7, 0xfd, 0x93, 0x26, 0x36, 0x3f, 0xf7, 0xcc, 0x34, 0xa5, 0xe5, 0xf1, 0x71, 0xd8, 0x31, 0x15,
|
||||
0x04, 0xc7, 0x23, 0xc3, 0x18, 0x96, 0x05, 0x9a, 0x07, 0x12, 0x80, 0xe2, 0xeb, 0x27, 0xb2, 0x75,
|
||||
0x09, 0x83, 0x2c, 0x1a, 0x1b, 0x6e, 0x5a, 0xa0, 0x52, 0x3b, 0xd6, 0xb3, 0x29, 0xe3, 0x2f, 0x84,
|
||||
0x53, 0xd1, 0x00, 0xed, 0x20, 0xfc, 0xb1, 0x5b, 0x6a, 0xcb, 0xbe, 0x39, 0x4a, 0x4c, 0x58, 0xcf,
|
||||
0xd0, 0xef, 0xaa, 0xfb, 0x43, 0x4d, 0x33, 0x85, 0x45, 0xf9, 0x02, 0x7f, 0x50, 0x3c, 0x9f, 0xa8,
|
||||
0x51, 0xa3, 0x40, 0x8f, 0x92, 0x9d, 0x38, 0xf5, 0xbc, 0xb6, 0xda, 0x21, 0x10, 0xff, 0xf3, 0xd2,
|
||||
0xcd, 0x0c, 0x13, 0xec, 0x5f, 0x97, 0x44, 0x17, 0xc4, 0xa7, 0x7e, 0x3d, 0x64, 0x5d, 0x19, 0x73,
|
||||
0x60, 0x81, 0x4f, 0xdc, 0x22, 0x2a, 0x90, 0x88, 0x46, 0xee, 0xb8, 0x14, 0xde, 0x5e, 0x0b, 0xdb,
|
||||
0xe0, 0x32, 0x3a, 0x0a, 0x49, 0x06, 0x24, 0x5c, 0xc2, 0xd3, 0xac, 0x62, 0x91, 0x95, 0xe4, 0x79,
|
||||
0xe7, 0xc8, 0x37, 0x6d, 0x8d, 0xd5, 0x4e, 0xa9, 0x6c, 0x56, 0xf4, 0xea, 0x65, 0x7a, 0xae, 0x08,
|
||||
0xba, 0x78, 0x25, 0x2e, 0x1c, 0xa6, 0xb4, 0xc6, 0xe8, 0xdd, 0x74, 0x1f, 0x4b, 0xbd, 0x8b, 0x8a,
|
||||
0x70, 0x3e, 0xb5, 0x66, 0x48, 0x03, 0xf6, 0x0e, 0x61, 0x35, 0x57, 0xb9, 0x86, 0xc1, 0x1d, 0x9e,
|
||||
0xe1, 0xf8, 0x98, 0x11, 0x69, 0xd9, 0x8e, 0x94, 0x9b, 0x1e, 0x87, 0xe9, 0xce, 0x55, 0x28, 0xdf,
|
||||
0x8c, 0xa1, 0x89, 0x0d, 0xbf, 0xe6, 0x42, 0x68, 0x41, 0x99, 0x2d, 0x0f, 0xb0, 0x54, 0xbb, 0x16
|
||||
};
|
||||
|
||||
|
||||
static const uint8_t Rcon[11] = {
|
||||
0x8d, 0x01, 0x02, 0x04, 0x08, 0x10, 0x20, 0x40, 0x80, 0x1b, 0x36
|
||||
};
|
||||
|
||||
#define getSBoxValue(num) (sbox[(num)])
|
||||
|
||||
|
||||
|
||||
|
||||
static void KeyExpansion(uint8_t *RoundKey, const uint8_t *Key)
|
||||
{
|
||||
unsigned i, j, k;
|
||||
uint8_t tempa[4];
|
||||
|
||||
|
||||
for (i = 0; i < Nk; ++i)
|
||||
{
|
||||
RoundKey[(i * 4) + 0] = Key[(i * 4) + 0];
|
||||
RoundKey[(i * 4) + 1] = Key[(i * 4) + 1];
|
||||
RoundKey[(i * 4) + 2] = Key[(i * 4) + 2];
|
||||
RoundKey[(i * 4) + 3] = Key[(i * 4) + 3];
|
||||
}
|
||||
|
||||
for (i = Nk; i < Nb * (Nr + 1); ++i)
|
||||
{
|
||||
{
|
||||
k = (i - 1) * 4;
|
||||
tempa[0] = RoundKey[k + 0];
|
||||
tempa[1] = RoundKey[k + 1];
|
||||
tempa[2] = RoundKey[k + 2];
|
||||
tempa[3] = RoundKey[k + 3];
|
||||
}
|
||||
|
||||
if (i % Nk == 0)
|
||||
{
|
||||
|
||||
const uint8_t u8tmp = tempa[0];
|
||||
tempa[0] = tempa[1];
|
||||
tempa[1] = tempa[2];
|
||||
tempa[2] = tempa[3];
|
||||
tempa[3] = u8tmp;
|
||||
|
||||
|
||||
tempa[0] = getSBoxValue(tempa[0]);
|
||||
tempa[1] = getSBoxValue(tempa[1]);
|
||||
tempa[2] = getSBoxValue(tempa[2]);
|
||||
tempa[3] = getSBoxValue(tempa[3]);
|
||||
|
||||
tempa[0] = tempa[0] ^ Rcon[i / Nk];
|
||||
}
|
||||
|
||||
|
||||
if (i % Nk == 4)
|
||||
{
|
||||
tempa[0] = getSBoxValue(tempa[0]);
|
||||
tempa[1] = getSBoxValue(tempa[1]);
|
||||
tempa[2] = getSBoxValue(tempa[2]);
|
||||
tempa[3] = getSBoxValue(tempa[3]);
|
||||
}
|
||||
|
||||
j = i * 4;
|
||||
k = (i - Nk) * 4;
|
||||
RoundKey[j + 0] = RoundKey[k + 0] ^ tempa[0];
|
||||
RoundKey[j + 1] = RoundKey[k + 1] ^ tempa[1];
|
||||
RoundKey[j + 2] = RoundKey[k + 2] ^ tempa[2];
|
||||
RoundKey[j + 3] = RoundKey[k + 3] ^ tempa[3];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
static void AddRoundKey(uint8_t round, state_t *state, const uint8_t *RoundKey)
|
||||
{
|
||||
uint8_t i, j;
|
||||
for (i = 0; i < 4; ++i)
|
||||
{
|
||||
for (j = 0; j < 4; ++j)
|
||||
{
|
||||
(*state)[i][j] ^= RoundKey[(round * Nb * 4) + (i * Nb) + j];
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void SubBytes(state_t *state)
|
||||
{
|
||||
uint8_t i, j;
|
||||
for (i = 0; i < 4; ++i)
|
||||
{
|
||||
for (j = 0; j < 4; ++j)
|
||||
{
|
||||
(*state)[j][i] = getSBoxValue((*state)[j][i]);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
static void ShiftRows(state_t *state)
|
||||
{
|
||||
uint8_t temp;
|
||||
|
||||
temp = (*state)[0][1];
|
||||
(*state)[0][1] = (*state)[1][1];
|
||||
(*state)[1][1] = (*state)[2][1];
|
||||
(*state)[2][1] = (*state)[3][1];
|
||||
(*state)[3][1] = temp;
|
||||
|
||||
temp = (*state)[0][2];
|
||||
(*state)[0][2] = (*state)[2][2];
|
||||
(*state)[2][2] = temp;
|
||||
|
||||
temp = (*state)[1][2];
|
||||
(*state)[1][2] = (*state)[3][2];
|
||||
(*state)[3][2] = temp;
|
||||
|
||||
temp = (*state)[0][3];
|
||||
(*state)[0][3] = (*state)[3][3];
|
||||
(*state)[3][3] = (*state)[2][3];
|
||||
(*state)[2][3] = (*state)[1][3];
|
||||
(*state)[1][3] = temp;
|
||||
}
|
||||
|
||||
static uint8_t xtime(uint8_t x)
|
||||
{
|
||||
return static_cast<uint8_t>((x << 1) ^ (((x >> 7) & 1) * 0x1b));
|
||||
}
|
||||
|
||||
static void MixColumns(state_t *state)
|
||||
{
|
||||
uint8_t i;
|
||||
uint8_t Tmp, Tm, t;
|
||||
for (i = 0; i < 4; ++i)
|
||||
{
|
||||
t = (*state)[i][0];
|
||||
Tmp = (*state)[i][0] ^ (*state)[i][1] ^ (*state)[i][2] ^ (*state)[i][3];
|
||||
Tm = (*state)[i][0] ^ (*state)[i][1];
|
||||
Tm = xtime(Tm);
|
||||
(*state)[i][0] ^= Tm ^ Tmp;
|
||||
Tm = (*state)[i][1] ^ (*state)[i][2];
|
||||
Tm = xtime(Tm);
|
||||
(*state)[i][1] ^= Tm ^ Tmp;
|
||||
Tm = (*state)[i][2] ^ (*state)[i][3];
|
||||
Tm = xtime(Tm);
|
||||
(*state)[i][2] ^= Tm ^ Tmp;
|
||||
Tm = (*state)[i][3] ^ t;
|
||||
Tm = xtime(Tm);
|
||||
(*state)[i][3] ^= Tm ^ Tmp;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
static void Cipher(state_t *state, const uint8_t *RoundKey)
|
||||
{
|
||||
uint8_t round = 0;
|
||||
|
||||
AddRoundKey(0, state, RoundKey);
|
||||
|
||||
for (round = 1;; ++round)
|
||||
{
|
||||
SubBytes(state);
|
||||
ShiftRows(state);
|
||||
if (round == Nr)
|
||||
{
|
||||
break;
|
||||
}
|
||||
MixColumns(state);
|
||||
AddRoundKey(round, state, RoundKey);
|
||||
}
|
||||
AddRoundKey(Nr, state, RoundKey);
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
extern "C" void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8_t *iv)
|
||||
{
|
||||
KeyExpansion(ctx->RoundKey, key);
|
||||
memcpy(ctx->Iv, iv, AES_BLOCKLEN);
|
||||
}
|
||||
|
||||
extern "C" void AES_ctx_set_iv(struct AES_ctx *ctx, const uint8_t *iv)
|
||||
{
|
||||
memcpy(ctx->Iv, iv, AES_BLOCKLEN);
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
extern "C" void AES_CTR_xcrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length)
|
||||
{
|
||||
uint8_t buffer[AES_BLOCKLEN];
|
||||
|
||||
size_t i;
|
||||
int bi;
|
||||
for (i = 0, bi = AES_BLOCKLEN; i < length; ++i, ++bi)
|
||||
{
|
||||
if (bi == AES_BLOCKLEN)
|
||||
{
|
||||
memcpy(buffer, ctx->Iv, AES_BLOCKLEN);
|
||||
Cipher(reinterpret_cast<state_t *>(buffer), ctx->RoundKey);
|
||||
|
||||
|
||||
for (bi = AES_BLOCKLEN - 1; bi >= 0; --bi)
|
||||
{
|
||||
if (ctx->Iv[bi] == 255)
|
||||
{
|
||||
ctx->Iv[bi] = 0;
|
||||
continue;
|
||||
}
|
||||
ctx->Iv[bi] += 1;
|
||||
break;
|
||||
}
|
||||
bi = 0;
|
||||
}
|
||||
|
||||
buf[i] = static_cast<uint8_t>(buf[i] ^ buffer[bi]);
|
||||
}
|
||||
}
|
||||
51
src/qEmbyCore/utils/aes_lite.h
Normal file
51
src/qEmbyCore/utils/aes_lite.h
Normal file
@@ -0,0 +1,51 @@
|
||||
#ifndef AES_LITE_H
|
||||
#define AES_LITE_H
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
#include <stddef.h>
|
||||
#include <stdint.h>
|
||||
|
||||
#define AES_BLOCKLEN 16
|
||||
#define AES_KEYLEN 32
|
||||
#define AES_keyExpSize 240
|
||||
|
||||
struct AES_ctx
|
||||
{
|
||||
uint8_t RoundKey[AES_keyExpSize];
|
||||
uint8_t Iv[AES_BLOCKLEN];
|
||||
};
|
||||
|
||||
#ifdef __cplusplus
|
||||
extern "C"
|
||||
{
|
||||
#endif
|
||||
|
||||
|
||||
void AES_init_ctx_iv(struct AES_ctx *ctx, const uint8_t *key, const uint8_t *iv);
|
||||
|
||||
|
||||
void AES_ctx_set_iv(struct AES_ctx *ctx, const uint8_t *iv);
|
||||
|
||||
|
||||
|
||||
void AES_CTR_xcrypt_buffer(struct AES_ctx *ctx, uint8_t *buf, size_t length);
|
||||
|
||||
#ifdef __cplusplus
|
||||
}
|
||||
#endif
|
||||
|
||||
#endif
|
||||
466
src/qEmbyCore/utils/securesecretbox.cpp
Normal file
466
src/qEmbyCore/utils/securesecretbox.cpp
Normal file
@@ -0,0 +1,466 @@
|
||||
#include "securesecretbox.h"
|
||||
|
||||
#include "aes_lite.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QDebug>
|
||||
#include <QDir>
|
||||
#include <QFile>
|
||||
#include <QFileInfo>
|
||||
#include <QMessageAuthenticationCode>
|
||||
#include <QRandomGenerator>
|
||||
#include <QStandardPaths>
|
||||
|
||||
#include <cstring>
|
||||
|
||||
namespace
|
||||
{
|
||||
|
||||
|
||||
|
||||
constexpr char kMagic[4] = {'Q', 'S', 'B', '1'};
|
||||
constexpr uint8_t kVersion = 0x01;
|
||||
constexpr uint8_t kFlagLocal = 0x00;
|
||||
constexpr uint8_t kFlagPassphrase = 0x01;
|
||||
|
||||
constexpr int kHeaderLen = 4 + 1 + 1 + 16 + 16 ;
|
||||
constexpr int kSaltLen = 16;
|
||||
constexpr int kNonceLen = 16;
|
||||
constexpr int kAesKeyLen = 32;
|
||||
constexpr int kHmacKeyLen = 32;
|
||||
constexpr int kHmacLen = 32;
|
||||
constexpr int kPbkdf2Iterations = 100000;
|
||||
constexpr int kKdfOutLen = kAesKeyLen + kHmacKeyLen;
|
||||
|
||||
|
||||
|
||||
QString localKeyFilePath()
|
||||
{
|
||||
const QString dir = QStandardPaths::writableLocation(QStandardPaths::AppLocalDataLocation);
|
||||
return QDir(dir).filePath(QStringLiteral("secret-box-key.bin"));
|
||||
}
|
||||
|
||||
|
||||
|
||||
QByteArray loadOrCreateLocalKeyMaterial()
|
||||
{
|
||||
const QString path = localKeyFilePath();
|
||||
|
||||
QFile file(path);
|
||||
if (file.exists() && file.open(QIODevice::ReadOnly))
|
||||
{
|
||||
const QByteArray content = file.readAll();
|
||||
file.close();
|
||||
if (content.size() == kKdfOutLen)
|
||||
{
|
||||
return content;
|
||||
}
|
||||
qWarning() << "[SecureSecretBox] local key file exists but size mismatch:"
|
||||
<< content.size() << "expected:" << kKdfOutLen
|
||||
<< "| path:" << path;
|
||||
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
|
||||
const QString dirPath = QFileInfo(path).absolutePath();
|
||||
if (!QDir().mkpath(dirPath))
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] failed to create local key dir:" << dirPath;
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray fresh = SecureSecretBox::randomBytes(kKdfOutLen);
|
||||
if (fresh.size() != kKdfOutLen)
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] randomBytes failed when creating local key";
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
if (!file.open(QIODevice::WriteOnly | QIODevice::Truncate))
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] failed to open local key file for write:" << path
|
||||
<< "|" << file.errorString();
|
||||
return QByteArray();
|
||||
}
|
||||
if (file.write(fresh) != fresh.size())
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] failed to write local key file:" << path;
|
||||
file.close();
|
||||
return QByteArray();
|
||||
}
|
||||
file.close();
|
||||
|
||||
|
||||
QFile::setPermissions(path, QFileDevice::ReadOwner | QFileDevice::WriteOwner);
|
||||
|
||||
qDebug() << "[SecureSecretBox] generated new local key file at" << path;
|
||||
return fresh;
|
||||
}
|
||||
|
||||
|
||||
void splitKeyMaterial(const QByteArray &material, QByteArray &aesKey, QByteArray &hmacKey)
|
||||
{
|
||||
aesKey = material.left(kAesKeyLen);
|
||||
hmacKey = material.mid(kAesKeyLen, kHmacKeyLen);
|
||||
}
|
||||
|
||||
|
||||
|
||||
QByteArray encryptCommon(const QByteArray &plain, const QByteArray &aesKey,
|
||||
const QByteArray &hmacKey, const QByteArray &salt,
|
||||
uint8_t flags)
|
||||
{
|
||||
if (aesKey.size() != kAesKeyLen || hmacKey.size() != kHmacKeyLen ||
|
||||
salt.size() != kSaltLen)
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] encryptCommon: invalid key/salt sizes";
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
const QByteArray nonce = SecureSecretBox::randomBytes(kNonceLen);
|
||||
if (nonce.size() != kNonceLen)
|
||||
{
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
|
||||
QByteArray cipher = plain;
|
||||
AES_ctx ctx;
|
||||
AES_init_ctx_iv(&ctx,
|
||||
reinterpret_cast<const uint8_t *>(aesKey.constData()),
|
||||
reinterpret_cast<const uint8_t *>(nonce.constData()));
|
||||
AES_CTR_xcrypt_buffer(&ctx,
|
||||
reinterpret_cast<uint8_t *>(cipher.data()),
|
||||
static_cast<size_t>(cipher.size()));
|
||||
|
||||
|
||||
QByteArray header;
|
||||
header.reserve(kHeaderLen);
|
||||
header.append(kMagic, 4);
|
||||
header.append(static_cast<char>(kVersion));
|
||||
header.append(static_cast<char>(flags));
|
||||
header.append(salt);
|
||||
header.append(nonce);
|
||||
|
||||
|
||||
QByteArray macInput;
|
||||
macInput.reserve(header.size() + cipher.size());
|
||||
macInput.append(header);
|
||||
macInput.append(cipher);
|
||||
const QByteArray mac = SecureSecretBox::hmacSha256(macInput, hmacKey);
|
||||
if (mac.size() != kHmacLen)
|
||||
{
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray out;
|
||||
out.reserve(header.size() + cipher.size() + mac.size());
|
||||
out.append(header);
|
||||
out.append(cipher);
|
||||
out.append(mac);
|
||||
return out;
|
||||
}
|
||||
|
||||
|
||||
bool parseHeader(const QByteArray &cipher, uint8_t &flagsOut, QByteArray &saltOut,
|
||||
QByteArray &nonceOut, QByteArray &payloadOut, QByteArray &macOut)
|
||||
{
|
||||
if (cipher.size() < kHeaderLen + kHmacLen)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
if (std::memcmp(cipher.constData(), kMagic, 4) != 0)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
if (static_cast<uint8_t>(cipher.at(4)) != kVersion)
|
||||
{
|
||||
return false;
|
||||
}
|
||||
|
||||
flagsOut = static_cast<uint8_t>(cipher.at(5));
|
||||
saltOut = cipher.mid(6, kSaltLen);
|
||||
nonceOut = cipher.mid(6 + kSaltLen, kNonceLen);
|
||||
|
||||
const int payloadLen = cipher.size() - kHeaderLen - kHmacLen;
|
||||
payloadOut = cipher.mid(kHeaderLen, payloadLen);
|
||||
macOut = cipher.right(kHmacLen);
|
||||
return true;
|
||||
}
|
||||
|
||||
std::optional<QByteArray> decryptCommon(const QByteArray &cipher, const QByteArray &aesKey,
|
||||
const QByteArray &hmacKey)
|
||||
{
|
||||
uint8_t flags = 0;
|
||||
QByteArray salt, nonce, payload, mac;
|
||||
if (!parseHeader(cipher, flags, salt, nonce, payload, mac))
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decrypt: header parse failed";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
|
||||
const QByteArray header = cipher.left(kHeaderLen);
|
||||
QByteArray macInput;
|
||||
macInput.reserve(header.size() + payload.size());
|
||||
macInput.append(header);
|
||||
macInput.append(payload);
|
||||
const QByteArray expectedMac = SecureSecretBox::hmacSha256(macInput, hmacKey);
|
||||
if (!SecureSecretBox::constantTimeEqual(expectedMac, mac))
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decrypt: HMAC mismatch";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
|
||||
QByteArray plain = payload;
|
||||
AES_ctx ctx;
|
||||
AES_init_ctx_iv(&ctx,
|
||||
reinterpret_cast<const uint8_t *>(aesKey.constData()),
|
||||
reinterpret_cast<const uint8_t *>(nonce.constData()));
|
||||
AES_CTR_xcrypt_buffer(&ctx,
|
||||
reinterpret_cast<uint8_t *>(plain.data()),
|
||||
static_cast<size_t>(plain.size()));
|
||||
return plain;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QByteArray SecureSecretBox::randomBytes(int n)
|
||||
{
|
||||
if (n <= 0)
|
||||
{
|
||||
return QByteArray();
|
||||
}
|
||||
QByteArray buf(n, Qt::Uninitialized);
|
||||
QRandomGenerator *gen = QRandomGenerator::system();
|
||||
|
||||
int filled = 0;
|
||||
while (filled < n)
|
||||
{
|
||||
const quint32 word = gen->generate();
|
||||
const int chunk = std::min(4, n - filled);
|
||||
std::memcpy(buf.data() + filled, &word, chunk);
|
||||
filled += chunk;
|
||||
}
|
||||
return buf;
|
||||
}
|
||||
|
||||
QByteArray SecureSecretBox::hmacSha256(const QByteArray &data, const QByteArray &key)
|
||||
{
|
||||
QMessageAuthenticationCode mac(QCryptographicHash::Sha256);
|
||||
mac.setKey(key);
|
||||
mac.addData(data);
|
||||
return mac.result();
|
||||
}
|
||||
|
||||
QByteArray SecureSecretBox::pbkdf2Sha256(const QByteArray &password, const QByteArray &salt,
|
||||
int iterations, int outLen)
|
||||
{
|
||||
if (iterations <= 0 || outLen <= 0 || password.isEmpty())
|
||||
{
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
constexpr int hLen = 32;
|
||||
const int blocks = (outLen + hLen - 1) / hLen;
|
||||
|
||||
QByteArray result;
|
||||
result.reserve(blocks * hLen);
|
||||
|
||||
for (int i = 1; i <= blocks; ++i)
|
||||
{
|
||||
|
||||
QByteArray block = salt;
|
||||
block.append(static_cast<char>((i >> 24) & 0xff));
|
||||
block.append(static_cast<char>((i >> 16) & 0xff));
|
||||
block.append(static_cast<char>((i >> 8) & 0xff));
|
||||
block.append(static_cast<char>(i & 0xff));
|
||||
|
||||
QByteArray u = hmacSha256(block, password);
|
||||
QByteArray t = u;
|
||||
|
||||
|
||||
for (int j = 1; j < iterations; ++j)
|
||||
{
|
||||
u = hmacSha256(u, password);
|
||||
for (int k = 0; k < hLen; ++k)
|
||||
{
|
||||
t[k] = static_cast<char>(static_cast<uint8_t>(t[k]) ^
|
||||
static_cast<uint8_t>(u[k]));
|
||||
}
|
||||
}
|
||||
result.append(t);
|
||||
}
|
||||
return result.left(outLen);
|
||||
}
|
||||
|
||||
bool SecureSecretBox::constantTimeEqual(const QByteArray &a, const QByteArray &b)
|
||||
{
|
||||
if (a.size() != b.size())
|
||||
{
|
||||
return false;
|
||||
}
|
||||
unsigned int diff = 0;
|
||||
for (int i = 0; i < a.size(); ++i)
|
||||
{
|
||||
diff |= static_cast<unsigned int>(static_cast<uint8_t>(a[i]) ^ static_cast<uint8_t>(b[i]));
|
||||
}
|
||||
return diff == 0;
|
||||
}
|
||||
|
||||
void SecureSecretBox::secureZero(QByteArray &buf)
|
||||
{
|
||||
if (buf.isEmpty())
|
||||
{
|
||||
return;
|
||||
}
|
||||
volatile char *p = buf.data();
|
||||
for (int i = 0; i < buf.size(); ++i)
|
||||
{
|
||||
p[i] = 0;
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QByteArray SecureSecretBox::encryptWithPassphrase(const QByteArray &plain, const QString &passphrase)
|
||||
{
|
||||
if (passphrase.isEmpty())
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] encryptWithPassphrase: empty passphrase";
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
const QByteArray salt = randomBytes(kSaltLen);
|
||||
if (salt.size() != kSaltLen)
|
||||
{
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray pwBytes = passphrase.toUtf8();
|
||||
QByteArray material = pbkdf2Sha256(pwBytes, salt, kPbkdf2Iterations, kKdfOutLen);
|
||||
secureZero(pwBytes);
|
||||
|
||||
if (material.size() != kKdfOutLen)
|
||||
{
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray aesKey, hmacKey;
|
||||
splitKeyMaterial(material, aesKey, hmacKey);
|
||||
QByteArray result = encryptCommon(plain, aesKey, hmacKey, salt, kFlagPassphrase);
|
||||
|
||||
secureZero(material);
|
||||
secureZero(aesKey);
|
||||
secureZero(hmacKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<QByteArray> SecureSecretBox::decryptWithPassphrase(const QByteArray &cipher,
|
||||
const QString &passphrase)
|
||||
{
|
||||
if (passphrase.isEmpty())
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
uint8_t flags = 0;
|
||||
QByteArray salt, nonce, payload, mac;
|
||||
if (!parseHeader(cipher, flags, salt, nonce, payload, mac))
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decryptWithPassphrase: header parse failed";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (flags != kFlagPassphrase)
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decryptWithPassphrase: flags mismatch (got"
|
||||
<< flags << "expected" << kFlagPassphrase << ")";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QByteArray pwBytes = passphrase.toUtf8();
|
||||
QByteArray material = pbkdf2Sha256(pwBytes, salt, kPbkdf2Iterations, kKdfOutLen);
|
||||
secureZero(pwBytes);
|
||||
|
||||
if (material.size() != kKdfOutLen)
|
||||
{
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QByteArray aesKey, hmacKey;
|
||||
splitKeyMaterial(material, aesKey, hmacKey);
|
||||
auto plain = decryptCommon(cipher, aesKey, hmacKey);
|
||||
|
||||
secureZero(material);
|
||||
secureZero(aesKey);
|
||||
secureZero(hmacKey);
|
||||
return plain;
|
||||
}
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
QByteArray SecureSecretBox::encryptLocalSecret(const QByteArray &plain)
|
||||
{
|
||||
QByteArray material = loadOrCreateLocalKeyMaterial();
|
||||
if (material.size() != kKdfOutLen)
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] encryptLocalSecret: local key unavailable";
|
||||
return QByteArray();
|
||||
}
|
||||
|
||||
QByteArray aesKey, hmacKey;
|
||||
splitKeyMaterial(material, aesKey, hmacKey);
|
||||
|
||||
|
||||
QByteArray zeroSalt(kSaltLen, '\0');
|
||||
QByteArray result = encryptCommon(plain, aesKey, hmacKey, zeroSalt, kFlagLocal);
|
||||
|
||||
secureZero(material);
|
||||
secureZero(aesKey);
|
||||
secureZero(hmacKey);
|
||||
return result;
|
||||
}
|
||||
|
||||
std::optional<QByteArray> SecureSecretBox::decryptLocalSecret(const QByteArray &cipher)
|
||||
{
|
||||
uint8_t flags = 0;
|
||||
QByteArray salt, nonce, payload, mac;
|
||||
if (!parseHeader(cipher, flags, salt, nonce, payload, mac))
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decryptLocalSecret: header parse failed";
|
||||
return std::nullopt;
|
||||
}
|
||||
if (flags != kFlagLocal)
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decryptLocalSecret: flags mismatch (got"
|
||||
<< flags << "expected" << kFlagLocal << ")";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QByteArray material = loadOrCreateLocalKeyMaterial();
|
||||
if (material.size() != kKdfOutLen)
|
||||
{
|
||||
qWarning() << "[SecureSecretBox] decryptLocalSecret: local key unavailable";
|
||||
return std::nullopt;
|
||||
}
|
||||
|
||||
QByteArray aesKey, hmacKey;
|
||||
splitKeyMaterial(material, aesKey, hmacKey);
|
||||
auto plain = decryptCommon(cipher, aesKey, hmacKey);
|
||||
|
||||
secureZero(material);
|
||||
secureZero(aesKey);
|
||||
secureZero(hmacKey);
|
||||
return plain;
|
||||
}
|
||||
85
src/qEmbyCore/utils/securesecretbox.h
Normal file
85
src/qEmbyCore/utils/securesecretbox.h
Normal file
@@ -0,0 +1,85 @@
|
||||
#ifndef SECURESECRETBOX_H
|
||||
#define SECURESECRETBOX_H
|
||||
|
||||
#include "../qEmbyCore_global.h"
|
||||
|
||||
#include <QByteArray>
|
||||
#include <QString>
|
||||
|
||||
#include <optional>
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
class QEMBYCORE_EXPORT SecureSecretBox
|
||||
{
|
||||
public:
|
||||
|
||||
|
||||
|
||||
|
||||
static QByteArray encryptWithPassphrase(const QByteArray &plain, const QString &passphrase);
|
||||
|
||||
|
||||
static std::optional<QByteArray> decryptWithPassphrase(const QByteArray &cipher, const QString &passphrase);
|
||||
|
||||
|
||||
|
||||
|
||||
static QByteArray encryptLocalSecret(const QByteArray &plain);
|
||||
|
||||
|
||||
static std::optional<QByteArray> decryptLocalSecret(const QByteArray &cipher);
|
||||
|
||||
|
||||
|
||||
|
||||
static QByteArray pbkdf2Sha256(const QByteArray &password, const QByteArray &salt,
|
||||
int iterations, int outLen);
|
||||
|
||||
|
||||
static QByteArray hmacSha256(const QByteArray &data, const QByteArray &key);
|
||||
|
||||
|
||||
static QByteArray randomBytes(int n);
|
||||
|
||||
|
||||
static bool constantTimeEqual(const QByteArray &a, const QByteArray &b);
|
||||
|
||||
|
||||
static void secureZero(QByteArray &buf);
|
||||
|
||||
private:
|
||||
SecureSecretBox() = delete;
|
||||
};
|
||||
|
||||
#endif
|
||||
Reference in New Issue
Block a user