chore: baseline usable version

This commit is contained in:
dela
2026-06-10 11:17:51 +08:00
commit a2f82a86b6
932 changed files with 172551 additions and 0 deletions

View 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}
)

View 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());
}

View 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

View 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);
}
}

View 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

View 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);
}

View 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

View 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();
}

View 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

View 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;
}

View 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

View 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

View 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;
}

View 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

View 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

View 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;
}

View 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

View 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();
}

View 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

View 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
View 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

View 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

View 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

View 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;
}

View 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

View 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;
}

View 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

View 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

View 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;
}

View 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

View 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

View 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;
}

View 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

View 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

View 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
View 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

File diff suppressed because it is too large Load Diff

View 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

View 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;
}

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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;
}

View 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

View 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);
}

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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);
}

View 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

View 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();
}

View 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

View 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);
}

View 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

File diff suppressed because it is too large Load Diff

View 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

View 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;
}

View 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

View 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]);
}
}

View 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

View 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;
}

View 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