1054 lines
37 KiB
C++
1054 lines
37 KiB
C++
#include "dandanplayprovider.h"
|
|
|
|
#include <QCoreApplication>
|
|
#include <QCryptographicHash>
|
|
#include <QDateTime>
|
|
#include <QDebug>
|
|
#include <QHash>
|
|
#include <QJsonArray>
|
|
#include <QJsonObject>
|
|
#include <QMap>
|
|
#include <QFileInfo>
|
|
#include <QUrl>
|
|
#include <QUrlQuery>
|
|
#include <algorithm>
|
|
#include <cstdlib>
|
|
#include <exception>
|
|
#include <stdexcept>
|
|
#include <utility>
|
|
|
|
namespace {
|
|
|
|
QString firstNonEmpty(std::initializer_list<QString> values)
|
|
{
|
|
for (const QString &value : values) {
|
|
if (!value.trimmed().isEmpty()) {
|
|
return value.trimmed();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QString stringField(const QJsonObject &obj,
|
|
std::initializer_list<const char *> keys)
|
|
{
|
|
for (const char *key : keys) {
|
|
const QString value = obj.value(QLatin1String(key)).toVariant().toString();
|
|
if (!value.trimmed().isEmpty()) {
|
|
return value.trimmed();
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
int intField(const QJsonObject &obj,
|
|
std::initializer_list<const char *> keys,
|
|
int defaultValue = 0)
|
|
{
|
|
for (const char *key : keys) {
|
|
const QJsonValue value = obj.value(QLatin1String(key));
|
|
if (!value.isUndefined() && !value.isNull()) {
|
|
return value.toVariant().toInt();
|
|
}
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
qint64 longField(const QJsonObject &obj,
|
|
std::initializer_list<const char *> keys,
|
|
qint64 defaultValue = 0)
|
|
{
|
|
for (const char *key : keys) {
|
|
const QJsonValue value = obj.value(QLatin1String(key));
|
|
if (!value.isUndefined() && !value.isNull()) {
|
|
return value.toVariant().toLongLong();
|
|
}
|
|
}
|
|
return defaultValue;
|
|
}
|
|
|
|
QColor parseColor(const QJsonValue &value)
|
|
{
|
|
if (value.isString()) {
|
|
const QColor color(value.toString());
|
|
if (color.isValid()) {
|
|
return color;
|
|
}
|
|
bool ok = false;
|
|
const uint rgb = value.toString().toUInt(&ok, 10);
|
|
if (ok) {
|
|
return QColor::fromRgb(rgb);
|
|
}
|
|
}
|
|
|
|
bool ok = false;
|
|
const uint rgb = value.toVariant().toUInt(&ok);
|
|
if (ok) {
|
|
return QColor::fromRgb(rgb);
|
|
}
|
|
return QColor(Qt::white);
|
|
}
|
|
|
|
QString providerIdValue(const QVariantMap &providerIds,
|
|
std::initializer_list<const char *> keys)
|
|
{
|
|
for (const char *key : keys) {
|
|
const QString value =
|
|
providerIds.value(QString::fromLatin1(key)).toString().trimmed();
|
|
if (!value.isEmpty()) {
|
|
return value;
|
|
}
|
|
}
|
|
return {};
|
|
}
|
|
|
|
QString normalizedHost(const QString &baseUrl)
|
|
{
|
|
const QUrl url = QUrl::fromUserInput(baseUrl.trimmed());
|
|
return url.host().trimmed().toLower();
|
|
}
|
|
|
|
bool isOfficialDandanplayEndpoint(const DanmakuProviderConfig &config)
|
|
{
|
|
return normalizedHost(config.baseUrl) == QStringLiteral("api.dandanplay.net");
|
|
}
|
|
|
|
QString missingCredentialsMessage()
|
|
{
|
|
return QCoreApplication::translate(
|
|
"DandanplayProvider",
|
|
"DandanPlay Open API now requires App ID and App Secret. Configure them in Danmaku Server settings.");
|
|
}
|
|
|
|
bool isMissingCredentialsErrorMessage(const QString &message)
|
|
{
|
|
return message.trimmed() == missingCredentialsMessage();
|
|
}
|
|
|
|
void ensureOfficialAuthentication(const DanmakuProviderConfig &config,
|
|
const QString &apiPath)
|
|
{
|
|
if (!isOfficialDandanplayEndpoint(config)) {
|
|
return;
|
|
}
|
|
|
|
const QString appId = config.appId.trimmed();
|
|
const QString appSecret = config.appSecret.trimmed();
|
|
if (!appId.isEmpty() && !appSecret.isEmpty()) {
|
|
return;
|
|
}
|
|
|
|
qWarning().noquote()
|
|
<< "[Danmaku][DandanPlay] Missing App credentials for official endpoint"
|
|
<< "| path:" << apiPath
|
|
<< "| baseUrl:" << config.baseUrl;
|
|
throw std::runtime_error(missingCredentialsMessage().toStdString());
|
|
}
|
|
|
|
QMap<QString, QString> buildHeaders(const DanmakuProviderConfig &config,
|
|
const QString &apiPath)
|
|
{
|
|
QMap<QString, QString> headers;
|
|
headers.insert(QStringLiteral("Accept"), QStringLiteral("application/json"));
|
|
headers.insert(QStringLiteral("User-Agent"),
|
|
QStringLiteral("qEmby/1.0 (Danmaku)"));
|
|
|
|
const QString appId = config.appId.trimmed();
|
|
const QString appSecret = config.appSecret.trimmed();
|
|
if (appId.isEmpty() || appSecret.isEmpty()) {
|
|
return headers;
|
|
}
|
|
|
|
const QString unixTimestamp =
|
|
QString::number(QDateTime::currentSecsSinceEpoch());
|
|
const QByteArray raw = (appId + unixTimestamp + apiPath + appSecret)
|
|
.toUtf8();
|
|
const QString signature = QString::fromLatin1(
|
|
QCryptographicHash::hash(raw, QCryptographicHash::Sha256).toBase64());
|
|
|
|
headers.insert(QStringLiteral("X-AppId"), appId);
|
|
headers.insert(QStringLiteral("X-Timestamp"), unixTimestamp);
|
|
headers.insert(QStringLiteral("X-Signature"), signature);
|
|
return headers;
|
|
}
|
|
|
|
QString buildUrl(const DanmakuProviderConfig &config,
|
|
const QString &apiPath,
|
|
const QList<QPair<QString, QString>> &queryItems)
|
|
{
|
|
QUrl url(config.baseUrl.trimmed().isEmpty()
|
|
? QStringLiteral("https://api.dandanplay.net")
|
|
: config.baseUrl.trimmed());
|
|
QString path = url.path();
|
|
if (!path.endsWith('/')) {
|
|
path.append('/');
|
|
}
|
|
const QString normalizedApiPath =
|
|
apiPath.startsWith('/') ? apiPath.mid(1) : apiPath;
|
|
path += normalizedApiPath;
|
|
url.setPath(path);
|
|
|
|
QUrlQuery query;
|
|
for (const auto &item : queryItems) {
|
|
if (!item.second.trimmed().isEmpty()) {
|
|
query.addQueryItem(item.first, item.second.trimmed());
|
|
}
|
|
}
|
|
url.setQuery(query);
|
|
return url.toString();
|
|
}
|
|
|
|
double titleScore(const QString &lhs, const QString &rhs)
|
|
{
|
|
if (lhs.isEmpty() || rhs.isEmpty()) {
|
|
return 0.0;
|
|
}
|
|
|
|
const QString rawLhs = lhs.trimmed().toLower();
|
|
const QString rawRhs = rhs.trimmed().toLower();
|
|
if (rawLhs == rawRhs) {
|
|
return 1.0;
|
|
}
|
|
|
|
auto cleaned = [](const QString &s) -> QString {
|
|
QString result = s;
|
|
const int fromIdx = result.lastIndexOf(QStringLiteral(" from "));
|
|
if (fromIdx > 0) {
|
|
result = result.left(fromIdx).trimmed();
|
|
}
|
|
int pos = 0;
|
|
while (pos < result.size()) {
|
|
const QChar ch = result.at(pos);
|
|
if (ch == QChar(0x3010) || ch == QLatin1Char('[')) {
|
|
const QChar closeChar =
|
|
ch == QChar(0x3010) ? QChar(0x3011) : QLatin1Char(']');
|
|
const int closeIdx = result.indexOf(closeChar, pos + 1);
|
|
if (closeIdx > pos) {
|
|
result.remove(pos, closeIdx - pos + 1);
|
|
continue;
|
|
}
|
|
}
|
|
++pos;
|
|
}
|
|
result = result.trimmed();
|
|
for (int i = result.size() - 1; i >= 0; --i) {
|
|
const QChar ch = result.at(i);
|
|
if (ch == QLatin1Char(')') || ch == QChar(0xFF09)) {
|
|
const QChar openChar =
|
|
ch == QLatin1Char(')') ? QLatin1Char('(') : QChar(0xFF08);
|
|
const int openIdx = result.lastIndexOf(openChar, i - 1);
|
|
if (openIdx >= 0 && i - openIdx == 5) {
|
|
bool isYear = true;
|
|
for (int j = openIdx + 1; j < i; ++j) {
|
|
if (!result.at(j).isDigit()) {
|
|
isYear = false;
|
|
break;
|
|
}
|
|
}
|
|
if (isYear) {
|
|
result.remove(openIdx, i - openIdx + 1);
|
|
result = result.trimmed();
|
|
}
|
|
}
|
|
break;
|
|
}
|
|
if (!ch.isSpace()) {
|
|
break;
|
|
}
|
|
}
|
|
return result.trimmed();
|
|
};
|
|
|
|
const QString cleanLhs = cleaned(rawLhs);
|
|
const QString cleanRhs = cleaned(rawRhs);
|
|
if (!cleanLhs.isEmpty() && !cleanRhs.isEmpty() &&
|
|
cleanLhs == cleanRhs) {
|
|
return 0.98;
|
|
}
|
|
|
|
const QString &shorter =
|
|
cleanLhs.size() <= cleanRhs.size() ? cleanLhs : cleanRhs;
|
|
const QString &longer =
|
|
cleanLhs.size() <= cleanRhs.size() ? cleanRhs : cleanLhs;
|
|
if (!shorter.isEmpty() && !longer.isEmpty()) {
|
|
if (longer.startsWith(shorter)) {
|
|
const double ratio = shorter.size() /
|
|
static_cast<double>(longer.size());
|
|
return 0.70 + ratio * 0.22;
|
|
}
|
|
if (longer.contains(shorter)) {
|
|
const double ratio = shorter.size() /
|
|
static_cast<double>(longer.size());
|
|
return 0.30 + ratio * 0.30;
|
|
}
|
|
}
|
|
|
|
if (rawLhs.contains(rawRhs) || rawRhs.contains(rawLhs)) {
|
|
return 0.45;
|
|
}
|
|
|
|
int overlap = 0;
|
|
for (const QChar ch : cleanLhs.isEmpty() ? rawLhs : cleanLhs) {
|
|
const QString &target = cleanRhs.isEmpty() ? rawRhs : cleanRhs;
|
|
if (!ch.isSpace() && target.contains(ch)) {
|
|
++overlap;
|
|
}
|
|
}
|
|
const int base = cleanLhs.isEmpty() ? rawLhs.size()
|
|
: cleanLhs.size();
|
|
return base == 0
|
|
? 0.0
|
|
: qBound(0.0, overlap / static_cast<double>(base), 0.35);
|
|
}
|
|
|
|
int extractYear(const QString &title)
|
|
{
|
|
const int parenIdx = title.lastIndexOf('(');
|
|
if (parenIdx < 0) {
|
|
return 0;
|
|
}
|
|
const int closeIdx = title.indexOf(')', parenIdx + 1);
|
|
if (closeIdx < 0 || closeIdx - parenIdx != 5) {
|
|
return 0;
|
|
}
|
|
bool ok = false;
|
|
const int year =
|
|
title.mid(parenIdx + 1, 4).toInt(&ok);
|
|
return (ok && year >= 1900 && year <= 2100) ? year : 0;
|
|
}
|
|
|
|
|
|
int chineseDigitsToInt(const QString &digits)
|
|
{
|
|
if (digits.isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
bool ok = false;
|
|
const int direct = digits.toInt(&ok);
|
|
if (ok && direct > 0) {
|
|
return direct;
|
|
}
|
|
|
|
static const QHash<QChar, int> digitMap = {
|
|
{QChar(0x4E00), 1}, {QChar(0x4E8C), 2}, {QChar(0x4E09), 3},
|
|
{QChar(0x56DB), 4}, {QChar(0x4E94), 5}, {QChar(0x516D), 6},
|
|
{QChar(0x4E03), 7}, {QChar(0x516B), 8}, {QChar(0x4E5D), 9}};
|
|
static const QChar shi(0x5341);
|
|
|
|
int total = 0;
|
|
int currentDigit = 0;
|
|
for (const QChar ch : digits) {
|
|
if (ch == shi) {
|
|
total += currentDigit == 0 ? 10 : currentDigit * 10;
|
|
currentDigit = 0;
|
|
} else if (digitMap.contains(ch)) {
|
|
currentDigit = digitMap.value(ch);
|
|
}
|
|
}
|
|
total += currentDigit;
|
|
return total;
|
|
}
|
|
|
|
|
|
QString chineseOrdinalString(int value)
|
|
{
|
|
if (value <= 0) {
|
|
return {};
|
|
}
|
|
|
|
static const QChar digits[] = {
|
|
QChar(0x4E00), QChar(0x4E8C), QChar(0x4E09),
|
|
QChar(0x56DB), QChar(0x4E94), QChar(0x516D),
|
|
QChar(0x4E03), QChar(0x516B), QChar(0x4E5D)};
|
|
static const QChar shi(0x5341);
|
|
|
|
if (value < 10) {
|
|
return QString(digits[value - 1]);
|
|
}
|
|
if (value == 10) {
|
|
return QString(shi);
|
|
}
|
|
if (value < 20) {
|
|
return QString(shi) + digits[value - 10 - 1];
|
|
}
|
|
if (value < 100) {
|
|
const int tens = value / 10;
|
|
const int ones = value % 10;
|
|
QString result = QString(digits[tens - 1]) + shi;
|
|
if (ones > 0) {
|
|
result += digits[ones - 1];
|
|
}
|
|
return result;
|
|
}
|
|
return QString::number(value);
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
int extractSeasonNumber(const QString &title)
|
|
{
|
|
const QString trimmed = title.trimmed();
|
|
if (trimmed.isEmpty()) {
|
|
return 0;
|
|
}
|
|
|
|
static const QRegularExpression cnPattern(
|
|
QStringLiteral(R"(第\s*([\x{4E00}-\x{9FFF}0-9]+)\s*[季部])"));
|
|
QRegularExpressionMatch match = cnPattern.match(trimmed);
|
|
if (match.hasMatch()) {
|
|
const int parsed = chineseDigitsToInt(match.captured(1).trimmed());
|
|
if (parsed > 0) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
static const QRegularExpression enPattern(
|
|
QStringLiteral(R"((?:^|[^A-Za-z])(?:Season|S)\s*0*(\d{1,2})(?:[^A-Za-z0-9]|$))"),
|
|
QRegularExpression::CaseInsensitiveOption);
|
|
match = enPattern.match(trimmed);
|
|
if (match.hasMatch()) {
|
|
const int parsed = match.captured(1).toInt();
|
|
if (parsed > 0) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
static const QRegularExpression romanPattern(
|
|
QStringLiteral(R"((?:^|\s)(VIII|VII|VI|IV|IX|III|II|V|X)\s*$)"));
|
|
match = romanPattern.match(trimmed);
|
|
if (match.hasMatch()) {
|
|
const QString roman = match.captured(1).toUpper();
|
|
static const QHash<QString, int> romanMap = {
|
|
{QStringLiteral("II"), 2}, {QStringLiteral("III"), 3},
|
|
{QStringLiteral("IV"), 4}, {QStringLiteral("V"), 5},
|
|
{QStringLiteral("VI"), 6}, {QStringLiteral("VII"), 7},
|
|
{QStringLiteral("VIII"), 8}, {QStringLiteral("IX"), 9},
|
|
{QStringLiteral("X"), 10}};
|
|
const int parsed = romanMap.value(roman, 0);
|
|
if (parsed > 1) {
|
|
return parsed;
|
|
}
|
|
}
|
|
|
|
return 0;
|
|
}
|
|
|
|
double computeScore(const DanmakuMediaContext &context,
|
|
const DanmakuMatchCandidate &candidate,
|
|
const QString &queryKeyword)
|
|
{
|
|
double score = 0.0;
|
|
const QString subjectTitle =
|
|
context.isEpisode() ? context.seriesName : context.title;
|
|
const QString &candidateSubject = candidate.subtitle.isEmpty()
|
|
? candidate.title
|
|
: candidate.subtitle;
|
|
score += titleScore(subjectTitle, candidateSubject) * 55.0;
|
|
score += titleScore(context.originalTitle, candidateSubject) * 18.0;
|
|
score += titleScore(context.title, candidate.title) * 18.0;
|
|
score += titleScore(queryKeyword, candidate.displayText()) * 12.0;
|
|
|
|
if (context.isEpisode() && context.episodeNumber > 0 &&
|
|
candidate.episodeNumber > 0 &&
|
|
context.episodeNumber == candidate.episodeNumber) {
|
|
score += 24.0;
|
|
}
|
|
|
|
|
|
|
|
|
|
if (context.isEpisode() && context.seasonNumber > 0) {
|
|
const int candidateSeason = extractSeasonNumber(candidateSubject);
|
|
if (candidateSeason > 0) {
|
|
if (candidateSeason == context.seasonNumber) {
|
|
score += 30.0;
|
|
} else {
|
|
score -= 30.0;
|
|
}
|
|
} else if (context.seasonNumber == 1) {
|
|
|
|
score += 6.0;
|
|
} else {
|
|
|
|
score -= 8.0;
|
|
}
|
|
}
|
|
|
|
if (context.durationMs > 0 && candidate.durationMs > 0) {
|
|
const qint64 diff = std::llabs(context.durationMs - candidate.durationMs);
|
|
if (diff <= 30 * 1000) {
|
|
score += 12.0;
|
|
} else if (diff <= 90 * 1000) {
|
|
score += 6.0;
|
|
}
|
|
}
|
|
|
|
if (candidate.commentCount > 0) {
|
|
score += std::min(10.0, candidate.commentCount / 80.0);
|
|
}
|
|
|
|
if (context.productionYear > 0) {
|
|
const int candidateYear = extractYear(candidateSubject);
|
|
if (candidateYear > 0) {
|
|
const int yearDiff = std::abs(context.productionYear - candidateYear);
|
|
if (yearDiff == 0) {
|
|
score += 18.0;
|
|
} else if (yearDiff <= 1) {
|
|
score += 8.0;
|
|
} else if (yearDiff <= 3) {
|
|
score += 3.0;
|
|
}
|
|
}
|
|
}
|
|
|
|
return score;
|
|
}
|
|
|
|
QList<DanmakuMatchCandidate> parseSearchResponse(const QJsonObject &response,
|
|
const DanmakuMediaContext &context,
|
|
const QString &queryKeyword)
|
|
{
|
|
QList<DanmakuMatchCandidate> candidates;
|
|
auto parseArray = [&](const QJsonArray &animeArray) {
|
|
for (const QJsonValue &animeValue : animeArray) {
|
|
const QJsonObject animeObj = animeValue.toObject();
|
|
const QString animeTitle = firstNonEmpty(
|
|
{stringField(animeObj, {"animeTitle", "title", "name"}),
|
|
stringField(animeObj, {"animeTitleCN", "animeTitleJP"})});
|
|
|
|
QJsonArray episodes = animeObj.value(QStringLiteral("episodes")).toArray();
|
|
if (episodes.isEmpty() &&
|
|
!animeObj.value(QStringLiteral("episodeId")).isUndefined()) {
|
|
episodes.append(animeObj);
|
|
}
|
|
if (episodes.isEmpty() &&
|
|
(!animeObj.value(QStringLiteral("animeId")).isUndefined() ||
|
|
!animeObj.value(QStringLiteral("bangumiId")).isUndefined())) {
|
|
QJsonObject fallbackEpisodeObj = animeObj;
|
|
const QString animeIdStr = stringField(
|
|
animeObj, {"animeId", "bangumiId"});
|
|
if (!animeIdStr.isEmpty()) {
|
|
fallbackEpisodeObj.insert(
|
|
QStringLiteral("episodeId"), animeIdStr);
|
|
}
|
|
if (fallbackEpisodeObj.value(
|
|
QStringLiteral("episodeTitle")).isUndefined()) {
|
|
fallbackEpisodeObj.insert(
|
|
QStringLiteral("episodeTitle"), animeTitle);
|
|
}
|
|
episodes.append(fallbackEpisodeObj);
|
|
}
|
|
|
|
for (const QJsonValue &episodeValue : episodes) {
|
|
const QJsonObject episodeObj = episodeValue.toObject();
|
|
DanmakuMatchCandidate candidate;
|
|
candidate.provider = QStringLiteral("dandanplay");
|
|
candidate.targetId =
|
|
stringField(episodeObj, {"episodeId", "id", "episodeID"});
|
|
candidate.title = stringField(
|
|
episodeObj, {"episodeTitle", "title", "name", "episodeName"});
|
|
candidate.subtitle = animeTitle;
|
|
candidate.episodeNumber =
|
|
intField(episodeObj, {"episodeNumber", "episode", "sort"}, -1);
|
|
candidate.durationMs = longField(
|
|
episodeObj, {"durationMs", "duration", "videoDuration"}, 0);
|
|
if (candidate.durationMs > 0 && candidate.durationMs < 1000) {
|
|
candidate.durationMs *= 1000;
|
|
}
|
|
candidate.commentCount = intField(
|
|
episodeObj, {"commentCount", "comments", "danmakuCount"}, 0);
|
|
candidate.score = computeScore(context, candidate, queryKeyword);
|
|
candidate.matchReason = QStringLiteral("search");
|
|
if (candidate.isValid()) {
|
|
candidates.append(candidate);
|
|
}
|
|
}
|
|
}
|
|
};
|
|
|
|
parseArray(response.value(QStringLiteral("animes")).toArray());
|
|
if (!candidates.isEmpty()) {
|
|
return candidates;
|
|
}
|
|
|
|
parseArray(response.value(QStringLiteral("data")).toArray());
|
|
return candidates;
|
|
}
|
|
|
|
QList<DanmakuComment> parseCommentsResponse(const QJsonObject &response)
|
|
{
|
|
QList<DanmakuComment> comments;
|
|
QJsonArray array = response.value(QStringLiteral("comments")).toArray();
|
|
if (array.isEmpty()) {
|
|
array = response.value(QStringLiteral("data")).toArray();
|
|
}
|
|
if (array.isEmpty()) {
|
|
array = response.value(QStringLiteral("result")).toArray();
|
|
}
|
|
|
|
comments.reserve(array.size());
|
|
for (const QJsonValue &value : array) {
|
|
const QJsonObject obj = value.toObject();
|
|
DanmakuComment comment;
|
|
comment.text = firstNonEmpty(
|
|
{stringField(obj, {"m", "text", "comment", "content"})});
|
|
|
|
const QString p = stringField(obj, {"p"});
|
|
if (!p.isEmpty()) {
|
|
const QStringList parts = p.split(',', Qt::KeepEmptyParts);
|
|
if (!parts.isEmpty()) {
|
|
comment.timeMs =
|
|
static_cast<qint64>(parts[0].toDouble() * 1000.0);
|
|
}
|
|
if (parts.size() > 1) {
|
|
bool ok = false;
|
|
const int parsedMode = parts[1].toInt(&ok);
|
|
if (ok) {
|
|
comment.mode = parsedMode;
|
|
}
|
|
}
|
|
if (parts.size() > 2) {
|
|
comment.color = parseColor(parts[2]);
|
|
}
|
|
if (parts.size() > 3) {
|
|
bool ok = false;
|
|
const int parsedFontLevel = parts[3].toInt(&ok);
|
|
if (ok) {
|
|
comment.fontLevel = parsedFontLevel;
|
|
}
|
|
}
|
|
} else {
|
|
comment.timeMs = longField(obj, {"timeMs", "time", "position"}, 0);
|
|
if (comment.timeMs > 0 && comment.timeMs < 1000) {
|
|
comment.timeMs *= 1000;
|
|
}
|
|
comment.mode = intField(obj, {"mode", "positionType"}, 1);
|
|
comment.color = parseColor(obj.value(QStringLiteral("color")));
|
|
comment.fontLevel = intField(obj, {"size", "fontLevel"}, 25);
|
|
}
|
|
|
|
comment.sender =
|
|
stringField(obj, {"sender", "user", "author", "nickname"});
|
|
const QString createdAtStr =
|
|
stringField(obj, {"dateTime", "createdAt", "date", "timeStamp"});
|
|
comment.createdAt =
|
|
QDateTime::fromString(createdAtStr, Qt::ISODate);
|
|
|
|
if (comment.isValid()) {
|
|
comments.append(comment);
|
|
}
|
|
}
|
|
return comments;
|
|
}
|
|
|
|
QList<DanmakuMatchCandidate> deduplicateCandidates(
|
|
const QList<DanmakuMatchCandidate> &candidates)
|
|
{
|
|
QHash<QString, DanmakuMatchCandidate> bestById;
|
|
for (const DanmakuMatchCandidate &candidate : candidates) {
|
|
const auto existing = bestById.constFind(candidate.targetId);
|
|
if (existing == bestById.constEnd() ||
|
|
candidate.score > existing->score) {
|
|
bestById.insert(candidate.targetId, candidate);
|
|
}
|
|
}
|
|
|
|
QList<DanmakuMatchCandidate> deduplicated = bestById.values();
|
|
std::sort(deduplicated.begin(), deduplicated.end(),
|
|
[](const DanmakuMatchCandidate &lhs,
|
|
const DanmakuMatchCandidate &rhs) {
|
|
if (!qFuzzyCompare(lhs.score, rhs.score)) {
|
|
return lhs.score > rhs.score;
|
|
}
|
|
return lhs.commentCount > rhs.commentCount;
|
|
});
|
|
return deduplicated;
|
|
}
|
|
|
|
QList<DanmakuMatchCandidate> parseMatchResponse(
|
|
const QJsonObject &response,
|
|
const DanmakuMediaContext &context,
|
|
const QString &fileName)
|
|
{
|
|
QList<DanmakuMatchCandidate> candidates;
|
|
const bool isMatched = response.value(QStringLiteral("isMatched")).toBool();
|
|
QJsonArray matches = response.value(QStringLiteral("matches")).toArray();
|
|
if (matches.isEmpty()) {
|
|
matches = response.value(QStringLiteral("animes")).toArray();
|
|
}
|
|
if (matches.isEmpty()) {
|
|
matches = response.value(QStringLiteral("data")).toArray();
|
|
}
|
|
|
|
for (const QJsonValue &matchValue : matches) {
|
|
const QJsonObject matchObj = matchValue.toObject();
|
|
DanmakuMatchCandidate candidate;
|
|
candidate.provider = QStringLiteral("dandanplay");
|
|
candidate.targetId =
|
|
stringField(matchObj, {"episodeId", "id", "episodeID"});
|
|
candidate.title = stringField(
|
|
matchObj, {"episodeTitle", "title", "name", "episodeName"});
|
|
candidate.subtitle = stringField(
|
|
matchObj, {"animeTitle", "animeTitleCN", "animeName"});
|
|
candidate.episodeNumber =
|
|
intField(matchObj, {"episodeNumber", "episode", "sort"}, -1);
|
|
candidate.durationMs = longField(
|
|
matchObj, {"durationMs", "duration", "videoDuration"}, 0);
|
|
if (candidate.durationMs > 0 && candidate.durationMs < 1000) {
|
|
candidate.durationMs *= 1000;
|
|
}
|
|
candidate.commentCount = intField(
|
|
matchObj, {"commentCount", "comments", "danmakuCount"}, 0);
|
|
|
|
if (candidate.targetId.isEmpty()) {
|
|
const QString animeIdStr =
|
|
stringField(matchObj, {"animeId", "bangumiId"});
|
|
if (!animeIdStr.isEmpty()) {
|
|
candidate.targetId = animeIdStr;
|
|
}
|
|
}
|
|
if (candidate.title.isEmpty()) {
|
|
candidate.title = candidate.subtitle;
|
|
}
|
|
|
|
candidate.score = computeScore(context, candidate, fileName);
|
|
if (isMatched) {
|
|
candidate.score += 30.0;
|
|
}
|
|
candidate.matchReason = QStringLiteral("match");
|
|
if (candidate.isValid()) {
|
|
candidates.append(candidate);
|
|
}
|
|
}
|
|
|
|
std::sort(candidates.begin(), candidates.end(),
|
|
[](const DanmakuMatchCandidate &lhs,
|
|
const DanmakuMatchCandidate &rhs) {
|
|
return lhs.score > rhs.score;
|
|
});
|
|
return candidates;
|
|
}
|
|
|
|
}
|
|
|
|
DandanplayProvider::DandanplayProvider(NetworkManager *networkManager)
|
|
: m_networkManager(networkManager)
|
|
{
|
|
}
|
|
|
|
QCoro::Task<QList<DanmakuMatchCandidate>> DandanplayProvider::searchCandidates(
|
|
DanmakuMediaContext context,
|
|
DanmakuProviderConfig config,
|
|
QString manualKeyword) const
|
|
{
|
|
QList<DanmakuMatchCandidate> allCandidates;
|
|
if (!m_networkManager) {
|
|
co_return allCandidates;
|
|
}
|
|
|
|
bool hadSuccessfulSearchResponse = false;
|
|
QString lastSearchError;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
QStringList keywords;
|
|
if (!manualKeyword.trimmed().isEmpty()) {
|
|
keywords << manualKeyword.trimmed();
|
|
} else if (context.isEpisode()) {
|
|
const QString trimmedSeries = context.seriesName.trimmed();
|
|
const QString trimmedOriginal = context.originalTitle.trimmed();
|
|
const QString trimmedTitle = context.title.trimmed();
|
|
if (!trimmedSeries.isEmpty()) {
|
|
|
|
if (context.seasonNumber > 1) {
|
|
const QString ordinal = chineseOrdinalString(context.seasonNumber);
|
|
if (!ordinal.isEmpty()) {
|
|
keywords << QStringLiteral("%1 第%2季")
|
|
.arg(trimmedSeries, ordinal);
|
|
}
|
|
}
|
|
keywords << trimmedSeries;
|
|
if (!trimmedOriginal.isEmpty() && trimmedOriginal != trimmedSeries) {
|
|
keywords << trimmedOriginal;
|
|
}
|
|
} else {
|
|
|
|
keywords << trimmedTitle << trimmedOriginal;
|
|
}
|
|
} else {
|
|
keywords << context.title << context.originalTitle;
|
|
}
|
|
QStringList normalizedKeywords;
|
|
normalizedKeywords.reserve(keywords.size());
|
|
for (const QString &keyword : std::as_const(keywords)) {
|
|
const QString trimmed = keyword.trimmed();
|
|
if (!trimmed.isEmpty()) {
|
|
normalizedKeywords.append(trimmed);
|
|
}
|
|
}
|
|
keywords = normalizedKeywords;
|
|
keywords.removeDuplicates();
|
|
|
|
const QString tmdbId = providerIdValue(
|
|
context.providerIds, {"Tmdb", "tmdb", "TMDb", "tmdbid"});
|
|
|
|
const QString filePath = context.path.trimmed();
|
|
const QString fileName =
|
|
filePath.isEmpty() ? QString()
|
|
: QFileInfo(filePath).fileName().trimmed();
|
|
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search start"
|
|
<< "| media:" << context.displayTitle()
|
|
<< "| mediaId:" << context.mediaId
|
|
<< "| isEpisode:" << context.isEpisode()
|
|
<< "| seriesName:" << context.seriesName.trimmed()
|
|
<< "| seasonNumber:" << context.seasonNumber
|
|
<< "| episodeNumber:" << context.episodeNumber
|
|
<< "| contentScope:" << config.contentScope
|
|
<< "| fileName:" << fileName
|
|
<< "| keywords:" << keywords.join(QStringLiteral(" | "));
|
|
|
|
if (!fileName.isEmpty() && manualKeyword.trimmed().isEmpty()) {
|
|
try {
|
|
const QString matchPath = QStringLiteral("/api/v2/match");
|
|
ensureOfficialAuthentication(config, matchPath);
|
|
QJsonObject payload;
|
|
payload.insert(QStringLiteral("fileName"), fileName);
|
|
payload.insert(QStringLiteral("fileHash"), QString());
|
|
payload.insert(QStringLiteral("fileSize"), 0);
|
|
payload.insert(QStringLiteral("matchMode"),
|
|
QStringLiteral("fileNameOnly"));
|
|
const QString matchUrl = buildUrl(config, matchPath, {});
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Match request"
|
|
<< "| fileName:" << fileName
|
|
<< "| url:" << matchUrl;
|
|
const QJsonObject matchResponse = co_await m_networkManager->post(
|
|
matchUrl, buildHeaders(config, matchPath), payload);
|
|
hadSuccessfulSearchResponse = true;
|
|
const QList<DanmakuMatchCandidate> matchCandidates =
|
|
parseMatchResponse(matchResponse, context, fileName);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Match result"
|
|
<< "| fileName:" << fileName
|
|
<< "| isMatched:"
|
|
<< matchResponse.value(QStringLiteral("isMatched")).toBool()
|
|
<< "| count:" << matchCandidates.size();
|
|
if (!matchCandidates.isEmpty()) {
|
|
allCandidates.append(matchCandidates);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
const QString errorMessage =
|
|
QString::fromUtf8(e.what()).trimmed();
|
|
if (isMissingCredentialsErrorMessage(errorMessage)) {
|
|
throw;
|
|
}
|
|
lastSearchError = errorMessage;
|
|
qWarning().noquote()
|
|
<< "[Danmaku][DandanPlay] Match request failed"
|
|
<< "| fileName:" << fileName
|
|
<< "| error:" << errorMessage;
|
|
}
|
|
}
|
|
|
|
for (const QString &keyword : std::as_const(keywords)) {
|
|
QList<DanmakuMatchCandidate> keywordCandidates;
|
|
try {
|
|
const QString apiPath = QStringLiteral("/api/v2/search/episodes");
|
|
ensureOfficialAuthentication(config, apiPath);
|
|
QList<QPair<QString, QString>> queryItems;
|
|
queryItems.append({QStringLiteral("anime"), keyword});
|
|
if (context.isEpisode()) {
|
|
if (context.episodeNumber > 0) {
|
|
queryItems.append({QStringLiteral("episode"),
|
|
QString::number(context.episodeNumber)});
|
|
}
|
|
if (!tmdbId.isEmpty()) {
|
|
queryItems.append({QStringLiteral("tmdbId"), tmdbId});
|
|
}
|
|
}
|
|
|
|
const QString url = buildUrl(config, apiPath, queryItems);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search request"
|
|
<< "| path:" << apiPath
|
|
<< "| keyword:" << keyword
|
|
<< "| url:" << url;
|
|
const QJsonObject response =
|
|
co_await m_networkManager->get(url, buildHeaders(config, apiPath));
|
|
hadSuccessfulSearchResponse = true;
|
|
keywordCandidates =
|
|
parseSearchResponse(response, context, keyword);
|
|
if (!keywordCandidates.isEmpty()) {
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search hit"
|
|
<< "| path:" << apiPath
|
|
<< "| keyword:" << keyword
|
|
<< "| count:" << keywordCandidates.size();
|
|
allCandidates.append(keywordCandidates);
|
|
}
|
|
} catch (const std::exception &e) {
|
|
const QString errorMessage = QString::fromUtf8(e.what()).trimmed();
|
|
if (isMissingCredentialsErrorMessage(errorMessage)) {
|
|
throw;
|
|
}
|
|
lastSearchError = errorMessage;
|
|
qWarning().noquote()
|
|
<< "[Danmaku][DandanPlay] Search request failed"
|
|
<< "| keyword:" << keyword
|
|
<< "| path: /api/v2/search/episodes"
|
|
<< "| error:" << errorMessage;
|
|
}
|
|
|
|
if (!keywordCandidates.isEmpty()) {
|
|
|
|
|
|
|
|
if (context.isEpisode() && context.seasonNumber > 0 &&
|
|
context.episodeNumber > 0) {
|
|
bool hasConfidentMatch = false;
|
|
for (const DanmakuMatchCandidate &c : keywordCandidates) {
|
|
const QString candidateSubject =
|
|
c.subtitle.isEmpty() ? c.title : c.subtitle;
|
|
const int candidateSeason =
|
|
extractSeasonNumber(candidateSubject);
|
|
const bool seasonHit =
|
|
candidateSeason == context.seasonNumber ||
|
|
(candidateSeason == 0 && context.seasonNumber == 1);
|
|
const bool episodeHit =
|
|
c.episodeNumber == context.episodeNumber;
|
|
if (seasonHit && episodeHit && c.score >= 60.0) {
|
|
hasConfidentMatch = true;
|
|
break;
|
|
}
|
|
}
|
|
if (hasConfidentMatch) {
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Confident match found,"
|
|
" skipping remaining keywords"
|
|
<< "| keyword:" << keyword
|
|
<< "| seasonNumber:" << context.seasonNumber
|
|
<< "| episodeNumber:" << context.episodeNumber;
|
|
break;
|
|
}
|
|
}
|
|
continue;
|
|
}
|
|
|
|
const QString fallbackPath = QStringLiteral("/api/v2/search/anime");
|
|
const QString fallbackUrl = buildUrl(
|
|
config, fallbackPath, {{QStringLiteral("keyword"), keyword}});
|
|
try {
|
|
ensureOfficialAuthentication(config, fallbackPath);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search fallback request"
|
|
<< "| path:" << fallbackPath
|
|
<< "| keyword:" << keyword
|
|
<< "| url:" << fallbackUrl;
|
|
const QJsonObject fallbackResponse =
|
|
co_await m_networkManager->get(
|
|
fallbackUrl, buildHeaders(config, fallbackPath));
|
|
hadSuccessfulSearchResponse = true;
|
|
const QList<DanmakuMatchCandidate> fallbackCandidates =
|
|
parseSearchResponse(fallbackResponse, context, keyword);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search fallback result"
|
|
<< "| keyword:" << keyword
|
|
<< "| count:" << fallbackCandidates.size();
|
|
allCandidates.append(fallbackCandidates);
|
|
} catch (const std::exception &e) {
|
|
const QString errorMessage = QString::fromUtf8(e.what()).trimmed();
|
|
if (isMissingCredentialsErrorMessage(errorMessage)) {
|
|
throw;
|
|
}
|
|
lastSearchError = errorMessage;
|
|
qWarning().noquote()
|
|
<< "[Danmaku][DandanPlay] Search fallback failed"
|
|
<< "| keyword:" << keyword
|
|
<< "| path:" << fallbackPath
|
|
<< "| error:" << errorMessage;
|
|
}
|
|
}
|
|
|
|
if (allCandidates.isEmpty() && !hadSuccessfulSearchResponse &&
|
|
!lastSearchError.isEmpty()) {
|
|
throw std::runtime_error(lastSearchError.toStdString());
|
|
}
|
|
|
|
const QList<DanmakuMatchCandidate> deduplicated =
|
|
deduplicateCandidates(allCandidates);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search finished"
|
|
<< "| mediaId:" << context.mediaId
|
|
<< "| rawCount:" << allCandidates.size()
|
|
<< "| deduplicatedCount:" << deduplicated.size();
|
|
|
|
|
|
if (context.isEpisode() && !deduplicated.isEmpty()) {
|
|
const int topN = std::min<int>(deduplicated.size(), 5);
|
|
for (int i = 0; i < topN; ++i) {
|
|
const DanmakuMatchCandidate &c = deduplicated.at(i);
|
|
const QString candidateSubject =
|
|
c.subtitle.isEmpty() ? c.title : c.subtitle;
|
|
const int candidateSeason = extractSeasonNumber(candidateSubject);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Search top candidate"
|
|
<< "| rank:" << (i + 1)
|
|
<< "| title:" << c.title
|
|
<< "| subtitle:" << c.subtitle
|
|
<< "| candidateSeason:" << candidateSeason
|
|
<< "| candidateEpisode:" << c.episodeNumber
|
|
<< "| seasonHit:"
|
|
<< (context.seasonNumber > 0 && candidateSeason > 0 &&
|
|
candidateSeason == context.seasonNumber)
|
|
<< "| episodeHit:"
|
|
<< (context.episodeNumber > 0 && c.episodeNumber > 0 &&
|
|
c.episodeNumber == context.episodeNumber)
|
|
<< "| score:" << c.score
|
|
<< "| commentCount:" << c.commentCount;
|
|
}
|
|
}
|
|
co_return deduplicated;
|
|
}
|
|
|
|
QCoro::Task<QList<DanmakuComment>> DandanplayProvider::fetchComments(
|
|
DanmakuMatchCandidate candidate,
|
|
DanmakuProviderConfig config) const
|
|
{
|
|
QList<DanmakuComment> comments;
|
|
if (!m_networkManager || !candidate.isValid()) {
|
|
co_return comments;
|
|
}
|
|
|
|
const QString apiPath =
|
|
QStringLiteral("/api/v2/comment/%1").arg(candidate.targetId);
|
|
ensureOfficialAuthentication(config, apiPath);
|
|
const QString url = buildUrl(
|
|
config, apiPath,
|
|
{{QStringLiteral("withRelated"),
|
|
config.withRelated ? QStringLiteral("true")
|
|
: QStringLiteral("false")}});
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Fetch comments"
|
|
<< "| targetId:" << candidate.targetId
|
|
<< "| withRelated:" << config.withRelated
|
|
<< "| url:" << url;
|
|
const QJsonObject response =
|
|
co_await m_networkManager->get(url, buildHeaders(config, apiPath));
|
|
comments = parseCommentsResponse(response);
|
|
qDebug().noquote()
|
|
<< "[Danmaku][DandanPlay] Fetch comments result"
|
|
<< "| targetId:" << candidate.targetId
|
|
<< "| count:" << comments.size();
|
|
co_return comments;
|
|
}
|