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