├── B23Downloader ├── .gitignore ├── AboutWidget.cpp ├── AboutWidget.h ├── B23Downloader.ico ├── B23Downloader.pro ├── DownloadDialog.cpp ├── DownloadDialog.h ├── DownloadTask.cpp ├── DownloadTask.h ├── Extractor.cpp ├── Extractor.h ├── Flv.cpp ├── Flv.h ├── LoginDialog.cpp ├── LoginDialog.h ├── MainWindow.cpp ├── MainWindow.h ├── MyTabWidget.cpp ├── MyTabWidget.h ├── Network.cpp ├── Network.h ├── QrCode.cpp ├── QrCode.h ├── Settings.cpp ├── Settings.h ├── TaskTable.cpp ├── TaskTable.h ├── icons.qrc ├── icons │ ├── about.svg │ ├── akkarin.png │ ├── download.svg │ ├── folder.svg │ ├── icon-96x96.png │ ├── logout.svg │ ├── manga.svg │ ├── refresh.png │ ├── remove.svg │ └── video.svg ├── main.cpp ├── utils.cpp └── utils.h ├── LICENSE ├── README.assets ├── FlvParse-LiveSample.png ├── FlvParse-Normal.png ├── download-example-bangumi.png ├── download-example-live.png ├── download-example-manga.png └── mainwindow.png └── README.md /B23Downloader/.gitignore: -------------------------------------------------------------------------------- 1 | /*.pro.user* -------------------------------------------------------------------------------- /B23Downloader/AboutWidget.cpp: -------------------------------------------------------------------------------- 1 | #include "AboutWidget.h" 2 | #include "Network.h" 3 | #include 4 | 5 | struct VersionInfo; 6 | 7 | class AboutWidgetPrivate 8 | { 9 | AboutWidget *p; 10 | public: 11 | AboutWidgetPrivate(AboutWidget *p): p(p) {} 12 | QTextEdit *textView; 13 | 14 | #ifdef ENABLE_UPDATE_CHECK 15 | std::unique_ptr httpReply; 16 | void startGetVersionInfo(const QString &url, std::function parser); 17 | void setUpdateInfoLabel(const VersionInfo &); 18 | #endif // ENABLE_UPDATE_CHECK 19 | }; 20 | 21 | 22 | #ifdef ENABLE_UPDATE_CHECK 23 | struct VersionInfo 24 | { 25 | QString ver; 26 | QString exeDownloadUrl; 27 | QString desc; 28 | }; 29 | 30 | static const auto ReleaseExecutableSuffix = "exe"; 31 | 32 | /** 33 | * @brief if versionA > versionB, return 1; 34 | * if versionA < versionB, return -1; 35 | * else return 0; 36 | */ 37 | static int cmpVersion(const QString &a, const QString &b) 38 | { 39 | int lenA = a.size(); 40 | int lenB = b.size(); 41 | int posA = 0; 42 | int posB = 0; 43 | 44 | auto nextNum = [](const QString &ver, int &pos) -> int { 45 | int n = 0; 46 | while (pos < ver.size() && ver[pos] != '.') { 47 | n = n * 10 + (ver[pos].toLatin1() - '0'); 48 | pos++; 49 | } 50 | pos++; // skip '.' 51 | return n; 52 | }; 53 | 54 | while (!(posA >= lenA && posB >= lenB)) { 55 | int num1 = nextNum(a, posA); 56 | int num2 = nextNum(b, posB); 57 | if (num1 < num2) { 58 | return -1; 59 | } else if (num1 > num2) { 60 | return 1; 61 | } 62 | } 63 | return 0; 64 | } 65 | 66 | VersionInfo parseVerInfoFromGithub(QNetworkReply *reply) 67 | { 68 | auto obj = QJsonDocument::fromJson(reply->readAll()).object(); 69 | VersionInfo verInfo; 70 | verInfo.ver = obj["tag_name"].toString().sliced(1); 71 | verInfo.desc = obj["body"].toString(); 72 | for (auto &&assetObjRef : obj["assets"].toArray()) { 73 | auto asset = assetObjRef.toObject(); 74 | if (asset["name"].toString().endsWith(ReleaseExecutableSuffix)) { 75 | verInfo.exeDownloadUrl = asset["browser_download_url"].toString(); 76 | break; 77 | } 78 | } 79 | return verInfo; 80 | } 81 | 82 | VersionInfo parseVerInfoFromNjugit(QNetworkReply *reply) 83 | { 84 | auto obj = QJsonDocument::fromJson(reply->readAll()).array().first().toObject(); 85 | VersionInfo verInfo; 86 | verInfo.ver = obj["tag_name"].toString().sliced(1); 87 | verInfo.desc = obj["description"].toString(); 88 | for (auto &&assetObjRef : obj["assets"].toObject()["links"].toArray()) { 89 | auto asset = assetObjRef.toObject(); 90 | if (asset["name"].toString().endsWith(ReleaseExecutableSuffix)) { 91 | verInfo.exeDownloadUrl = asset["direct_asset_url"].toString(); 92 | break; 93 | } 94 | } 95 | return verInfo; 96 | } 97 | 98 | void AboutWidgetPrivate::startGetVersionInfo(const QString &url, std::function parser) 99 | { 100 | auto rqst = QNetworkRequest(QUrl(url)); 101 | httpReply.reset(Network::accessManager()->get(rqst)); 102 | textView->setText("请求中..."); 103 | p->setEnabled(false); 104 | p->connect(httpReply.get(), &QNetworkReply::finished, p, [this, parser]{ 105 | p->setEnabled(true); 106 | auto reply = httpReply.release(); 107 | reply->deleteLater(); 108 | 109 | if (reply->error() == QNetworkReply::OperationCanceledError) { 110 | return; 111 | } else if (reply->error() != QNetworkReply::NoError) { 112 | textView->setText("网络错误"); 113 | return; 114 | } 115 | 116 | setUpdateInfoLabel(parser(reply)); 117 | }); 118 | } 119 | 120 | void AboutWidgetPrivate::setUpdateInfoLabel(const VersionInfo &verInfo) 121 | { 122 | if (verInfo.exeDownloadUrl.isEmpty()) { 123 | textView->clear(); 124 | return; 125 | } 126 | 127 | if (cmpVersion(QApplication::applicationVersion(), verInfo.ver) >= 0) { 128 | textView->setText("当前已是最新版本, 无需更新。"); 129 | return; 130 | } 131 | 132 | textView->setText( 133 | QStringLiteral("版本 v") + verInfo.ver + 134 | QStringLiteral("
下载链接: " + verInfo.exeDownloadUrl + 135 | "
" + verInfo.desc 136 | ); 137 | } 138 | #endif // ENABLE_UPDATE_CHECK 139 | 140 | 141 | 142 | AboutWidget::AboutWidget(QWidget *parent) 143 | : QWidget(parent), d(new AboutWidgetPrivate(this)) 144 | { 145 | auto layout = new QVBoxLayout(this); 146 | 147 | constexpr auto iconSize = QSize(64, 64); 148 | auto iconLabel = new QLabel; 149 | iconLabel->setFixedSize(iconSize); 150 | iconLabel->setScaledContents(true); 151 | iconLabel->setPixmap(QPixmap(":/icons/icon-96x96.png")); 152 | 153 | auto info = 154 | QStringLiteral("

B23Downloader

版本: v") + QApplication::applicationVersion() + 155 | QStringLiteral( 156 | "
本软件开源免费。" 157 | "
项目链接: GitHub" 158 | " 或 NJU Git" 159 | ); 160 | auto infoLabel = new QLabel(info); 161 | infoLabel->setOpenExternalLinks(true); 162 | 163 | auto infoLayout = new QHBoxLayout; 164 | infoLayout->addWidget(iconLabel); 165 | infoLayout->addSpacing(20); 166 | infoLayout->addWidget(infoLabel); 167 | infoLayout->setAlignment(Qt::AlignCenter); 168 | infoLayout->addSpacing(iconSize.width()); 169 | layout->addSpacing(10); 170 | layout->addLayout(infoLayout); 171 | 172 | auto textView = new QTextBrowser; 173 | d->textView = textView; 174 | textView->setOpenExternalLinks(true); 175 | textView->setFrameShape(QFrame::NoFrame); 176 | textView->setMinimumWidth(320); 177 | layout->addSpacing(10); 178 | layout->addWidget(textView, 1, Qt::AlignCenter); 179 | 180 | #ifdef ENABLE_UPDATE_CHECK 181 | auto checkUpdateViaGithubBtn = new QPushButton("检查更新 (via GitHub)"); 182 | auto checkUpdateViaNjugitBtn = new QPushButton("检查更新 (via NJU Git)"); 183 | auto checkUpdateLayout = new QHBoxLayout; 184 | checkUpdateLayout->setAlignment(Qt::AlignHCenter | Qt::AlignTop); 185 | checkUpdateLayout->addWidget(checkUpdateViaGithubBtn); 186 | checkUpdateLayout->addSpacing(10); 187 | checkUpdateLayout->addWidget(checkUpdateViaNjugitBtn); 188 | layout->addLayout(checkUpdateLayout); 189 | 190 | connect(checkUpdateViaGithubBtn, &QPushButton::clicked, this, [this]{ 191 | d->startGetVersionInfo( 192 | "https://api.github.com/repos/vooidzero/B23Downloader/releases/latest", 193 | &parseVerInfoFromGithub 194 | ); 195 | }); 196 | 197 | connect(checkUpdateViaNjugitBtn, &QPushButton::clicked, this, [this]{ 198 | d->startGetVersionInfo( 199 | "https://git.nju.edu.cn/api/v4/projects/3171/releases", 200 | &parseVerInfoFromNjugit 201 | ); 202 | }); 203 | #endif // ENABLE_UPDATE_CHECK 204 | } 205 | 206 | void AboutWidget::showEvent(QShowEvent *event) 207 | { 208 | d->textView->setText(QStringLiteral( 209 | "
こんなちいさな星座なのに
" 210 | "
ここにいたこと 気付いてくれて
" 211 | "
ありがとう!
" 212 | )); 213 | QWidget::showEvent(event); 214 | } 215 | 216 | void AboutWidget::hideEvent(QHideEvent *event) 217 | { 218 | #ifdef ENABLE_UPDATE_CHECK 219 | if (d->httpReply != nullptr) { 220 | d->httpReply->abort(); 221 | } 222 | #endif 223 | d->textView->clear(); 224 | QWidget::hideEvent(event); 225 | } 226 | -------------------------------------------------------------------------------- /B23Downloader/AboutWidget.h: -------------------------------------------------------------------------------- 1 | #ifndef ABOUTWIDGET_H 2 | #define ABOUTWIDGET_H 3 | 4 | #include 5 | 6 | class AboutWidgetPrivate; 7 | 8 | class AboutWidget : public QWidget 9 | { 10 | public: 11 | AboutWidget(QWidget *parent = nullptr); 12 | 13 | protected: 14 | void showEvent(QShowEvent *event) override; 15 | void hideEvent(QHideEvent *event) override; 16 | 17 | private: 18 | friend class AboutWidgetPrivate; 19 | std::unique_ptr d; 20 | }; 21 | 22 | #endif // ABOUTWIDGET_H 23 | -------------------------------------------------------------------------------- /B23Downloader/B23Downloader.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/B23Downloader/B23Downloader.ico -------------------------------------------------------------------------------- /B23Downloader/B23Downloader.pro: -------------------------------------------------------------------------------- 1 | VERSION = 0.9.5 2 | QT += core gui network 3 | 4 | win32-g++:contains(QMAKE_HOST.arch, x86_64):{ 5 | # DEFINES += ENABLE_UPDATE_CHECK 6 | } 7 | 8 | # On Windows, QApplication::applicationVersion() returns VERSION defined above 9 | # But on Linux, QApplication::applicationVersion() returns empty string 10 | !windows:DEFINES += APP_VERSION=\\\"$$VERSION\\\" 11 | 12 | 13 | greaterThan(QT_MAJOR_VERSION, 4): QT += widgets 14 | 15 | CONFIG += c++11 16 | 17 | RC_ICONS = B23Downloader.ico 18 | 19 | # You can make your code fail to compile if it uses deprecated APIs. 20 | # In order to do so, uncomment the following line. 21 | DEFINES += QT_DISABLE_DEPRECATED_BEFORE=0x060000 # disables all the APIs deprecated before Qt 6.0.0 22 | 23 | SOURCES += \ 24 | AboutWidget.cpp \ 25 | DownloadDialog.cpp \ 26 | DownloadTask.cpp \ 27 | Extractor.cpp \ 28 | Flv.cpp \ 29 | LoginDialog.cpp \ 30 | MainWindow.cpp \ 31 | MyTabWidget.cpp \ 32 | Network.cpp \ 33 | QrCode.cpp \ 34 | Settings.cpp \ 35 | TaskTable.cpp \ 36 | main.cpp \ 37 | utils.cpp 38 | 39 | HEADERS += \ 40 | AboutWidget.h \ 41 | DownloadDialog.h \ 42 | DownloadTask.h \ 43 | Extractor.h \ 44 | Flv.h \ 45 | LoginDialog.h \ 46 | MainWindow.h \ 47 | MyTabWidget.h \ 48 | Network.h \ 49 | QrCode.h \ 50 | Settings.h \ 51 | TaskTable.h \ 52 | utils.h 53 | 54 | # Default rules for deployment. 55 | qnx: target.path = /tmp/$${TARGET}/bin 56 | else: unix:!android: target.path = /opt/$${TARGET}/bin 57 | !isEmpty(target.path): INSTALLS += target 58 | 59 | RESOURCES += \ 60 | icons.qrc 61 | -------------------------------------------------------------------------------- /B23Downloader/DownloadDialog.h: -------------------------------------------------------------------------------- 1 | #ifndef DOWNLOADDIALOG_H 2 | #define DOWNLOADDIALOG_H 3 | 4 | #include 5 | #include 6 | #include "DownloadTask.h" 7 | #include "Extractor.h" 8 | 9 | class Extractor; 10 | class AbstractVideoDownloadTask; 11 | class ContentTreeWidget; 12 | class ElidedTextLabel; 13 | 14 | class QBoxLayout; 15 | class QVBoxLayout; 16 | class QLabel; 17 | class QLineEdit; 18 | class QComboBox; 19 | class QCheckBox; 20 | class QAbstractButton; 21 | class QToolButton; 22 | 23 | class DownloadDialog : public QDialog 24 | { 25 | Q_OBJECT 26 | 27 | public: 28 | DownloadDialog(const QString &url, QWidget *parent); 29 | ~DownloadDialog(); 30 | QList getDownloadTasks(); 31 | void open() override; 32 | void show() = delete; 33 | private: 34 | using QDialog::exec; 35 | 36 | private slots: 37 | void abortExtract(); 38 | void extratorErrorOccured(const QString &errorString); 39 | void setupUi(); 40 | 41 | void selAllBtnClicked(); 42 | void selCntChanged(int curr, int prev); 43 | void startGetCurrentItemQnList(); 44 | void getCurrentItemQnListFinished(); 45 | void selectPath(); 46 | 47 | private: 48 | void updateQnComboBox(QnList qnList); 49 | void addPlayDirect(QBoxLayout *layout); 50 | 51 | QDialog *activityTipDialog; 52 | Extractor *extractor; 53 | 54 | QLabel *titleLabel; 55 | 56 | QToolButton *selAllBtn = nullptr; 57 | QLabel *selCountLabel = nullptr; 58 | ContentTreeWidget *tree = nullptr; 59 | QComboBox *qnComboBox = nullptr; 60 | QPushButton *getQnListBtn = nullptr; 61 | 62 | ElidedTextLabel *pathLabel; 63 | QPushButton *selPathButton; 64 | QPushButton* okButton; 65 | 66 | ContentType contentType; 67 | qint64 contentId; 68 | qint64 contentItemId; 69 | QNetworkReply *getQnListReply; 70 | }; 71 | 72 | 73 | class ContentTreeItem; // definition: DonwloadDialog.cpp 74 | 75 | class ContentTreeWidget: public QTreeWidget 76 | { 77 | Q_OBJECT 78 | 79 | static const int RowSpacing; 80 | int enabledItemCnt = 0; 81 | int selCnt = 0; 82 | int selCntBeforeBlock; 83 | bool blockSigSelCntChanged = false; 84 | 85 | signals: 86 | void selectedItemCntChanged(int currentCount, int previousCount); 87 | 88 | public: 89 | ContentTreeWidget(const Extractor::Result *content, QWidget *parent = nullptr); 90 | 91 | ContentTreeItem *currentItem() const; 92 | 93 | // QTreeWidget::selectAll() is not satisfactory. see comment in definition for detail. 94 | void selectAll() override; 95 | int getEnabledItemCount() const { return enabledItemCnt; } 96 | int getSelectedItemCount() const { return selCnt; } 97 | 98 | // used in selectAll() and ContentTreeItem 99 | // to emit selectedItemCntChanged() only once when select or deselect multiple items 100 | void beginSelMultItems(); 101 | void endSelMultItems(); 102 | 103 | private: 104 | using QTreeWidget::addTopLevelItem; 105 | using QTreeWidget::addTopLevelItems; 106 | using QTreeWidget::insertTopLevelItem; 107 | using QTreeWidget::insertTopLevelItems; 108 | 109 | 110 | private: 111 | QList selection2Items(const QItemSelection &selection); 112 | 113 | protected: 114 | void drawRow(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override; 115 | void selectionChanged(const QItemSelection &selected, const QItemSelection &deselected) override; 116 | }; 117 | 118 | 119 | #endif // DOWNLOADDIALOG_H 120 | -------------------------------------------------------------------------------- /B23Downloader/DownloadTask.cpp: -------------------------------------------------------------------------------- 1 | // Created by voidzero 2 | 3 | #include "DownloadTask.h" 4 | #include "Extractor.h" 5 | #include "Network.h" 6 | #include "utils.h" 7 | #include "Flv.h" 8 | #include 9 | 10 | // 127: 8K 超高清 11 | // 126: 杜比视界 12 | // 125: HDR 真彩 13 | static QMap videoQnDescMap { 14 | {120, "4K 超清"}, 15 | {116, "1080P 60帧"}, 16 | {112, "1080P 高码率"}, 17 | {80, "1080P 高清"}, 18 | {74, "720P 60帧"}, 19 | {64, "720P 高清"}, 20 | {32, "480P 清晰"}, 21 | {16, "360P 流畅"}, 22 | }; 23 | 24 | static QMap liveQnDescMap { 25 | {10000, "原画"}, 26 | {400, "蓝光"}, 27 | {250, "超清"}, 28 | {150, "高清"}, 29 | {80, "流畅"} 30 | }; 31 | 32 | 33 | inline bool jsonValue2Bool(const QJsonValue &val, bool defaultVal = false) { 34 | if (val.isNull() || val.isUndefined()) { 35 | return defaultVal; 36 | } 37 | if (val.isBool()) { 38 | return val.toBool(); 39 | } 40 | if (val.isDouble()) { 41 | return static_cast(val.toInt()); 42 | } 43 | if (val.isString()) { 44 | auto str = val.toString().toLower(); 45 | if (str == "1" || str == "true") { 46 | return true; 47 | } 48 | if (str == "0" || str == "false") { 49 | return false; 50 | } 51 | } 52 | return defaultVal; 53 | } 54 | 55 | 56 | AbstractDownloadTask::~AbstractDownloadTask() 57 | { 58 | if (httpReply != nullptr) { 59 | httpReply->abort(); 60 | } 61 | } 62 | 63 | QString AbstractDownloadTask::getTitle() const 64 | { 65 | return QFileInfo(path).baseName(); 66 | } 67 | 68 | AbstractDownloadTask *AbstractDownloadTask::fromJsonObj(const QJsonObject &json) 69 | { 70 | int type = json["type"].toInt(-1); 71 | switch (type) { 72 | case static_cast(ContentType::PGC): 73 | return new PgcDownloadTask(json); 74 | case static_cast(ContentType::PUGV): 75 | return new PugvDownloadTask(json); 76 | case static_cast(ContentType::UGC): 77 | return new UgcDownloadTask(json); 78 | case static_cast(ContentType::Comic): 79 | return new ComicDownloadTask(json); 80 | } 81 | return nullptr; 82 | } 83 | 84 | QJsonValue AbstractDownloadTask::getReplyJson(const QString &dataKey) 85 | { 86 | auto reply = this->httpReply; 87 | this->httpReply = nullptr; 88 | reply->deleteLater(); 89 | 90 | // abort() is called. 91 | if (reply->error() == QNetworkReply::OperationCanceledError) { 92 | return QJsonValue(); 93 | } 94 | 95 | const auto [json, errorString] = Network::Bili::parseReply(reply, dataKey); 96 | 97 | if (!errorString.isNull()) { 98 | emit errorOccurred(errorString); 99 | return QJsonValue(); 100 | } 101 | 102 | if (dataKey.isEmpty()) { 103 | return json; 104 | } else { 105 | return json[dataKey]; 106 | } 107 | } 108 | 109 | 110 | 111 | AbstractVideoDownloadTask::~AbstractVideoDownloadTask() = default; 112 | 113 | qint64 AbstractVideoDownloadTask::getDownloadedBytesCnt() const 114 | { 115 | return downloadedBytesCnt; 116 | } 117 | 118 | void AbstractVideoDownloadTask::startDownload() 119 | { 120 | httpReply = getPlayUrlInfo(); 121 | connect(httpReply, &QNetworkReply::finished, this, [this]{ 122 | auto data = getReplyJson(getPlayUrlInfoDataKey()).toObject(); 123 | if (data.isEmpty()) { 124 | return; 125 | } 126 | parsePlayUrlInfo(data); 127 | }); 128 | } 129 | 130 | void AbstractVideoDownloadTask::stopDownload() 131 | { 132 | if (httpReply != nullptr) { 133 | httpReply->abort(); 134 | } 135 | } 136 | 137 | 138 | 139 | QJsonObject VideoDownloadTask::toJsonObj() const 140 | { 141 | return QJsonObject{ 142 | {"path", path}, 143 | {"qn", qn}, 144 | {"bytes", downloadedBytesCnt}, 145 | {"total", totalBytesCnt} 146 | }; 147 | } 148 | 149 | VideoDownloadTask::VideoDownloadTask(const QJsonObject &json) 150 | : AbstractVideoDownloadTask(json["path"].toString(), json["qn"].toInt()) 151 | { 152 | downloadedBytesCnt = json["bytes"].toInteger(0); 153 | totalBytesCnt = json["total"].toInteger(0); 154 | } 155 | 156 | void VideoDownloadTask::removeFile() 157 | { 158 | QFile::remove(path); 159 | } 160 | 161 | int VideoDownloadTask::estimateRemainingSeconds(qint64 downBytesPerSec) const 162 | { 163 | if (downBytesPerSec == 0 || totalBytesCnt == 0) { 164 | return -1; 165 | } 166 | qint64 ret = (totalBytesCnt - downloadedBytesCnt) / downBytesPerSec; 167 | return (ret > INT32_MAX ? -1 : static_cast(ret)); 168 | } 169 | 170 | double VideoDownloadTask::getProgress() const 171 | { 172 | if (totalBytesCnt == 0) { 173 | return 0; 174 | } 175 | return static_cast(downloadedBytesCnt) / totalBytesCnt; 176 | } 177 | 178 | QString VideoDownloadTask::getProgressStr() const 179 | { 180 | if (totalBytesCnt == 0) { 181 | return QString(); 182 | } 183 | return QStringLiteral("%1/%2").arg( 184 | Utils::formattedDataSize(downloadedBytesCnt), 185 | Utils::formattedDataSize(totalBytesCnt) 186 | ); 187 | } 188 | 189 | QnList VideoDownloadTask::getAllPossibleQn() 190 | { 191 | return videoQnDescMap.keys(); 192 | } 193 | 194 | QString VideoDownloadTask::getQnDescription(int qn) 195 | { 196 | return videoQnDescMap.value(qn); 197 | } 198 | 199 | QString VideoDownloadTask::getQnDescription() const 200 | { 201 | return getQnDescription(qn); 202 | } 203 | 204 | QnInfo VideoDownloadTask::getQnInfoFromPlayUrlInfo(const QJsonObject &data) 205 | { 206 | QnInfo qnInfo; 207 | for (auto &&fmtValR : data["support_formats"].toArray()) { 208 | auto fmtObj = fmtValR.toObject(); 209 | auto qn = fmtObj["quality"].toInt(); 210 | auto desc = fmtObj["new_description"].toString(); 211 | qnInfo.qnList.append(qn); 212 | if (videoQnDescMap.value(qn) != desc) { 213 | videoQnDescMap.insert(qn, desc); 214 | } 215 | } 216 | qnInfo.currentQn = data["quality"].toInt(); 217 | return qnInfo; 218 | } 219 | 220 | bool VideoDownloadTask::checkQn(int qnFromReply) 221 | { 222 | if (qnFromReply != qn) { 223 | if (downloadedBytesCnt == 0) { 224 | qn = qnFromReply; 225 | } else { 226 | emit errorOccurred("获取到画质与已下载部分不同. 请确定登录/会员状态"); 227 | return false; 228 | } 229 | } 230 | 231 | return true; 232 | } 233 | 234 | bool VideoDownloadTask::checkSize(qint64 sizeFromReply) 235 | { 236 | if (totalBytesCnt != sizeFromReply) { 237 | if (downloadedBytesCnt > 0) { 238 | emit errorOccurred("获取到文件大小与先前不一致"); 239 | return false; 240 | } else { 241 | totalBytesCnt = sizeFromReply; 242 | } 243 | } 244 | return true; 245 | } 246 | 247 | void VideoDownloadTask::parsePlayUrlInfo(const QJsonObject &data) 248 | { 249 | if (jsonValue2Bool(data["is_preview"], 0)) { 250 | if (!jsonValue2Bool(data["has_paid"], 1)) { 251 | emit errorOccurred("该视频需要大会员/付费"); 252 | return; 253 | } /* else { 254 | emit errorOccurred("该视频为预览"); 255 | return; 256 | } */ 257 | } 258 | 259 | auto qnInfo = getQnInfoFromPlayUrlInfo(data); 260 | if (!checkQn(qnInfo.currentQn)) { 261 | return; 262 | } 263 | 264 | auto durl = data["durl"].toArray(); 265 | if (durl.size() == 0) { 266 | emit errorOccurred("请求错误: durl 为空"); 267 | return; 268 | } else if (durl.size() > 1) { 269 | emit errorOccurred("该视频当前画质有分段(不支持)"); 270 | return; 271 | } 272 | 273 | auto durlObj = durl.first().toObject(); 274 | if (!checkSize(durlObj["size"].toInteger())) { 275 | return; 276 | } 277 | 278 | durationInMSec = durlObj["length"].toInt(); 279 | startDownloadStream(durlObj["url"].toString()); 280 | } 281 | 282 | std::unique_ptr VideoDownloadTask::openFileForWrite() 283 | { 284 | auto dir = QFileInfo(path).absolutePath(); 285 | if (!QFileInfo::exists(dir)) { 286 | if (!QDir().mkpath(dir)) { 287 | emit errorOccurred("创建目录失败"); 288 | return nullptr; 289 | } 290 | } 291 | 292 | auto file = std::make_unique(path); 293 | // WriteOnly: QFile implies Truncate (All earlier contents are lost) 294 | // unless combined with ReadOnly, Append or NewOnly. 295 | if (!file->open(QIODevice::ReadWrite)) { 296 | emit errorOccurred("打开文件失败"); 297 | return nullptr; 298 | } 299 | 300 | auto fileSize = file->size(); 301 | if (fileSize < downloadedBytesCnt) { 302 | qDebug() << QString("filesize(%1) < bytes(%2)").arg(fileSize).arg(downloadedBytesCnt); 303 | downloadedBytesCnt = fileSize; 304 | } 305 | file->seek(downloadedBytesCnt); 306 | return file; 307 | } 308 | 309 | void VideoDownloadTask::startDownloadStream(const QUrl &url) 310 | { 311 | emit getUrlInfoFinished(); 312 | 313 | // check extension of filename 314 | auto ext = Utils::fileExtension(url.fileName()); 315 | if (downloadedBytesCnt == 0 && !path.endsWith(ext, Qt::CaseInsensitive)) { 316 | path.append(ext); 317 | } 318 | 319 | file = openFileForWrite(); 320 | if (!file) { 321 | return; 322 | } 323 | 324 | auto request = Network::Bili::Request(url); 325 | if (downloadedBytesCnt != 0) { 326 | request.setRawHeader("Range", "bytes=" + QByteArray::number(downloadedBytesCnt) + "-"); 327 | } 328 | 329 | httpReply = Network::accessManager()->get(request); 330 | connect(httpReply, &QNetworkReply::readyRead, this, &VideoDownloadTask::onStreamReadyRead); 331 | connect(httpReply, &QNetworkReply::finished, this, &VideoDownloadTask::onStreamFinished); 332 | } 333 | 334 | void VideoDownloadTask::onStreamFinished() 335 | { 336 | auto reply = httpReply; 337 | httpReply->deleteLater(); 338 | httpReply = nullptr; 339 | 340 | file.reset(); 341 | 342 | if (reply->error() == QNetworkReply::OperationCanceledError) { 343 | return; 344 | } 345 | 346 | if (reply->error() != QNetworkReply::NoError) { 347 | emit errorOccurred("网络请求错误"); 348 | return; 349 | } 350 | 351 | emit downloadFinished(); 352 | } 353 | 354 | void VideoDownloadTask::onStreamReadyRead() 355 | { 356 | auto tmp = downloadedBytesCnt + httpReply->bytesAvailable(); 357 | Q_ASSERT(file != nullptr); 358 | if (-1 == file->write(httpReply->readAll())) { 359 | emit errorOccurred("文件写入失败: " + file->errorString()); 360 | httpReply->abort(); 361 | } else { 362 | downloadedBytesCnt = tmp; 363 | } 364 | } 365 | 366 | 367 | 368 | QJsonObject PgcDownloadTask::toJsonObj() const 369 | { 370 | auto json = VideoDownloadTask::toJsonObj(); 371 | json.insert("type", static_cast(ContentType::PGC)); 372 | json.insert("ssid", ssId); 373 | json.insert("epid", epId); 374 | return json; 375 | } 376 | 377 | PgcDownloadTask::PgcDownloadTask(const QJsonObject &json) 378 | : VideoDownloadTask(json), 379 | ssId(json["ssid"].toInteger()), 380 | epId(json["epid"].toInteger()) 381 | { 382 | } 383 | 384 | QNetworkReply *PgcDownloadTask::getPlayUrlInfo(qint64 epId, int qn) 385 | { 386 | auto api = "https://api.bilibili.com/pgc/player/web/playurl"; 387 | auto query = QString("?ep_id=%1&qn=%2&fourk=1").arg(epId).arg(qn); 388 | return Network::Bili::get(api + query); 389 | } 390 | 391 | QNetworkReply *PgcDownloadTask::getPlayUrlInfo() const 392 | { 393 | return getPlayUrlInfo(epId, qn); 394 | } 395 | 396 | const QString PgcDownloadTask::playUrlInfoDataKey = "result"; 397 | 398 | QString PgcDownloadTask::getPlayUrlInfoDataKey() const 399 | { 400 | return playUrlInfoDataKey; 401 | } 402 | 403 | 404 | 405 | QJsonObject PugvDownloadTask::toJsonObj() const 406 | { 407 | auto json = VideoDownloadTask::toJsonObj(); 408 | json.insert("type", static_cast(ContentType::PUGV)); 409 | json.insert("ssid", ssId); 410 | json.insert("epid", epId); 411 | return json; 412 | } 413 | 414 | PugvDownloadTask::PugvDownloadTask(const QJsonObject &json) 415 | : VideoDownloadTask(json), 416 | ssId(json["ssid"].toInteger()), 417 | epId(json["epid"].toInteger()) 418 | { 419 | } 420 | 421 | QNetworkReply *PugvDownloadTask::getPlayUrlInfo(qint64 epId, int qn) 422 | { 423 | auto api = "https://api.bilibili.com/pugv/player/web/playurl"; 424 | auto query = QString("?ep_id=%1&qn=%2&fourk=1").arg(epId).arg(qn); 425 | return Network::Bili::get(api + query); 426 | } 427 | 428 | QNetworkReply *PugvDownloadTask::getPlayUrlInfo() const 429 | { 430 | return getPlayUrlInfo(epId, qn); 431 | } 432 | 433 | const QString PugvDownloadTask::playUrlInfoDataKey = "data"; 434 | 435 | QString PugvDownloadTask::getPlayUrlInfoDataKey() const 436 | { 437 | return playUrlInfoDataKey; 438 | } 439 | 440 | 441 | 442 | QJsonObject UgcDownloadTask::toJsonObj() const 443 | { 444 | auto json = VideoDownloadTask::toJsonObj(); 445 | json.insert("type", static_cast(ContentType::UGC)); 446 | json.insert("aid", aid); 447 | json.insert("cid", cid); 448 | return json; 449 | } 450 | 451 | UgcDownloadTask::UgcDownloadTask(const QJsonObject &json) 452 | : VideoDownloadTask(json), 453 | aid(json["aid"].toInteger()), 454 | cid(json["cid"].toInteger()) 455 | { 456 | } 457 | 458 | QNetworkReply *UgcDownloadTask::getPlayUrlInfo(qint64 aid, qint64 cid, int qn) 459 | { 460 | auto api = "https://api.bilibili.com/x/player/playurl"; 461 | auto query = QString("?avid=%1&cid=%2&qn=%3&fourk=1").arg(aid).arg(cid).arg(qn); 462 | return Network::Bili::get(api + query); 463 | } 464 | 465 | QNetworkReply *UgcDownloadTask::getPlayUrlInfo() const 466 | { 467 | return getPlayUrlInfo(aid, cid, qn); 468 | } 469 | 470 | const QString UgcDownloadTask::playUrlInfoDataKey = "data"; 471 | 472 | QString UgcDownloadTask::getPlayUrlInfoDataKey() const 473 | { 474 | return playUrlInfoDataKey; 475 | } 476 | 477 | 478 | 479 | QNetworkReply *LiveDownloadTask::getPlayUrlInfo(qint64 roomId, int qn) 480 | { 481 | auto api = "https://api.live.bilibili.com/xlive/web-room/v2/index/getRoomPlayInfo"; 482 | auto query = QString("?protocol=0,1&format=0,1,2&codec=0,1&room_id=%1&qn=%2").arg(roomId).arg(qn); 483 | return Network::Bili::get(api + query); 484 | } 485 | 486 | QNetworkReply *LiveDownloadTask::getPlayUrlInfo() const 487 | { 488 | return getPlayUrlInfo(roomId, qn); 489 | } 490 | 491 | const QString LiveDownloadTask::playUrlInfoDataKey = "data"; 492 | 493 | QString LiveDownloadTask::getPlayUrlInfoDataKey() const 494 | { 495 | return playUrlInfoDataKey; 496 | } 497 | 498 | LiveDownloadTask::LiveDownloadTask(qint64 roomId, int qn, const QString &path) 499 | : AbstractVideoDownloadTask(QString(), qn), basePath(path), roomId(roomId) 500 | { 501 | } 502 | 503 | LiveDownloadTask::~LiveDownloadTask() = default; 504 | 505 | QJsonObject LiveDownloadTask::toJsonObj() const 506 | { 507 | return QJsonObject(); 508 | } 509 | 510 | //LiveDownloadTask::LiveDownloadTask(const QJsonObject &json) 511 | //{ 512 | 513 | //} 514 | 515 | QString LiveDownloadTask::getTitle() const 516 | { 517 | return QFileInfo(basePath).baseName(); 518 | } 519 | 520 | void LiveDownloadTask::removeFile() 521 | { 522 | // don't delete 523 | } 524 | 525 | int LiveDownloadTask::estimateRemainingSeconds(qint64 downBytesPerSec) const 526 | { 527 | Q_UNUSED(downBytesPerSec) 528 | // return duration of downloaded video instead 529 | return (dldDelegate == nullptr ? 0 : dldDelegate->getDurationInMSec() / 1000); 530 | } 531 | 532 | QString LiveDownloadTask::getProgressStr() const 533 | { 534 | if (downloadedBytesCnt == 0) { 535 | return QString(); 536 | } 537 | return Utils::formattedDataSize(downloadedBytesCnt); 538 | } 539 | 540 | QnList LiveDownloadTask::getAllPossibleQn() 541 | { 542 | return liveQnDescMap.keys(); 543 | } 544 | 545 | QString LiveDownloadTask::getQnDescription(int qn) 546 | { 547 | return liveQnDescMap.value(qn); 548 | } 549 | 550 | QString LiveDownloadTask::getQnDescription() const 551 | { 552 | return getQnDescription(qn); 553 | } 554 | 555 | QnInfo LiveDownloadTask::getQnInfoFromPlayUrlInfo(const QJsonObject &data) 556 | { 557 | QnInfo qnInfo; 558 | auto infoObj = data["playurl_info"].toObject()["playurl"].toObject(); 559 | 560 | QMap m; 561 | for (auto &&qnDescValR : infoObj["g_qn_desc"].toArray()) { 562 | auto qnDescObj = qnDescValR.toObject(); 563 | auto qn = qnDescObj["qn"].toInt(); 564 | auto desc = qnDescObj["desc"].toString(); 565 | m[qn] = desc; 566 | } 567 | auto obj = infoObj["stream"].toArray().first() 568 | ["format"].toArray().first() 569 | ["codec"].toArray().first(); 570 | qnInfo.currentQn = obj["current_qn"].toInt(); 571 | for (auto &&qnValR : obj["accept_qn"].toArray()) { 572 | auto qn = qnValR.toInt(); 573 | qnInfo.qnList.append(qn); 574 | if (liveQnDescMap.value(qn) != m[qn]) { 575 | liveQnDescMap.insert(qn, m[qn]); 576 | } 577 | } 578 | return qnInfo; 579 | } 580 | 581 | QString LiveDownloadTask::getPlayUrlFromPlayUrlInfo(const QJsonObject &data) 582 | { 583 | auto urlObj = data["playurl_info"].toObject() 584 | ["playurl"].toObject() 585 | ["stream"].toArray().first() 586 | ["format"].toArray().first() 587 | ["codec"].toArray().first(); 588 | auto baseUrl = urlObj["base_url"].toString(); 589 | auto obj = urlObj["url_info"].toArray().first(); 590 | auto host = obj["host"].toString(); 591 | auto extra = obj["extra"].toString(); 592 | return host + baseUrl + extra; 593 | } 594 | 595 | void LiveDownloadTask::parsePlayUrlInfo(const QJsonObject &data) 596 | { 597 | if (data["live_status"].toInt() != 1) { 598 | emit errorOccurred("未开播或正在轮播"); 599 | return; 600 | } 601 | qn = getQnInfoFromPlayUrlInfo(data).currentQn; 602 | auto url = getPlayUrlFromPlayUrlInfo(data); 603 | auto ext = Utils::fileExtension(QUrl(url).fileName()); 604 | if (ext != ".flv") { 605 | emit errorOccurred("非FLV"); 606 | return; 607 | } 608 | 609 | emit getUrlInfoFinished(); 610 | 611 | downloadedBytesCnt = 0; 612 | httpReply = Network::Bili::get(url); 613 | dldDelegate = std::make_unique(*httpReply, [this](){ 614 | auto dateStr = QDateTime::currentDateTime().toString("[yyyy.MM.dd] hh.mm.ss"); 615 | auto path = basePath + " " + dateStr + ".flv"; 616 | auto file = std::make_unique(path); 617 | if (file->open(QIODevice::WriteOnly)) { 618 | this->path = std::move(path); 619 | return file; 620 | } else { 621 | return decltype(file)(); 622 | } 623 | }); 624 | 625 | connect(httpReply, &QNetworkReply::readyRead, this, [this]() { 626 | auto ret = dldDelegate->newDataArrived(); 627 | if (!ret) { 628 | httpReply->abort(); 629 | emit errorOccurred(dldDelegate->errorString()); 630 | } 631 | downloadedBytesCnt = dldDelegate->getReadBytesCnt() + httpReply->bytesAvailable(); 632 | }); 633 | 634 | connect(httpReply, &QNetworkReply::finished, this, [this](){ 635 | auto reply = httpReply; 636 | httpReply = nullptr; 637 | reply->deleteLater(); 638 | dldDelegate.reset(); 639 | 640 | if (reply->error() == QNetworkReply::OperationCanceledError) { 641 | return; 642 | } else if (reply->error() != QNetworkReply::NoError) { 643 | emit errorOccurred("网络请求错误"); 644 | } else { 645 | emit errorOccurred("已结束或下载速度过慢"); 646 | } 647 | }); 648 | } 649 | 650 | 651 | ComicDownloadTask::~ComicDownloadTask() = default; 652 | 653 | QJsonObject ComicDownloadTask::toJsonObj() const 654 | { 655 | return QJsonObject { 656 | {"type", static_cast(ContentType::Comic)}, 657 | {"path", path}, 658 | {"id", comicId}, 659 | {"epid", epId}, 660 | {"imgs", finishedImgCnt}, 661 | {"bytes", bytesCntTillLastImg}, 662 | {"total", totalImgCnt}, 663 | }; 664 | } 665 | 666 | ComicDownloadTask::ComicDownloadTask(const QJsonObject &json) 667 | : AbstractDownloadTask(json["path"].toString()), 668 | comicId(json["id"].toInteger()), 669 | epId(json["epid"].toInteger()), 670 | totalImgCnt(json["total"].toInt(0)), 671 | finishedImgCnt(json["imgs"].toInt(0)), 672 | bytesCntTillLastImg(json["bytes"].toInteger(0)) 673 | { 674 | } 675 | 676 | void ComicDownloadTask::startDownload() 677 | { 678 | auto getImgPathsUrl = "https://manga.bilibili.com/twirp/comic.v1.Comic/GetImageIndex?device=pc&platform=web"; 679 | // auto postData = "{\"ep_id\":" + QByteArray::number(epid) + "}"; 680 | // httpReply = Network::postJson(getImgPathsUrl, postData); 681 | httpReply = Network::Bili::postJson(getImgPathsUrl, {{"ep_id", epId}}); 682 | connect(httpReply, &QNetworkReply::finished, this, &ComicDownloadTask::getImgInfoFinished); 683 | } 684 | 685 | void ComicDownloadTask::stopDownload() 686 | { 687 | if (httpReply != nullptr) { 688 | httpReply->abort(); 689 | } 690 | } 691 | 692 | void ComicDownloadTask::removeFile() 693 | { 694 | // simple but may delete innocent files !!! 695 | QDir(path).removeRecursively(); 696 | } 697 | 698 | void ComicDownloadTask::getImgInfoFinished() 699 | { 700 | auto data = getReplyJson("data").toObject(); 701 | if (data.isEmpty()) { 702 | return; 703 | } 704 | 705 | // assert: imgRqstPaths.isEmpty() 706 | 707 | auto images = data["images"].toArray(); 708 | totalImgCnt = images.size(); 709 | imgRqstPaths.reserve(totalImgCnt); 710 | for (auto &&imgObjRef : images) { 711 | auto imgObj = imgObjRef.toObject(); 712 | imgRqstPaths.append(imgObj["path"].toString()); 713 | } 714 | emit getUrlInfoFinished(); 715 | downloadNextImg(); 716 | } 717 | 718 | void ComicDownloadTask::downloadNextImg() 719 | { 720 | if (finishedImgCnt == totalImgCnt) { 721 | emit downloadFinished(); 722 | return; 723 | } 724 | auto getTokenUrl = "https://manga.bilibili.com/twirp/comic.v1.Comic/ImageToken?device=pc&platform=web"; 725 | auto postData = R"({"urls":"[\")" + imgRqstPaths[finishedImgCnt].toUtf8() + R"(\"]"})"; 726 | httpReply = Network::Bili::postJson(getTokenUrl, postData); 727 | 728 | connect(httpReply, &QNetworkReply::finished, this, &ComicDownloadTask::getImgTokenFinished); 729 | } 730 | 731 | void ComicDownloadTask::getImgTokenFinished() 732 | { 733 | auto data = getReplyJson("data"); 734 | if (data.isNull() || data.isUndefined()) { 735 | return; 736 | } 737 | auto obj = data.toArray().first(); 738 | auto token = obj["token"].toString(); 739 | auto url = obj["url"].toString(); 740 | auto index = Utils::paddedNum(finishedImgCnt + 1, Utils::numberOfDigit(totalImgCnt)); 741 | auto fileName = index + Utils::fileExtension(url); 742 | file = openFileForWrite(fileName); 743 | if (!file) { 744 | return; 745 | } 746 | httpReply = Network::Bili::get(url + "?token=" + token); 747 | connect(httpReply, &QNetworkReply::readyRead, this, &ComicDownloadTask::onImgReadyRead); 748 | connect(httpReply, &QNetworkReply::finished, this, &ComicDownloadTask::downloadImgFinished); 749 | } 750 | 751 | std::unique_ptr ComicDownloadTask::openFileForWrite(const QString &fileName) 752 | { 753 | if (!QFileInfo::exists(path)) { 754 | if (!QDir().mkpath(path)) { 755 | emit errorOccurred("创建目录失败"); 756 | return nullptr; 757 | } 758 | } 759 | 760 | auto f = std::make_unique(QDir(path).filePath(fileName)); 761 | if (!f->open(QIODevice::WriteOnly)) { 762 | emit errorOccurred("打开文件失败"); 763 | return nullptr; 764 | } 765 | 766 | return f; 767 | } 768 | 769 | void ComicDownloadTask::onImgReadyRead() 770 | { 771 | if (curImgTotalBytesCnt == 0) { 772 | curImgRecvBytesCnt = httpReply->header(QNetworkRequest::ContentLengthHeader).toLongLong(); 773 | } 774 | auto size = httpReply->bytesAvailable(); 775 | if (-1 == file->write(httpReply->readAll())) { 776 | emit errorOccurred("文件写入失败: " + file->errorString()); 777 | abortCurrentImg(); 778 | httpReply->abort(); 779 | } else { 780 | curImgRecvBytesCnt += size; 781 | } 782 | } 783 | 784 | void ComicDownloadTask::abortCurrentImg() 785 | { 786 | curImgRecvBytesCnt = 0; 787 | curImgTotalBytesCnt = 0; 788 | file.reset(); 789 | } 790 | 791 | void ComicDownloadTask::downloadImgFinished() 792 | { 793 | auto imgSize = curImgRecvBytesCnt; 794 | curImgRecvBytesCnt = 0; 795 | curImgTotalBytesCnt = 0; 796 | 797 | auto httpReply = this->httpReply; 798 | this->httpReply->deleteLater(); 799 | this->httpReply = nullptr; 800 | 801 | auto file = std::move(this->file); 802 | 803 | auto error = httpReply->error(); 804 | if (error != QNetworkReply::NoError) { 805 | if (error != QNetworkReply::OperationCanceledError) { 806 | emit errorOccurred("网络错误"); 807 | } 808 | return; 809 | } 810 | 811 | if (!file->commit()) { 812 | emit errorOccurred("保存文件失败"); 813 | return; 814 | } 815 | 816 | finishedImgCnt++; 817 | bytesCntTillLastImg += imgSize; 818 | downloadNextImg(); 819 | } 820 | 821 | qint64 ComicDownloadTask::getDownloadedBytesCnt() const 822 | { 823 | return bytesCntTillLastImg + curImgRecvBytesCnt; 824 | } 825 | 826 | int ComicDownloadTask::estimateRemainingSeconds(qint64 downBytesPerSec) const 827 | { 828 | if (downBytesPerSec == 0) { 829 | return Unknown; 830 | } 831 | 832 | if (finishedImgCnt == totalImgCnt - 1 && curImgTotalBytesCnt != 0) { 833 | // last image, remaining bytes count is known 834 | return (curImgTotalBytesCnt - curImgRecvBytesCnt) / downBytesPerSec; 835 | } else if (finishedImgCnt == 0) { 836 | if (curImgTotalBytesCnt == 0) { 837 | return Unknown; 838 | } else { 839 | return (curImgTotalBytesCnt * totalImgCnt - curImgRecvBytesCnt) / downBytesPerSec; 840 | } 841 | } else { 842 | int estimateTotalBytes = bytesCntTillLastImg * totalImgCnt / finishedImgCnt; 843 | return (estimateTotalBytes - bytesCntTillLastImg - curImgRecvBytesCnt) / downBytesPerSec; 844 | } 845 | } 846 | 847 | double ComicDownloadTask::getProgress() const 848 | { 849 | if (totalImgCnt == 0) { 850 | // new created. total Image count unknown 851 | return 0; 852 | } 853 | 854 | return static_cast(finishedImgCnt) / totalImgCnt; 855 | // double progress = static_cast(finishedImgCnt * 100) / static_cast(totalImgCnt); 856 | // if (curImgTotalBytesCnt != 0) { 857 | // int estimateTotalBytes = totalImgCnt * curImgTotalBytesCnt; 858 | // progress += static_cast(curImgRecvBytesCnt * 100) / static_cast(estimateTotalBytes); 859 | // } 860 | // return static_cast(progress); 861 | } 862 | 863 | QString ComicDownloadTask::getProgressStr() const 864 | { 865 | if (totalImgCnt == 0) { 866 | return QString(); 867 | } else { 868 | return QStringLiteral("%1页/%2页").arg(finishedImgCnt).arg(totalImgCnt); 869 | } 870 | } 871 | 872 | QString ComicDownloadTask::getQnDescription() const 873 | { 874 | return QString(); 875 | } 876 | -------------------------------------------------------------------------------- /B23Downloader/DownloadTask.h: -------------------------------------------------------------------------------- 1 | #ifndef DOWNLOADTASK_H 2 | #define DOWNLOADTASK_H 3 | 4 | #include 5 | #include 6 | #include 7 | #include 8 | //#include 9 | 10 | class QNetworkReply; 11 | class QFile; 12 | 13 | using QnList = QList; 14 | 15 | struct QnInfo { 16 | QnList qnList; 17 | int currentQn; 18 | }; 19 | 20 | class AbstractDownloadTask: public QObject 21 | { 22 | Q_OBJECT 23 | 24 | public: 25 | static constexpr qint64 Unknown = -1; 26 | 27 | /** 28 | * @return a new AbstractDownloadTask object constructed from json. returns nullptr if invalid 29 | */ 30 | static AbstractDownloadTask *fromJsonObj(const QJsonObject &json); 31 | virtual QJsonObject toJsonObj() const = 0; 32 | 33 | virtual void startDownload() = 0; 34 | virtual void stopDownload() = 0; 35 | 36 | /** 37 | * @brief download task should be stopped when this method is called. 38 | */ 39 | virtual void removeFile() = 0; 40 | 41 | signals: 42 | void downloadFinished(); 43 | void errorOccurred(const QString &errorString); 44 | void getUrlInfoFinished(); 45 | 46 | protected: 47 | QString path; 48 | QNetworkReply *httpReply = nullptr; 49 | QJsonValue getReplyJson(const QString &dataKey = QString()); 50 | 51 | // AbstractDownloadTask() = default; 52 | 53 | AbstractDownloadTask(const QString &path): path(path) {} 54 | 55 | public: 56 | virtual ~AbstractDownloadTask(); 57 | 58 | QString getPath() const { return path; } 59 | virtual QString getTitle() const; 60 | 61 | /** 62 | * @brief can be used to calculate download speed 63 | */ 64 | virtual qint64 getDownloadedBytesCnt() const = 0; 65 | 66 | /** 67 | * @return estimate remaining time (in seconds) using downBytesPerSec. 68 | * -1 for INF or unknown. LiveDownloadTask returns time since download started. 69 | */ 70 | virtual int estimateRemainingSeconds(qint64 downBytesPerSec) const = 0; 71 | 72 | /** 73 | * @return progress (0.0 to 1.0) for non-live. LiveDownloadTask returns -1. 74 | */ 75 | virtual double getProgress() const = 0; 76 | 77 | virtual QString getProgressStr() const = 0; 78 | 79 | /** 80 | * @return quality description if exists, else null QString 81 | */ 82 | virtual QString getQnDescription() const = 0; 83 | }; 84 | 85 | 86 | class AbstractVideoDownloadTask : public AbstractDownloadTask 87 | { 88 | Q_OBJECT 89 | 90 | protected: 91 | int durationInMSec = 0; 92 | int qn = 0; // quality (1080P, 720P, ...) 93 | 94 | qint64 downloadedBytesCnt = 0; // bytes downloaded, or total bytes if finished 95 | 96 | AbstractVideoDownloadTask(const QString &path, int qn) 97 | : AbstractDownloadTask(path), qn(qn) {} 98 | 99 | public: 100 | ~AbstractVideoDownloadTask(); 101 | 102 | qint64 getDownloadedBytesCnt() const override; 103 | 104 | void startDownload() override; 105 | void stopDownload() override; 106 | 107 | virtual QNetworkReply *getPlayUrlInfo() const = 0; 108 | virtual QString getPlayUrlInfoDataKey() const = 0; 109 | 110 | protected: 111 | /** 112 | * @brief parse json returned from getPlayUrlInfo request. 113 | * start download if success, otherwise emit signal errorOccurred() 114 | */ 115 | virtual void parsePlayUrlInfo(const QJsonObject &data) = 0; 116 | }; 117 | 118 | 119 | class FlvLiveDownloadDelegate; 120 | class LiveDownloadTask : public AbstractVideoDownloadTask 121 | { 122 | Q_OBJECT 123 | 124 | QString basePath; 125 | 126 | std::unique_ptr dldDelegate; 127 | 128 | public: 129 | const qint64 roomId; 130 | 131 | LiveDownloadTask(qint64 roomId, int qn, const QString &path); 132 | // LiveDownloadTask(const QJsonObject &json); 133 | ~LiveDownloadTask(); 134 | 135 | QJsonObject toJsonObj() const override; 136 | 137 | QString getTitle() const override; 138 | void removeFile() override; 139 | int estimateRemainingSeconds(qint64 downBytesPerSec) const override; 140 | double getProgress() const override { return -1; } 141 | QString getProgressStr() const override; 142 | QString getQnDescription() const override; 143 | 144 | static QnList getAllPossibleQn(); 145 | static QString getQnDescription(int qn); 146 | static QnInfo getQnInfoFromPlayUrlInfo(const QJsonObject &); 147 | static QString getPlayUrlFromPlayUrlInfo(const QJsonObject &); 148 | static QNetworkReply *getPlayUrlInfo(qint64 roomId, int qn); 149 | QNetworkReply *getPlayUrlInfo() const override; 150 | static const QString playUrlInfoDataKey; 151 | QString getPlayUrlInfoDataKey() const override; 152 | 153 | protected: 154 | void parsePlayUrlInfo(const QJsonObject &data) override; 155 | }; 156 | 157 | class VideoDownloadTask : public AbstractVideoDownloadTask 158 | { 159 | Q_OBJECT 160 | 161 | qint64 totalBytesCnt = 0; 162 | 163 | public: 164 | void removeFile() override; 165 | int estimateRemainingSeconds(qint64 downBytesPerSec) const override; 166 | double getProgress() const override; 167 | QString getProgressStr() const override; 168 | QString getQnDescription() const override; 169 | 170 | static QnList getAllPossibleQn(); 171 | static QString getQnDescription(int qn); 172 | static QnInfo getQnInfoFromPlayUrlInfo(const QJsonObject &); 173 | 174 | QJsonObject toJsonObj() const override; 175 | 176 | protected: 177 | VideoDownloadTask(const QJsonObject &json); 178 | using AbstractVideoDownloadTask::AbstractVideoDownloadTask; // ctor 179 | 180 | std::unique_ptr file; 181 | std::unique_ptr openFileForWrite(); 182 | 183 | void parsePlayUrlInfo(const QJsonObject &data) override; 184 | void startDownloadStream(const QUrl &url); 185 | void onStreamReadyRead(); 186 | void onStreamFinished(); 187 | 188 | bool checkQn(int qnFromReply); 189 | bool checkSize(qint64 sizeFromReply); 190 | }; 191 | 192 | class PgcDownloadTask : public VideoDownloadTask 193 | { 194 | Q_OBJECT 195 | 196 | public: 197 | const qint64 ssId; 198 | const qint64 epId; 199 | 200 | PgcDownloadTask(qint64 ssId, qint64 epId, int qn, const QString &path) 201 | : VideoDownloadTask(path, qn), ssId(ssId), epId(epId) {} 202 | 203 | QJsonObject toJsonObj() const override; 204 | PgcDownloadTask(const QJsonObject &json); 205 | 206 | static QNetworkReply *getPlayUrlInfo(qint64 epId, int qn); 207 | QNetworkReply *getPlayUrlInfo() const override; 208 | 209 | static const QString playUrlInfoDataKey; 210 | QString getPlayUrlInfoDataKey() const override; 211 | }; 212 | 213 | 214 | class PugvDownloadTask : public VideoDownloadTask 215 | { 216 | Q_OBJECT 217 | 218 | public: 219 | const qint64 ssId; 220 | const qint64 epId; 221 | 222 | PugvDownloadTask(qint64 ssId, qint64 epId, int qn, const QString &path) 223 | : VideoDownloadTask(path, qn), ssId(ssId), epId(epId) {} 224 | 225 | QJsonObject toJsonObj() const override; 226 | PugvDownloadTask(const QJsonObject &json); 227 | 228 | static QNetworkReply *getPlayUrlInfo(qint64 epId, int qn); 229 | QNetworkReply *getPlayUrlInfo() const override; 230 | 231 | static const QString playUrlInfoDataKey; 232 | QString getPlayUrlInfoDataKey() const override; 233 | }; 234 | 235 | class UgcDownloadTask : public VideoDownloadTask 236 | { 237 | Q_OBJECT 238 | 239 | public: 240 | const qint64 aid; 241 | const qint64 cid; 242 | 243 | UgcDownloadTask(qint64 aid, qint64 cid, int qn, const QString &path) 244 | : VideoDownloadTask(path, qn), aid(aid), cid(cid) {} 245 | 246 | QJsonObject toJsonObj() const override; 247 | UgcDownloadTask(const QJsonObject &json); 248 | 249 | static QNetworkReply *getPlayUrlInfo(qint64 aid, qint64 cid, int qn); 250 | QNetworkReply *getPlayUrlInfo() const override; 251 | 252 | static const QString playUrlInfoDataKey; 253 | QString getPlayUrlInfoDataKey() const override; 254 | }; 255 | 256 | 257 | class QSaveFile; 258 | class ComicDownloadTask : public AbstractDownloadTask 259 | { 260 | Q_OBJECT 261 | 262 | private: 263 | int totalImgCnt = 0; 264 | int finishedImgCnt = 0; 265 | int curImgRecvBytesCnt = 0; 266 | int curImgTotalBytesCnt = 0; 267 | qint64 bytesCntTillLastImg = 0; 268 | 269 | QVector imgRqstPaths; 270 | std::unique_ptr file; 271 | 272 | public: 273 | const qint64 comicId; 274 | const qint64 epId; 275 | ComicDownloadTask(qint64 comicId, qint64 epId, const QString &path) 276 | : AbstractDownloadTask(path), comicId(comicId), epId(epId) {} 277 | ~ComicDownloadTask(); 278 | 279 | QJsonObject toJsonObj() const override; 280 | ComicDownloadTask(const QJsonObject &json); 281 | 282 | void startDownload() override; 283 | void stopDownload() override; 284 | void removeFile() override; 285 | 286 | qint64 getDownloadedBytesCnt() const override; 287 | int estimateRemainingSeconds(qint64 downBytesPerSec) const override; 288 | double getProgress() const override; 289 | QString getProgressStr() const override; 290 | 291 | QString getQnDescription() const override; 292 | 293 | private slots: 294 | void getImgInfoFinished(); 295 | void getImgTokenFinished(); 296 | void onImgReadyRead(); 297 | void downloadImgFinished(); 298 | 299 | private: 300 | void downloadNextImg(); 301 | void abortCurrentImg(); 302 | 303 | std::unique_ptr openFileForWrite(const QString &fileName); 304 | }; 305 | 306 | 307 | #endif // DOWNLOADTASK_H 308 | -------------------------------------------------------------------------------- /B23Downloader/Extractor.cpp: -------------------------------------------------------------------------------- 1 | // Created by voidzero 2 | 3 | #include "utils.h" 4 | #include "Extractor.h" 5 | #include "Network.h" 6 | #include 7 | 8 | using QRegExp = QRegularExpression; 9 | using std::make_unique; 10 | using std::move; 11 | 12 | 13 | Extractor::Extractor() 14 | { 15 | focusItemId = 0; 16 | } 17 | 18 | Extractor::~Extractor() 19 | { 20 | if (httpReply != nullptr) { 21 | httpReply->abort(); 22 | } 23 | } 24 | 25 | void Extractor::urlNotSupported() 26 | { 27 | static const QString supportedUrlTip = 28 | "输入错误或不支持. 支持的输入有:
    " 29 | "
  • B站 剧集/视频/直播/课程/漫画 链接
  • " 30 | "
  • 剧集(番剧,电影等)ss或ep号, 比如《招魂》: ss28341ep281280
  • " 31 | "
  • 视频BV或av号, 比如: BV1A2b3X4y5Zav123456
  • " 32 | "
  • live+房间号, 比如LOL赛事直播: live6
  • " 33 | "
" 34 | "

by: github.com/vooidzero

"; 35 | emit errorOccurred(supportedUrlTip); 36 | } 37 | 38 | void Extractor::start(QString url) 39 | { 40 | url = url.trimmed(); 41 | QRegularExpressionMatch m; 42 | 43 | // bad coding style?! 44 | 45 | // try to match short forms 46 | if ((m = QRegExp("^(?:BV|bv)([a-zA-Z0-9]+)$").match(url)).hasMatch()) { 47 | return startUgcByBvId("BV" + m.captured(1)); 48 | } 49 | if ((m = QRegExp(R"(^av(\d+)$)").match(url)).hasMatch()) { 50 | return startUgcByAvId(m.captured(1).toLongLong()); 51 | } 52 | if ((m = QRegExp(R"(^(ss|ep)(\d+)$)").match(url)).hasMatch()) { 53 | auto idType = (m.captured(1) == "ss" ? PgcIdType::SeasonId : PgcIdType::EpisodeId); 54 | return startPgc(idType, m.captured(2).toLongLong()); 55 | } 56 | if ((m = QRegExp(R"(^live(\d+)$)").match(url)).hasMatch()) { 57 | return startLive(m.captured(1).toLongLong()); 58 | } 59 | 60 | if (!url.startsWith("http://") && !url.startsWith("https://")) { 61 | parseUrl("https://" + url); 62 | } else { 63 | parseUrl(url); 64 | } 65 | } 66 | 67 | void Extractor::parseUrl(QUrl url) 68 | { 69 | auto host = url.authority().toLower(); 70 | auto path = url.path(); 71 | auto query = QUrlQuery(url); 72 | QRegularExpressionMatch m; 73 | 74 | if (QRegExp(R"(^(?:www\.|m\.)?bilibili\.com$)").match(host).hasMatch()) { 75 | if ((m = QRegExp(R"(^/bangumi/play/(ss|ep)(\d+)/?$)").match(path)).hasMatch()) { 76 | auto idType = (m.captured(1) == "ss" ? PgcIdType::SeasonId : PgcIdType::EpisodeId); 77 | return startPgc(idType, m.captured(2).toLongLong()); 78 | } 79 | if ((m = QRegExp(R"(^/bangumi/media/md(\d+)/?$)").match(path)).hasMatch()) { 80 | return startPgcByMdId(m.captured(1).toLongLong()); 81 | } 82 | if ((m = QRegExp(R"(^/cheese/play/(ss|ep)(\d+)/?$)").match(path)).hasMatch()) { 83 | auto idType = (m.captured(1) == "ss" ? PugvIdType::SeasonId : PugvIdType::EpisodeId); 84 | return startPugv(idType, m.captured(2).toLongLong()); 85 | } 86 | 87 | focusItemId = query.queryItemValue("p").toLongLong(); 88 | if ((m = QRegExp(R"(^/(?:(?:s/)?video/)?(?:BV|bv)([a-zA-Z0-9]+)/?$)").match(path)).hasMatch()) { 89 | return startUgcByBvId("BV" + m.captured(1)); 90 | } 91 | if ((m = QRegExp(R"(^/(?:(?:s/)?video/)?av(\d+)/?$)").match(path)).hasMatch()) { 92 | return startUgcByAvId(m.captured(1).toLongLong()); 93 | } 94 | // if ((m = QRegExp(R"(^$)").match(path)).hasMatch()) { 95 | // } 96 | return urlNotSupported(); 97 | } 98 | 99 | if (host == "bangumi.bilibili.com") { 100 | if ((m = QRegExp(R"(^/anime/(\d+)/?$)").match(path)).hasMatch()) { 101 | return startPgc(PgcIdType::SeasonId, m.captured(1).toLongLong()); 102 | } 103 | return urlNotSupported(); 104 | } 105 | 106 | if (host == "live.bilibili.com") { 107 | if ((m = QRegExp(R"(^/(?:h5/)?(\d+)/?$)").match(path)).hasMatch()) { 108 | return startLive(m.captured(1).toLongLong()); 109 | } 110 | if ((m = QRegExp(R"(^/blackboard/activity-.*\.html$)").match(path)).hasMatch()) { 111 | return startLiveActivity(url); 112 | } 113 | 114 | return urlNotSupported(); 115 | } 116 | 117 | if (host == "b23.tv") { 118 | if ((m = QRegExp(R"(^/(ss|ep)(\d+)$)").match(path)).hasMatch()) { 119 | auto idType = (m.captured(1) == "ss" ? PugvIdType::SeasonId : PugvIdType::EpisodeId); 120 | return startPgc(idType, m.captured(2).toLongLong()); 121 | } else { 122 | return tryRedirect(url); 123 | } 124 | } 125 | 126 | if (host == "manga.bilibili.com") { 127 | if ((m = QRegExp(R"(^/(?:m/)?detail/mc(\d+)/?$)").match(path)).hasMatch()) { 128 | focusItemId = query.queryItemValue("epId").toLongLong(); 129 | return startComic(m.captured(1).toLongLong()); 130 | } 131 | if ((m = QRegExp(R"(^/(?:m/)?mc(\d+)/(\d+)/?$)").match(path)).hasMatch()) { 132 | focusItemId = m.captured(2).toLongLong(); 133 | return startComic(m.captured(1).toLongLong()); 134 | } 135 | return urlNotSupported(); 136 | } 137 | 138 | if (host == "b22.top") { 139 | return tryRedirect(url); 140 | } 141 | 142 | // auto tenkinokoWebAct = "www.bilibili.com/blackboard/topic/activity-jjR1nNRUF.html"; 143 | // auto tenkinokoMobiAct = "www.bilibili.com/blackboard/topic/activity-4AL5_Jqb3"; 144 | 145 | return urlNotSupported(); 146 | } 147 | 148 | void Extractor::abort() 149 | { 150 | if (httpReply != nullptr) { 151 | httpReply->abort(); 152 | } 153 | } 154 | 155 | std::unique_ptr Extractor::getResult() 156 | { 157 | result->focusItemId = this->focusItemId; 158 | return move(result); 159 | } 160 | 161 | 162 | QJsonObject Extractor::getReplyJsonObj(const QString &requiredKey) 163 | { 164 | auto reply = this->httpReply; 165 | this->httpReply = nullptr; 166 | reply->deleteLater(); 167 | if (reply->error() == QNetworkReply::OperationCanceledError) { 168 | return QJsonObject(); 169 | } 170 | 171 | const auto [json, errorString] = Network::Bili::parseReply(reply, requiredKey); 172 | 173 | if (!errorString.isNull()) { 174 | emit errorOccurred(errorString); 175 | return QJsonObject(); 176 | } 177 | 178 | 179 | if (requiredKey.isEmpty()) { 180 | return json; 181 | } else { 182 | auto ret = json[requiredKey].toObject(); 183 | if (ret.isEmpty()) { 184 | emit errorOccurred("请求错误: 内容为空"); 185 | return QJsonObject(); 186 | } 187 | return ret; 188 | } 189 | } 190 | 191 | QString Extractor::getReplyText() 192 | { 193 | auto reply = httpReply; 194 | httpReply = nullptr; 195 | reply->deleteLater(); 196 | if (reply->error() == QNetworkReply::OperationCanceledError) { 197 | return QString(); 198 | } 199 | if (reply->error() != QNetworkReply::NoError) { 200 | emit errorOccurred("网络请求错误"); 201 | return QString(); 202 | } 203 | 204 | return QString::fromUtf8(reply->readAll()); 205 | } 206 | 207 | void Extractor::tryRedirect(const QUrl &url) 208 | { 209 | auto rqst = QNetworkRequest(url); 210 | rqst.setMaximumRedirectsAllowed(0); 211 | httpReply = Network::accessManager()->get(rqst); 212 | connect(httpReply, &QNetworkReply::finished, this, [this]{ 213 | auto reply = httpReply; 214 | httpReply = nullptr; 215 | reply->deleteLater(); 216 | if (reply->hasRawHeader("Location")) { 217 | auto redirect = QString::fromUtf8(reply->rawHeader("Location")); 218 | if (redirect.contains("bilibili.com")) { 219 | parseUrl(redirect); 220 | } else { 221 | emit errorOccurred("重定向目标非B站"); 222 | } 223 | } else if (reply->error() != QNetworkReply::NoError) { 224 | emit errorOccurred("网络错误"); 225 | } else { 226 | emit errorOccurred("未知错误"); 227 | } 228 | }); 229 | } 230 | 231 | 232 | void Extractor::startPgcByMdId(qint64 mdId) 233 | { 234 | auto query = "https://api.bilibili.com/pgc/review/user?media_id=" + QString::number(mdId); 235 | httpReply = Network::Bili::get(query); 236 | connect(httpReply, &QNetworkReply::finished, this, [this] { 237 | auto result = getReplyJsonObj("result"); 238 | if (result.isEmpty()) { 239 | return; 240 | } 241 | auto ssid = result["media"].toObject()["season_id"].toInteger(); 242 | startPgc(PgcIdType::SeasonId, ssid); 243 | }); 244 | } 245 | 246 | void Extractor::startPgc(PgcIdType idType, qint64 id) 247 | { 248 | if (idType == PgcIdType::EpisodeId) { 249 | focusItemId = id; 250 | } 251 | auto api = "https://api.bilibili.com/pgc/view/web/season"; 252 | auto query = QString("?%1=%2").arg(idType == PgcIdType::SeasonId ? "season_id" : "ep_id").arg(id); 253 | httpReply = Network::Bili::get(api + query); 254 | connect(httpReply, &QNetworkReply::finished, this, &Extractor::pgcFinished); 255 | } 256 | 257 | static int epFlags(int epStatus) 258 | { 259 | switch (epStatus) { 260 | case 2: 261 | return ContentItemFlag::NoFlags; 262 | case 13: 263 | return ContentItemFlag::VipOnly; 264 | case 6: case 7: 265 | case 8: case 9: 266 | case 12: 267 | return ContentItemFlag::PayOnly; 268 | // case 14: 限定 https://www.bilibili.com/bangumi/media/md28234679 269 | default: 270 | qDebug() << "unknown ep status" << epStatus; 271 | return ContentItemFlag::NoFlags; 272 | } 273 | } 274 | 275 | static QString epIndexedTitle(const QString &title, int fieldWidth, const QString &indexSuffix) 276 | { 277 | bool isNum; 278 | title.toDouble(&isNum); 279 | if (!isNum) { 280 | return title; 281 | } 282 | 283 | auto &unpadNumStr = title; 284 | auto dotPos = unpadNumStr.indexOf('.'); // in case of float number like 34.5 285 | auto padLen = fieldWidth - (dotPos == -1 ? unpadNumStr.size() : dotPos); 286 | return QString("第%1%2").arg(QString(padLen, '0') + unpadNumStr, indexSuffix); 287 | } 288 | 289 | 290 | static QString epTitle (const QString &title, const QString &longTitle) { 291 | if (longTitle.isEmpty()) { 292 | return title; 293 | } else { 294 | return title + " " + longTitle; 295 | } 296 | } 297 | 298 | void Extractor::pgcFinished() 299 | { 300 | auto res = getReplyJsonObj("result"); 301 | if (res.isEmpty()) { 302 | return; 303 | } 304 | 305 | auto type = res["type"].toInt(); 306 | QString indexSuffix = (type == 1 || type == 4) ? "话" : "集"; 307 | 308 | auto ssid = res["season_id"].toInteger(); 309 | auto title = res["title"].toString(); 310 | auto pgcRes = make_unique(ContentType::PGC, ssid, title); 311 | auto mainSecEps = res["episodes"].toArray(); 312 | auto totalEps = res["total"].toInt(); 313 | if (totalEps <= 0) { 314 | totalEps = mainSecEps.size(); 315 | } 316 | auto indexFieldWidth = QString::number(totalEps).size(); 317 | pgcRes->sections.emplaceBack("正片"); 318 | auto &mainSection = pgcRes->sections.first(); 319 | for (auto epValR : mainSecEps) { 320 | auto epObj = epValR.toObject(); 321 | auto title = epObj["title"].toString(); 322 | auto longTitle = epObj["long_title"].toString(); 323 | mainSection.episodes.emplaceBack( 324 | epObj["id"].toInteger(), 325 | epTitle(epIndexedTitle(title, indexFieldWidth, indexSuffix), longTitle), 326 | qRound(epObj["duration"].toDouble() / 1000.0), 327 | epFlags(epObj["status"].toInt()) 328 | ); 329 | } 330 | if (focusItemId == 0 && mainSection.episodes.size() == 1) { 331 | focusItemId = mainSection.episodes.first().id; 332 | } 333 | 334 | for (auto &&secValR : res["section"].toArray()) { 335 | auto secObj = secValR.toObject(); 336 | auto eps = secObj["episodes"].toArray(); 337 | if (eps.size() == 0) { 338 | continue; 339 | } 340 | pgcRes->sections.emplaceBack(secObj["title"].toString()); 341 | auto &sec = pgcRes->sections.last(); 342 | for (auto &&epValR : eps) { 343 | auto epObj = epValR.toObject(); 344 | sec.episodes.emplaceBack( 345 | epObj["id"].toInteger(), 346 | epTitle(epObj["title"].toString(), epObj["long_title"].toString()), 347 | qRound(epObj["duration"].toDouble() / 1000.0), 348 | epFlags(epObj["status"].toInt()) 349 | ); 350 | } 351 | } 352 | 353 | // season is free 354 | if (res["status"].toInt() == 2) { 355 | this->result = move(pgcRes); 356 | emit success(); 357 | return; 358 | } 359 | 360 | // season is not free. add user payment info 361 | auto userStatApi = "https://api.bilibili.com/pgc/view/web/season/user/status"; 362 | auto query = "?season_id=" + QString::number(ssid); 363 | httpReply = Network::Bili::get(userStatApi + query); 364 | connect(httpReply, &QNetworkReply::finished, this, [this, pgcRes = move(pgcRes)]() mutable { 365 | auto result = getReplyJsonObj("result"); 366 | if (result.isEmpty()) { 367 | return; 368 | } 369 | auto userIsVip = (result["vip_info"].toObject()["status"].toInt() == 1); 370 | auto userHasPaid = (result["pay"].toInt(1) == 1); 371 | if (!userHasPaid) { 372 | for (auto &video : pgcRes->sections.first().episodes) { 373 | auto notFree = (video.flags & ContentItemFlag::VipOnly) or (video.flags & ContentItemFlag::PayOnly); 374 | if (notFree && !userHasPaid) { 375 | video.flags |= ContentItemFlag::Disabled; 376 | } 377 | } 378 | } 379 | 380 | this->result = move(pgcRes); 381 | emit success(); 382 | }); 383 | } 384 | 385 | 386 | void Extractor::startLive(qint64 roomId) 387 | { 388 | auto api = "https://api.live.bilibili.com/xlive/web-room/v1/index/getInfoByRoom"; 389 | auto query = "?room_id=" + QString::number(roomId); 390 | httpReply = Network::Bili::get(api + query); 391 | connect(httpReply, &QNetworkReply::finished, this, &Extractor::liveFinished); 392 | } 393 | 394 | void Extractor::liveFinished() 395 | { 396 | auto json = getReplyJsonObj(); 397 | if (json.isEmpty()) { 398 | return; 399 | } 400 | 401 | if (!json.contains("data") || json["data"].type() == QJsonValue::Null) { 402 | emit errorOccurred("B站请求错误: 非法房间号"); 403 | return; 404 | } 405 | 406 | auto data = json["data"].toObject(); 407 | auto roomInfo = data["room_info"].toObject(); 408 | if (!roomInfo.contains("live_status")) { 409 | emit errorOccurred("发生了错误: getInfoByRoom: 未找到live_status"); 410 | return; 411 | } 412 | auto roomStatus = roomInfo["live_status"].toInt(); 413 | if (roomStatus == 0) { 414 | emit errorOccurred("该房间当前未开播"); 415 | return; 416 | } 417 | if (roomStatus == 2) { 418 | emit errorOccurred("该房间正在轮播."); 419 | return; 420 | } 421 | 422 | auto roomId = roomInfo["room_id"].toInteger(); 423 | bool hasPayment = (roomInfo["special_type"].toInt() == 1); 424 | auto title = roomInfo["title"].toString(); 425 | auto uname = data["anchor_info"].toObject()["base_info"].toObject()["uname"].toString(); 426 | 427 | auto liveRes = make_unique(roomId, QString("【%1】%2").arg(uname, title)); 428 | if (!hasPayment) { 429 | this->result = move(liveRes); 430 | emit success(); 431 | return; 432 | } 433 | 434 | auto validateApi = "https://api.live.bilibili.com/av/v1/PayLive/liveValidate"; 435 | auto query = "?room_id=" + QString::number(roomId); 436 | httpReply = Network::Bili::get(validateApi + query); 437 | connect(httpReply, &QNetworkReply::finished, this, [this, liveRes = move(liveRes)]() mutable { 438 | auto data = getReplyJsonObj("data"); 439 | if (data.isEmpty()) { 440 | return; 441 | } 442 | 443 | if (data["permission"].toInt()) { 444 | this->result = move(liveRes); 445 | emit success(); 446 | } else { 447 | emit errorOccurred("该直播需要付费购票观看"); 448 | } 449 | }); 450 | } 451 | 452 | void Extractor::startLiveActivity(const QUrl &url) 453 | { 454 | httpReply = Network::Bili::get(url); 455 | connect(httpReply, &QNetworkReply::finished, this, &Extractor::liveActivityFinished); 456 | } 457 | 458 | void Extractor::liveActivityFinished() 459 | { 460 | auto text = getReplyText(); 461 | if (text.isNull()) { 462 | return; 463 | } 464 | 465 | auto parseFailed = [this]{ 466 | emit errorOccurred("解析活动页面失败。
建议尝试数字房间号链接, 比如live.bilibili.com/22586886"); 467 | }; 468 | 469 | auto m = QRegExp(R"(window.__BILIACT_ENV__\s?=([^;]+);)").match(text); 470 | auto platform = m.captured(1); // null if no match 471 | if (platform.contains("H5")) { 472 | m = QRegExp(R"(\\?"jumpUrl\\?"\s?:\s?\\?"([^"\\]+)\\?")").match(text, m.capturedEnd(0)); 473 | if (m.hasMatch()) { 474 | startLiveActivity(m.captured(1)); 475 | } else { 476 | parseFailed(); 477 | } 478 | return; 479 | } 480 | 481 | m = QRegExp(R"(\\?"defaultRoomId\\?"\s?:\s?\\?"?(\d+))").match(text, m.capturedEnd(0)); 482 | if (!m.hasMatch()) { 483 | parseFailed(); 484 | return; 485 | } 486 | startLive(m.captured(1).toLongLong()); 487 | } 488 | 489 | 490 | 491 | void Extractor::startPugv(PugvIdType idType, qint64 id) 492 | { 493 | if (idType == PgcIdType::EpisodeId) { 494 | focusItemId = id; 495 | } 496 | auto api = "https://api.bilibili.com/pugv/view/web/season"; 497 | auto query = QString("?%1=%2").arg(idType == PugvIdType::SeasonId ? "season_id" : "ep_id").arg(id); 498 | httpReply = Network::Bili::get(api + query); 499 | connect(httpReply, &QNetworkReply::finished, this, &Extractor::pugvFinished); 500 | } 501 | 502 | void Extractor::pugvFinished() 503 | { 504 | auto data = getReplyJsonObj("data"); 505 | if (data.isEmpty()) { 506 | return; 507 | } 508 | 509 | if (!data["user_status"].toObject()["payed"].toInt(1)) { 510 | emit errorOccurred("未登录或未购买该课程"); 511 | return; 512 | } 513 | 514 | auto ssid = data["season_id"].toInteger(); 515 | auto title = data["title"].toString(); 516 | ItemListResult(ContentType::PUGV, ssid, title); 517 | auto pugvRes = make_unique(ContentType::PUGV, ssid, title); 518 | for (auto &&epValR : data["episodes"].toArray()) { 519 | auto epObj = epValR.toObject(); 520 | pugvRes->items.emplaceBack( 521 | epObj["id"].toInteger(), 522 | epObj["title"].toString(), 523 | epObj["duration"].toInt() 524 | ); 525 | } 526 | 527 | this->result = move(pugvRes); 528 | emit success(); 529 | } 530 | 531 | 532 | void Extractor::startUgcByBvId(const QString &bvid) 533 | { 534 | startUgc("bvid=" + bvid); 535 | } 536 | 537 | void Extractor::startUgcByAvId(qint64 avid) 538 | { 539 | startUgc("aid=" + QString::number(avid)); 540 | } 541 | 542 | void Extractor::startUgc(const QString &query) 543 | { 544 | httpReply = Network::Bili::get("https://api.bilibili.com/x/web-interface/view?" + query); 545 | connect(httpReply, &QNetworkReply::finished, this, &Extractor::ugcFinished); 546 | } 547 | 548 | void Extractor::ugcFinished() 549 | { 550 | auto data = getReplyJsonObj("data"); 551 | if (data.isEmpty()) { 552 | return; 553 | } 554 | 555 | auto isInteractVideo = data["rights"].toObject()["is_stein_gate"].toInt(); 556 | if (isInteractVideo) { 557 | emit errorOccurred("不支持互动视频"); 558 | return; 559 | } 560 | 561 | auto redirect = data["redirect_url"].toString(); 562 | if (!redirect.isNull()) { 563 | parseUrl(redirect); 564 | return; 565 | } 566 | 567 | auto pages = data["pages"].toArray(); 568 | if (pages.isEmpty()) { 569 | emit errorOccurred(data.isEmpty() ? "发生了错误: getUgcInfo: data为空" : "什么都没有..."); 570 | return; 571 | } 572 | 573 | auto avid = data["aid"].toInteger(); 574 | auto title = data["title"].toString(); 575 | auto ugcRes = make_unique(ContentType::UGC, avid, title); 576 | for (auto &&pageValR : pages) { 577 | auto pageObj = pageValR.toObject(); 578 | ugcRes->items.emplaceBack( 579 | pageObj["cid"].toInteger(), 580 | pageObj["part"].toString(), 581 | pageObj["duration"].toInt() 582 | ); 583 | } 584 | 585 | auto index = focusItemId; 586 | if (index > 0 && index <= ugcRes->items.size()) { 587 | focusItemId = ugcRes->items[index - 1].id; 588 | } else if (ugcRes->items.size() == 1) { 589 | focusItemId = ugcRes->items.first().id; 590 | } else { 591 | focusItemId = 0; 592 | } 593 | this->result = move(ugcRes); 594 | emit success(); 595 | } 596 | 597 | 598 | 599 | void Extractor::startComic(int comicId) 600 | { 601 | auto url = "https://manga.bilibili.com/twirp/comic.v1.Comic/ComicDetail?device=pc&platform=web"; 602 | httpReply = Network::Bili::postJson(QString(url), QJsonObject{{"comic_id", comicId}}); 603 | connect(httpReply, &QNetworkReply::finished, this, &Extractor::comicFinished); 604 | } 605 | 606 | enum ComicType { Comic, Video }; 607 | 608 | static QString comicEpTitle(const QString &shortTitle, const QString &title, int indexWidth) 609 | { 610 | // 想优化一下标题。但情况太杂了,这个函数就简单写吧 611 | 612 | bool isNumber; 613 | int index = shortTitle.toInt(&isNumber); 614 | if (isNumber) { 615 | if (index == title.toInt(&isNumber) && isNumber) { 616 | return Utils::paddedNum(index, indexWidth); 617 | } 618 | } 619 | return (title.isEmpty() ? shortTitle : shortTitle + " " + title); 620 | } 621 | 622 | void Extractor::comicFinished() 623 | { 624 | auto data = getReplyJsonObj("data"); 625 | if (data.isEmpty()) { 626 | return; 627 | } 628 | 629 | if (data["type"].toInt() == ComicType::Video) { 630 | emit errorOccurred("暂不支持Vomic"); 631 | return; 632 | } 633 | 634 | auto id = data["id"].toInteger(); 635 | auto title = data["title"].toString(); 636 | auto comicRes = make_unique(ContentType::Comic, id, title); 637 | auto epList = data["ep_list"].toArray(); 638 | comicRes->items.reserve(epList.size()); 639 | auto indexWidth = Utils::numberOfDigit(static_cast(epList.size())); 640 | 641 | // Episodes in epList should be sorted by 'ord' (which may start from 0 or 1). 642 | // Though currently episodes in reply are already sorted in descending order, 643 | // I think it's not good to rely on that. 644 | QMap ordMap; 645 | for (int i = 0; i < epList.size(); i++) { 646 | auto ord = epList[i].toObject()["ord"].toInt(); 647 | ordMap[ord] = i; 648 | } 649 | for (int idx : ordMap) { 650 | const auto epObj = epList[idx].toObject(); 651 | auto flags = ContentItemFlag::NoFlags; 652 | if (epObj["is_locked"].toBool()) { 653 | flags |= ContentItemFlag::Disabled; 654 | } 655 | if (epObj["allow_wait_free"].toBool()) { 656 | flags |= ContentItemFlag::AllowWaitFree; 657 | } else if (epObj["pay_mode"].toInt(0) != 0) { 658 | flags |= ContentItemFlag::PayOnly; 659 | } 660 | 661 | auto title = comicEpTitle( 662 | epObj["short_title"].toString(), 663 | epObj["title"].toString(), 664 | indexWidth 665 | ); 666 | 667 | auto epid = epObj["id"].toInteger(); 668 | comicRes->items.emplaceBack(epid, title, 0, flags); 669 | } 670 | 671 | 672 | this->result = move(comicRes); 673 | emit success(); 674 | } 675 | -------------------------------------------------------------------------------- /B23Downloader/Extractor.h: -------------------------------------------------------------------------------- 1 | #ifndef EXTRACTOR_H 2 | #define EXTRACTOR_H 3 | 4 | #include 5 | #include 6 | 7 | class QNetworkReply; 8 | 9 | namespace ContentItemFlag 10 | { 11 | constexpr int NoFlags = 0; 12 | constexpr int Disabled = 1; 13 | constexpr int VipOnly = 2; 14 | constexpr int PayOnly = 4; 15 | constexpr int AllowWaitFree = 8; // manga 16 | 17 | } 18 | 19 | // UGC (User Generated Content): 普通视频 20 | // PGC (Professional Generated Content): 剧集(番剧、电影、纪录片等) 21 | // PUGV (Professional User Generated Video): 课程 22 | enum class ContentType { UGC = 1, PGC, PUGV, Live, Comic }; 23 | 24 | 25 | // extract videos (title and cid) in url 26 | class Extractor : public QObject 27 | { 28 | Q_OBJECT 29 | 30 | public: 31 | struct Result 32 | { 33 | ContentType type; 34 | qint64 id; 35 | QString title; 36 | qint64 focusItemId = 0; 37 | Result(ContentType type, qint64 id, const QString &title) 38 | : type(type), id(id), title(title) {} 39 | virtual ~Result() = default; 40 | }; 41 | 42 | struct LiveResult: Result 43 | { 44 | LiveResult(qint64 roomId, const QString &title) 45 | : Result(ContentType::Live, roomId, title) {} 46 | }; 47 | 48 | struct ContentItem 49 | { 50 | qint64 id; 51 | QString title; 52 | qint32 durationInSec; 53 | int flags; 54 | ContentItem(qint64 id, const QString &title, qint32 dur, int flags=ContentItemFlag::NoFlags) 55 | : id(id), title(title), durationInSec(dur), flags(flags) {} 56 | }; 57 | 58 | struct Section 59 | { 60 | QString title; 61 | // qint64 id; 62 | QList episodes; 63 | Section() {} 64 | Section(const QString &title): title(title) {} 65 | }; 66 | 67 | struct ItemListResult: Result 68 | { 69 | QList items; 70 | using Result::Result; 71 | }; 72 | 73 | struct SectionListResult: Result 74 | { 75 | QList
sections; 76 | using Result::Result; 77 | }; 78 | 79 | enum class PgcIdType { SeasonId, EpisodeId }; 80 | using PugvIdType = PgcIdType; 81 | 82 | Extractor(); 83 | ~Extractor(); 84 | void start(QString url); 85 | void abort(); 86 | std::unique_ptr getResult(); 87 | 88 | signals: 89 | void errorOccurred(const QString &errorString); 90 | void success(); 91 | 92 | private: 93 | qint64 focusItemId = 0; 94 | std::unique_ptr result; 95 | QNetworkReply *httpReply = nullptr; 96 | QJsonObject getReplyJsonObj(const QString &requiredKey = QString()); 97 | QString getReplyText(); 98 | 99 | void parseUrl(QUrl url); 100 | void tryRedirect(const QUrl &url); 101 | void startUgc(const QString &query); 102 | void startUgcByBvId(const QString &bvid); 103 | void startUgcByAvId(qint64 avid); 104 | void startPgcByMdId(qint64 mdId); 105 | void startPgc(PgcIdType idType, qint64 id); 106 | void startPugv(PugvIdType idType, qint64 id); 107 | void startLive(qint64 roomId); 108 | void startLiveActivity(const QUrl &url); 109 | void startComic(int comicId); 110 | 111 | void urlNotSupported(); 112 | 113 | private slots: 114 | void ugcFinished(); 115 | void pgcFinished(); 116 | void pugvFinished(); 117 | void liveFinished(); 118 | void liveActivityFinished(); 119 | void comicFinished(); 120 | }; 121 | 122 | #endif // EXTRACTOR_H 123 | -------------------------------------------------------------------------------- /B23Downloader/Flv.cpp: -------------------------------------------------------------------------------- 1 | #include "Flv.h" 2 | #include 3 | #include 4 | #include 5 | #include 6 | 7 | using std::shared_ptr; 8 | using std::make_shared; 9 | using std::unique_ptr; 10 | using std::make_unique; 11 | 12 | double Flv::readDouble(QIODevice &in) 13 | { 14 | char data[8]; 15 | in.read(data, 8); 16 | return qFromBigEndian(data); 17 | } 18 | 19 | uint32_t Flv::readUInt32(QIODevice &in) 20 | { 21 | char data[4]; 22 | in.read(data, 4); 23 | return qFromBigEndian(data); 24 | } 25 | 26 | uint32_t Flv::readUInt24(QIODevice &in) 27 | { 28 | char data[4]; 29 | data[0] = 0; 30 | in.read(data + 1, 3); 31 | return qFromBigEndian(data); 32 | } 33 | 34 | uint16_t Flv::readUInt16(QIODevice &in) 35 | { 36 | char data[2]; 37 | in.read(data, 2); 38 | return qFromBigEndian(data); 39 | } 40 | 41 | uint8_t Flv::readUInt8(QIODevice &in) 42 | { 43 | uint8_t data; 44 | in.read(reinterpret_cast(&data), 1); 45 | return data; 46 | } 47 | 48 | void Flv::writeDouble(QIODevice &out, double val) 49 | { 50 | val = qToBigEndian(val); 51 | out.write(reinterpret_cast(&val), 8); 52 | } 53 | 54 | void Flv::writeUInt32(QIODevice &out, uint32_t val) 55 | { 56 | val = qToBigEndian(val); 57 | out.write(reinterpret_cast(&val), 4); 58 | } 59 | 60 | void Flv::writeUInt24(QIODevice &out, uint32_t val) 61 | { 62 | val = qToBigEndian(val); 63 | out.write(reinterpret_cast(&val) + 1, 3); 64 | } 65 | 66 | 67 | void Flv::writeUInt16(QIODevice &out, uint16_t val) 68 | { 69 | val = qToBigEndian(val); 70 | out.write(reinterpret_cast(&val), 2); 71 | } 72 | 73 | void Flv::writeUInt8(QIODevice &out, uint8_t val) 74 | { 75 | out.write(reinterpret_cast(&val), 1); 76 | } 77 | 78 | 79 | Flv::FileHeader::FileHeader(QIODevice &in) 80 | { 81 | constexpr uint32_t FlvSignatureUInt32 = 'F' | ('L' << 8) | ('V' << 16); 82 | char signature[4] = {0}; 83 | in.read(signature, 3); 84 | if (*reinterpret_cast(signature) != FlvSignatureUInt32) { 85 | valid = false; 86 | return; 87 | } 88 | 89 | valid = true; 90 | version = readUInt8(in); 91 | typeFlags = readUInt8(in); 92 | dataOffset = readUInt32(in); 93 | } 94 | 95 | void Flv::FileHeader::writeTo(QIODevice &out) 96 | { 97 | out.write("FLV", 3); 98 | writeUInt8(out, version); 99 | writeUInt8(out, typeFlags); 100 | writeUInt32(out, dataOffset); 101 | } 102 | 103 | bool Flv::TagHeader::readFrom(QIODevice &in) 104 | { 105 | flags = readUInt8(in); 106 | if (filter) { 107 | return false; 108 | } 109 | 110 | dataSize = readUInt24(in); 111 | auto tsLow = readUInt24(in); 112 | auto tsExt = readUInt8(in); 113 | timestamp = (tsExt << 24) | tsLow; 114 | readUInt24(in); // stream id, always 0 115 | return true; 116 | } 117 | 118 | void Flv::TagHeader::writeTo(QIODevice &out) 119 | { 120 | writeUInt8(out, flags); 121 | writeUInt24(out, dataSize); 122 | writeUInt24(out, timestamp & 0x00FFFFFF); 123 | writeUInt8(out, static_cast(timestamp >> 24)); 124 | writeUInt24(out, 0); 125 | } 126 | 127 | Flv::AudioTagHeader::AudioTagHeader(QIODevice &in) 128 | { 129 | rawData = in.peek(2); 130 | auto flags = readUInt8(in); 131 | if (SoundFormat::AAC == ((flags >> 4) & 0xF)) { 132 | isAacSequenceHeader = (readUInt8(in) == AacPacketType::SequenceHeader); 133 | } else { 134 | isAacSequenceHeader = false; 135 | rawData.chop(1); 136 | } 137 | } 138 | 139 | void Flv::AudioTagHeader::writeTo(QIODevice &out) 140 | { 141 | out.write(rawData); 142 | } 143 | 144 | Flv::VideoTagHeader::VideoTagHeader(QIODevice &in) 145 | { 146 | rawData = in.peek(5); 147 | auto byte = readUInt8(in); 148 | codecId = byte & 0xF; 149 | frameType = (byte >> 4) & 0xF; 150 | if (codecId == VideoCodecId::AVC) { 151 | avcPacketType = readUInt8(in); 152 | readUInt24(in); // compositionTime (SI24) 153 | } else { 154 | avcPacketType = 0xFF; 155 | rawData.chop(4); 156 | } 157 | } 158 | 159 | bool Flv::VideoTagHeader::isKeyFrame() 160 | { 161 | return frameType == VideoFrameType::Keyframe; 162 | } 163 | 164 | bool Flv::VideoTagHeader::isAvcSequenceHeader() 165 | { 166 | return (codecId == VideoCodecId::AVC && avcPacketType == AvcPacketType::SequenceHeader); 167 | } 168 | 169 | void Flv::VideoTagHeader::writeTo(QIODevice &out) 170 | { 171 | out.write(rawData); 172 | } 173 | 174 | void Flv::writeAvcEndOfSeqTag(QIODevice &out, int timestamp) 175 | { 176 | TagHeader(TagType::Video, 5, timestamp).writeTo(out); 177 | const char TagData[5] = { 178 | 0x17, // frameType=1 (Key frame), codecId=7 (AVC) 179 | 0x02, // avcPacketType=AvcEndOfSequence 180 | 0x00, 0x00, 0x00 // compositionTimeOffset=0 181 | }; 182 | out.write(TagData, 5); 183 | writeUInt32(out, 16); // prev tag size = 16 184 | } 185 | 186 | 187 | 188 | Flv::ScriptBody::ScriptBody(QIODevice &in) 189 | { 190 | name = readAmfValue(in); 191 | value = readAmfValue(in); 192 | } 193 | 194 | bool Flv::ScriptBody::isOnMetaData() const 195 | { 196 | if (name->type != AmfValueType::String) { 197 | return false; 198 | } 199 | return static_cast(name.get())->data == "onMetaData"; 200 | } 201 | 202 | void Flv::ScriptBody::writeTo(QIODevice &out) 203 | { 204 | name->writeTo(out); 205 | value->writeTo(out); 206 | } 207 | 208 | unique_ptr Flv::readAmfValue(QIODevice &in) 209 | { 210 | auto type = readUInt8(in); 211 | switch (type) { 212 | case AmfValueType::Number: return make_unique(in); 213 | case AmfValueType::Boolean: return make_unique(in); 214 | case AmfValueType::String: return make_unique(in); 215 | case AmfValueType::Object: return make_unique(in); 216 | case AmfValueType::MovieClip: return make_unique(AmfValueType::MovieClip); 217 | case AmfValueType::Null: return make_unique(AmfValueType::Null); 218 | case AmfValueType::Undefined: return make_unique(AmfValueType::Undefined); 219 | case AmfValueType::Reference: return make_unique(in); 220 | case AmfValueType::EcmaArray: return make_unique(in); 221 | case AmfValueType::ObjectEndMark: return make_unique(AmfValueType::ObjectEndMark); 222 | case AmfValueType::StrictArray: return make_unique(in); 223 | case AmfValueType::Date: return make_unique(in); 224 | case AmfValueType::LongString: return make_unique(in); 225 | default: return make_unique(AmfValueType::Null); 226 | } 227 | } 228 | 229 | Flv::AmfString::AmfString(QIODevice &in) : AmfValue(AmfValueType::String) 230 | { 231 | auto len = readUInt16(in); 232 | data = in.read(len); 233 | } 234 | 235 | void Flv::AmfString::writeTo(QIODevice &out) 236 | { 237 | AmfValue::writeTo(out); 238 | writeUInt16(out, static_cast(data.size())); 239 | out.write(data); 240 | } 241 | 242 | void Flv::AmfString::writeStrWithoutValType(QIODevice &out, const QByteArray &data) 243 | { 244 | writeUInt16(out, static_cast(data.size())); 245 | out.write(data); 246 | } 247 | 248 | Flv::AmfLongString::AmfLongString(QIODevice &in) : AmfValue(AmfValueType::LongString) 249 | { 250 | auto len = readUInt32(in); 251 | data = in.read(len); 252 | } 253 | 254 | void Flv::AmfLongString::writeTo(QIODevice &out) 255 | { 256 | AmfValue::writeTo(out); 257 | writeUInt32(out, static_cast(data.size())); 258 | out.write(data); 259 | } 260 | 261 | Flv::AmfObjectProperty::AmfObjectProperty(QIODevice &in) 262 | { 263 | auto nameLen = readUInt16(in); 264 | name = in.read(nameLen); 265 | value = readAmfValue(in); 266 | } 267 | 268 | void Flv::AmfObjectProperty::writeTo(QIODevice &out) 269 | { 270 | AmfString::writeStrWithoutValType(out, name); 271 | value->writeTo(out); 272 | } 273 | 274 | void Flv::AmfObjectProperty::write(QIODevice &out, const QByteArray &name, AmfValue *value) 275 | { 276 | AmfString::writeStrWithoutValType(out, name); 277 | value->writeTo(out); 278 | } 279 | 280 | bool Flv::AmfObjectProperty::isObjectEnd() 281 | { 282 | return (name.size() == 0 && value->type == AmfValueType::ObjectEndMark); 283 | } 284 | 285 | void Flv::AmfObjectProperty::writeObjectEndTo(QIODevice &out) 286 | { 287 | static const char objEndMark[3] = {0, 0, AmfValueType::ObjectEndMark}; 288 | out.write(objEndMark, 3); 289 | } 290 | 291 | Flv::AmfEcmaArray::AmfEcmaArray(std::vector &&properties_) 292 | : AmfValue(AmfValueType::EcmaArray), properties(std::move(properties_)) 293 | { 294 | } 295 | 296 | Flv::AmfEcmaArray::AmfEcmaArray(QIODevice &in) : AmfValue(AmfValueType::EcmaArray) 297 | { 298 | readUInt32(in); // ecmaArrayLength, approximate number of items in ECMA array 299 | while (!in.atEnd()) { 300 | auto p = AmfObjectProperty(in); 301 | if (p.isObjectEnd()) { 302 | break; 303 | } 304 | properties.emplace_back(std::move(p)); 305 | } 306 | } 307 | 308 | void Flv::AmfEcmaArray::writeTo(QIODevice &out) 309 | { 310 | AmfValue::writeTo(out); 311 | writeUInt32(out, static_cast(properties.size())); 312 | for (auto &p : properties) { 313 | p.writeTo(out); 314 | } 315 | AmfObjectProperty::writeObjectEndTo(out); 316 | } 317 | 318 | unique_ptr &Flv::AmfEcmaArray::operator[](QByteArray name) 319 | { 320 | auto it = std::find_if( 321 | properties.begin(), 322 | properties.end(), 323 | [name](const AmfObjectProperty &obj) { return obj.name == name; } 324 | ); 325 | if (it != properties.end()) { 326 | return it->value; 327 | } 328 | properties.emplace_back(); 329 | properties.back().name = std::move(name); 330 | return properties.back().value; 331 | } 332 | 333 | Flv::AmfObject::AmfObject(QIODevice &in) : AmfValue(AmfValueType::Object) 334 | { 335 | while (!in.atEnd()) { 336 | auto p = AmfObjectProperty(in); 337 | if (p.isObjectEnd()) { 338 | break; 339 | } 340 | properties.emplace_back(std::move(p)); 341 | } 342 | } 343 | 344 | void Flv::AmfObject::writeTo(QIODevice &out) 345 | { 346 | AmfValue::writeTo(out); 347 | for (auto &p : properties) { 348 | p.writeTo(out); 349 | } 350 | for (auto &anchor : anchors) { 351 | anchor->writeTo(out); 352 | } 353 | AmfObjectProperty::writeObjectEndTo(out); 354 | } 355 | 356 | unique_ptr Flv::AmfObject::moveToEcmaArray() 357 | { 358 | return make_unique(std::move(properties)); 359 | } 360 | 361 | shared_ptr 362 | Flv::AmfObject::insertReservedNumberArray(QByteArray name, int maxSize) 363 | { 364 | properties.erase( 365 | std::remove_if( 366 | properties.begin(), 367 | properties.end(), 368 | [&name](const AmfObjectProperty &obj){ return obj.name == name; } 369 | ), 370 | properties.end() 371 | ); 372 | auto anchor = make_shared(std::move(name), maxSize); 373 | anchors.emplace_back(anchor); 374 | return anchor; 375 | } 376 | 377 | Flv::AmfStrictArray::AmfStrictArray(QIODevice &in) : AmfValue(AmfValueType::StrictArray) 378 | { 379 | auto len = readUInt32(in); 380 | assert(len < 0XFFFF); 381 | values.reserve(len); 382 | for (uint32_t i = 0; i < len; i++) { 383 | values.emplace_back(readAmfValue(in)); 384 | } 385 | } 386 | 387 | void Flv::AmfStrictArray::writeTo(QIODevice &out) 388 | { 389 | writeUInt32(out, static_cast(values.size())); 390 | for (auto &val : values) { 391 | val->writeTo(out); 392 | } 393 | } 394 | 395 | QByteArray Flv::AmfObject::ReservedArrayAnchor::spacerName() const 396 | { 397 | return name + "Spacer"; 398 | } 399 | 400 | void Flv::AmfObject::ReservedArrayAnchor::writeTo(QIODevice &out) 401 | { 402 | currentSize = 0; 403 | AmfString::writeStrWithoutValType(out, name); 404 | writeUInt8(out, AmfValueType::StrictArray); 405 | arrBeginPos = out.pos(); 406 | writeUInt32(out, 0); 407 | arrEndPos = out.pos(); 408 | AmfString::writeStrWithoutValType(out, spacerName()); 409 | writeUInt8(out, AmfValueType::StrictArray); 410 | writeUInt32(out, maxSize); 411 | 412 | char element[9]; memset(element, 0, 9); 413 | for (int i = 0; i < maxSize; i++) { 414 | // equal to AmfNumber(0).writeTo(out) 415 | out.write(element, 9); 416 | } 417 | 418 | if (!out.isSequential()) { 419 | outDev = &out; 420 | } 421 | } 422 | 423 | void Flv::AmfObject::ReservedArrayAnchor::appendNumber(double val) 424 | { 425 | // qDebug() << "appending" << name << val; 426 | if (outDev == nullptr || currentSize == maxSize) { 427 | return; 428 | } 429 | 430 | auto pos = outDev->pos(); 431 | 432 | currentSize += 1; 433 | outDev->seek(arrBeginPos); 434 | writeUInt32(*outDev, currentSize); 435 | outDev->seek(arrEndPos); 436 | AmfNumber(val).writeTo(*outDev); 437 | arrEndPos = outDev->pos(); 438 | AmfString::writeStrWithoutValType(*outDev, spacerName()); 439 | writeUInt8(*outDev, AmfValueType::StrictArray); 440 | writeUInt32(*outDev, maxSize - currentSize); 441 | 442 | outDev->seek(pos); 443 | } 444 | 445 | Flv::AnchoredAmfNumber::AnchoredAmfNumber(double val) 446 | : AmfNumber(val), anchor(make_shared()) 447 | { 448 | } 449 | 450 | void Flv::AnchoredAmfNumber::writeTo(QIODevice &out) 451 | { 452 | writeUInt8(out, AmfValueType::Number); 453 | auto pos = out.pos(); 454 | writeDouble(out, val); 455 | if (!out.isSequential()) { 456 | anchor->outDev = &out; 457 | anchor->pos = pos; 458 | } 459 | } 460 | 461 | shared_ptr Flv::AnchoredAmfNumber::getAnchor() 462 | { 463 | return anchor; 464 | } 465 | 466 | void Flv::AnchoredAmfNumber::Anchor::update(double val) 467 | { 468 | if (outDev == nullptr) { 469 | return; 470 | } 471 | auto tmp = outDev->pos(); 472 | outDev->seek(pos); 473 | writeDouble(*outDev, val); 474 | outDev->seek(tmp); 475 | } 476 | 477 | 478 | 479 | FlvLiveDownloadDelegate::FlvLiveDownloadDelegate(QIODevice &in_, CreateFileHandler createFileHandler_) 480 | :in(in_), createFileHandler(createFileHandler_) 481 | { 482 | bytesRequired = Flv::FileHeader::BytesCnt + 4; // FlvFileHeader + prevTagSize (UI32) 483 | } 484 | 485 | FlvLiveDownloadDelegate::~FlvLiveDownloadDelegate() 486 | { 487 | if (out != nullptr) { 488 | closeFile(); 489 | } 490 | } 491 | 492 | QString FlvLiveDownloadDelegate::errorString() 493 | { 494 | switch (error) { 495 | case Error::NoError: 496 | return QString(); 497 | case Error::FlvParseError: 498 | return QStringLiteral("FLV 解析错误"); 499 | case Error::SaveFileOpenError: 500 | return QStringLiteral("文件打开失败"); 501 | case Error::HevcNotSupported: 502 | return QStringLiteral("暂不支持 HEVC"); 503 | default: 504 | return "undefined error"; 505 | } 506 | } 507 | 508 | qint64 FlvLiveDownloadDelegate::getDurationInMSec() 509 | { 510 | return totalDuration + curFileVideoDuration; 511 | } 512 | 513 | qint64 FlvLiveDownloadDelegate::getReadBytesCnt() 514 | { 515 | return readBytesCnt; 516 | } 517 | 518 | bool FlvLiveDownloadDelegate::newDataArrived() 519 | { 520 | bool noError = true; 521 | while (noError) { 522 | if (in.bytesAvailable() < bytesRequired) { 523 | break; 524 | } 525 | auto tmp = bytesRequired; 526 | switch (state) { 527 | case State::Begin: 528 | noError = handleFileHeader(); 529 | break; 530 | case State::ReadingTagHeader: 531 | noError = handleTagHeader(); 532 | if (noError) { 533 | state = State::ReadingTagBody; 534 | bytesRequired = tagHeader.dataSize + 4; // tagBody + prevTagSize (UI32) 535 | } 536 | break; 537 | case State::ReadingTagBody: 538 | noError = handleTagBody(); 539 | if (noError) { 540 | state = State::ReadingTagHeader; 541 | bytesRequired = Flv::TagHeader::BytesCnt; 542 | } 543 | break; 544 | case State::ReadingDummy: 545 | state = State::ReadingTagHeader; 546 | in.skip(bytesRequired); 547 | bytesRequired = Flv::TagHeader::BytesCnt; 548 | break; 549 | case State::Stopped: 550 | return true; 551 | } 552 | readBytesCnt += tmp; 553 | } 554 | if (!noError) { 555 | stop(); 556 | } 557 | return noError; 558 | } 559 | 560 | bool FlvLiveDownloadDelegate::openNewFileToWrite() 561 | { 562 | if (out != nullptr) { 563 | closeFile(); 564 | } 565 | 566 | out = createFileHandler(); 567 | if (out == nullptr) { 568 | error = Error::SaveFileOpenError; 569 | return false; 570 | } 571 | 572 | out->write(fileHeaderBuffer); 573 | 574 | auto scriptTagHeader = Flv::TagHeader(Flv::TagType::Script, 0, 0); 575 | auto scriptTagBeginPos = out->pos(); 576 | scriptTagHeader.writeTo(*out); 577 | onMetaDataScript->writeTo(*out); 578 | auto scriptTagEndPos = out->pos(); 579 | auto scriptTagSize = scriptTagEndPos - scriptTagBeginPos; 580 | scriptTagHeader.dataSize = scriptTagSize - Flv::TagHeader::BytesCnt; 581 | out->seek(scriptTagBeginPos); 582 | scriptTagHeader.writeTo(*out); 583 | out->seek(scriptTagEndPos); 584 | Flv::writeUInt32(*out, scriptTagSize); 585 | 586 | out->write(aacSeqHeaderBuffer); 587 | out->write(avcSeqHeaderBuffer); 588 | return true; 589 | } 590 | 591 | void FlvLiveDownloadDelegate::updateMetaDataKeyframes(qint64 filePos, int timeInMSec) 592 | { 593 | keyframesFileposAnchor->appendNumber(filePos); 594 | keyframesTimesAnchor->appendNumber(timeInMSec / 1000.0); 595 | } 596 | 597 | void FlvLiveDownloadDelegate::updateMetaDataDuration() 598 | { 599 | durationAnchor->update(std::max(curFileVideoDuration, curFileAudioDuration) / 1000.0); 600 | } 601 | 602 | void FlvLiveDownloadDelegate::closeFile() 603 | { 604 | if (!avcSeqHeaderBuffer.isEmpty()) { 605 | updateMetaDataKeyframes(out->pos(), curFileVideoDuration); 606 | Flv::writeAvcEndOfSeqTag(*out, curFileVideoDuration); 607 | } 608 | updateMetaDataDuration(); 609 | out.reset(); 610 | } 611 | 612 | void FlvLiveDownloadDelegate::stop() 613 | { 614 | if (out != nullptr) { 615 | closeFile(); 616 | } 617 | state = State::Stopped; 618 | } 619 | 620 | bool FlvLiveDownloadDelegate::handleFileHeader() 621 | { 622 | Flv::FileHeader flvFileHeader(in); 623 | Flv::readUInt32(in); // read dummy prev tag size (UInt32) 624 | if (!flvFileHeader.valid) { 625 | error = Error::FlvParseError; 626 | return false; 627 | } 628 | 629 | if (flvFileHeader.dataOffset != Flv::FileHeader::BytesCnt) { 630 | state = State::ReadingDummy; 631 | bytesRequired = flvFileHeader.dataOffset - Flv::FileHeader::BytesCnt; 632 | flvFileHeader.dataOffset = Flv::FileHeader::BytesCnt; 633 | } else { 634 | state = State::ReadingTagHeader; 635 | bytesRequired = Flv::TagHeader::BytesCnt; 636 | } 637 | 638 | QBuffer buffer(&fileHeaderBuffer); 639 | buffer.open(QIODevice::WriteOnly); 640 | flvFileHeader.writeTo(buffer); 641 | Flv::writeUInt32(buffer, 0); 642 | return true; 643 | } 644 | 645 | bool FlvLiveDownloadDelegate::handleTagHeader() 646 | { 647 | if (!tagHeader.readFrom(in)) { 648 | error = Error::FlvParseError; 649 | return false; 650 | } 651 | if (onMetaDataScript == nullptr && tagHeader.tagType != Flv::TagType::Script) { 652 | error = Error::FlvParseError; 653 | return false; 654 | } 655 | return true; 656 | } 657 | 658 | bool FlvLiveDownloadDelegate::handleTagBody() 659 | { 660 | switch (tagHeader.tagType) { 661 | case Flv::TagType::Script: 662 | return handleScriptTagBody(); 663 | case Flv::TagType::Audio: 664 | return handleAudioTagBody(); 665 | case Flv::TagType::Video: 666 | return handleVideoTagBody(); 667 | default: 668 | error = Error::FlvParseError; 669 | return false; 670 | } 671 | } 672 | 673 | bool FlvLiveDownloadDelegate::handleScriptTagBody() 674 | { 675 | onMetaDataScript = make_unique(in); 676 | Flv::readUInt32(in); // read prevTagSize (UInt32) 677 | if (!onMetaDataScript->isOnMetaData()) { 678 | error = Error::FlvParseError; 679 | return false; 680 | } 681 | if (onMetaDataScript->value->type == Flv::AmfValueType::Object) { 682 | onMetaDataScript->value = static_cast(onMetaDataScript->value.get())->moveToEcmaArray(); 683 | } else if (onMetaDataScript->value->type != Flv::AmfValueType::EcmaArray) { 684 | error = Error::FlvParseError; 685 | return false; 686 | } 687 | 688 | auto &ecmaArr = *static_cast(onMetaDataScript->value.get()); 689 | auto comment = "created by B23Downloader v" + QCoreApplication::applicationVersion().toUtf8(); 690 | comment += " github.com/vooidzero/B23Downloader"; 691 | ecmaArr["Comment"] = make_unique(comment); 692 | 693 | auto duration = make_unique(); 694 | durationAnchor = duration->getAnchor(); 695 | ecmaArr["duration"] = std::move(duration); 696 | 697 | auto keyframesObj = make_unique(); 698 | keyframesFileposAnchor = keyframesObj->insertReservedNumberArray("filepositions", MaxKeyframes); 699 | keyframesTimesAnchor = keyframesObj->insertReservedNumberArray("times", MaxKeyframes); 700 | ecmaArr["keyframes"] = std::move(keyframesObj); 701 | return true; 702 | } 703 | 704 | bool FlvLiveDownloadDelegate::handleAudioTagBody() 705 | { 706 | auto audioHeader = Flv::AudioTagHeader(in); 707 | auto writeTagTo = [this, &audioHeader](QIODevice &outDev) { 708 | tagHeader.writeTo(outDev); 709 | audioHeader.writeTo(outDev); 710 | auto audioDataSize = tagHeader.dataSize - audioHeader.rawData.size(); 711 | outDev.write(in.read(audioDataSize + 4)); // audio data + prevDataSize 712 | }; 713 | 714 | if (audioHeader.isAacSequenceHeader) { 715 | tagHeader.timestamp = 0; 716 | auto prevSeqHeader = std::move(aacSeqHeaderBuffer); 717 | 718 | QBuffer buffer(&aacSeqHeaderBuffer); 719 | buffer.open(QIODevice::WriteOnly); 720 | writeTagTo(buffer); 721 | 722 | if (!prevSeqHeader.isEmpty() && prevSeqHeader != aacSeqHeaderBuffer) { 723 | error = Error::FlvParseError; 724 | return false; 725 | } 726 | if (out != nullptr) { 727 | out->write(aacSeqHeaderBuffer); 728 | } 729 | } else { 730 | if (!isTimestampBaseValid) { 731 | isTimestampBaseValid = true; 732 | timestampBase = tagHeader.timestamp; 733 | } 734 | tagHeader.timestamp -= timestampBase; 735 | if (tagHeader.timestamp < curFileAudioDuration) { 736 | error = Error::FlvParseError; 737 | return false; 738 | } 739 | curFileAudioDuration = tagHeader.timestamp; 740 | if (out == nullptr && !openNewFileToWrite()) { 741 | return false; 742 | } 743 | writeTagTo(*out); 744 | } 745 | return true; 746 | } 747 | 748 | bool FlvLiveDownloadDelegate::handleVideoTagBody() 749 | { 750 | auto videoHeader = Flv::VideoTagHeader(in); 751 | if (videoHeader.codecId == Flv::VideoCodecId::HEVC) { 752 | error = Error::HevcNotSupported; 753 | return false; 754 | } 755 | 756 | auto writeTagTo = [this, &videoHeader](QIODevice &outDev) { 757 | tagHeader.writeTo(outDev); 758 | videoHeader.writeTo(outDev); 759 | auto audioDataSize = tagHeader.dataSize - videoHeader.rawData.size(); 760 | outDev.write(in.read(audioDataSize + 4)); // audio data + prevDataSize 761 | }; 762 | 763 | if (videoHeader.isAvcSequenceHeader()) { 764 | tagHeader.timestamp = 0; 765 | auto prevSeqHeader = std::move(avcSeqHeaderBuffer); 766 | 767 | QBuffer buffer(&avcSeqHeaderBuffer); 768 | buffer.open(QIODevice::WriteOnly); 769 | writeTagTo(buffer); 770 | 771 | if (!prevSeqHeader.isEmpty() && prevSeqHeader != avcSeqHeaderBuffer) { 772 | error = Error::FlvParseError; 773 | return false; 774 | } 775 | if (out != nullptr) { 776 | out->write(avcSeqHeaderBuffer); 777 | } 778 | 779 | } else { 780 | if (!isTimestampBaseValid) { 781 | isTimestampBaseValid = true; 782 | timestampBase = tagHeader.timestamp; 783 | } 784 | tagHeader.timestamp -= timestampBase; 785 | if (tagHeader.timestamp < curFileVideoDuration) { 786 | error = Error::FlvParseError; 787 | return false; 788 | } 789 | curFileVideoDuration = tagHeader.timestamp; 790 | if (out == nullptr && !openNewFileToWrite()) { 791 | return false; 792 | } 793 | if (videoHeader.isKeyFrame() 794 | && tagHeader.timestamp - prevKeyframeTimestamp >= LeastKeyframeInterval) { 795 | auto shouldSeg = keyframesFileposAnchor->size() == keyframesFileposAnchor->maxSize - 1; 796 | if (shouldSeg) { 797 | if (!openNewFileToWrite()) { 798 | return false; 799 | } 800 | totalDuration += curFileVideoDuration; 801 | timestampBase = timestampBase + curFileVideoDuration; 802 | curFileAudioDuration = 0; 803 | curFileVideoDuration = 0; 804 | tagHeader.timestamp = 0; 805 | } 806 | updateMetaDataKeyframes(out->pos(), tagHeader.timestamp); 807 | updateMetaDataDuration(); 808 | prevKeyframeTimestamp = tagHeader.timestamp; 809 | } 810 | 811 | writeTagTo(*out); 812 | } 813 | return true; 814 | } 815 | -------------------------------------------------------------------------------- /B23Downloader/Flv.h: -------------------------------------------------------------------------------- 1 | #ifndef FLV_H 2 | #define FLV_H 3 | 4 | #include 5 | #include 6 | 7 | namespace Flv { 8 | 9 | using std::unique_ptr; 10 | using std::shared_ptr; 11 | 12 | // read from big endian 13 | double readDouble(QIODevice&); 14 | uint32_t readUInt32(QIODevice&); 15 | uint32_t readUInt24(QIODevice&); 16 | uint16_t readUInt16(QIODevice&); 17 | uint8_t readUInt8(QIODevice&); 18 | 19 | // write as big endian 20 | void writeDouble(QIODevice&, double); 21 | void writeUInt32(QIODevice&, uint32_t); 22 | void writeUInt24(QIODevice&, uint32_t); 23 | void writeUInt16(QIODevice&, uint16_t); 24 | void writeUInt8(QIODevice&, uint8_t); 25 | 26 | namespace TagType { enum { 27 | Audio = 8, 28 | Video = 9, 29 | Script = 18 30 | }; } 31 | 32 | namespace SoundFormat { enum { 33 | AAC = 10 34 | }; } 35 | 36 | namespace AacPacketType { enum { 37 | SequenceHeader = 0, 38 | Raw = 1 39 | }; } 40 | 41 | namespace VideoFrameType { enum { 42 | Keyframe = 1, 43 | InterFrame = 2, 44 | DisposableInterFrame = 3, 45 | GeneratedKeyframe = 4, 46 | VideoInfoOrCmdFrame = 5 47 | }; } 48 | 49 | namespace VideoCodecId { enum { 50 | AVC = 7, 51 | HEVC = 12 52 | }; } 53 | 54 | namespace AvcPacketType { enum { 55 | SequenceHeader = 0, 56 | NALU = 1, 57 | EndOfSequence = 2 58 | }; } 59 | 60 | namespace AmfValueType { enum { 61 | Number = 0, // DOUBLE (8 bytes) 62 | Boolean = 1, // UInt8 63 | String = 2, 64 | Object = 3, 65 | MovieClip = 4, 66 | Null = 5, 67 | Undefined = 6, 68 | Reference = 7, // UI16 69 | EcmaArray = 8, 70 | ObjectEndMark = 9, 71 | StrictArray = 10, 72 | Date = 11, 73 | LongString = 12 74 | }; } 75 | 76 | 77 | class FileHeader 78 | { 79 | public: 80 | static constexpr auto BytesCnt = 9; 81 | 82 | bool valid; 83 | uint8_t version; 84 | union { 85 | struct { 86 | uint8_t video : 1; 87 | uint8_t reserved1 : 1; 88 | uint8_t audio : 1; 89 | uint8_t reserved5 : 5; 90 | }; 91 | uint8_t typeFlags; 92 | }; 93 | uint32_t dataOffset; 94 | 95 | FileHeader(QIODevice &in); 96 | void writeTo(QIODevice &out); 97 | }; 98 | 99 | 100 | class TagHeader 101 | { 102 | public: 103 | static constexpr int BytesCnt = 11; 104 | 105 | union { 106 | struct { 107 | uint8_t tagType : 5; 108 | uint8_t filter : 1; 109 | uint8_t reserved : 2; 110 | }; 111 | uint8_t flags; 112 | }; 113 | uint32_t dataSize; // UInt24 114 | int timestamp; 115 | 116 | TagHeader() {} 117 | bool readFrom(QIODevice &in); 118 | void writeTo(QIODevice &out); 119 | 120 | TagHeader(QIODevice &in) { readFrom(in); } 121 | TagHeader(int tagType_, uint32_t dataSize_, int timestamp_) 122 | : flags(static_cast(tagType_)), dataSize(dataSize_), timestamp(timestamp_) {} 123 | }; 124 | 125 | 126 | class AudioTagHeader 127 | { 128 | public: 129 | QByteArray rawData; 130 | bool isAacSequenceHeader; 131 | 132 | AudioTagHeader(QIODevice &in); 133 | void writeTo(QIODevice &out); 134 | }; 135 | 136 | 137 | class VideoTagHeader 138 | { 139 | public: 140 | QByteArray rawData; 141 | uint8_t codecId; 142 | uint8_t frameType; 143 | uint8_t avcPacketType; 144 | bool isKeyFrame(); 145 | bool isAvcSequenceHeader(); 146 | 147 | VideoTagHeader(QIODevice &in); 148 | void writeTo(QIODevice &out); 149 | }; 150 | 151 | void writeAvcEndOfSeqTag(QIODevice &out, int timestamp); 152 | 153 | 154 | 155 | class AmfValue 156 | { 157 | public: 158 | const int type; 159 | AmfValue(int type_) : type(type_) {} 160 | 161 | ~AmfValue() = default; 162 | 163 | virtual void writeTo(QIODevice& out) 164 | { 165 | writeUInt8(out, static_cast(type)); 166 | }; 167 | }; 168 | 169 | 170 | unique_ptr readAmfValue(QIODevice &in); 171 | 172 | 173 | class ScriptBody 174 | { 175 | public: 176 | unique_ptr name; 177 | unique_ptr value; 178 | 179 | ScriptBody(QIODevice &in); 180 | bool isOnMetaData() const; 181 | 182 | void writeTo(QIODevice &out); 183 | }; 184 | 185 | 186 | class AmfNumber : public AmfValue 187 | { 188 | public: 189 | double val; 190 | AmfNumber(double val_) : AmfValue(AmfValueType::Number), val(val_) {} 191 | AmfNumber(QIODevice &in) : AmfValue(AmfValueType::Number) { val = readDouble(in); } 192 | void writeTo(QIODevice &out) override { AmfValue::writeTo(out); writeDouble(out, val); } 193 | }; 194 | 195 | 196 | class AmfBoolean : public AmfValue 197 | { 198 | public: 199 | bool val; 200 | AmfBoolean(bool val_) : AmfValue(AmfValueType::Boolean), val(val_) {} 201 | AmfBoolean(QIODevice &in) : AmfValue(AmfValueType::Boolean) { val = readUInt8(in); } 202 | void writeTo(QIODevice &out) override { AmfValue::writeTo(out); writeUInt8(out, val); } 203 | }; 204 | 205 | 206 | class AmfString : public AmfValue 207 | { 208 | public: 209 | QByteArray data; 210 | AmfString(QByteArray data_) : AmfValue(AmfValueType::String), data(std::move(data_)) {} 211 | AmfString(QIODevice &in); 212 | void writeTo(QIODevice &out) override; 213 | static void writeStrWithoutValType(QIODevice &out, const QByteArray &data); 214 | }; 215 | 216 | 217 | class AmfLongString : public AmfValue 218 | { 219 | public: 220 | QByteArray data; 221 | AmfLongString(QIODevice &in); 222 | void writeTo(QIODevice &out) override; 223 | }; 224 | 225 | 226 | class AmfReference : public AmfValue 227 | { 228 | public: 229 | uint16_t val; 230 | AmfReference(QIODevice &in) : AmfValue(AmfValueType::Reference) { val = readUInt16(in); } 231 | void writeTo(QIODevice &out) override { AmfValue::writeTo(out); writeUInt16(out, val); } 232 | }; 233 | 234 | 235 | class AmfDate : public AmfValue 236 | { 237 | public: 238 | // double dateTime; 239 | // int16_t localDateTimeOffset; 240 | QByteArray rawData; 241 | AmfDate(QIODevice &in) : AmfValue(AmfValueType::Date) { rawData = in.read(10); } 242 | void writeTo(QIODevice &out) override { AmfValue::writeTo(out); out.write(rawData); } 243 | }; 244 | 245 | 246 | class AmfObjectProperty 247 | { 248 | public: 249 | QByteArray name; 250 | unique_ptr value; 251 | AmfObjectProperty() {} 252 | AmfObjectProperty(QIODevice &in); 253 | void writeTo(QIODevice &out); 254 | static void write(QIODevice &out, const QByteArray &name, AmfValue *value); 255 | 256 | bool isObjectEnd(); 257 | static void writeObjectEndTo(QIODevice &out); 258 | }; 259 | 260 | 261 | class AmfEcmaArray : public AmfValue 262 | { 263 | std::vector properties; 264 | 265 | public: 266 | AmfEcmaArray() : AmfValue(AmfValueType::EcmaArray) {} 267 | AmfEcmaArray(std::vector &&properties); 268 | 269 | AmfEcmaArray(QIODevice &in); 270 | void writeTo(QIODevice &out) override; 271 | 272 | unique_ptr& operator[] (QByteArray name); 273 | }; 274 | 275 | 276 | class AmfObject : public AmfValue 277 | { 278 | std::vector properties; 279 | 280 | public: 281 | AmfObject() : AmfValue(AmfValueType::Object) {} 282 | AmfObject(QIODevice &in); 283 | void writeTo(QIODevice &out) override; 284 | 285 | /** 286 | * @brief moves properties to a new AmfEcmaArray. notice: anchors are not moved 287 | */ 288 | unique_ptr moveToEcmaArray(); 289 | 290 | /** 291 | * @brief Applied on random access output device. 292 | * - WriteTo(QIODevice &out) writes an empty array followed by a spacer array. 293 | * Pointer to `out` and position in file are stored. 294 | * - Later, appendNumber() would append a number to the first array and reduce 295 | * the size of spacer array. 296 | */ 297 | class ReservedArrayAnchor 298 | { 299 | QByteArray name; 300 | int currentSize = 0; 301 | QIODevice *outDev = nullptr; 302 | qint64 arrBeginPos; 303 | qint64 arrEndPos; 304 | 305 | QByteArray spacerName() const; 306 | 307 | public: 308 | const int maxSize; 309 | 310 | ReservedArrayAnchor(QByteArray name_, int maxSize_) 311 | :name(std::move(name_)), maxSize(maxSize_) {} 312 | 313 | int size() { return currentSize; } 314 | void writeTo(QIODevice &out); 315 | void appendNumber(double val); 316 | }; 317 | 318 | shared_ptr insertReservedNumberArray(QByteArray name, int maxSize); 319 | 320 | private: 321 | std::vector> anchors; 322 | }; 323 | 324 | 325 | class AmfStrictArray : public AmfValue 326 | { 327 | public: 328 | /* QVector (an alias for QList since Qt 6) do not accept move-only objects. 329 | * This is "Unfixable because QVector does implicit sharing." (copy on write?) 330 | * --from https://bugreports.qt.io/browse/QTBUG-57629 331 | * Therefore std::vector is used instead. 332 | */ 333 | std::vector> values; 334 | AmfStrictArray() : AmfValue(AmfValueType::StrictArray) {} 335 | AmfStrictArray(QIODevice &in); 336 | void writeTo(QIODevice &out) override; 337 | }; 338 | 339 | 340 | class AnchoredAmfNumber : public AmfNumber 341 | { 342 | public: 343 | class Anchor { 344 | friend class AnchoredAmfNumber; 345 | qint64 pos; 346 | QIODevice *outDev = nullptr; 347 | public: 348 | Anchor() {} 349 | void update(double val); 350 | }; 351 | 352 | AnchoredAmfNumber(double val = 0); 353 | void writeTo(QIODevice &out) override; 354 | shared_ptr getAnchor(); 355 | 356 | private: 357 | shared_ptr anchor; 358 | }; 359 | 360 | } // namespace Flv 361 | 362 | 363 | 364 | 365 | class QFileDevice; 366 | 367 | /** 368 | * @brief The FlvLiveDownloadDelegate class performs remuxing on the input FLV stream: 369 | * -# Makes timestamps start from 0. (Timestamps in raw data of Bilibili Live is the time since live started) 370 | * Timestamp starting from non-zero causes: 371 | * - extremely slow seeking for PotPlayer 372 | * - video unable to seek for VLC 373 | * -# Adds keyframes array at the beginning of file. This occupies about 100 KB, 374 | * which is enough for 5 hours if the interval of keyframes is 3 seconds. 375 | * If keyframes array is full, data is written to another file. 376 | * 377 | * hevc is not supported. 378 | */ 379 | class FlvLiveDownloadDelegate 380 | { 381 | QIODevice ∈ 382 | std::unique_ptr out; 383 | 384 | public: 385 | static constexpr auto MaxKeyframes = 6000; 386 | static constexpr auto LeastKeyframeInterval = 2500; // ms 387 | using CreateFileHandler = std::function()>; 388 | 389 | 390 | FlvLiveDownloadDelegate(QIODevice &in_, CreateFileHandler createFileHandler_); 391 | ~FlvLiveDownloadDelegate(); 392 | 393 | /** 394 | * @brief Inform that new data is available. Data is read and written to file if there is a whole FLV tag. \n 395 | * Usage: call newDataArrived() in the slot of in.QIODevice::readyRead (connected outside this class), 396 | * then handle the error if this function returns false. 397 | * @return true if no error, otherwise false 398 | */ 399 | bool newDataArrived(); 400 | void stop(); 401 | 402 | QString errorString(); 403 | qint64 getDurationInMSec(); 404 | qint64 getReadBytesCnt(); 405 | 406 | private: 407 | enum class Error { NoError, FlvParseError, SaveFileOpenError, HevcNotSupported }; 408 | Error error = Error::NoError; 409 | 410 | enum class State 411 | { 412 | Begin, ReadingTagHeader, ReadingTagBody, ReadingDummy, Stopped 413 | }; 414 | 415 | State state = State::Begin; 416 | qint64 bytesRequired; 417 | qint64 readBytesCnt = 0; 418 | CreateFileHandler createFileHandler; 419 | 420 | bool openNewFileToWrite(); 421 | void closeFile(); 422 | /** 423 | * @brief call this function when a new keyframe video tag is about to be written 424 | */ 425 | void updateMetaDataKeyframes(qint64 filePos, int timeInMSec); 426 | void updateMetaDataDuration(); 427 | 428 | bool handleFileHeader(); 429 | bool handleTagHeader(); 430 | bool handleTagBody(); 431 | bool handleScriptTagBody(); 432 | bool handleAudioTagBody(); 433 | bool handleVideoTagBody(); 434 | 435 | 436 | bool isTimestampBaseValid = false; 437 | int timestampBase; 438 | int curFileAudioDuration = 0; // ms 439 | int curFileVideoDuration = 0; // ms 440 | qint64 totalDuration = 0; // ms 441 | int prevKeyframeTimestamp = -LeastKeyframeInterval; 442 | 443 | Flv::TagHeader tagHeader; 444 | 445 | using FlvArrayAnchor = Flv::AmfObject::ReservedArrayAnchor; 446 | using FlvNumberAnchor = Flv::AnchoredAmfNumber::Anchor; 447 | 448 | std::shared_ptr keyframesFileposAnchor; 449 | std::shared_ptr keyframesTimesAnchor; 450 | std::shared_ptr durationAnchor; 451 | 452 | std::unique_ptr onMetaDataScript; 453 | QByteArray fileHeaderBuffer; 454 | QByteArray aacSeqHeaderBuffer; 455 | QByteArray avcSeqHeaderBuffer; 456 | }; 457 | 458 | #endif // FLV_H 459 | -------------------------------------------------------------------------------- /B23Downloader/LoginDialog.cpp: -------------------------------------------------------------------------------- 1 | #include "LoginDialog.h" 2 | #include "Network.h" 3 | #include "QrCode.h" 4 | 5 | #include 6 | #include 7 | #include 8 | 9 | static constexpr int QrCodeExpireTime = 180; // seconds 10 | static constexpr int PollInterval = 2000; // ms 11 | static constexpr int MaxPollTimes = (QrCodeExpireTime * 1000 / PollInterval) - 3; 12 | static constexpr QColor QrCodeColor = QColor(251, 114, 153); // B站粉 13 | 14 | static constexpr auto ScanToLoginTip = "请使用B站客户端
扫描二维码登录"; 15 | 16 | LoginDialog::LoginDialog(QWidget *parent) 17 | : QDialog(parent) 18 | { 19 | auto mainLayout = new QVBoxLayout(this); 20 | qrCodeLabel = new QLabel(this); 21 | qrCodeLabel->setFixedSize(123, 123); 22 | qrCodeLabel->setAlignment(Qt::AlignCenter); 23 | tipLabel= new QLabel(ScanToLoginTip, this); 24 | tipLabel->setAlignment(Qt::AlignCenter); 25 | auto font = tipLabel->font(); 26 | font.setPointSize(11); 27 | tipLabel->setFont(font); 28 | 29 | refreshButton = new QToolButton(qrCodeLabel); 30 | refreshButton->setStyleSheet("background-color: white; border: 2px solid #cccccc;"); 31 | refreshButton->setCursor(Qt::PointingHandCursor); 32 | refreshButton->setIcon(QIcon(":/icons/refresh.png")); 33 | refreshButton->setIconSize(QSize(32, 32)); 34 | refreshButton->setToolButtonStyle(Qt::ToolButtonTextUnderIcon); 35 | refreshButton->setText("点击刷新"); 36 | refreshButton->setHidden(true); 37 | connect(refreshButton, &QToolButton::clicked, this, [this]() { 38 | hideRefreshButton(); 39 | startGetLoginUrl(); 40 | }); 41 | 42 | mainLayout->setSizeConstraint(QLayout::SetFixedSize); 43 | mainLayout->addWidget(qrCodeLabel, 0, Qt::AlignCenter); 44 | mainLayout->addWidget(tipLabel, 0, Qt::AlignCenter); 45 | setLayout(mainLayout); 46 | setWindowTitle("B站登录"); 47 | 48 | pollTimer = new QTimer(this); 49 | pollTimer->setInterval(PollInterval); 50 | pollTimer->setSingleShot(true); 51 | connect(pollTimer, &QTimer::timeout, this, &LoginDialog::pollLoginInfo); 52 | 53 | startGetLoginUrl(); 54 | } 55 | 56 | void LoginDialog::closeEvent(QCloseEvent *e) 57 | { 58 | if (httpReply != nullptr) { 59 | httpReply->abort(); 60 | httpReply = nullptr; 61 | } 62 | QDialog::closeEvent(e); 63 | } 64 | 65 | LoginDialog::~LoginDialog() = default; 66 | 67 | void LoginDialog::showRefreshButton() 68 | { 69 | refreshButton->setHidden(false); 70 | refreshButton->move(qrCodeLabel->rect().center() - refreshButton->rect().center()); 71 | } 72 | 73 | void LoginDialog::hideRefreshButton() 74 | { 75 | refreshButton->setHidden(true); 76 | } 77 | 78 | void LoginDialog::qrCodeExpired() 79 | { 80 | // blur the QR code 81 | QPixmap pixmap = qrCodeLabel->pixmap(); 82 | QPainter painter(&pixmap); 83 | painter.setPen(Qt::NoPen); 84 | painter.setBrush(QBrush(QColor(255, 255, 255, 196))); 85 | painter.drawRect(QRect(QPoint(0, 0), pixmap.size())); 86 | qrCodeLabel->setPixmap(pixmap); 87 | 88 | tipLabel->setText("二维码已失效"); 89 | showRefreshButton(); 90 | } 91 | 92 | // if error occured, handle the error and return empty object 93 | QJsonValue LoginDialog::getReplyData() 94 | { 95 | auto reply = httpReply; 96 | httpReply->deleteLater(); 97 | httpReply = nullptr; 98 | 99 | if (reply->error() == QNetworkReply::OperationCanceledError) { 100 | // aborted (in close event) 101 | return QJsonValue(); 102 | } 103 | 104 | const auto [json, errorString] = Network::Bili::parseReply(reply, "data"); 105 | if (!errorString.isNull()) { 106 | tipLabel->setText(errorString); 107 | showRefreshButton(); 108 | return QJsonValue(); 109 | } 110 | 111 | return json["data"]; 112 | } 113 | 114 | void LoginDialog::startGetLoginUrl() 115 | { 116 | httpReply = Network::Bili::get("https://passport.bilibili.com/qrcode/getLoginUrl"); 117 | connect(httpReply, &QNetworkReply::finished, this, &LoginDialog::getLoginUrlFinished); 118 | } 119 | 120 | void LoginDialog::getLoginUrlFinished() 121 | { 122 | auto data = getReplyData().toObject(); 123 | if (data.isEmpty()) { 124 | // network error 125 | return; 126 | } 127 | 128 | QString url = data["url"].toString(); 129 | oauthKey = data["oauthKey"].toString(); 130 | setQrCode(url); 131 | tipLabel->setText(ScanToLoginTip); 132 | polledTimes = 0; 133 | pollTimer->start(); 134 | } 135 | 136 | void LoginDialog::pollLoginInfo() 137 | { 138 | auto postData = QString("oauthKey=%1").arg(oauthKey).toUtf8(); 139 | httpReply = Network::Bili::postUrlEncoded("https://passport.bilibili.com/qrcode/getLoginInfo", postData); 140 | connect(httpReply, &QNetworkReply::finished, this, &LoginDialog::getLoginInfoFinished); 141 | } 142 | 143 | void LoginDialog::getLoginInfoFinished() 144 | { 145 | polledTimes++; 146 | auto data = getReplyData(); 147 | if (data.isNull() || data.isUndefined()) { 148 | // network error 149 | return; 150 | } 151 | 152 | bool isPollEnded = false; 153 | if (data.isDouble()) { 154 | switch (data.toInt()) { 155 | case -1: // oauthKey is wrong. should never be this case 156 | QMessageBox::critical(this, "", "oauthKey error"); 157 | break; 158 | case -2: // login url (qrcode) is expired 159 | isPollEnded = true; 160 | qrCodeExpired(); 161 | break; 162 | case -4: // qrcode not scanned 163 | break; 164 | case -5: // scanned but not confirmed 165 | tipLabel->setText("✅扫描成功
请在手机上确认"); 166 | break; 167 | default: 168 | QMessageBox::warning(this, "Poll Warning", QString("unknown code: %1").arg(data.toInteger())); 169 | } 170 | } else { 171 | // scanned and confirmed 172 | isPollEnded = true; 173 | accept(); 174 | } 175 | 176 | if (!isPollEnded) { 177 | if (polledTimes == MaxPollTimes) { 178 | qrCodeExpired(); 179 | } else { 180 | pollTimer->start(); 181 | } 182 | } 183 | } 184 | 185 | void LoginDialog::setQrCode(const QString &content) 186 | { 187 | using namespace qrcodegen; 188 | QrCode qr = QrCode::encodeText(content.toUtf8(), QrCode::Ecc::MEDIUM); 189 | int n = qr.getSize(); 190 | 191 | QPixmap pixmap(n * 3, n * 3); 192 | QPainter painter(&pixmap); 193 | QPen pen(QrCodeColor); 194 | pen.setWidth(3); 195 | painter.setPen(pen); 196 | 197 | pixmap.fill(); 198 | for (int row = 0; row < n; row++) { 199 | for (int col = 0; col < n; col++) { 200 | auto val = qr.getModule(col, row); 201 | if (val) { 202 | painter.drawPoint(row * 3 + 1, col * 3 + 1); 203 | } 204 | } 205 | } 206 | 207 | qrCodeLabel->setFixedSize(pixmap.size()); 208 | qrCodeLabel->setPixmap(pixmap); 209 | } 210 | -------------------------------------------------------------------------------- /B23Downloader/LoginDialog.h: -------------------------------------------------------------------------------- 1 | #ifndef LOGINDIALOG_H 2 | #define LOGINDIALOG_H 3 | 4 | #include 5 | #include 6 | 7 | class QLabel; 8 | class QTimer; 9 | class QToolButton; 10 | class QNetworkReply; 11 | 12 | class LoginDialog : public QDialog 13 | { 14 | Q_OBJECT 15 | 16 | protected: 17 | void closeEvent(QCloseEvent *e) override; 18 | 19 | public: 20 | explicit LoginDialog(QWidget *parent = nullptr); 21 | ~LoginDialog() override; 22 | void setQrCode(const QString &content); 23 | 24 | private: 25 | QJsonValue getReplyData(); 26 | void startGetLoginUrl(); 27 | 28 | private slots: 29 | void getLoginUrlFinished(); 30 | void pollLoginInfo(); 31 | void getLoginInfoFinished(); 32 | 33 | private: 34 | void qrCodeExpired(); 35 | void showRefreshButton(); 36 | void hideRefreshButton(); 37 | 38 | QString oauthKey; 39 | int polledTimes = 0; 40 | QTimer *pollTimer; 41 | QLabel *qrCodeLabel; 42 | QLabel *tipLabel; 43 | QToolButton *refreshButton; 44 | QNetworkReply *httpReply = nullptr; 45 | }; 46 | 47 | #endif // LOGINDIALOG_H 48 | -------------------------------------------------------------------------------- /B23Downloader/MainWindow.cpp: -------------------------------------------------------------------------------- 1 | #include "MainWindow.h" 2 | #include "Settings.h" 3 | #include "Network.h" 4 | #include "utils.h" 5 | 6 | #include "Extractor.h" 7 | #include "LoginDialog.h" 8 | #include "DownloadDialog.h" 9 | #include "TaskTable.h" 10 | #include "AboutWidget.h" 11 | #include "MyTabWidget.h" 12 | 13 | #include 14 | #include 15 | 16 | static constexpr int GetUserInfoRetryInterval = 10000; // ms 17 | static constexpr int GetUserInfoTimeout = 10000; // ms 18 | 19 | MainWindow::~MainWindow() = default; 20 | 21 | 22 | MainWindow::MainWindow(QWidget *parent) 23 | : QMainWindow(parent) 24 | { 25 | #ifdef APP_VERSION 26 | QApplication::setApplicationVersion(APP_VERSION); 27 | #endif 28 | 29 | Network::accessManager()->setCookieJar(Settings::inst()->getCookieJar()); 30 | setWindowTitle("B23Downloader"); 31 | setCentralWidget(new QWidget); 32 | auto mainLayout = new QVBoxLayout(centralWidget()); 33 | auto topLayout = new QHBoxLayout; 34 | 35 | // set up user info widgets 36 | ufaceButton = new QToolButton; 37 | ufaceButton->setText("登录"); 38 | ufaceButton->setFixedSize(32, 32); 39 | ufaceButton->setIconSize(QSize(32, 32)); 40 | ufaceButton->setCursor(Qt::PointingHandCursor); 41 | auto loginTextFont = font(); 42 | loginTextFont.setBold(true); 43 | loginTextFont.setPointSize(font().pointSize() + 1); 44 | ufaceButton->setFont(loginTextFont); 45 | ufaceButton->setPopupMode(QToolButton::InstantPopup); 46 | ufaceButton->setStyleSheet(R"( 47 | QToolButton { 48 | color: #00a1d6; 49 | background-color: white; 50 | border: none; 51 | } 52 | QToolButton::menu-indicator { image: none; } 53 | )"); 54 | connect(ufaceButton, &QToolButton::clicked, this, &MainWindow::ufaceButtonClicked); 55 | 56 | unameLabel = new ElidedTextLabel; 57 | unameLabel->setHintWidthToString("晚安玛卡巴卡!やさしい夢見てね"); 58 | topLayout->addWidget(ufaceButton); 59 | topLayout->addWidget(unameLabel, 1); 60 | 61 | // set up download url lineEdit 62 | auto downloadUrlLayout = new QHBoxLayout; 63 | downloadUrlLayout->setSpacing(0); 64 | urlLineEdit = new QLineEdit; 65 | urlLineEdit->setFixedHeight(32); 66 | urlLineEdit->setClearButtonEnabled(true); 67 | urlLineEdit->setPlaceholderText("bilibili 直播/视频/漫画 URL"); 68 | 69 | auto downloadButton = new QPushButton; 70 | downloadButton->setToolTip("下载"); 71 | downloadButton->setFixedSize(QSize(32, 32)); 72 | downloadButton->setIconSize(QSize(28, 28)); 73 | downloadButton->setIcon(QIcon(":/icons/download.svg")); 74 | downloadButton->setCursor(Qt::PointingHandCursor); 75 | downloadButton->setStyleSheet( 76 | "QPushButton{border:1px solid gray; border-left:0px; background-color:white;}" 77 | "QPushButton:hover{background-color:rgb(229,229,229);}" 78 | "QPushButton:pressed{background-color:rgb(204,204,204);}" 79 | ); 80 | connect(urlLineEdit, &QLineEdit::returnPressed, this, &MainWindow::downloadButtonClicked); 81 | connect(downloadButton, &QPushButton::clicked, this, &MainWindow::downloadButtonClicked); 82 | 83 | downloadUrlLayout->addWidget(urlLineEdit, 1); 84 | downloadUrlLayout->addWidget(downloadButton); 85 | topLayout->addLayout(downloadUrlLayout, 2); 86 | mainLayout->addLayout(topLayout); 87 | 88 | taskTable = new TaskTableWidget; 89 | QTimer::singleShot(0, this, [this]{ taskTable->load(); }); 90 | auto tabs = new MyTabWidget; 91 | tabs->addTab(taskTable, QIcon(":/icons/download.svg"), "正在下载"); 92 | tabs->addTab(new AboutWidget, QIcon(":/icons/about.svg"), "关于"); 93 | mainLayout->addWidget(tabs); 94 | 95 | setStyleSheet("QMainWindow{background-color:white;}QTableWidget{border:none;}"); 96 | setMinimumSize(650, 360); 97 | QTimer::singleShot(0, this, [this]{ resize(minimumSize()); }); 98 | 99 | urlLineEdit->setFocus(); 100 | startGetUserInfo(); 101 | // auto reply = B23Api::get("https://www.bilibili.com/blackboard/topic/activity-4AL5_Jqb3.html"); 102 | } 103 | 104 | void MainWindow::closeEvent(QCloseEvent *event) 105 | { 106 | auto dlg = QMessageBox(QMessageBox::Warning, "退出", "是否退出?", QMessageBox::NoButton, this); 107 | dlg.addButton("确定", QMessageBox::AcceptRole); 108 | dlg.addButton("取消", QMessageBox::RejectRole); 109 | auto ret = dlg.exec(); 110 | if (ret == QMessageBox::AcceptRole) { 111 | taskTable->stopAll(); 112 | taskTable->save(); 113 | event->accept(); 114 | } else { 115 | event->ignore(); 116 | } 117 | } 118 | 119 | void MainWindow::startGetUserInfo() 120 | { 121 | if (!Settings::inst()->hasCookies()) { 122 | return; 123 | } 124 | if (hasGotUInfo || uinfoReply != nullptr) { 125 | return; 126 | } 127 | unameLabel->setText("登录中...", Qt::gray); 128 | auto rqst = Network::Bili::Request(QUrl("https://api.bilibili.com/nav")); 129 | rqst.setTransferTimeout(GetUserInfoTimeout); 130 | uinfoReply = Network::accessManager()->get(rqst);; 131 | connect(uinfoReply, &QNetworkReply::finished, this, &MainWindow::getUserInfoFinished); 132 | } 133 | 134 | void MainWindow::getUserInfoFinished() 135 | { 136 | auto reply = uinfoReply; 137 | uinfoReply->deleteLater(); 138 | uinfoReply = nullptr; 139 | 140 | if (reply->error() == QNetworkReply::OperationCanceledError) { 141 | unameLabel->setErrText("网络请求超时"); 142 | QTimer::singleShot(GetUserInfoRetryInterval, this, &MainWindow::startGetUserInfo); 143 | return; 144 | } 145 | 146 | const auto [json, errorString] = Network::Bili::parseReply(reply, "data"); 147 | 148 | if (!json.empty() && !errorString.isNull()) { 149 | // cookies is wrong, or expired? 150 | unameLabel->clear(); 151 | Settings::inst()->removeCookies(); 152 | } else if (!errorString.isNull()) { 153 | unameLabel->setErrText(errorString); 154 | QTimer::singleShot(GetUserInfoRetryInterval, this, &MainWindow::startGetUserInfo); 155 | } else { 156 | // success 157 | hasGotUInfo = true; 158 | auto data = json["data"]; 159 | auto uname = data["uname"].toString(); 160 | ufaceUrl = data["face"].toString() + "@64w_64h.png"; 161 | if (data["vipStatus"].toInt()) { 162 | unameLabel->setText(uname, B23Style::Pink); 163 | } else { 164 | unameLabel->setText(uname); 165 | } 166 | 167 | auto logoutAction = new QAction(QIcon(":/icons/logout.svg"), "退出"); 168 | ufaceButton->addAction(logoutAction); 169 | ufaceButton->setIcon(QIcon(":/icons/akkarin.png")); 170 | connect(logoutAction, &QAction::triggered, this, &MainWindow::logoutActionTriggered); 171 | 172 | startGetUFace(); 173 | } 174 | } 175 | 176 | void MainWindow::logoutActionTriggered() 177 | { 178 | hasGotUInfo = false; 179 | hasGotUFace = false; 180 | ufaceUrl.clear(); 181 | 182 | unameLabel->clear(); 183 | ufaceButton->setIcon(QIcon()); 184 | auto actions = ufaceButton->actions(); 185 | if (!actions.isEmpty()) { 186 | ufaceButton->removeAction(actions.first()); 187 | } 188 | 189 | if (uinfoReply != nullptr) { 190 | uinfoReply->abort(); 191 | } 192 | 193 | auto settings = Settings::inst(); 194 | auto exitPostData = "biliCSRF=" + settings->getCookieJar()->getCookie("bili_jct"); 195 | auto exitReply = Network::Bili::postUrlEncoded("https://passport.bilibili.com/login/exit/v2", exitPostData); 196 | connect(exitReply, &QNetworkReply::finished, this, [=]{ exitReply->deleteLater(); }); 197 | settings->removeCookies(); 198 | } 199 | 200 | void MainWindow::startGetUFace() 201 | { 202 | if (ufaceUrl.isNull()) { 203 | return; 204 | } 205 | if (hasGotUFace || uinfoReply != nullptr) { 206 | return; 207 | } 208 | 209 | auto rqst = Network::Bili::Request(ufaceUrl); 210 | rqst.setTransferTimeout(GetUserInfoTimeout); 211 | uinfoReply = Network::accessManager()->get(rqst); 212 | connect(uinfoReply, &QNetworkReply::finished, this, &MainWindow::getUFaceFinished); 213 | } 214 | 215 | void MainWindow::getUFaceFinished() 216 | { 217 | auto reply = uinfoReply; 218 | uinfoReply->deleteLater(); 219 | uinfoReply = nullptr; 220 | 221 | if (!hasGotUInfo && reply->error() == QNetworkReply::OperationCanceledError) { 222 | // aborted 223 | return; 224 | } 225 | if (reply->error() != QNetworkReply::NoError) { 226 | QTimer::singleShot(GetUserInfoRetryInterval, this, &MainWindow::startGetUFace); 227 | return; 228 | } 229 | 230 | hasGotUFace = true; 231 | QPixmap pixmap; 232 | pixmap.loadFromData(reply->readAll()); 233 | ufaceButton->setIcon(QIcon(pixmap)); 234 | } 235 | 236 | // login 237 | void MainWindow::ufaceButtonClicked() 238 | { 239 | auto settings = Settings::inst(); 240 | if (hasGotUInfo) { 241 | return; 242 | } else if (settings->hasCookies()) { 243 | // is trying to get user info 244 | if (uinfoReply != nullptr) { 245 | // is requesting 246 | return; 247 | } 248 | // retry immediately 249 | startGetUserInfo(); 250 | } else { 251 | settings->getCookieJar()->clear(); // remove unnecessary cookie 252 | auto dlg = new LoginDialog(this); 253 | connect(dlg, &QDialog::finished, this, [=](int result) { 254 | dlg->deleteLater(); 255 | if (result == QDialog::Accepted) { 256 | settings->saveCookies(); 257 | startGetUserInfo(); 258 | } else { 259 | settings->getCookieJar()->clear(); // remove unnecessary cookie 260 | } 261 | }); 262 | dlg->open(); 263 | } 264 | } 265 | 266 | void MainWindow::downloadButtonClicked() 267 | { 268 | auto trimmed = urlLineEdit->text().trimmed(); 269 | if (trimmed.isEmpty()) { 270 | urlLineEdit->clear(); 271 | return; 272 | } 273 | 274 | auto dlg = new DownloadDialog(trimmed, this); 275 | connect(dlg, &QDialog::finished, this, [this, dlg](int result) { 276 | dlg->deleteLater(); 277 | // urlLineEdit->clear(); 278 | if (result == QDialog::Accepted) { 279 | taskTable->addTasks(dlg->getDownloadTasks()); 280 | } 281 | }); 282 | dlg->open(); 283 | } 284 | -------------------------------------------------------------------------------- /B23Downloader/MainWindow.h: -------------------------------------------------------------------------------- 1 | #ifndef MAINWINDOW_H 2 | #define MAINWINDOW_H 3 | 4 | #include 5 | 6 | class QLabel; 7 | class QLineEdit; 8 | class QPushButton; 9 | class QToolButton; 10 | class QNetworkReply; 11 | 12 | class ElidedTextLabel; 13 | class TaskTableWidget; 14 | 15 | 16 | class MainWindow : public QMainWindow 17 | { 18 | Q_OBJECT 19 | 20 | public: 21 | MainWindow(QWidget *parent = nullptr); 22 | ~MainWindow() override; 23 | 24 | protected: 25 | void closeEvent(QCloseEvent *event) override; 26 | 27 | private: 28 | void startGetUserInfo(); 29 | void startGetUFace(); 30 | 31 | private slots: 32 | void downloadButtonClicked(); 33 | void getUserInfoFinished(); 34 | void getUFaceFinished(); 35 | void ufaceButtonClicked(); 36 | void logoutActionTriggered(); 37 | 38 | private: 39 | bool hasGotUInfo = false; 40 | bool hasGotUFace = false; 41 | QNetworkReply *uinfoReply = nullptr; 42 | QString ufaceUrl; 43 | 44 | QToolButton *ufaceButton; 45 | ElidedTextLabel *unameLabel; 46 | QLineEdit *urlLineEdit; 47 | TaskTableWidget *taskTable; 48 | }; 49 | #endif // MAINWINDOW_H 50 | -------------------------------------------------------------------------------- /B23Downloader/MyTabWidget.cpp: -------------------------------------------------------------------------------- 1 | #include "MyTabWidget.h" 2 | #include 3 | 4 | MyTabWidget::MyTabWidget(QWidget *parent) : QWidget(parent) 5 | { 6 | auto layout = new QHBoxLayout(this); 7 | layout->setContentsMargins(0, 0, 0, 0); 8 | bar = new QToolBar; 9 | bar->setIconSize(QSize(28, 28)); 10 | bar->setOrientation(Qt::Orientation::Vertical); 11 | stack = new QStackedWidget; 12 | layout->addWidget(bar); 13 | layout->addWidget(stack); 14 | actGroup = new QActionGroup(this); 15 | actGroup->setExclusive(true); 16 | connect(bar, &QToolBar::actionTriggered, this, [stack=this->stack](QAction *act) { 17 | stack->setCurrentIndex(act->data().toInt()); 18 | }); 19 | 20 | bar->setStyleSheet( 21 | "QToolBar{border-right:1px solid rgb(224,224,224);}" 22 | "QToolButton{padding:3px;border:none;margin-right:3px;}" 23 | "QToolButton:checked{background-color:rgb(232,232,232);}" 24 | "QToolButton:hover{background-color:rgb(232,232,232);}" 25 | "QToolButton:pressed{background-color:rgb(232,232,232);}" 26 | ); 27 | } 28 | 29 | void MyTabWidget::setTabToolButtonStyle(Qt::ToolButtonStyle style) 30 | { 31 | bar->setToolButtonStyle(style); 32 | } 33 | 34 | void MyTabWidget::addTab(QWidget *page, const QIcon &icon, const QString &label) 35 | { 36 | stack->addWidget(page); 37 | 38 | auto idx = stack->count() - 1; 39 | auto act = new QAction(icon, label); 40 | act->setCheckable(true); 41 | act->setData(idx); 42 | if (idx == 0) { 43 | act->setChecked(true); 44 | } 45 | actGroup->addAction(act); 46 | bar->addAction(act); 47 | } 48 | -------------------------------------------------------------------------------- /B23Downloader/MyTabWidget.h: -------------------------------------------------------------------------------- 1 | #ifndef MYTABWIDGET_H 2 | #define MYTABWIDGET_H 3 | 4 | #include 5 | 6 | class QToolBar; 7 | class QActionGroup; 8 | class QStackedWidget; 9 | 10 | class MyTabWidget : public QWidget 11 | { 12 | QToolBar *bar; 13 | QActionGroup *actGroup; 14 | QStackedWidget *stack; 15 | 16 | public: 17 | MyTabWidget(QWidget *parent = nullptr); 18 | void setTabToolButtonStyle(Qt::ToolButtonStyle); 19 | void addTab(QWidget *page, const QIcon &icon, const QString &label); 20 | }; 21 | 22 | #endif // MYTABWIDGET_H 23 | -------------------------------------------------------------------------------- /B23Downloader/Network.cpp: -------------------------------------------------------------------------------- 1 | #include "Network.h" 2 | #include 3 | 4 | namespace Network 5 | { 6 | 7 | Q_GLOBAL_STATIC(QNetworkAccessManager, nam) 8 | 9 | QNetworkAccessManager *accessManager() 10 | { 11 | return nam(); 12 | } 13 | 14 | int statusCode(QNetworkReply *reply) 15 | { 16 | return reply->attribute(QNetworkRequest::HttpStatusCodeAttribute).toInt(); 17 | } 18 | 19 | 20 | 21 | const QByteArray Bili::Referer("https://www.bilibili.com"); 22 | const QByteArray Bili::UserAgent("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/90.0.4430.85 Safari/537.36 Edg/90.0.818.49"); 23 | 24 | Bili::Request::Request(const QUrl &url) 25 | : QNetworkRequest(url) 26 | { 27 | setMaximumRedirectsAllowed(0); 28 | setRawHeader("Referer", Bili::Referer); 29 | setRawHeader("User-Agent", Bili::UserAgent); 30 | } 31 | 32 | 33 | QNetworkReply *Bili::get(const QUrl &url) 34 | { 35 | return nam->get(Bili::Request(url)); 36 | } 37 | 38 | QNetworkReply *Bili::get(const QString &url) 39 | { 40 | return nam->get(Bili::Request(url)); 41 | } 42 | 43 | QNetworkReply *Bili::postUrlEncoded(const QString &url, const QByteArray &data) 44 | { 45 | auto request = Bili::Request(url); 46 | request.setRawHeader("content-type", "application/x-www-form-urlencoded;charset=UTF-8"); 47 | return nam->post(request, data); 48 | } 49 | 50 | QNetworkReply *Bili::postJson(const QString &url, const QByteArray &data) 51 | { 52 | auto request = Bili::Request(url); 53 | request.setRawHeader("content-type", "application/json;charset=UTF-8"); 54 | return nam->post(request, data); 55 | } 56 | 57 | QNetworkReply *Bili::postJson(const QString &url, const QJsonObject &obj) 58 | { 59 | auto request = Bili::Request(url); 60 | request.setRawHeader("content-type", "application/json;charset=UTF-8"); 61 | return nam->post(request, QJsonDocument(obj).toJson(QJsonDocument::Compact)); 62 | } 63 | 64 | static bool isJsonValueInvalid(const QJsonValue &val) 65 | { 66 | return val.isNull() || val.isUndefined(); 67 | } 68 | 69 | std::pair Bili::parseReply(QNetworkReply *reply, const QString& requiredKey) 70 | { 71 | if (reply->error() != QNetworkReply::NoError) { 72 | qDebug() << "network error:" << reply->errorString() << ", url=" << reply->url().toString(); 73 | return { QJsonObject(), "网络请求错误" }; 74 | } 75 | if (!reply->header(QNetworkRequest::ContentTypeHeader).toString().contains("json")) { 76 | return { QJsonObject(), "http请求错误" }; 77 | } 78 | auto data = reply->readAll(); 79 | auto jsonDoc = QJsonDocument::fromJson(data); 80 | auto jsonObj = jsonDoc.object(); 81 | qDebug() << "reply from" << reply->url(); // << QString::fromUtf8(data); 82 | 83 | if (jsonObj.isEmpty()) { 84 | return { QJsonObject(), "http请求错误" }; 85 | } 86 | 87 | int code = jsonObj["code"].toInt(0); 88 | if (code < 0 || (!requiredKey.isEmpty() && isJsonValueInvalid(jsonObj[requiredKey]))) { 89 | if (jsonObj.contains("message")) { 90 | return { jsonObj, jsonObj["message"].toString() }; 91 | } 92 | if (jsonObj.contains("msg")) { 93 | return { jsonObj, jsonObj["msg"].toString() }; 94 | } 95 | auto format = QStringLiteral("B站请求错误: code = %1, requiredKey = %2\nURL: %3"); 96 | auto msg = format.arg(QString::number(code), requiredKey, reply->url().toString()); 97 | return { jsonObj, msg }; 98 | } 99 | 100 | return { jsonObj, QString() }; 101 | } 102 | 103 | 104 | } // namespace Network 105 | -------------------------------------------------------------------------------- /B23Downloader/Network.h: -------------------------------------------------------------------------------- 1 | #ifndef NETWORK_H 2 | #define NETWORK_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | 10 | namespace Network 11 | { 12 | 13 | QNetworkAccessManager *accessManager(); 14 | 15 | int statusCode(QNetworkReply *reply); 16 | 17 | 18 | namespace Bili { 19 | 20 | extern const QByteArray Referer; 21 | extern const QByteArray UserAgent; 22 | 23 | 24 | /** 25 | * @brief simple subclass of QNetworkRequest that sets referer (to bilibili.com) and user-agent header in ctor 26 | */ 27 | class Request : public QNetworkRequest { 28 | public: 29 | Request(const QUrl &url); 30 | }; 31 | 32 | 33 | QNetworkReply *get(const QString &url); 34 | QNetworkReply *get(const QUrl &url); 35 | 36 | /** 37 | * @brief post data to url with content-type header set to application/x-www-form-urlencoded 38 | */ 39 | QNetworkReply *postUrlEncoded(const QString &url, const QByteArray &data); 40 | 41 | /** 42 | * @brief post data to url with content-type header set to application/json 43 | */ 44 | QNetworkReply *postJson(const QString &url, const QByteArray &data); 45 | 46 | /** 47 | * @brief post JsonObj to url (content-type header set to application/json) 48 | */ 49 | QNetworkReply *postJson(const QString &url, const QJsonObject &obj); 50 | 51 | /*! 52 | * @return pair (json object, error string) (error string isNull if no error). 53 | * @param requiredKey is only used to help checking whether request is succeed. 54 | */ 55 | std::pair parseReply(QNetworkReply *reply, const QString& requiredKey = QString()); 56 | } // end namespace Bili 57 | 58 | 59 | } // end namespace Network 60 | 61 | 62 | #endif // NETWORK_H 63 | -------------------------------------------------------------------------------- /B23Downloader/QrCode.h: -------------------------------------------------------------------------------- 1 | /* 2 | * QR Code generator library (C++) 3 | * 4 | * Copyright (c) Project Nayuki. (MIT License) 5 | * https://www.nayuki.io/page/qr-code-generator-library 6 | * 7 | * Permission is hereby granted, free of charge, to any person obtaining a copy of 8 | * this software and associated documentation files (the "Software"), to deal in 9 | * the Software without restriction, including without limitation the rights to 10 | * use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of 11 | * the Software, and to permit persons to whom the Software is furnished to do so, 12 | * subject to the following conditions: 13 | * - The above copyright notice and this permission notice shall be included in 14 | * all copies or substantial portions of the Software. 15 | * - The Software is provided "as is", without warranty of any kind, express or 16 | * implied, including but not limited to the warranties of merchantability, 17 | * fitness for a particular purpose and noninfringement. In no event shall the 18 | * authors or copyright holders be liable for any claim, damages or other 19 | * liability, whether in an action of contract, tort or otherwise, arising from, 20 | * out of or in connection with the Software or the use or other dealings in the 21 | * Software. 22 | */ 23 | 24 | #pragma once 25 | 26 | #include 27 | #include 28 | #include 29 | #include 30 | #include 31 | 32 | 33 | namespace qrcodegen { 34 | 35 | /* 36 | * A segment of character/binary/control data in a QR Code symbol. 37 | * Instances of this class are immutable. 38 | * The mid-level way to create a segment is to take the payload data 39 | * and call a static factory function such as QrSegment::makeNumeric(). 40 | * The low-level way to create a segment is to custom-make the bit buffer 41 | * and call the QrSegment() constructor with appropriate values. 42 | * This segment class imposes no length restrictions, but QR Codes have restrictions. 43 | * Even in the most favorable conditions, a QR Code can only hold 7089 characters of data. 44 | * Any segment longer than this is meaningless for the purpose of generating QR Codes. 45 | */ 46 | class QrSegment final { 47 | 48 | /*---- Public helper enumeration ----*/ 49 | 50 | /* 51 | * Describes how a segment's data bits are interpreted. Immutable. 52 | */ 53 | public: class Mode final { 54 | 55 | /*-- Constants --*/ 56 | 57 | public: static const Mode NUMERIC; 58 | public: static const Mode ALPHANUMERIC; 59 | public: static const Mode BYTE; 60 | public: static const Mode KANJI; 61 | public: static const Mode ECI; 62 | 63 | 64 | /*-- Fields --*/ 65 | 66 | // The mode indicator bits, which is a uint4 value (range 0 to 15). 67 | private: int modeBits; 68 | 69 | // Number of character count bits for three different version ranges. 70 | private: int numBitsCharCount[3]; 71 | 72 | 73 | /*-- Constructor --*/ 74 | 75 | private: Mode(int mode, int cc0, int cc1, int cc2); 76 | 77 | 78 | /*-- Methods --*/ 79 | 80 | /* 81 | * (Package-private) Returns the mode indicator bits, which is an unsigned 4-bit value (range 0 to 15). 82 | */ 83 | public: int getModeBits() const; 84 | 85 | /* 86 | * (Package-private) Returns the bit width of the character count field for a segment in 87 | * this mode in a QR Code at the given version number. The result is in the range [0, 16]. 88 | */ 89 | public: int numCharCountBits(int ver) const; 90 | 91 | }; 92 | 93 | 94 | 95 | /*---- Static factory functions (mid level) ----*/ 96 | 97 | /* 98 | * Returns a segment representing the given binary data encoded in 99 | * byte mode. All input byte vectors are acceptable. Any text string 100 | * can be converted to UTF-8 bytes and encoded as a byte mode segment. 101 | */ 102 | public: static QrSegment makeBytes(const std::vector &data); 103 | 104 | 105 | /* 106 | * Returns a segment representing the given string of decimal digits encoded in numeric mode. 107 | */ 108 | public: static QrSegment makeNumeric(const char *digits); 109 | 110 | 111 | /* 112 | * Returns a segment representing the given text string encoded in alphanumeric mode. 113 | * The characters allowed are: 0 to 9, A to Z (uppercase only), space, 114 | * dollar, percent, asterisk, plus, hyphen, period, slash, colon. 115 | */ 116 | public: static QrSegment makeAlphanumeric(const char *text); 117 | 118 | 119 | /* 120 | * Returns a list of zero or more segments to represent the given text string. The result 121 | * may use various segment modes and switch modes to optimize the length of the bit stream. 122 | */ 123 | public: static std::vector makeSegments(const char *text); 124 | 125 | 126 | /* 127 | * Returns a segment representing an Extended Channel Interpretation 128 | * (ECI) designator with the given assignment value. 129 | */ 130 | public: static QrSegment makeEci(long assignVal); 131 | 132 | 133 | /*---- Public static helper functions ----*/ 134 | 135 | /* 136 | * Tests whether the given string can be encoded as a segment in alphanumeric mode. 137 | * A string is encodable iff each character is in the following set: 0 to 9, A to Z 138 | * (uppercase only), space, dollar, percent, asterisk, plus, hyphen, period, slash, colon. 139 | */ 140 | public: static bool isAlphanumeric(const char *text); 141 | 142 | 143 | /* 144 | * Tests whether the given string can be encoded as a segment in numeric mode. 145 | * A string is encodable iff each character is in the range 0 to 9. 146 | */ 147 | public: static bool isNumeric(const char *text); 148 | 149 | 150 | 151 | /*---- Instance fields ----*/ 152 | 153 | /* The mode indicator of this segment. Accessed through getMode(). */ 154 | private: Mode mode; 155 | 156 | /* The length of this segment's unencoded data. Measured in characters for 157 | * numeric/alphanumeric/kanji mode, bytes for byte mode, and 0 for ECI mode. 158 | * Always zero or positive. Not the same as the data's bit length. 159 | * Accessed through getNumChars(). */ 160 | private: int numChars; 161 | 162 | /* The data bits of this segment. Accessed through getData(). */ 163 | private: std::vector data; 164 | 165 | 166 | /*---- Constructors (low level) ----*/ 167 | 168 | /* 169 | * Creates a new QR Code segment with the given attributes and data. 170 | * The character count (numCh) must agree with the mode and the bit buffer length, 171 | * but the constraint isn't checked. The given bit buffer is copied and stored. 172 | */ 173 | public: QrSegment(Mode md, int numCh, const std::vector &dt); 174 | 175 | 176 | /* 177 | * Creates a new QR Code segment with the given parameters and data. 178 | * The character count (numCh) must agree with the mode and the bit buffer length, 179 | * but the constraint isn't checked. The given bit buffer is moved and stored. 180 | */ 181 | public: QrSegment(Mode md, int numCh, std::vector &&dt); 182 | 183 | 184 | /*---- Methods ----*/ 185 | 186 | /* 187 | * Returns the mode field of this segment. 188 | */ 189 | public: Mode getMode() const; 190 | 191 | 192 | /* 193 | * Returns the character count field of this segment. 194 | */ 195 | public: int getNumChars() const; 196 | 197 | 198 | /* 199 | * Returns the data bits of this segment. 200 | */ 201 | public: const std::vector &getData() const; 202 | 203 | 204 | // (Package-private) Calculates the number of bits needed to encode the given segments at 205 | // the given version. Returns a non-negative number if successful. Otherwise returns -1 if a 206 | // segment has too many characters to fit its length field, or the total bits exceeds INT_MAX. 207 | public: static int getTotalBits(const std::vector &segs, int version); 208 | 209 | 210 | /*---- Private constant ----*/ 211 | 212 | /* The set of all legal characters in alphanumeric mode, where 213 | * each character value maps to the index in the string. */ 214 | private: static const char *ALPHANUMERIC_CHARSET; 215 | 216 | }; 217 | 218 | 219 | 220 | /* 221 | * A QR Code symbol, which is a type of two-dimension barcode. 222 | * Invented by Denso Wave and described in the ISO/IEC 18004 standard. 223 | * Instances of this class represent an immutable square grid of black and white cells. 224 | * The class provides static factory functions to create a QR Code from text or binary data. 225 | * The class covers the QR Code Model 2 specification, supporting all versions (sizes) 226 | * from 1 to 40, all 4 error correction levels, and 4 character encoding modes. 227 | * 228 | * Ways to create a QR Code object: 229 | * - High level: Take the payload data and call QrCode::encodeText() or QrCode::encodeBinary(). 230 | * - Mid level: Custom-make the list of segments and call QrCode::encodeSegments(). 231 | * - Low level: Custom-make the array of data codeword bytes (including 232 | * segment headers and final padding, excluding error correction codewords), 233 | * supply the appropriate version number, and call the QrCode() constructor. 234 | * (Note that all ways require supplying the desired error correction level.) 235 | */ 236 | class QrCode final { 237 | 238 | /*---- Public helper enumeration ----*/ 239 | 240 | /* 241 | * The error correction level in a QR Code symbol. 242 | */ 243 | public: enum class Ecc { 244 | LOW = 0 , // The QR Code can tolerate about 7% erroneous codewords 245 | MEDIUM , // The QR Code can tolerate about 15% erroneous codewords 246 | QUARTILE, // The QR Code can tolerate about 25% erroneous codewords 247 | HIGH , // The QR Code can tolerate about 30% erroneous codewords 248 | }; 249 | 250 | 251 | // Returns a value in the range 0 to 3 (unsigned 2-bit integer). 252 | private: static int getFormatBits(Ecc ecl); 253 | 254 | 255 | 256 | /*---- Static factory functions (high level) ----*/ 257 | 258 | /* 259 | * Returns a QR Code representing the given Unicode text string at the given error correction level. 260 | * As a conservative upper bound, this function is guaranteed to succeed for strings that have 2953 or fewer 261 | * UTF-8 code units (not Unicode code points) if the low error correction level is used. The smallest possible 262 | * QR Code version is automatically chosen for the output. The ECC level of the result may be higher than 263 | * the ecl argument if it can be done without increasing the version. 264 | */ 265 | public: static QrCode encodeText(const char *text, Ecc ecl); 266 | 267 | 268 | /* 269 | * Returns a QR Code representing the given binary data at the given error correction level. 270 | * This function always encodes using the binary segment mode, not any text mode. The maximum number of 271 | * bytes allowed is 2953. The smallest possible QR Code version is automatically chosen for the output. 272 | * The ECC level of the result may be higher than the ecl argument if it can be done without increasing the version. 273 | */ 274 | public: static QrCode encodeBinary(const std::vector &data, Ecc ecl); 275 | 276 | 277 | /*---- Static factory functions (mid level) ----*/ 278 | 279 | /* 280 | * Returns a QR Code representing the given segments with the given encoding parameters. 281 | * The smallest possible QR Code version within the given range is automatically 282 | * chosen for the output. Iff boostEcl is true, then the ECC level of the result 283 | * may be higher than the ecl argument if it can be done without increasing the 284 | * version. The mask number is either between 0 to 7 (inclusive) to force that 285 | * mask, or -1 to automatically choose an appropriate mask (which may be slow). 286 | * This function allows the user to create a custom sequence of segments that switches 287 | * between modes (such as alphanumeric and byte) to encode text in less space. 288 | * This is a mid-level API; the high-level API is encodeText() and encodeBinary(). 289 | */ 290 | public: static QrCode encodeSegments(const std::vector &segs, Ecc ecl, 291 | int minVersion=1, int maxVersion=40, int mask=-1, bool boostEcl=true); // All optional parameters 292 | 293 | 294 | 295 | /*---- Instance fields ----*/ 296 | 297 | // Immutable scalar parameters: 298 | 299 | /* The version number of this QR Code, which is between 1 and 40 (inclusive). 300 | * This determines the size of this barcode. */ 301 | private: int version; 302 | 303 | /* The width and height of this QR Code, measured in modules, between 304 | * 21 and 177 (inclusive). This is equal to version * 4 + 17. */ 305 | private: int size; 306 | 307 | /* The error correction level used in this QR Code. */ 308 | private: Ecc errorCorrectionLevel; 309 | 310 | /* The index of the mask pattern used in this QR Code, which is between 0 and 7 (inclusive). 311 | * Even if a QR Code is created with automatic masking requested (mask = -1), 312 | * the resulting object still has a mask value between 0 and 7. */ 313 | private: int mask; 314 | 315 | // Private grids of modules/pixels, with dimensions of size*size: 316 | 317 | // The modules of this QR Code (false = white, true = black). 318 | // Immutable after constructor finishes. Accessed through getModule(). 319 | private: std::vector > modules; 320 | 321 | // Indicates function modules that are not subjected to masking. Discarded when constructor finishes. 322 | private: std::vector > isFunction; 323 | 324 | 325 | 326 | /*---- Constructor (low level) ----*/ 327 | 328 | /* 329 | * Creates a new QR Code with the given version number, 330 | * error correction level, data codeword bytes, and mask number. 331 | * This is a low-level API that most users should not use directly. 332 | * A mid-level API is the encodeSegments() function. 333 | */ 334 | public: QrCode(int ver, Ecc ecl, const std::vector &dataCodewords, int msk); 335 | 336 | 337 | 338 | /*---- Public instance methods ----*/ 339 | 340 | /* 341 | * Returns this QR Code's version, in the range [1, 40]. 342 | */ 343 | public: int getVersion() const; 344 | 345 | 346 | /* 347 | * Returns this QR Code's size, in the range [21, 177]. 348 | */ 349 | public: int getSize() const; 350 | 351 | 352 | /* 353 | * Returns this QR Code's error correction level. 354 | */ 355 | public: Ecc getErrorCorrectionLevel() const; 356 | 357 | 358 | /* 359 | * Returns this QR Code's mask, in the range [0, 7]. 360 | */ 361 | public: int getMask() const; 362 | 363 | 364 | /* 365 | * Returns the color of the module (pixel) at the given coordinates, which is false 366 | * for white or true for black. The top left corner has the coordinates (x=0, y=0). 367 | * If the given coordinates are out of bounds, then false (white) is returned. 368 | */ 369 | public: bool getModule(int x, int y) const; 370 | 371 | 372 | /* 373 | * Returns a string of SVG code for an image depicting this QR Code, with the given number 374 | * of border modules. The string always uses Unix newlines (\n), regardless of the platform. 375 | */ 376 | public: std::string toSvgString(int border) const; 377 | 378 | 379 | 380 | /*---- Private helper methods for constructor: Drawing function modules ----*/ 381 | 382 | // Reads this object's version field, and draws and marks all function modules. 383 | private: void drawFunctionPatterns(); 384 | 385 | 386 | // Draws two copies of the format bits (with its own error correction code) 387 | // based on the given mask and this object's error correction level field. 388 | private: void drawFormatBits(int msk); 389 | 390 | 391 | // Draws two copies of the version bits (with its own error correction code), 392 | // based on this object's version field, iff 7 <= version <= 40. 393 | private: void drawVersion(); 394 | 395 | 396 | // Draws a 9*9 finder pattern including the border separator, 397 | // with the center module at (x, y). Modules can be out of bounds. 398 | private: void drawFinderPattern(int x, int y); 399 | 400 | 401 | // Draws a 5*5 alignment pattern, with the center module 402 | // at (x, y). All modules must be in bounds. 403 | private: void drawAlignmentPattern(int x, int y); 404 | 405 | 406 | // Sets the color of a module and marks it as a function module. 407 | // Only used by the constructor. Coordinates must be in bounds. 408 | private: void setFunctionModule(int x, int y, bool isBlack); 409 | 410 | 411 | // Returns the color of the module at the given coordinates, which must be in range. 412 | private: bool module(int x, int y) const; 413 | 414 | 415 | /*---- Private helper methods for constructor: Codewords and masking ----*/ 416 | 417 | // Returns a new byte string representing the given data with the appropriate error correction 418 | // codewords appended to it, based on this object's version and error correction level. 419 | private: std::vector addEccAndInterleave(const std::vector &data) const; 420 | 421 | 422 | // Draws the given sequence of 8-bit codewords (data and error correction) onto the entire 423 | // data area of this QR Code. Function modules need to be marked off before this is called. 424 | private: void drawCodewords(const std::vector &data); 425 | 426 | 427 | // XORs the codeword modules in this QR Code with the given mask pattern. 428 | // The function modules must be marked and the codeword bits must be drawn 429 | // before masking. Due to the arithmetic of XOR, calling applyMask() with 430 | // the same mask value a second time will undo the mask. A final well-formed 431 | // QR Code needs exactly one (not zero, two, etc.) mask applied. 432 | private: void applyMask(int msk); 433 | 434 | 435 | // Calculates and returns the penalty score based on state of this QR Code's current modules. 436 | // This is used by the automatic mask choice algorithm to find the mask pattern that yields the lowest score. 437 | private: long getPenaltyScore() const; 438 | 439 | 440 | 441 | /*---- Private helper functions ----*/ 442 | 443 | // Returns an ascending list of positions of alignment patterns for this version number. 444 | // Each position is in the range [0,177), and are used on both the x and y axes. 445 | // This could be implemented as lookup table of 40 variable-length lists of unsigned bytes. 446 | private: std::vector getAlignmentPatternPositions() const; 447 | 448 | 449 | // Returns the number of data bits that can be stored in a QR Code of the given version number, after 450 | // all function modules are excluded. This includes remainder bits, so it might not be a multiple of 8. 451 | // The result is in the range [208, 29648]. This could be implemented as a 40-entry lookup table. 452 | private: static int getNumRawDataModules(int ver); 453 | 454 | 455 | // Returns the number of 8-bit data (i.e. not error correction) codewords contained in any 456 | // QR Code of the given version number and error correction level, with remainder bits discarded. 457 | // This stateless pure function could be implemented as a (40*4)-cell lookup table. 458 | private: static int getNumDataCodewords(int ver, Ecc ecl); 459 | 460 | 461 | // Returns a Reed-Solomon ECC generator polynomial for the given degree. This could be 462 | // implemented as a lookup table over all possible parameter values, instead of as an algorithm. 463 | private: static std::vector reedSolomonComputeDivisor(int degree); 464 | 465 | 466 | // Returns the Reed-Solomon error correction codeword for the given data and divisor polynomials. 467 | private: static std::vector reedSolomonComputeRemainder(const std::vector &data, const std::vector &divisor); 468 | 469 | 470 | // Returns the product of the two given field elements modulo GF(2^8/0x11D). 471 | // All inputs are valid. This could be implemented as a 256*256 lookup table. 472 | private: static std::uint8_t reedSolomonMultiply(std::uint8_t x, std::uint8_t y); 473 | 474 | 475 | // Can only be called immediately after a white run is added, and 476 | // returns either 0, 1, or 2. A helper function for getPenaltyScore(). 477 | private: int finderPenaltyCountPatterns(const std::array &runHistory) const; 478 | 479 | 480 | // Must be called at the end of a line (row or column) of modules. A helper function for getPenaltyScore(). 481 | private: int finderPenaltyTerminateAndCount(bool currentRunColor, int currentRunLength, std::array &runHistory) const; 482 | 483 | 484 | // Pushes the given value to the front and drops the last value. A helper function for getPenaltyScore(). 485 | private: void finderPenaltyAddHistory(int currentRunLength, std::array &runHistory) const; 486 | 487 | 488 | // Returns true iff the i'th bit of x is set to 1. 489 | private: static bool getBit(long x, int i); 490 | 491 | 492 | /*---- Constants and tables ----*/ 493 | 494 | // The minimum version number supported in the QR Code Model 2 standard. 495 | public: static constexpr int MIN_VERSION = 1; 496 | 497 | // The maximum version number supported in the QR Code Model 2 standard. 498 | public: static constexpr int MAX_VERSION = 40; 499 | 500 | 501 | // For use in getPenaltyScore(), when evaluating which mask is best. 502 | private: static const int PENALTY_N1; 503 | private: static const int PENALTY_N2; 504 | private: static const int PENALTY_N3; 505 | private: static const int PENALTY_N4; 506 | 507 | 508 | private: static const std::int8_t ECC_CODEWORDS_PER_BLOCK[4][41]; 509 | private: static const std::int8_t NUM_ERROR_CORRECTION_BLOCKS[4][41]; 510 | 511 | }; 512 | 513 | 514 | 515 | /*---- Public exception class ----*/ 516 | 517 | /* 518 | * Thrown when the supplied data does not fit any QR Code version. Ways to handle this exception include: 519 | * - Decrease the error correction level if it was greater than Ecc::LOW. 520 | * - If the encodeSegments() function was called with a maxVersion argument, then increase 521 | * it if it was less than QrCode::MAX_VERSION. (This advice does not apply to the other 522 | * factory functions because they search all versions up to QrCode::MAX_VERSION.) 523 | * - Split the text data into better or optimal segments in order to reduce the number of bits required. 524 | * - Change the text or binary data to be shorter. 525 | * - Change the text to fit the character set of a particular segment mode (e.g. alphanumeric). 526 | * - Propagate the error upward to the caller/user. 527 | */ 528 | class data_too_long : public std::length_error { 529 | 530 | public: explicit data_too_long(const std::string &msg); 531 | 532 | }; 533 | 534 | 535 | 536 | /* 537 | * An appendable sequence of bits (0s and 1s). Mainly used by QrSegment. 538 | */ 539 | class BitBuffer final : public std::vector { 540 | 541 | /*---- Constructor ----*/ 542 | 543 | // Creates an empty bit buffer (length 0). 544 | public: BitBuffer(); 545 | 546 | 547 | 548 | /*---- Method ----*/ 549 | 550 | // Appends the given number of low-order bits of the given value 551 | // to this buffer. Requires 0 <= len <= 31 and val < 2^len. 552 | public: void appendBits(std::uint32_t val, int len); 553 | 554 | }; 555 | 556 | } 557 | -------------------------------------------------------------------------------- /B23Downloader/Settings.cpp: -------------------------------------------------------------------------------- 1 | #include "Settings.h" 2 | #include 3 | 4 | CookieJar::CookieJar(QObject *parent) 5 | : QNetworkCookieJar(parent) 6 | {} 7 | 8 | CookieJar::CookieJar(const QString &cookies, QObject *parent) 9 | : QNetworkCookieJar(parent) 10 | { 11 | fromString(cookies); 12 | } 13 | 14 | bool CookieJar::isEmpty() const 15 | { 16 | return allCookies().isEmpty(); 17 | } 18 | 19 | void CookieJar::clear() 20 | { 21 | setAllCookies(QList()); 22 | } 23 | 24 | QByteArray CookieJar::getCookie(const QString &name) const 25 | { 26 | for (auto &cookie : allCookies()) { 27 | if (cookie.name() == name) { 28 | return cookie.value(); 29 | } 30 | } 31 | return QByteArray(); 32 | } 33 | 34 | QString CookieJar::toString() const 35 | { 36 | QString ret; 37 | for (auto &cookie : allCookies()) { 38 | if (!ret.isEmpty()) { 39 | ret.append(CookiesSeparator); 40 | } 41 | ret.append(cookie.toRawForm()); 42 | } 43 | return ret; 44 | } 45 | 46 | void CookieJar::fromString(const QString &data) 47 | { 48 | QList cookies; 49 | auto cookieStrings = data.split(CookiesSeparator); 50 | for (auto &cookieStr : cookieStrings) { 51 | cookies.append(QNetworkCookie::parseCookies(cookieStr.toUtf8())); 52 | } 53 | setAllCookies(cookies); 54 | } 55 | 56 | 57 | Q_GLOBAL_STATIC(Settings, settings) 58 | 59 | static constexpr auto KeyCookies = "cookies"; 60 | 61 | Settings::Settings(QObject *parent) 62 | :QSettings(QSettings::IniFormat, QSettings::UserScope, "VoidZero", "B23Downloader", parent) 63 | { 64 | setFallbacksEnabled(false); 65 | auto cookiesStr = value(KeyCookies).toString(); 66 | cookieJar = new CookieJar(cookiesStr, parent); 67 | if (!cookiesStr.isEmpty() && cookieJar->isEmpty()) { 68 | remove(KeyCookies); 69 | } 70 | } 71 | 72 | Settings *Settings::inst() 73 | { 74 | return settings(); 75 | } 76 | 77 | CookieJar *Settings::getCookieJar() 78 | { 79 | return cookieJar; 80 | } 81 | 82 | bool Settings::hasCookies() 83 | { 84 | return contains(KeyCookies); // !cookieJar->isEmpty(); 85 | } 86 | 87 | void Settings::saveCookies() 88 | { 89 | setValue(KeyCookies, cookieJar->toString()); 90 | } 91 | 92 | void Settings::removeCookies() 93 | { 94 | cookieJar->clear(); 95 | remove(KeyCookies); 96 | } 97 | -------------------------------------------------------------------------------- /B23Downloader/Settings.h: -------------------------------------------------------------------------------- 1 | #ifndef SETTINGS_H 2 | #define SETTINGS_H 3 | 4 | #include 5 | #include 6 | #include 7 | 8 | #include 9 | namespace B23Style { 10 | constexpr QColor Pink(251, 114, 153); 11 | constexpr QColor Blue(0, 161, 214); 12 | } 13 | 14 | class CookieJar: public QNetworkCookieJar { 15 | static constexpr auto CookiesSeparator = '\n'; 16 | 17 | public: 18 | CookieJar(QObject *parent = nullptr); 19 | CookieJar(const QString &cookies, QObject *parent = nullptr); 20 | QByteArray getCookie(const QString &name) const; 21 | bool isEmpty() const; 22 | void clear(); 23 | QString toString() const; 24 | 25 | private: 26 | void fromString(const QString &cookies); 27 | }; 28 | 29 | class Settings: public QSettings 30 | { 31 | CookieJar *cookieJar; 32 | 33 | public: 34 | Settings(QObject *parent = nullptr); 35 | static Settings *inst(); 36 | 37 | CookieJar *getCookieJar(); 38 | bool hasCookies(); 39 | void saveCookies(); 40 | void removeCookies(); 41 | }; 42 | 43 | #endif // SETTINGS_H 44 | -------------------------------------------------------------------------------- /B23Downloader/TaskTable.cpp: -------------------------------------------------------------------------------- 1 | #include "TaskTable.h" 2 | #include "DownloadTask.h" 3 | #include "Settings.h" 4 | #include "utils.h" 5 | 6 | #include 7 | 8 | static constexpr int MaxConcurrentTaskCount = 3; 9 | static constexpr int SaveTasksInterval = 5000; // ms 10 | 11 | static constexpr int DownRateTimerInterval = 500; // ms 12 | static constexpr int DownRateWindowLength = 10; 13 | 14 | TaskTableWidget::TaskTableWidget(QWidget *parent) 15 | : QTableWidget(parent) 16 | { 17 | horizontalHeader()->hide(); 18 | verticalHeader()->hide(); 19 | setColumnCount(1); 20 | horizontalHeader()->setSectionResizeMode(QHeaderView::Stretch); 21 | setFocusPolicy(Qt::NoFocus); 22 | setStyleSheet( "QTableWidget {" 23 | "selection-background-color: rgb(227, 227, 229);" 24 | "}"); 25 | 26 | startAllAct = new QAction("全部开始"); 27 | stopAllAct = new QAction("全部暂停"); 28 | removeAllAct = new QAction("全部删除"); 29 | connect(startAllAct, &QAction::triggered, this, &TaskTableWidget::startAll); 30 | connect(stopAllAct, &QAction::triggered, this, &TaskTableWidget::stopAll); 31 | connect(removeAllAct, &QAction::triggered, this, &TaskTableWidget::removeAll); 32 | 33 | saveTasksTimer = new QTimer(this); 34 | saveTasksTimer->setInterval(SaveTasksInterval); 35 | saveTasksTimer->setSingleShot(false); 36 | connect(saveTasksTimer, &QTimer::timeout, this, &TaskTableWidget::save); 37 | } 38 | 39 | static QAction* createOpenDirAct(QString path) 40 | { 41 | auto openDirAct = new QAction("打开文件夹"); 42 | QObject::connect(openDirAct, &QAction::triggered, [path=std::move(path)](){ 43 | #ifdef Q_OS_WIN 44 | QProcess::startDetached("explorer.exe", {"/select,", QDir::toNativeSeparators(path)}); 45 | #else 46 | QDesktopServices::openUrl(QUrl::fromLocalFile(QFileInfo(path).absolutePath())); 47 | #endif 48 | }); 49 | return openDirAct; 50 | } 51 | 52 | void TaskTableWidget::contextMenuEvent(QContextMenuEvent *event) 53 | { 54 | QMenu contextMenu(this); 55 | auto selection = selectedIndexes(); 56 | if (selection.size() == 1) { 57 | auto cell = cellWidget(selection.first().row()); 58 | auto path = cell->getTask()->getPath(); 59 | if (QFileInfo::exists(path)) { 60 | auto openAct = new QAction("打开"); 61 | connect(openAct, &QAction::triggered, [path](){ 62 | QDesktopServices::openUrl(QUrl::fromLocalFile(path)); 63 | }); 64 | contextMenu.addAction(openAct); 65 | contextMenu.addAction(createOpenDirAct(std::move(path))); 66 | } 67 | } 68 | 69 | contextMenu.addAction(startAllAct); 70 | contextMenu.addAction(stopAllAct); 71 | contextMenu.addAction(removeAllAct); 72 | contextMenu.exec(event->globalPos()); 73 | } 74 | 75 | TaskCellWidget *TaskTableWidget::cellWidget(int row) const 76 | { 77 | return static_cast(QTableWidget::cellWidget(row, 0)); 78 | } 79 | 80 | int TaskTableWidget::rowOfCell(TaskCellWidget *cell) const 81 | { 82 | int rowCnt = rowCount(); 83 | for (int row = 0; row < rowCnt; row++) { 84 | if (cellWidget(row) == cell) { 85 | return row; 86 | } 87 | } 88 | return -1; 89 | } 90 | 91 | void TaskTableWidget::setDirty() 92 | { 93 | if (dirty) { return; } 94 | dirty = true; 95 | if (!saveTasksTimer->isActive()) { 96 | saveTasksTimer->start(); 97 | } 98 | } 99 | 100 | void TaskTableWidget::save() 101 | { 102 | if (!dirty) { 103 | return; 104 | } 105 | 106 | // brute but works as the number of tasks is assumed to be small (at most a one or two thousand). 107 | QJsonArray array; 108 | for (int row = 0; row < rowCount(); row++) { 109 | auto cell = cellWidget(row); 110 | if (cell->getState() == TaskCellWidget::Finished) { 111 | continue; 112 | } 113 | 114 | auto task = cell->getTask(); 115 | if (qobject_cast(task)) { 116 | // don't save live tasks 117 | continue; 118 | } 119 | array.append(task->toJsonObj()); 120 | } 121 | auto settings = Settings::inst(); 122 | settings->setValue("tasks", QJsonDocument(std::move(array)).toJson(QJsonDocument::Compact)); 123 | 124 | if (activeTaskCnt == 0) { 125 | dirty = false; 126 | saveTasksTimer->stop(); 127 | } 128 | } 129 | 130 | void TaskTableWidget::load() 131 | { 132 | auto settings = Settings::inst(); 133 | auto array = QJsonDocument::fromJson(settings->value("tasks").toByteArray()).array(); 134 | QList tasks; 135 | for (auto obj : array) { 136 | auto task = AbstractDownloadTask::fromJsonObj(obj.toObject()); 137 | if (task != nullptr) { 138 | tasks.append(task); 139 | } 140 | } 141 | addTasks(tasks, false); 142 | } 143 | 144 | void TaskTableWidget::addTasks(const QList &tasks, bool activate) 145 | { 146 | auto shouldSetDirty = false; 147 | auto rowHt = TaskCellWidget::cellHeight(); 148 | for (auto task : tasks) { 149 | auto cell = new TaskCellWidget(task); 150 | int idx = rowCount(); 151 | insertRow(idx); 152 | setRowHeight(idx, rowHt); 153 | setCellWidget(idx, 0, cell); 154 | 155 | connect(cell, &TaskCellWidget::downloadStopped, this, &TaskTableWidget::onCellTaskStopped); 156 | connect(cell, &TaskCellWidget::downloadFinished, this, &TaskTableWidget::onCellTaskFinished); 157 | connect(cell, &TaskCellWidget::startBtnClicked, this, &TaskTableWidget::onCellStartBtnClicked); 158 | connect(cell, &TaskCellWidget::removeBtnClicked, this, &TaskTableWidget::onCellRemoveBtnClicked); 159 | 160 | if (activate) { 161 | if (activeTaskCnt < MaxConcurrentTaskCount) { 162 | activeTaskCnt++; 163 | shouldSetDirty = true; 164 | cell->startDownload(); 165 | } else { 166 | cell->setWaitState(); 167 | } 168 | } 169 | } 170 | 171 | if (shouldSetDirty) { 172 | setDirty(); 173 | } 174 | } 175 | 176 | void TaskTableWidget::stopAll() 177 | { 178 | for (int row = 0; row < rowCount(); row++) { 179 | cellWidget(row)->stopDownload(); 180 | } 181 | activeTaskCnt = 0; 182 | } 183 | 184 | void TaskTableWidget::startAll() 185 | { 186 | auto shouldSetDirty = false; 187 | for (int row = 0; row < rowCount(); row++) { 188 | auto cell = cellWidget(row); 189 | if (cell->getState() != TaskCellWidget::Stopped) { 190 | continue; 191 | } 192 | if (activeTaskCnt < MaxConcurrentTaskCount) { 193 | activeTaskCnt++; 194 | shouldSetDirty = true; 195 | cell->startDownload(); 196 | } else { 197 | cell->setWaitState(); 198 | } 199 | } 200 | if (shouldSetDirty) { 201 | setDirty(); 202 | } 203 | } 204 | 205 | void TaskTableWidget::removeAll() 206 | { 207 | auto rowCnt = rowCount(); 208 | for (int row = 0; row < rowCnt; row++) { 209 | cellWidget(row)->remove(); 210 | } 211 | activeTaskCnt = 0; 212 | for (int row = rowCnt - 1; row >= 0; row--) { 213 | removeRow(row); 214 | } 215 | if (rowCnt != 0) { 216 | setDirty(); 217 | } 218 | } 219 | 220 | void TaskTableWidget::activateWaitingTasks() 221 | { 222 | auto rowCnt = rowCount(); 223 | for (int row = 0; activeTaskCnt < MaxConcurrentTaskCount && row < rowCnt; row++) { 224 | auto cell = cellWidget(row); 225 | if (cell->getState() == TaskCellWidget::Waiting) { 226 | activeTaskCnt++; 227 | cell->startDownload(); 228 | } 229 | } 230 | } 231 | 232 | void TaskTableWidget::onCellTaskStopped() 233 | { 234 | activeTaskCnt--; 235 | activateWaitingTasks(); 236 | } 237 | 238 | void TaskTableWidget::onCellTaskFinished() 239 | { 240 | auto cell = static_cast(sender()); 241 | QTimer::singleShot(3000, this, [=]{ 242 | removeRow(rowOfCell(cell)); 243 | }); 244 | activeTaskCnt--; 245 | activateWaitingTasks(); 246 | setDirty(); 247 | } 248 | 249 | void TaskTableWidget::onCellStartBtnClicked() 250 | { 251 | auto cell = static_cast(sender()); 252 | if (activeTaskCnt < MaxConcurrentTaskCount) { 253 | activeTaskCnt++; 254 | cell->startDownload(); 255 | } else { 256 | cell->setWaitState(); 257 | } 258 | 259 | setDirty(); 260 | } 261 | 262 | void TaskTableWidget::onCellRemoveBtnClicked() 263 | { 264 | auto cell = static_cast(sender()); 265 | removeRow(rowOfCell(cell)); 266 | setDirty(); 267 | } 268 | 269 | 270 | 271 | int TaskCellWidget::cellHeight() 272 | { 273 | auto lineSpacing = QFontMetrics(QApplication::font()).lineSpacing(); 274 | auto style = QApplication::style(); 275 | auto layoutTopMargin = style->pixelMetric(QStyle::PM_LayoutTopMargin); 276 | auto layoutBtmMargin = style->pixelMetric(QStyle::PM_LayoutBottomMargin); 277 | auto layoutSpacing = style->pixelMetric(QStyle::PM_LayoutVerticalSpacing); 278 | return lineSpacing * 2 + layoutTopMargin + layoutSpacing + layoutBtmMargin + 2; 279 | } 280 | 281 | 282 | TaskCellWidget::~TaskCellWidget() 283 | { 284 | delete task; 285 | } 286 | 287 | static void flattenPushButton(QPushButton *btn) 288 | { 289 | btn->setCursor(Qt::PointingHandCursor); 290 | btn->setFlat(true); 291 | btn->setStyleSheet("QPushButton:pressed{border:none; }"); 292 | } 293 | 294 | TaskCellWidget::TaskCellWidget(AbstractDownloadTask *task, QWidget *parent) 295 | : QWidget(parent), task(task) 296 | { 297 | auto fm = fontMetrics(); 298 | auto lineSpacing = fontMetrics().lineSpacing(); 299 | auto mainLayout = new QHBoxLayout(this); 300 | 301 | iconButton = new QPushButton; 302 | iconButton->setFixedSize(32, 32); 303 | iconButton->setIconSize(QSize(32, 32)); 304 | iconButton->setToolTip("打开"); 305 | if (qobject_cast(task)) { 306 | iconButton->setIcon(QIcon(":/icons/manga.svg")); 307 | } else { 308 | iconButton->setIcon(QIcon(":/icons/video.svg")); 309 | } 310 | flattenPushButton(iconButton); 311 | mainLayout->addWidget(iconButton); 312 | 313 | auto leftVLayout = new QVBoxLayout; 314 | titleLabel = new ElidedTextLabel; 315 | titleLabel->setText(task->getTitle()); 316 | // titleLabel->setHintWidthToString("魔卡少女樱 Clear Card篇 第01话 小樱与透明卡牌"); 317 | auto layoutUnderTitle = new QHBoxLayout; 318 | qnDescLabel = new QLabel; 319 | qnDescLabel->setEnabled(false); // set gray color 320 | progressLabel = new QLabel; 321 | progressLabel->setEnabled(false); // set gray color 322 | layoutUnderTitle->addWidget(qnDescLabel, 1); 323 | layoutUnderTitle->addWidget(progressLabel, 2); 324 | leftVLayout->addWidget(titleLabel); 325 | leftVLayout->addLayout(layoutUnderTitle); 326 | mainLayout->addLayout(leftVLayout, 1); 327 | 328 | auto centerVLayout = new QVBoxLayout; 329 | auto centerWidgetsSize = QSize(fm.horizontalAdvance("00:00:00++++123.45KB/s"), lineSpacing); 330 | progressBar = new QProgressBar; 331 | progressBar->setFixedSize(centerWidgetsSize); 332 | progressBar->setAlignment(Qt::AlignCenter); 333 | centerVLayout->addWidget(progressBar); 334 | 335 | statusStackedWidget = new QStackedWidget; 336 | statusStackedWidget->setFixedSize(centerWidgetsSize); 337 | statusTextLabel = new ElidedTextLabel("暂停中..."); 338 | statusTextLabel->setEnabled(false); // set gray color as default color 339 | timeLeftLabel = new QLabel; 340 | timeLeftLabel->setEnabled(false); // set gray color 341 | downRateLabel = new QLabel; 342 | downRateLabel->setToolTip("下载速度"); 343 | downRateLabel->setEnabled(false); // set gray color 344 | downloadStatsWidget = new QWidget; 345 | auto hLayout = new QHBoxLayout(downloadStatsWidget); 346 | hLayout->setContentsMargins(0, 0, 0, 0); 347 | hLayout->addWidget(statusTextLabel); 348 | hLayout->addWidget(timeLeftLabel); 349 | hLayout->addStretch(1); 350 | hLayout->addWidget(downRateLabel); 351 | statusStackedWidget->addWidget(statusTextLabel); 352 | statusStackedWidget->addWidget(downloadStatsWidget); 353 | centerVLayout->addWidget(statusStackedWidget); 354 | mainLayout->addLayout(centerVLayout); 355 | mainLayout->addSpacing(20); 356 | 357 | startStopButton = new QPushButton; 358 | flattenPushButton(startStopButton); 359 | removeButton = new QPushButton; 360 | removeButton->setToolTip("删除"); 361 | removeButton->setIcon(QIcon(":/icons/remove.svg")); 362 | flattenPushButton(removeButton); 363 | mainLayout->addWidget(startStopButton); 364 | mainLayout->addWidget(removeButton); 365 | 366 | updateStartStopBtn(); 367 | initProgressWidgets(); 368 | qnDescLabel->setText(task->getQnDescription()); 369 | 370 | if (qobject_cast(task)) { 371 | timeLeftLabel->setToolTip("已下载时长"); 372 | } else { 373 | timeLeftLabel->setToolTip("剩余时间"); 374 | } 375 | 376 | downRateTimer = new QTimer(this); 377 | downRateTimer->setInterval(DownRateTimerInterval); 378 | downRateTimer->setSingleShot(false); 379 | downRateWindow.reserve(DownRateWindowLength); 380 | 381 | connect(iconButton, &QAbstractButton::clicked, this, &TaskCellWidget::open); 382 | 383 | connect(task, &AbstractDownloadTask::errorOccurred, this, &TaskCellWidget::onErrorOccurred); 384 | 385 | connect(task, &AbstractDownloadTask::getUrlInfoFinished, this, [this]{ 386 | initProgressWidgets(); 387 | // titleLabel->setText(this->task->getTitle()); 388 | qnDescLabel->setText(this->task->getQnDescription()); 389 | startCalcDownRate(); 390 | }); 391 | 392 | connect(task, &AbstractDownloadTask::downloadFinished, this, &TaskCellWidget::onFinished); 393 | 394 | connect(startStopButton, &QAbstractButton::clicked, this, &TaskCellWidget::startStopBtnClicked); 395 | 396 | connect(removeButton, &QAbstractButton::clicked, this, [this]{ 397 | remove(); 398 | emit removeBtnClicked(); 399 | }); 400 | 401 | connect(downRateTimer, &QTimer::timeout, this, &TaskCellWidget::updateDownloadStats); 402 | } 403 | 404 | void TaskCellWidget::onErrorOccurred(const QString &errStr) 405 | { 406 | statusTextLabel->setErrText(errStr); 407 | state = State::Stopped; 408 | updateStartStopBtn(); 409 | emit downloadStopped(); 410 | } 411 | 412 | void TaskCellWidget::onFinished() 413 | { 414 | state = State::Finished; 415 | statusTextLabel->setText("已完成"); 416 | startStopButton->setEnabled(false); 417 | removeButton->setEnabled(false); 418 | stopCalcDownRate(); 419 | emit downloadFinished(); 420 | } 421 | 422 | void TaskCellWidget::open() 423 | { 424 | auto path = task->getPath(); 425 | if (QFileInfo::exists(path)) { 426 | QDesktopServices::openUrl(QUrl::fromLocalFile(path)); 427 | } 428 | } 429 | 430 | void TaskCellWidget::startCalcDownRate() 431 | { 432 | downRateWindow.append(task->getDownloadedBytesCnt()); 433 | downRateTimer->start(); 434 | statusStackedWidget->setCurrentWidget(downloadStatsWidget); 435 | } 436 | 437 | void TaskCellWidget::stopCalcDownRate() 438 | { 439 | downRateTimer->stop(); 440 | downRateWindow.clear(); 441 | downRateLabel->clear(); 442 | timeLeftLabel->clear(); 443 | updateProgressWidgets(); 444 | statusStackedWidget->setCurrentWidget(statusTextLabel); 445 | } 446 | 447 | void TaskCellWidget::initProgressWidgets() 448 | { 449 | if (qobject_cast(task)) { 450 | progressBar->reset(); 451 | } else { 452 | progressBar->setRange(0, 100); 453 | updateProgressWidgets(); 454 | } 455 | } 456 | 457 | void TaskCellWidget::updateProgressWidgets() 458 | { 459 | progressLabel->setText(task->getProgressStr()); 460 | auto progress = task->getProgress(); 461 | if (progress >= 0) { 462 | progressBar->setValue(static_cast(progress * 100)); 463 | } 464 | } 465 | 466 | void TaskCellWidget::updateDownloadStats() 467 | { 468 | qint64 downloadedBytes = task->getDownloadedBytesCnt(); 469 | qint64 bytes = downloadedBytes - downRateWindow.first(); 470 | if (bytes < 0) { 471 | bytes = 0; 472 | downRateWindow.clear(); 473 | } 474 | double seconds = downRateWindow.size() * ((double)DownRateTimerInterval / 1000.0); 475 | qint64 downBytesPerSec = static_cast(static_cast(bytes) / seconds); 476 | downRateLabel->setText(Utils::formattedDataSize(downBytesPerSec) + "/s"); 477 | 478 | if (downRateWindow.size() == DownRateWindowLength) { 479 | downRateWindow.removeFirst(); 480 | } 481 | downRateWindow.append(downloadedBytes); 482 | 483 | auto infTime = "--:--:--"; 484 | auto secs = task->estimateRemainingSeconds(downBytesPerSec); 485 | // secs > 99 * 3600 486 | timeLeftLabel->setText(secs < 0 ? infTime : Utils::secs2HmsStr(secs)); 487 | 488 | updateProgressWidgets(); 489 | } 490 | 491 | void TaskCellWidget::updateStartStopBtn() 492 | { 493 | if (state == State::Waiting || state == State::Downloading) { 494 | startStopButton->setIcon(style()->standardIcon(QStyle::SP_MediaPause)); 495 | startStopButton->setToolTip("暂停"); 496 | } else { 497 | startStopButton->setIcon(style()->standardIcon(QStyle::SP_MediaPlay)); 498 | startStopButton->setToolTip("开始"); 499 | } 500 | } 501 | 502 | void TaskCellWidget::startStopBtnClicked() 503 | { 504 | switch (state) { 505 | case State::Stopped: 506 | emit startBtnClicked(); 507 | break; 508 | case State::Waiting: 509 | stopDownload(); 510 | break; 511 | case State::Downloading: 512 | stopDownload(); 513 | emit downloadStopped(); 514 | break; 515 | default: 516 | break; 517 | } 518 | } 519 | 520 | void TaskCellWidget::stopDownload() 521 | { 522 | if (state == State::Stopped || state == State::Finished) { 523 | return; 524 | } 525 | statusTextLabel->setText("暂停中"); 526 | if (state == State::Downloading) { 527 | task->stopDownload(); 528 | stopCalcDownRate(); 529 | } 530 | state = State::Stopped; 531 | updateStartStopBtn(); 532 | } 533 | 534 | void TaskCellWidget::remove() 535 | { 536 | task->stopDownload(); 537 | task->removeFile(); 538 | } 539 | 540 | void TaskCellWidget::setWaitState() 541 | { 542 | if (state != State::Stopped) { 543 | return; 544 | } 545 | statusTextLabel->setText("等待下载"); 546 | state = State::Waiting; 547 | updateStartStopBtn(); 548 | } 549 | 550 | void TaskCellWidget::startDownload() 551 | { 552 | if (state != State::Stopped && state != State::Waiting) { 553 | return; 554 | } 555 | statusTextLabel->setText("请求中"); 556 | state = State::Downloading; 557 | task->startDownload(); 558 | updateStartStopBtn(); 559 | } 560 | -------------------------------------------------------------------------------- /B23Downloader/TaskTable.h: -------------------------------------------------------------------------------- 1 | #ifndef TASKTABLE_H 2 | #define TASKTABLE_H 3 | 4 | #include 5 | 6 | class QTimer; 7 | class QLabel; 8 | class QMenu; 9 | class QProgressBar; 10 | class QToolButton; 11 | class QPushButton; 12 | class QStackedWidget; 13 | 14 | class AbstractDownloadTask; 15 | class ElidedTextLabel; 16 | class TaskCellWidget; 17 | 18 | class TaskTableWidget : public QTableWidget 19 | { 20 | Q_OBJECT 21 | 22 | public: 23 | explicit TaskTableWidget(QWidget *parent = nullptr); 24 | 25 | void save(); 26 | void load(); 27 | void addTasks(const QList &, bool activate = true); 28 | 29 | void stopAll(); 30 | void startAll(); 31 | void removeAll(); 32 | 33 | private slots: 34 | void onCellTaskStopped(); 35 | void onCellTaskFinished(); 36 | void onCellStartBtnClicked(); 37 | void onCellRemoveBtnClicked(); 38 | 39 | protected: 40 | void contextMenuEvent(QContextMenuEvent *event) override; 41 | 42 | private: 43 | TaskCellWidget *cellWidget(int row) const; 44 | int rowOfCell(TaskCellWidget *cell) const; 45 | 46 | QAction *startAllAct; 47 | QAction *stopAllAct; 48 | QAction *removeAllAct; 49 | 50 | bool dirty = false; 51 | QTimer *saveTasksTimer; 52 | void setDirty(); 53 | 54 | int activeTaskCnt = 0; 55 | void activateWaitingTasks(); 56 | }; 57 | 58 | 59 | 60 | class TaskCellWidget : public QWidget 61 | { 62 | Q_OBJECT 63 | 64 | public: 65 | enum State { Stopped, Waiting, Downloading, Finished }; 66 | 67 | private: 68 | State state = Stopped; 69 | AbstractDownloadTask *task = nullptr; 70 | 71 | public: 72 | TaskCellWidget(AbstractDownloadTask *task, QWidget *parent = nullptr); 73 | ~TaskCellWidget(); 74 | static int cellHeight(); 75 | 76 | const AbstractDownloadTask *getTask() const { return task; } 77 | State getState() const { return state; } 78 | void setState(State); 79 | 80 | void setWaitState(); 81 | void startDownload(); 82 | void stopDownload(); // not emit stopped signal 83 | void remove(); 84 | 85 | signals: 86 | // state changed from Downloading to Stopped (caused by error or stop button) 87 | void downloadStopped(); 88 | void downloadFinished(); 89 | void startBtnClicked(); 90 | void removeBtnClicked(); 91 | 92 | private slots: 93 | void onErrorOccurred(const QString &errStr); 94 | void onFinished(); 95 | void open(); 96 | 97 | private: 98 | QPushButton *iconButton; 99 | ElidedTextLabel *titleLabel; 100 | QLabel *qnDescLabel; 101 | QLabel *progressLabel; 102 | 103 | QProgressBar *progressBar; 104 | 105 | QLabel *downRateLabel; 106 | QLabel *timeLeftLabel; 107 | ElidedTextLabel *statusTextLabel; 108 | QWidget *downloadStatsWidget; 109 | QStackedWidget *statusStackedWidget; 110 | 111 | QPushButton *startStopButton; 112 | QPushButton *removeButton; 113 | 114 | QTimer *downRateTimer; 115 | QList downRateWindow; 116 | 117 | void initProgressWidgets(); 118 | void updateDownloadStats(); 119 | 120 | void updateProgressWidgets(); 121 | void updateStartStopBtn(); 122 | void startStopBtnClicked(); 123 | 124 | void startCalcDownRate(); 125 | void stopCalcDownRate(); 126 | }; 127 | 128 | #endif // TASKTABLE_H 129 | -------------------------------------------------------------------------------- /B23Downloader/icons.qrc: -------------------------------------------------------------------------------- 1 | 2 | 3 | icons/refresh.png 4 | icons/folder.svg 5 | icons/logout.svg 6 | icons/akkarin.png 7 | icons/remove.svg 8 | icons/manga.svg 9 | icons/video.svg 10 | icons/about.svg 11 | icons/download.svg 12 | icons/icon-96x96.png 13 | 14 | 15 | -------------------------------------------------------------------------------- /B23Downloader/icons/about.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/icons/akkarin.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/B23Downloader/icons/akkarin.png -------------------------------------------------------------------------------- /B23Downloader/icons/download.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/icons/folder.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/icons/icon-96x96.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/B23Downloader/icons/icon-96x96.png -------------------------------------------------------------------------------- /B23Downloader/icons/logout.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/icons/manga.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/icons/refresh.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/B23Downloader/icons/refresh.png -------------------------------------------------------------------------------- /B23Downloader/icons/remove.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/icons/video.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /B23Downloader/main.cpp: -------------------------------------------------------------------------------- 1 | #include "MainWindow.h" 2 | #include 3 | #include 4 | 5 | #ifdef Q_OS_WIN // ensure single application instance at Windows 6 | 7 | #include 8 | 9 | void raiseWindow(const HWND hWnd) 10 | { 11 | WINDOWPLACEMENT placement; 12 | GetWindowPlacement(hWnd, &placement); 13 | if (placement.showCmd == SW_SHOWMINIMIZED) { 14 | ShowWindow(hWnd, SW_RESTORE); 15 | } else { 16 | SetForegroundWindow(hWnd); 17 | } 18 | } 19 | 20 | int main(int argc, char *argv[]) 21 | { 22 | QApplication a(argc, argv); 23 | 24 | QSharedMemory sharedMem("B23Dld-HWND"); 25 | 26 | auto setHwnd = [&sharedMem](HWND hWnd) { 27 | sharedMem.lock(); 28 | auto ptr = static_cast(sharedMem.data()); 29 | *ptr = hWnd; 30 | sharedMem.unlock(); 31 | }; 32 | 33 | auto getHwnd = [&sharedMem]() -> HWND { 34 | sharedMem.attach(QSharedMemory::ReadOnly); 35 | sharedMem.lock(); 36 | HWND hWnd = *static_cast(sharedMem.constData()); 37 | sharedMem.unlock(); 38 | return hWnd; 39 | }; 40 | 41 | bool isNoAppAlreadyExist = sharedMem.create(sizeof(HWND)); 42 | if (isNoAppAlreadyExist) { 43 | MainWindow w; 44 | setHwnd((HWND)w.winId()); 45 | w.show(); 46 | return a.exec(); 47 | } else { 48 | raiseWindow(getHwnd()); 49 | return 0; 50 | } 51 | } 52 | 53 | #else // non-Windows platform 54 | int main(int argc, char *argv[]) 55 | { 56 | QApplication a(argc, argv); 57 | MainWindow w; 58 | w.show(); 59 | return a.exec(); 60 | } 61 | #endif 62 | -------------------------------------------------------------------------------- /B23Downloader/utils.cpp: -------------------------------------------------------------------------------- 1 | // Created by voidzero 2 | 3 | #include "utils.h" 4 | #include 5 | #include 6 | 7 | void ElidedTextLabel::paintEvent(QPaintEvent *event) 8 | { 9 | Q_UNUSED(event) 10 | QPainter painter(this); 11 | if (this->color.isValid()) { 12 | auto pen = painter.pen(); 13 | pen.setColor(this->color); 14 | painter.setPen(pen); 15 | } 16 | auto fm = fontMetrics(); 17 | auto elidedText = fm.elidedText(text(), elideMode, width()); 18 | painter.drawText(rect(), static_cast(alignment()), elidedText); 19 | } 20 | 21 | bool ElidedTextLabel::event(QEvent *e) 22 | { 23 | if (e->type() == QEvent::ToolTip) { 24 | auto helpEvent = static_cast(e); 25 | auto displayedText = fontMetrics().elidedText(text(), Qt::ElideRight, width()); 26 | if (helpEvent->x() <= fontMetrics().horizontalAdvance(displayedText)) { 27 | return QLabel::event(e); 28 | } else { 29 | return true; 30 | } 31 | } else { 32 | return QLabel::event(e); 33 | } 34 | } 35 | 36 | QSize ElidedTextLabel::minimumSizeHint() const 37 | { 38 | if (hintWidth == 0) { 39 | return QLabel::minimumSizeHint(); 40 | } else { 41 | return QSize(hintWidth, QLabel::minimumSizeHint().height()); 42 | } 43 | } 44 | 45 | QSize ElidedTextLabel::sizeHint() const 46 | { 47 | if (hintWidth == 0) { 48 | return QLabel::minimumSizeHint(); 49 | } else { 50 | return QSize(hintWidth, QLabel::minimumSizeHint().height()); 51 | } 52 | } 53 | 54 | ElidedTextLabel::ElidedTextLabel(QWidget *parent) 55 | : QLabel(parent) {} 56 | 57 | ElidedTextLabel::ElidedTextLabel(const QString &text, QWidget *parent) 58 | : QLabel(text, parent) 59 | { 60 | setToolTip(text); 61 | } 62 | 63 | void ElidedTextLabel::setElideMode(Qt::TextElideMode mode) 64 | { 65 | elideMode = mode; 66 | } 67 | 68 | void ElidedTextLabel::setHintWidthToString(const QString &sample) 69 | { 70 | hintWidth = fontMetrics().horizontalAdvance(sample); 71 | } 72 | 73 | void ElidedTextLabel::setFixedWidthToString(const QString &sample) 74 | { 75 | setFixedWidth(fontMetrics().horizontalAdvance(sample)); 76 | } 77 | 78 | void ElidedTextLabel::clear() 79 | { 80 | this->color = QColor(); 81 | QLabel::clear(); 82 | } 83 | 84 | void ElidedTextLabel::setText(const QString &str, const QColor &color) 85 | { 86 | QLabel::setText(str); 87 | this->color = color; 88 | setToolTip(str); 89 | } 90 | 91 | void ElidedTextLabel::setErrText(const QString &str) 92 | { 93 | QLabel::setText(str); 94 | this->color = Qt::red; 95 | setToolTip(str); 96 | } 97 | 98 | 99 | 100 | QString Utils::fileExtension(const QString &fileName) 101 | { 102 | auto dotPos = fileName.lastIndexOf('.'); 103 | if (dotPos < 0) { 104 | return QString(); 105 | } 106 | return fileName.sliced(dotPos); 107 | } 108 | 109 | int Utils::numberOfDigit(int num) 110 | { 111 | if (num == 0) { 112 | return 1; 113 | } 114 | int ret = 0; 115 | while (num != 0) { 116 | num /= 10; 117 | ret++; 118 | } 119 | return ret; 120 | } 121 | 122 | QString Utils::paddedNum(int num, int width) 123 | { 124 | auto s = QString::number(num); 125 | auto padWidth = width - s.size(); 126 | s.prepend(QString(padWidth, '0')); 127 | return s; 128 | } 129 | 130 | QString Utils::legalizedFileName(QString title) 131 | { 132 | return title.simplified() 133 | .replace('\\', u'\').replace('/', u'/').replace(':', u':') 134 | .replace('*', u'*').replace('?', u'?').replace('"', u'“') 135 | .replace('<', u'<').replace('>', u'>').replace('|', u'|'); 136 | // 整个路径的合法性检查可以参考 https://stackoverflow.com/q/62771 137 | } 138 | 139 | std::tuple Utils::secs2HMS(int secs) 140 | { 141 | auto s = secs % 60; 142 | auto mins = secs / 60; 143 | auto m = mins % 60; 144 | auto h = mins / 60; 145 | return { h, m, s }; 146 | } 147 | 148 | QString Utils::secs2HmsStr(int secs) 149 | { 150 | auto [h, m, s] = secs2HMS(secs); 151 | QLatin1Char fillChar('0'); 152 | return QStringLiteral("%1:%2:%3") 153 | .arg(h, 2, 10, fillChar) 154 | .arg(m, 2, 10, fillChar) 155 | .arg(s, 2, 10, fillChar); 156 | } 157 | 158 | QString Utils::secs2HmsLocaleStr(int secs) 159 | { 160 | auto [h, m, s] = secs2HMS(secs); 161 | QString ret; 162 | int fieldWidth = 1; 163 | QLatin1Char fillChar('0'); 164 | if (h != 0) { 165 | ret.append(QString::number(h) + "小时"); 166 | fieldWidth = 2; 167 | } 168 | if (m != 0) { 169 | ret.append(QStringLiteral("%1分").arg(m, fieldWidth, 10, fillChar)); 170 | fieldWidth = 2; 171 | } 172 | ret.append(QStringLiteral("%1秒").arg(s, fieldWidth, 10, fillChar)); 173 | return ret; 174 | } 175 | 176 | QString Utils::formattedDataSize(qint64 bytes) 177 | { 178 | constexpr qint64 Kilo = 1024; 179 | constexpr qint64 Mega = 1024 * 1024; 180 | constexpr qint64 Giga = 1024 * 1024 * 1024; 181 | 182 | if (bytes > Giga) { 183 | return QString::number(static_cast(bytes) / Giga, 'f', 2) + " GB"; 184 | } else if (bytes > Mega) { 185 | return QString::number(static_cast(bytes) / Mega, 'f', 2) + " MB"; 186 | } else if (bytes > Kilo) { 187 | return QString::number(static_cast(bytes) / Kilo, 'f', 1) + " KB"; 188 | } else if (bytes >= 0) { 189 | return QString::number(bytes) + " B"; 190 | } else { 191 | return "NaN"; 192 | } 193 | } 194 | -------------------------------------------------------------------------------- /B23Downloader/utils.h: -------------------------------------------------------------------------------- 1 | #ifndef UTILS_H 2 | #define UTILS_H 3 | 4 | #include 5 | 6 | // instances should set fixed/hint/max width to work 7 | class ElidedTextLabel : public QLabel 8 | { 9 | Q_OBJECT 10 | 11 | QColor color; 12 | int hintWidth = 0; 13 | Qt::TextElideMode elideMode = Qt::ElideRight; 14 | 15 | public: 16 | ElidedTextLabel(QWidget *parent = nullptr); 17 | ElidedTextLabel(const QString &text, QWidget *parent = nullptr); 18 | 19 | void setElideMode(Qt::TextElideMode mode); 20 | void setHintWidthToString(const QString &sample); 21 | void setFixedWidthToString(const QString &sample); 22 | 23 | // clear color and text 24 | void clear(); 25 | 26 | // set text and text color; toolTip is set to be same as text 27 | // if arg 'color' is invalid (default val), text color is set to default (black) 28 | void setText(const QString &str, const QColor &color = QColor()); 29 | 30 | // the color is set to red 31 | void setErrText(const QString &str); 32 | 33 | protected: 34 | void paintEvent(QPaintEvent *event) override; 35 | QSize minimumSizeHint() const override; 36 | QSize sizeHint() const override; 37 | bool event(QEvent *e) override; 38 | }; 39 | 40 | 41 | 42 | namespace Utils 43 | { 44 | // fileExtension("abc.txt") == ".txt" 45 | // fileExtension("abc") is Null 46 | QString fileExtension(const QString &fileName); 47 | 48 | int numberOfDigit(int num); 49 | 50 | // pad str(num) with leading 0 so that its length is equal to width 51 | QString paddedNum(int num, int width); 52 | 53 | // 用全角字符替换视频标题中的 \/:*?"<>| 从而确保文件名合法 54 | QString legalizedFileName(QString title); 55 | 56 | std::tuple secs2HMS(int secs); 57 | 58 | QString secs2HmsStr(int secs); 59 | 60 | QString secs2HmsLocaleStr(int secs); 61 | 62 | QString formattedDataSize(qint64 bytes); 63 | 64 | } // namespace Utils 65 | 66 | #endif // UTILS_H 67 | -------------------------------------------------------------------------------- /README.assets/FlvParse-LiveSample.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/README.assets/FlvParse-LiveSample.png -------------------------------------------------------------------------------- /README.assets/FlvParse-Normal.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/README.assets/FlvParse-Normal.png -------------------------------------------------------------------------------- /README.assets/download-example-bangumi.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/README.assets/download-example-bangumi.png -------------------------------------------------------------------------------- /README.assets/download-example-live.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/README.assets/download-example-live.png -------------------------------------------------------------------------------- /README.assets/download-example-manga.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/README.assets/download-example-manga.png -------------------------------------------------------------------------------- /README.assets/mainwindow.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/vooidzero/B23Downloader/cc8c4b9c0abfb6423c013ede8e505272c5eeafc4/README.assets/mainwindow.png -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 |

2 |
3 | B23Downloader-icon 4 |
5 | B23Downloader 6 |
7 |

8 | 9 | B23Downloader: 下载B站 视频/直播/漫画 10 | 11 | + 下载链接:[GitHub](https://github.com/vooidzero/B23Downloader/releases) 12 | 13 | + [使用说明](#使用说明) 14 | 15 | + [Build-Issues](#build-issues) 16 | 17 | + [开发日志](#开发日志) 18 | 19 | 20 | 21 | # 使用说明 22 | 23 | ## Main Window 24 | 25 | mainwindow 26 | 27 | 简单,但也够用了。没有历史记录功能。(当然,对于正在下载的任务,关闭程序后再打开还是在的) 28 | 29 |
30 | 31 | ## 下载位置 32 | 33 | ### 视频类 34 | 35 | download-example-bangumi 36 | 37 | 在上图中,选择的下载位置为 **E:/tmp**,那么选中的两个视频分别下载到 38 | 39 | - **E:/tmp/天气之子 原版.flv** 和 40 | - **E:/tmp/天气之子 预告花絮 MV1 爱能做到的还有什么.flv** 41 | 42 | ### 漫画 43 | 44 | download-example-manga 45 | 46 | 如上图,下载位置还是 **E:/tmp**,选中的两项分别下载到文件夹 47 | 48 | - **E:/tmp/恋如雨止 81 第81话/** 和 49 | - **E:/tmp/恋如雨止 82 最终话/** 50 | 51 | 漫画是一页一页下载的,在该示例中,*82 最终话* 将下载为 **E:/tmp/恋如雨止 82 最终话/01.jpg - 32.jpg**(32 张图片)。 52 | 53 | > 目前删除漫画下载任务会粗暴地删除整个文件夹,如示例中的 E:/tmp/82 最终话/ 54 | 55 | ### 直播 56 | 57 | download-example-live 58 | 59 | 上图中,对话框的标题为 *【哔哩哔哩英雄联盟赛事】【直播】HLE vs LNG*,其命名规则为【<用户名>】<房间标题>,示例中用户名为 *哔哩哔哩英雄联盟赛事*,房间标题为 *【直播】HLE vs LNG*。 60 | 61 | 下载文件的命名为 <标题> <下载开始时间>.flv,比如【哔哩哔哩英雄联盟赛事】【直播】HLE vs LNG [2021.10.05] 18.59.22.flv,其所在文件夹为上图中所选的 **E:/tmp/** 62 | 63 | 目前的直播下载任务策略为: 64 | 65 | - 暂停直播下载任务后重新开始,会写入另一个文件,比如 【哔哩哔哩英雄联盟赛事】【直播】HLE vs LNG [2021.10.05] **19.32.11**.flv 66 | - 删除任务不会删除任何相关文件 67 | - 任务不会被保存,即退出程序后再启动,之前的直播下载任务不被保留 68 | 69 | > 如果添加直播下载任务时,正在下载的任务数量超过最大可同时下载任务数(代码里硬编码为 3),那么这个直播下载任务会处于“等待下载”状态。 70 | 71 |
72 | 73 | ## 支持的 URL 输入 74 | 75 | - 用户投稿类视频链接 ,不支持互动视频 76 | - 剧集(番剧,电影等)链接 ,暂不支持活动页链接如[「天气之子」B站正在热播!](https://www.bilibili.com/blackboard/topic/activity-jjR1nNRUF.html) 77 | - 课程类视频链接 78 | - 直播链接 79 | - 直播活动页链接,如 [Aimer线上演唱会 "Walpurgis"](https://live.bilibili.com/blackboard/activity-Aimer0501pc.html) 80 | - 漫画链接,暂不支持 Vomic 81 | - b23.tv 视频短链,b22.top 漫画短链 82 | 83 | 部分类型可以使用编号: 84 | 85 | - 视频 BV 或 av 号,如 ***BV1ab411c7St*** 或 ***av35581924*** 86 | - 剧集 ssid 或 epid,如 ***ss28341*** 或 ***ep281280*** 87 | - live+直播房间号,如 ***live6*** 88 | 89 |
90 | 91 | ## 网络代理 92 | 93 | 暂未实现“设置”功能(以后有时间会加上的),代理跟随系统,你可以设置全局代理来下载地域限制内容(比如代理服务器在香港,那么可以下载“仅限港澳台地区”的动漫)。 94 | 95 |
96 | 97 | # Build-Issues 98 | B23Downloader 使用 Qt 6 (C++ 17) 开发,虽然 Release 只有 Windows 64-bit 的,但你可以在其他桌面平台编译使用。 99 | 100 | 由于所有请求链接均采用 HTTPS,所以依赖 OpenSSL库。在 **Windows** 上,虽然 Qt Installer 可以勾选 OpenSSL Toolkit,但 Qt Installer 并不会设置好相关环境,于是会出现找不到 SSL 库的错误(如 **TLS initialization failed**),解决方法参考 [TLS initialization failed on GET Request - Stack Overflow](https://stackoverflow.com/questions/53805704/tls-initialization-failed-on-get-request/59072649#59072649). 101 | 102 |
103 | 104 | # 开发日志 105 | + **正在考虑代码重构** 106 | 107 | + 2021/10/08 - 2021/10/11 108 |
解决了一个老问题:下载的直播视频文件无法拖动进度条(需要极长时间来完成响应) 109 |

最初我是用 ffmpeg 来下载直播的,那时得到的文件并没有问题。2021 年 05 月,我尝试用 wget 直接下载而不是通过 ffmpeg,发现下载的文件有「无法拖动进度条」的问题,如果用 ffmpeg 处理 (remux) 一下就正常了:ffmpeg -i <raw_file> -c copy <remuxed_file>

110 |

由于不想引入 ffmpeg 依赖,而且 FLV 还算简单,我决定自己实现 FLV remuxing. 111 | 首先就是读 Adobe FLV 文档,挺少的也就 10 页。然后写了些代码解析并打印信息(FlvParse.exe 这小工具有些问题,没解析出 AMF Object,tag header 中 duration 也是错的)。

112 |
FLV Parse Result: Live-SampleFLV Parse Result: Normal
113 | 比对下载的直播原始数据和 B 站常规(非直播)视频 FLV,发现了以下问题:
    114 |
  • 常规 FLV 文件的时间轴是从 0 开始的;而直播流 FLV 时间轴是直播已持续的时间,下载得到的文件时间轴并不是从 0 开始。在把时间轴改为从 0 开始后,PotPlayer 就能正常 seek 了
  • 115 |
  • 常规视频头部的 onMetaData 中有个名为 keyframes 的 Object,包含 filepositions 和 times 两个数组。同时发现:
    • 对于 PotPlayer,FLV 有没有 keyframes 结构基本没有区别(这怎么做到的?!);
    • 对于 VLC,没有 keyframes 的话 seek 会很慢(磁盘开销大,应该是顺序读过去),不过之后再 seek 就很快了(应该是把读过的部分 keyframes 记下来了)。
116 | B站录播姬的做法是在头部留一个 spacer 大数组,其结构是:
  • "keyframes:{ "times":[], "filepositions":[], "spacer":[] }"
117 | 如果关键帧 3 秒一个的话,占用一百多 KB 就能够支撑 5 小时。
118 | 我的实现做了个小优化,把结构改成了:
  • "keyframes:{ "times":[], "spacer1":[], "filepositions":[], "spacer2":[] }"
119 |
参考:Adoebe Flash Video File Format Specification Version 10.1.pdf 120 |
121 | 122 | + 2021/10/02 123 |
在 Windows 上保证只运行一个实例 124 |

位置:main.cpp

125 |

需求:在打开时,如果应用已在运行,则弹出已在运行的应用窗口,新运行的应用退出。

126 |
127 |

通过搜索引擎可以找到 QtSingleApplication, SingleApplication, 以及一些轻量些的解决方法。其中,因为一些资源在 Unix 平台上由 qt 而不是 os 拥有,其在程序崩溃后不会被回收,所以在 Unix 平台时要多一些操作。简单起见,我就不管 Windows 之外的平台了

128 |

要判断应用是否已在运行,可以在执行时尝试创建某种命名的资源,如果返回错误“已被创建”,则说明应用已在运行。可选的有: QSharedMemory, QLockFile, CreateMutexA (WinApi) 等

129 |

弹出已在运行的应用,网上找到的都是用 QLocalServerQLocalSocket 实现的,本质就是进程间通信。在 Windows 上,设置窗口到最前方 (foreground) 是有限制的,即应用不能自己把自己弹到最前面(防止流氓程序)。foreground 程序将另一个程序设置为 foreground 是允许的,这里新运行的那个程序就是 foreground。让新进程获取原进程的 hWnd (handle to a window),如果已最小化就调用 ShowWindow(hWnd, SW_RESTORE),否则调用 SetForegroundWindow(hWnd)

130 |
131 |

最后选择用 QSharedMemory 来实现 HWND 的共享,QSharedMemory::create() 用来判断应用是否已运行。

132 |
133 | 134 |
135 | 136 | > 最后感谢 [SocialSisterYi/bilibili-API-collect: 哔哩哔哩-API收集整理](https://github.com/SocialSisterYi/bilibili-API-collect),虽然 B23Downloader 里用的 API 有很大一部分是我自己后面找的。以后有时间也为这个仓库贡献一下。 137 | 138 | --------------------------------------------------------------------------------