]> git.sur5r.net Git - minitube/blob - src/ytvideo.cpp
New upstream version 3.3
[minitube] / src / ytvideo.cpp
1 #include "ytvideo.h"
2
3 #include "datautils.h"
4 #include "http.h"
5 #include "httputils.h"
6 #include "jsfunctions.h"
7 #include "temporary.h"
8 #include "videodefinition.h"
9 #include "yt3.h"
10
11 #include <QJSEngine>
12 #include <QJSValue>
13 #include <QtNetwork>
14
15 namespace {
16 static const QString jsNameChars = "a-zA-Z0-9\\$_";
17 }
18
19 YTVideo::YTVideo(const QString &videoId, QObject *parent)
20     : QObject(parent), videoId(videoId), definitionCode(0), elIndex(0), ageGate(false),
21       loadingStreamUrl(false) {}
22
23 void YTVideo::loadStreamUrl() {
24     if (loadingStreamUrl) {
25         qDebug() << "Already loading stream URL for" << videoId;
26         return;
27     }
28     loadingStreamUrl = true;
29     elIndex = 0;
30     ageGate = false;
31     webPageLoaded = false;
32
33     // getVideoInfo();
34     loadWebPage();
35 }
36
37 void YTVideo::getVideoInfo() {
38     static const QStringList elTypes = {"&el=embedded", "&el=detailpage", "&el=vevo", ""};
39
40     QUrl url;
41     if (elIndex == elTypes.size()) {
42         qDebug() << "Trying special embedded el param";
43         url = QUrl("https://www.youtube.com/get_video_info");
44         QUrlQuery q;
45         q.addQueryItem("video_id", videoId);
46         q.addQueryItem("el", "embedded");
47         q.addQueryItem("gl", "US");
48         q.addQueryItem("hl", "en");
49         q.addQueryItem("eurl", "https://youtube.googleapis.com/v/" + videoId);
50         q.addQueryItem("asv", "3");
51         q.addQueryItem("sts", "1588");
52         url.setQuery(q);
53     } else if (elIndex > elTypes.size() - 1) {
54         qDebug() << "Cannot get video info";
55         if (!webPageLoaded) {
56             // no video info file, but we can try loading the "urlmap" from the web page
57             loadWebPage();
58         } else {
59             emitError("Cannot get video info");
60         }
61         return;
62     } else {
63         // qDebug() << "Trying el param:" << elTypes.at(elIndex) << elIndex;
64         url = QUrl(QString("https://www.youtube.com/"
65                            "get_video_info?video_id=%1%2&ps=default&eurl=&gl=US&hl=en")
66                            .arg(videoId, elTypes.at(elIndex)));
67     }
68
69     QObject *reply = HttpUtils::yt().get(url);
70     connect(reply, SIGNAL(data(QByteArray)), SLOT(gotVideoInfo(QByteArray)));
71     connect(reply, SIGNAL(error(QString)), SLOT(emitError(QString)));
72
73     // see you in gotVideoInfo...
74 }
75
76 void YTVideo::gotVideoInfo(const QByteArray &bytes) {
77     QString videoInfo = QString::fromUtf8(bytes);
78     // qDebug() << "videoInfo" << videoInfo;
79
80     // get player_response
81     static const QRegExp playerResponseRE("&player_response=([^&]+)");
82     if (playerResponseRE.indexIn(videoInfo) != -1) {
83         QString playerResponse = playerResponseRE.cap(1);
84         QByteArray playerResponseUtf8 = QByteArray::fromPercentEncoding(playerResponse.toUtf8());
85         // qDebug() << "player_response" << playerResponseUtf8;
86         QJsonDocument doc = QJsonDocument::fromJson(playerResponseUtf8);
87         QJsonObject obj = doc.object();
88         if (obj.contains("streamingData")) {
89             auto parseFormats = [this](const QJsonArray &formats) {
90                 for (const QJsonValue &format : formats) {
91                     QJsonObject formatObj = format.toObject();
92                     int itag = formatObj["itag"].toInt();
93                     QString url = formatObj["url"].toString();
94                     if (url.isEmpty()) {
95                         QString cipher = formatObj["cipher"].toString();
96                         QUrlQuery q(cipher);
97                         qDebug() << "Cipher is " << q.toString();
98                         url = q.queryItemValue("url").trimmed();
99                         // while (url.contains('%'))
100                         url = QByteArray::fromPercentEncoding(url.toUtf8());
101                         if (q.hasQueryItem("s")) {
102                             QString s = q.queryItemValue("s");
103                             qDebug() << "s is" << s;
104                             s = decryptSignature(s);
105                             if (!s.isEmpty()) {
106                                 qDebug() << "Added signature" << s;
107                                 url += "&sig=";
108                                 url += s;
109                             }
110                         }
111                     }
112                     // qDebug() << "player_response format" << itag << url;
113                     if (!url.isEmpty()) urlMap.insert(itag, url);
114                 }
115             };
116             QJsonObject streamingDataObj = obj["streamingData"].toObject();
117             // qDebug() << "Found streamingData" << streamingDataObj;
118             parseFormats(streamingDataObj["formats"].toArray());
119             parseFormats(streamingDataObj["adaptiveFormats"].toArray());
120         }
121     }
122
123     /*
124     // get video token
125     static const QRegExp videoTokeRE(JsFunctions::instance()->videoTokenRE());
126     if (videoTokeRE.indexIn(videoInfo) == -1) {
127         qDebug() << "Cannot get token. Trying next el param" << videoTokeRE.pattern() << videoInfo;
128         // Don't panic! We're gonna try another magic "el" param
129         elIndex++;
130         getVideoInfo();
131         return;
132     }
133
134     QString videoToken = videoTokeRE.cap(1);
135     while (videoToken.contains('%'))
136         videoToken = QByteArray::fromPercentEncoding(videoToken.toLatin1());
137     qDebug() << "videoToken" << videoToken;
138     this->videoToken = videoToken;
139
140     // get fmt_url_map
141     static const QRegExp fmtMapRE(JsFunctions::instance()->videoInfoFmtMapRE());
142     if (fmtMapRE.indexIn(videoInfo) == -1) {
143         qDebug() << "Cannot get urlMap. Trying next el param";
144         // Don't panic! We're gonna try another magic "el" param
145         elIndex++;
146         getVideoInfo();
147         return;
148     }
149     QString fmtUrlMap = fmtMapRE.cap(1);
150     // qDebug() << "got fmtUrlMap" << fmtUrlMap;
151     fmtUrlMap = QByteArray::fromPercentEncoding(fmtUrlMap.toUtf8());
152 */
153
154     if (urlMap.isEmpty()) {
155         elIndex++;
156         getVideoInfo();
157         return;
158     }
159
160     qDebug() << "Got token and urlMap" << elIndex << videoToken << fmtUrlMap;
161     parseFmtUrlMap(fmtUrlMap);
162 }
163
164 void YTVideo::parseFmtUrlMap(const QString &fmtUrlMap) {
165     int videoFormat = 0;
166     const VideoDefinition &definition = YT3::instance().maxVideoDefinition();
167
168     // qDebug() << "fmtUrlMap" << fmtUrlMap;
169     const QVector<QStringRef> formatUrls = fmtUrlMap.splitRef(',', QString::SkipEmptyParts);
170
171     for (const QStringRef &formatUrl : formatUrls) {
172         // qDebug() << "formatUrl" << formatUrl;
173         const QVector<QStringRef> urlParams = formatUrl.split('&', QString::SkipEmptyParts);
174         // qDebug() << "urlParams" << urlParams;
175
176         int format = -1;
177         QString url;
178         QString sig;
179         QStringRef sp;
180         for (const QStringRef &urlParam : urlParams) {
181             qDebug() << "urlParam" << urlParam;
182             if (sp.isNull() && urlParam.startsWith(QLatin1String("sp"))) {
183                 int separator = urlParam.indexOf('=');
184                 sp = urlParam.mid(separator + 1);
185             }
186             if (urlParam.startsWith(QLatin1String("itag="))) {
187                 int separator = urlParam.indexOf('=');
188                 format = urlParam.mid(separator + 1).toInt();
189             } else if (urlParam.startsWith(QLatin1String("url="))) {
190                 int separator = urlParam.indexOf('=');
191                 url = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
192             } else if (urlParam.startsWith(QLatin1String("sig="))) {
193                 int separator = urlParam.indexOf('=');
194                 sig = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
195             } else if (urlParam.startsWith(QLatin1String("s="))) {
196                 if (webPageLoaded || ageGate) {
197                     int separator = urlParam.indexOf('=');
198                     sig = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
199                     sig = decryptSignature(sig);
200                     if (sig.isEmpty()) sig = JsFunctions::instance()->decryptSignature(sig);
201                     if (sig.isEmpty()) qWarning() << "Empty signature";
202                 } else {
203                     loadWebPage();
204                     return;
205                 }
206             }
207         }
208         if (format == -1 || url.isNull()) continue;
209
210         if (!sig.isEmpty()) {
211             if (sp.isEmpty())
212                 url += QLatin1String("&signature=") + sig;
213             else
214                 url += '&' + sp + '=' + sig;
215         }
216
217         if (!url.contains(QLatin1String("ratebypass"))) url += QLatin1String("&ratebypass=yes");
218
219         qDebug() << format;
220         if (format == definition.getCode()) {
221             qDebug() << "Found format" << format;
222             if (definition.hasAudio()) {
223                 // we found the exact match with an audio/video stream
224                 saveDefinitionForUrl(url, definition);
225                 return;
226             }
227             videoFormat = format;
228         }
229         urlMap.insert(format, url);
230     }
231
232     if (!webPageLoaded && !ageGate) {
233         loadWebPage();
234         return;
235     }
236
237     if (videoFormat != 0) {
238         // exact match with video stream was found
239         const VideoDefinition &definition = VideoDefinition::forCode(videoFormat);
240         saveDefinitionForUrl(urlMap.value(videoFormat), definition);
241         return;
242     }
243
244     qDebug() << "available formats" << urlMap.keys();
245     const QVector<VideoDefinition> &definitions = VideoDefinition::getDefinitions();
246     int previousIndex = std::max(definitions.indexOf(definition) - 1, 0);
247     for (; previousIndex >= 0; previousIndex--) {
248         const VideoDefinition &previousDefinition = definitions.at(previousIndex);
249         qDebug() << "Testing format" << previousDefinition.getCode();
250         if (urlMap.contains(previousDefinition.getCode())) {
251             qDebug() << "Found format" << previousDefinition.getCode();
252             saveDefinitionForUrl(urlMap.value(previousDefinition.getCode()), previousDefinition);
253             return;
254         }
255     }
256
257     emit errorStreamUrl(tr("Cannot get video stream for %1").arg(videoId));
258 }
259
260 void YTVideo::loadWebPage() {
261     QUrl url("https://www.youtube.com/watch");
262     QUrlQuery q;
263     q.addQueryItem("v", videoId);
264     q.addQueryItem("gl", "US");
265     q.addQueryItem("hl", "en");
266     q.addQueryItem("has_verified", "1");
267     q.addQueryItem("bpctr", "9999999999");
268     url.setQuery(q);
269
270     // QUrl url("https://www.youtube.com/embed/" + videoId);
271
272     qDebug() << "Loading webpage" << url;
273     QObject *reply = HttpUtils::yt().get(url);
274     connect(reply, SIGNAL(data(QByteArray)), SLOT(scrapeWebPage(QByteArray)));
275     connect(reply, SIGNAL(error(QString)), SLOT(emitError(QString)));
276     // see you in scrapWebPage(QByteArray)
277 }
278
279 void YTVideo::emitError(const QString &message) {
280     qWarning() << message;
281     emit errorStreamUrl(message);
282 }
283
284 void YTVideo::scrapeWebPage(const QByteArray &bytes) {
285     webPageLoaded = true;
286
287     const QString html = QString::fromUtf8(bytes);
288     // qDebug() << "scrapeWebPage" << html;
289
290     static const QRegExp ageGateRE(JsFunctions::instance()->ageGateRE());
291     if (ageGateRE.indexIn(html) != -1) {
292         qDebug() << "Found ageGate";
293         ageGate = true;
294         elIndex = 4;
295         getVideoInfo();
296         return;
297     }
298
299     // "\"url_encoded_fmt_stream_map\":\s*\"([^\"]+)\""
300     static const QRegExp fmtMapRE(JsFunctions::instance()->webPageFmtMapRE());
301     if (fmtMapRE.indexIn(html) != -1) {
302         fmtUrlMap = fmtMapRE.cap(1);
303         fmtUrlMap.replace("\\u0026", "&");
304     }
305
306     QRegExp adaptiveFormatsRE("\"adaptive_fmts\":\\s*\"([^\"]+)\"");
307     if (adaptiveFormatsRE.indexIn(html) != -1) {
308         qDebug() << "Found adaptive_fmts";
309         if (!fmtUrlMap.isEmpty()) fmtUrlMap += ',';
310         fmtUrlMap += adaptiveFormatsRE.cap(1).replace("\\u0026", "&");
311     }
312
313     if (fmtUrlMap.isEmpty() && urlMap.isEmpty()) {
314         qWarning() << "Cannot get fmtUrlMap from video page. Trying next el";
315         // elIndex++;
316         // getVideoInfo();
317         // return;
318     }
319
320     static const QRegExp jsPlayerRe(JsFunctions::instance()->jsPlayerRE());
321     if (jsPlayerRe.indexIn(html) != -1) {
322         QString jsPlayerUrl = jsPlayerRe.cap(1);
323         jsPlayerUrl.remove('\\');
324         if (jsPlayerUrl.startsWith(QLatin1String("//"))) {
325             jsPlayerUrl = QLatin1String("https:") + jsPlayerUrl;
326         } else if (jsPlayerUrl.startsWith("/")) {
327             jsPlayerUrl = QLatin1String("https://youtube.com") + jsPlayerUrl;
328         }
329         // qDebug() << "jsPlayerUrl" << jsPlayerUrl;
330         /*
331                     QRegExp jsPlayerIdRe("-(.+)\\.js");
332                     jsPlayerIdRe.indexIn(jsPlayerUrl);
333                     QString jsPlayerId = jsPlayerRe.cap(1);
334                     */
335         QObject *reply = HttpUtils::yt().get(jsPlayerUrl);
336         connect(reply, SIGNAL(data(QByteArray)), SLOT(parseJsPlayer(QByteArray)));
337         connect(reply, SIGNAL(error(QString)), SLOT(emitError(QString)));
338     }
339 }
340
341 void YTVideo::parseJsPlayer(const QByteArray &bytes) {
342     jsPlayer = QString::fromUtf8(bytes);
343     // qDebug() << "jsPlayer" << jsPlayer;
344
345     // QRegExp funcNameRe("[\"']signature[\"']\\s*,\\s*([" + jsNameChars + "]+)\\(");
346     static const QVector<QRegExp> funcNameRes = [] {
347         QVector<QRegExp> res;
348         for (const QString &s : JsFunctions::instance()->signatureFunctionNameREs()) {
349             res << QRegExp(s.arg(jsNameChars));
350         }
351         return res;
352     }();
353     for (const QRegExp &funcNameRe : funcNameRes) {
354         if (funcNameRe.indexIn(jsPlayer) == -1) {
355             qDebug() << "Cannot capture signature function name" << funcNameRe;
356             continue;
357         } else {
358             sigFuncName = funcNameRe.cap(1);
359             qDebug() << "Captures" << funcNameRe.captureCount() << funcNameRe.capturedTexts();
360             if (sigFuncName.isEmpty()) {
361                 qDebug() << "Empty capture for" << funcNameRe;
362                 continue;
363             }
364             captureFunction(sigFuncName, jsPlayer);
365             qDebug() << sigFunctions << sigObjects;
366             break;
367         }
368     }
369     if (sigFuncName.isEmpty()) qDebug() << "Empty signature function name";
370
371     // parseFmtUrlMap(fmtUrlMap, true);
372     getVideoInfo();
373 }
374
375 void YTVideo::captureFunction(const QString &name, const QString &js) {
376     qDebug() << __PRETTY_FUNCTION__ << name;
377     const QString argsAndBody =
378             QLatin1String("\\s*\\([") + jsNameChars + QLatin1String(",\\s]*\\)\\s*\\{[^\\}]+\\}");
379     QString func;
380     QRegExp funcRe(QLatin1String("function\\s+") + QRegExp::escape(name) + argsAndBody);
381     if (funcRe.indexIn(js) != -1) {
382         func = funcRe.cap(0);
383     } else {
384         // try var foo = function(bar) { };
385         funcRe = QRegExp(QLatin1String("var\\s+") + QRegExp::escape(name) +
386                          QLatin1String("\\s*=\\s*function") + argsAndBody);
387         if (funcRe.indexIn(js) != -1) {
388             func = funcRe.cap(0);
389         } else {
390             // try ,gr= function(bar) { };
391             funcRe = QRegExp(QLatin1String("[,\\s;}\\.\\)](") + QRegExp::escape(name) +
392                              QLatin1String("\\s*=\\s*function") + argsAndBody + ")");
393             if (funcRe.indexIn(js) != -1) {
394                 func = funcRe.cap(1);
395             } else {
396                 qWarning() << "Cannot capture function" << name;
397                 return;
398             }
399         }
400     }
401     sigFunctions.insert(name, func);
402
403     // capture inner functions
404     static const QRegExp invokedFuncRe(QLatin1String("[\\s=;\\(]([") + jsNameChars +
405                                        QLatin1String("]+)\\s*\\([") + jsNameChars +
406                                        QLatin1String(",\\s]+\\)"));
407     int pos = name.length() + 9;
408     while ((pos = invokedFuncRe.indexIn(func, pos)) != -1) {
409         QString funcName = invokedFuncRe.cap(1);
410         if (!sigFunctions.contains(funcName)) captureFunction(funcName, js);
411         pos += invokedFuncRe.matchedLength();
412     }
413
414     // capture referenced objects
415     static const QRegExp objRe(QLatin1String("[\\s=;\\(]([") + jsNameChars +
416                                QLatin1String("]+)\\.[") + jsNameChars + QLatin1String("]+"));
417     pos = name.length() + 9;
418     while ((pos = objRe.indexIn(func, pos)) != -1) {
419         QString objName = objRe.cap(1);
420         if (!sigObjects.contains(objName)) captureObject(objName, js);
421         pos += objRe.matchedLength();
422     }
423 }
424
425 void YTVideo::captureObject(const QString &name, const QString &js) {
426     QRegExp re(QLatin1String("var\\s+") + QRegExp::escape(name) +
427                QLatin1String("\\s*=\\s*\\{.*\\}\\s*;"));
428     re.setMinimal(true);
429     if (re.indexIn(js) == -1) {
430         qWarning() << "Cannot capture object" << name;
431         return;
432     }
433     QString obj = re.cap(0);
434     sigObjects.insert(name, obj);
435 }
436
437 QString YTVideo::decryptSignature(const QString &s) {
438     qDebug() << "decryptSignature" << sigFuncName << sigFunctions << sigObjects;
439     if (sigFuncName.isEmpty()) return QString();
440     QJSEngine engine;
441     for (const QString &f : sigObjects) {
442         QJSValue value = engine.evaluate(f);
443         if (value.isError()) qWarning() << "Error in" << f << value.toString();
444     }
445     for (const QString &f : sigFunctions) {
446         QJSValue value = engine.evaluate(f);
447         if (value.isError()) qWarning() << "Error in" << f << value.toString();
448     }
449     QString js = sigFuncName + "('" + s + "');";
450     QJSValue value = engine.evaluate(js);
451     bool error = false;
452     if (value.isUndefined()) {
453         qWarning() << "Undefined result for" << js;
454         error = true;
455     }
456     if (value.isError()) {
457         qWarning() << "Error in" << js << value.toString();
458         error = true;
459     }
460     if (error) {
461         QJSEngine engine2;
462         engine2.evaluate(jsPlayer);
463         value = engine2.evaluate(js);
464         if (value.isUndefined()) {
465             qWarning() << "Undefined result for" << js;
466             error = true;
467         }
468         if (value.isError()) {
469             qWarning() << "Error in" << js << value.toString();
470             error = true;
471         }
472     }
473     if (error) return QString();
474     return value.toString();
475 }
476
477 void YTVideo::saveDefinitionForUrl(const QString &url, const VideoDefinition &definition) {
478     qDebug() << "Selected video format" << definition.getCode() << definition.getName()
479              << definition.hasAudio();
480     m_streamUrl = url;
481     definitionCode = definition.getCode();
482
483     QString audioUrl;
484     if (!definition.hasAudio()) {
485         qDebug() << "Finding audio format";
486         static const QVector<int> audioFormats({251, 171, 140});
487         for (int audioFormat : audioFormats) {
488             qDebug() << "Trying audio format" << audioFormat;
489             auto i = urlMap.constFind(audioFormat);
490             if (i != urlMap.constEnd()) {
491                 qDebug() << "Found audio format" << i.value();
492                 audioUrl = i.value();
493                 break;
494             }
495         }
496     }
497
498     loadingStreamUrl = false;
499     emit gotStreamUrl(url, audioUrl);
500 }