]> git.sur5r.net Git - minitube/blob - src/ytvideo.cpp
Upload 3.9.3-2 to unstable
[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::stealthAndNotCached().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                         if (cipher.isEmpty()) cipher = formatObj["signatureCipher"].toString();
97                         QUrlQuery q(cipher);
98                         qDebug() << "Cipher is " << q.toString();
99                         url = q.queryItemValue("url").trimmed();
100                         // while (url.contains('%'))
101                         url = QByteArray::fromPercentEncoding(url.toUtf8());
102                         if (q.hasQueryItem("s")) {
103                             QString s = q.queryItemValue("s");
104                             qDebug() << "s is" << s;
105                             s = decryptSignature(s);
106                             if (!s.isEmpty()) {
107                                 qDebug() << "Added signature" << s;
108                                 url += "&sig=";
109                                 url += s;
110                             }
111                         }
112                     }
113                     // qDebug() << "player_response format" << itag << url;
114                     if (!url.isEmpty()) urlMap.insert(itag, url);
115                 }
116             };
117             QJsonObject streamingDataObj = obj["streamingData"].toObject();
118             // qDebug() << "Found streamingData" << streamingDataObj;
119             parseFormats(streamingDataObj["formats"].toArray());
120             parseFormats(streamingDataObj["adaptiveFormats"].toArray());
121         }
122     }
123
124     /*
125     // get video token
126     static const QRegExp videoTokeRE(JsFunctions::instance()->videoTokenRE());
127     if (videoTokeRE.indexIn(videoInfo) == -1) {
128         qDebug() << "Cannot get token. Trying next el param" << videoTokeRE.pattern() << videoInfo;
129         // Don't panic! We're gonna try another magic "el" param
130         elIndex++;
131         getVideoInfo();
132         return;
133     }
134
135     QString videoToken = videoTokeRE.cap(1);
136     while (videoToken.contains('%'))
137         videoToken = QByteArray::fromPercentEncoding(videoToken.toLatin1());
138     qDebug() << "videoToken" << videoToken;
139     this->videoToken = videoToken;
140
141     // get fmt_url_map
142     static const QRegExp fmtMapRE(JsFunctions::instance()->videoInfoFmtMapRE());
143     if (fmtMapRE.indexIn(videoInfo) == -1) {
144         qDebug() << "Cannot get urlMap. Trying next el param";
145         // Don't panic! We're gonna try another magic "el" param
146         elIndex++;
147         getVideoInfo();
148         return;
149     }
150     QString fmtUrlMap = fmtMapRE.cap(1);
151     // qDebug() << "got fmtUrlMap" << fmtUrlMap;
152     fmtUrlMap = QByteArray::fromPercentEncoding(fmtUrlMap.toUtf8());
153 */
154
155     if (urlMap.isEmpty()) {
156         qDebug() << "empty urlMap, trying next el";
157         elIndex++;
158         getVideoInfo();
159         return;
160     }
161
162     qDebug() << "Got token and urlMap" << elIndex << videoToken << fmtUrlMap;
163     parseFmtUrlMap(fmtUrlMap);
164 }
165
166 void YTVideo::parseFmtUrlMap(const QString &fmtUrlMap) {
167     int videoFormat = 0;
168     const VideoDefinition &definition = YT3::instance().maxVideoDefinition();
169
170     // qDebug() << "fmtUrlMap" << fmtUrlMap;
171     const QVector<QStringRef> formatUrls = fmtUrlMap.splitRef(',', QString::SkipEmptyParts);
172
173     for (const QStringRef &formatUrl : formatUrls) {
174         // qDebug() << "formatUrl" << formatUrl;
175         const QVector<QStringRef> urlParams = formatUrl.split('&', QString::SkipEmptyParts);
176         // qDebug() << "urlParams" << urlParams;
177
178         int format = -1;
179         QString url;
180         QString sig;
181         QStringRef sp;
182         for (const QStringRef &urlParam : urlParams) {
183             qDebug() << "urlParam" << urlParam;
184             if (sp.isNull() && urlParam.startsWith(QLatin1String("sp"))) {
185                 int separator = urlParam.indexOf('=');
186                 sp = urlParam.mid(separator + 1);
187             }
188             if (urlParam.startsWith(QLatin1String("itag="))) {
189                 int separator = urlParam.indexOf('=');
190                 format = urlParam.mid(separator + 1).toInt();
191             } else if (urlParam.startsWith(QLatin1String("url="))) {
192                 int separator = urlParam.indexOf('=');
193                 url = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
194             } else if (urlParam.startsWith(QLatin1String("sig="))) {
195                 int separator = urlParam.indexOf('=');
196                 sig = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
197             } else if (urlParam.startsWith(QLatin1String("s="))) {
198                 if (webPageLoaded || ageGate) {
199                     int separator = urlParam.indexOf('=');
200                     sig = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
201                     sig = decryptSignature(sig);
202                     if (sig.isEmpty()) sig = JsFunctions::instance()->decryptSignature(sig);
203                     if (sig.isEmpty()) qWarning() << "Empty signature";
204                 } else {
205                     loadWebPage();
206                     return;
207                 }
208             }
209         }
210         if (format == -1 || url.isNull()) continue;
211
212         if (!sig.isEmpty()) {
213             if (sp.isEmpty())
214                 url += QLatin1String("&signature=") + sig;
215             else
216                 url += '&' + sp + '=' + sig;
217         }
218
219         if (!url.contains(QLatin1String("ratebypass"))) url += QLatin1String("&ratebypass=yes");
220
221         qDebug() << format;
222         if (format == definition.getCode()) {
223             qDebug() << "Found format" << format;
224             if (definition.hasAudio()) {
225                 // we found the exact match with an audio/video stream
226                 saveDefinitionForUrl(url, definition);
227                 return;
228             }
229             videoFormat = format;
230         }
231         urlMap.insert(format, url);
232     }
233
234     if (!webPageLoaded && !ageGate) {
235         loadWebPage();
236         return;
237     }
238
239     if (videoFormat != 0) {
240         // exact match with video stream was found
241         const VideoDefinition &definition = VideoDefinition::forCode(videoFormat);
242         saveDefinitionForUrl(urlMap.value(videoFormat), definition);
243         return;
244     }
245
246     qDebug() << "available formats" << urlMap.keys();
247     const QVector<VideoDefinition> &definitions = VideoDefinition::getDefinitions();
248     int previousIndex = std::max(definitions.indexOf(definition) - 1, 0);
249     for (; previousIndex >= 0; previousIndex--) {
250         const VideoDefinition &previousDefinition = definitions.at(previousIndex);
251         qDebug() << "Testing format" << previousDefinition.getCode();
252         if (urlMap.contains(previousDefinition.getCode())) {
253             qDebug() << "Found format" << previousDefinition.getCode();
254             saveDefinitionForUrl(urlMap.value(previousDefinition.getCode()), previousDefinition);
255             return;
256         }
257     }
258
259     emit errorStreamUrl(tr("Cannot get video stream for %1").arg(videoId));
260 }
261
262 void YTVideo::loadWebPage() {
263     QUrl url("https://www.youtube.com/watch");
264     QUrlQuery q;
265     q.addQueryItem("v", videoId);
266     q.addQueryItem("gl", "US");
267     q.addQueryItem("hl", "en");
268     q.addQueryItem("has_verified", "1");
269     q.addQueryItem("bpctr", "9999999999");
270     url.setQuery(q);
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::loadEmbedPage() {
280     QUrl url("https://www.youtube.com/embed/" + videoId);
281     auto reply = HttpUtils::yt().get(url);
282     connect(reply, &HttpReply::finished, this, [this](const HttpReply &reply) {
283         if (!reply.isSuccessful()) {
284             getVideoInfo();
285             return;
286         }
287         static const QRegExp embedRE("\"sts\"\\s*:\\s*(\\d+)");
288         QString sts;
289         if (embedRE.indexIn(reply.body()) == -1) {
290             // qDebug() << "Cannot get sts" << reply.body();
291         } else {
292             sts = embedRE.cap(1);
293             qDebug() << "sts" << sts;
294         }
295         QUrlQuery q;
296         q.addQueryItem("video_id", videoId);
297         q.addQueryItem("eurl", "https://youtube.googleapis.com/v/" + videoId);
298         q.addQueryItem("sts", sts);
299         QUrl url = QUrl("https://www.youtube.com/get_video_info");
300         url.setQuery(q);
301         HttpReply *r = HttpUtils::stealthAndNotCached().get(url);
302         connect(r, &HttpReply::data, this, [this](const QByteArray &bytes) {
303             QByteArray decodedBytes = QByteArray::fromPercentEncoding(bytes);
304             gotVideoInfo(decodedBytes);
305         });
306         connect(r, &HttpReply::error, this, &YTVideo::emitError);
307     });
308 }
309
310 void YTVideo::emitError(const QString &message) {
311     qWarning() << message;
312     emit errorStreamUrl(message);
313 }
314
315 void YTVideo::scrapeWebPage(const QByteArray &bytes) {
316     webPageLoaded = true;
317
318     const QString html = QString::fromUtf8(bytes);
319     // qDebug() << "scrapeWebPage" << html;
320
321     static const QRegExp ageGateRE(JsFunctions::instance()->ageGateRE());
322     if (ageGateRE.indexIn(html) != -1 || html.contains("desktopLegacyAgeGateReason")) {
323         qDebug() << "Found ageGate";
324         ageGate = true;
325         // elIndex = 4;
326         // getVideoInfo();
327         loadEmbedPage();
328         return;
329     }
330
331     // "\"url_encoded_fmt_stream_map\":\s*\"([^\"]+)\""
332     static const QRegExp fmtMapRE(JsFunctions::instance()->webPageFmtMapRE());
333     if (fmtMapRE.indexIn(html) != -1) {
334         fmtUrlMap = fmtMapRE.cap(1);
335         fmtUrlMap.replace("\\u0026", "&");
336     }
337
338     QRegExp adaptiveFormatsRE("\"adaptive_fmts\":\\s*\"([^\"]+)\"");
339     if (adaptiveFormatsRE.indexIn(html) != -1) {
340         qDebug() << "Found adaptive_fmts";
341         if (!fmtUrlMap.isEmpty()) fmtUrlMap += ',';
342         fmtUrlMap += adaptiveFormatsRE.cap(1).replace("\\u0026", "&");
343     }
344
345     if (fmtUrlMap.isEmpty() && urlMap.isEmpty()) {
346         qDebug() << "Cannot get fmtUrlMap from video page. Trying next el";
347         // elIndex++;
348         // getVideoInfo();
349         // return;
350     }
351
352     static const QRegExp jsPlayerRe(JsFunctions::instance()->jsPlayerRE());
353     if (jsPlayerRe.indexIn(html) != -1) {
354         QString jsPlayerUrl = jsPlayerRe.cap(1);
355         jsPlayerUrl.remove('\\');
356         if (jsPlayerUrl.startsWith(QLatin1String("//"))) {
357             jsPlayerUrl = QLatin1String("https:") + jsPlayerUrl;
358         } else if (jsPlayerUrl.startsWith("/")) {
359             jsPlayerUrl = QLatin1String("https://youtube.com") + jsPlayerUrl;
360         }
361         // qDebug() << "jsPlayerUrl" << jsPlayerUrl;
362         /*
363                     QRegExp jsPlayerIdRe("-(.+)\\.js");
364                     jsPlayerIdRe.indexIn(jsPlayerUrl);
365                     QString jsPlayerId = jsPlayerRe.cap(1);
366                     */
367         QObject *reply = HttpUtils::stealthAndNotCached().get(jsPlayerUrl);
368         connect(reply, SIGNAL(data(QByteArray)), SLOT(parseJsPlayer(QByteArray)));
369         connect(reply, SIGNAL(error(QString)), SLOT(emitError(QString)));
370     } else {
371         qDebug() << "Cannot find jsPlayer";
372     }
373 }
374
375 void YTVideo::parseJsPlayer(const QByteArray &bytes) {
376     jsPlayer = QString::fromUtf8(bytes);
377     // qDebug() << "jsPlayer" << jsPlayer;
378
379     // QRegExp funcNameRe("[\"']signature[\"']\\s*,\\s*([" + jsNameChars + "]+)\\(");
380     static const QVector<QRegExp> funcNameRes = [] {
381         QVector<QRegExp> res;
382         for (const QString &s : JsFunctions::instance()->signatureFunctionNameREs()) {
383             res << QRegExp(s.arg(jsNameChars));
384         }
385         return res;
386     }();
387     for (const QRegExp &funcNameRe : funcNameRes) {
388         if (funcNameRe.indexIn(jsPlayer) == -1) {
389             qDebug() << "Cannot capture signature function name" << funcNameRe;
390             continue;
391         } else {
392             sigFuncName = funcNameRe.cap(1);
393             qDebug() << "Captures" << funcNameRe.captureCount() << funcNameRe.capturedTexts();
394             if (sigFuncName.isEmpty()) {
395                 qDebug() << "Empty capture for" << funcNameRe;
396                 continue;
397             }
398             captureFunction(sigFuncName, jsPlayer);
399             qDebug() << sigFunctions << sigObjects;
400             break;
401         }
402     }
403     if (sigFuncName.isEmpty()) qDebug() << "Empty signature function name" << jsPlayer;
404
405     // parseFmtUrlMap(fmtUrlMap, true);
406     getVideoInfo();
407 }
408
409 void YTVideo::captureFunction(const QString &name, const QString &js) {
410     qDebug() << __PRETTY_FUNCTION__ << name;
411     const QString argsAndBody =
412             QLatin1String("\\s*\\([") + jsNameChars + QLatin1String(",\\s]*\\)\\s*\\{[^\\}]+\\}");
413     QString func;
414     QRegExp funcRe(QLatin1String("function\\s+") + QRegExp::escape(name) + argsAndBody);
415     if (funcRe.indexIn(js) != -1) {
416         func = funcRe.cap(0);
417     } else {
418         // try var foo = function(bar) { };
419         funcRe = QRegExp(QLatin1String("var\\s+") + QRegExp::escape(name) +
420                          QLatin1String("\\s*=\\s*function") + argsAndBody);
421         if (funcRe.indexIn(js) != -1) {
422             func = funcRe.cap(0);
423         } else {
424             // try ,gr= function(bar) { };
425             funcRe = QRegExp(QLatin1String("[,\\s;}\\.\\)](") + QRegExp::escape(name) +
426                              QLatin1String("\\s*=\\s*function") + argsAndBody + ")");
427             if (funcRe.indexIn(js) != -1) {
428                 func = funcRe.cap(1);
429             } else {
430                 qWarning() << "Cannot capture function" << name;
431                 return;
432             }
433         }
434     }
435     sigFunctions.insert(name, func);
436
437     // capture inner functions
438     static const QRegExp invokedFuncRe(QLatin1String("[\\s=;\\(]([") + jsNameChars +
439                                        QLatin1String("]+)\\s*\\([") + jsNameChars +
440                                        QLatin1String(",\\s]+\\)"));
441     int pos = name.length() + 9;
442     while ((pos = invokedFuncRe.indexIn(func, pos)) != -1) {
443         QString funcName = invokedFuncRe.cap(1);
444         if (!sigFunctions.contains(funcName)) captureFunction(funcName, js);
445         pos += invokedFuncRe.matchedLength();
446     }
447
448     // capture referenced objects
449     static const QRegExp objRe(QLatin1String("[\\s=;\\(]([") + jsNameChars +
450                                QLatin1String("]+)\\.[") + jsNameChars + QLatin1String("]+"));
451     pos = name.length() + 9;
452     while ((pos = objRe.indexIn(func, pos)) != -1) {
453         QString objName = objRe.cap(1);
454         if (!sigObjects.contains(objName)) captureObject(objName, js);
455         pos += objRe.matchedLength();
456     }
457 }
458
459 void YTVideo::captureObject(const QString &name, const QString &js) {
460     QRegExp re(QLatin1String("var\\s+") + QRegExp::escape(name) +
461                QLatin1String("\\s*=\\s*\\{.*\\}\\s*;"));
462     re.setMinimal(true);
463     if (re.indexIn(js) == -1) {
464         qWarning() << "Cannot capture object" << name;
465         return;
466     }
467     QString obj = re.cap(0);
468     sigObjects.insert(name, obj);
469 }
470
471 QString YTVideo::decryptSignature(const QString &s) {
472     qDebug() << "decryptSignature" << sigFuncName << sigFunctions << sigObjects;
473     if (sigFuncName.isEmpty()) return QString();
474     QJSEngine engine;
475     for (const QString &f : sigObjects) {
476         QJSValue value = engine.evaluate(f);
477         if (value.isError()) qWarning() << "Error in" << f << value.toString();
478     }
479     for (const QString &f : sigFunctions) {
480         QJSValue value = engine.evaluate(f);
481         if (value.isError()) qWarning() << "Error in" << f << value.toString();
482     }
483     QString js = sigFuncName + "('" + s + "');";
484     QJSValue value = engine.evaluate(js);
485     bool error = false;
486     if (value.isUndefined()) {
487         qWarning() << "Undefined result for" << js;
488         error = true;
489     }
490     if (value.isError()) {
491         qWarning() << "Error in" << js << value.toString();
492         error = true;
493     }
494     if (error) {
495         QJSEngine engine2;
496         engine2.evaluate(jsPlayer);
497         value = engine2.evaluate(js);
498         if (value.isUndefined()) {
499             qWarning() << "Undefined result for" << js;
500             error = true;
501         }
502         if (value.isError()) {
503             qWarning() << "Error in" << js << value.toString();
504             error = true;
505         }
506     }
507     if (error) return QString();
508     return value.toString();
509 }
510
511 void YTVideo::saveDefinitionForUrl(const QString &url, const VideoDefinition &definition) {
512     qDebug() << "Selected video format" << definition.getCode() << definition.getName()
513              << definition.hasAudio();
514     m_streamUrl = url;
515     definitionCode = definition.getCode();
516
517     QString audioUrl;
518     if (!definition.hasAudio()) {
519         qDebug() << "Finding audio format";
520         static const QVector<int> audioFormats({251, 171, 140});
521         for (int audioFormat : audioFormats) {
522             qDebug() << "Trying audio format" << audioFormat;
523             auto i = urlMap.constFind(audioFormat);
524             if (i != urlMap.constEnd()) {
525                 qDebug() << "Found audio format" << i.value();
526                 audioUrl = i.value();
527                 break;
528             }
529         }
530     }
531
532     loadingStreamUrl = false;
533     emit gotStreamUrl(url, audioUrl);
534 }