]> git.sur5r.net Git - minitube/blob - src/ytvideo.cpp
e84f7e16bc4540b4efac4c710c9092c616549a18
[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
10 #include <QJSEngine>
11 #include <QJSValue>
12 #include <QtNetwork>
13
14 namespace {
15 static const QString jsNameChars = "a-zA-Z0-9\\$_";
16 }
17
18 YTVideo::YTVideo(const QString &videoId, QObject *parent)
19     : QObject(parent), videoId(videoId), definitionCode(0), elIndex(0), ageGate(false),
20       loadingStreamUrl(false) {}
21
22 void YTVideo::loadStreamUrl() {
23     if (loadingStreamUrl) {
24         qDebug() << "Already loading stream URL for" << videoId;
25         return;
26     }
27     loadingStreamUrl = true;
28     elIndex = 0;
29     ageGate = false;
30
31     getVideoInfo();
32 }
33
34 void YTVideo::getVideoInfo() {
35     static const QStringList elTypes = {"&el=embedded", "&el=detailpage", "&el=vevo", ""};
36
37     QUrl url;
38     if (elIndex == elTypes.size()) {
39         // qDebug() << "Trying special embedded el param";
40         url = QUrl("https://www.youtube.com/get_video_info");
41         QUrlQuery q;
42         q.addQueryItem("video_id", videoId);
43         q.addQueryItem("el", "embedded");
44         q.addQueryItem("gl", "US");
45         q.addQueryItem("hl", "en");
46         q.addQueryItem("eurl", "https://youtube.googleapis.com/v/" + videoId);
47         q.addQueryItem("asv", "3");
48         q.addQueryItem("sts", "1588");
49         url.setQuery(q);
50     } else if (elIndex > elTypes.size() - 1) {
51         qWarning() << "Cannot get video info";
52         loadingStreamUrl = false;
53         emit errorStreamUrl("Cannot get video info");
54         return;
55     } else {
56         // qDebug() << "Trying el param:" << elTypes.at(elIndex) << elIndex;
57         url = QUrl(QString("https://www.youtube.com/"
58                            "get_video_info?video_id=%1%2&ps=default&eurl=&gl=US&hl=en")
59                            .arg(videoId, elTypes.at(elIndex)));
60     }
61
62     QObject *reply = HttpUtils::yt().get(url);
63     connect(reply, SIGNAL(data(QByteArray)), SLOT(gotVideoInfo(QByteArray)));
64     connect(reply, SIGNAL(error(QString)), SLOT(errorVideoInfo(QString)));
65
66     // see you in gotVideoInfo...
67 }
68
69 void YTVideo::gotVideoInfo(const QByteArray &bytes) {
70     QString videoInfo = QString::fromUtf8(bytes);
71     // qDebug() << "videoInfo" << videoInfo;
72
73     // get video token
74     static const QRegExp videoTokeRE(JsFunctions::instance()->videoTokenRE());
75     if (videoTokeRE.indexIn(videoInfo) == -1) {
76         qDebug() << "Cannot get token. Trying next el param" << videoInfo << videoTokeRE.pattern();
77         // Don't panic! We're gonna try another magic "el" param
78         elIndex++;
79         getVideoInfo();
80         return;
81     }
82
83     QString videoToken = videoTokeRE.cap(1);
84     qDebug() << "got token" << videoToken;
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     const QString definitionName = QSettings().value("definition", "360p").toString();
110     const VideoDefinition &definition = VideoDefinition::forName(definitionName);
111
112     qDebug() << "fmtUrlMap" << fmtUrlMap;
113     const QVector<QStringRef> formatUrls = fmtUrlMap.splitRef(',', QString::SkipEmptyParts);
114     QMap<int, QString> urlMap;
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         for (const QStringRef &urlParam : urlParams) {
124             // qWarning() << urlParam;
125             if (urlParam.startsWith(QLatin1String("itag="))) {
126                 int separator = urlParam.indexOf('=');
127                 format = urlParam.mid(separator + 1).toInt();
128             } else if (urlParam.startsWith(QLatin1String("url="))) {
129                 int separator = urlParam.indexOf('=');
130                 url = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
131             } else if (urlParam.startsWith(QLatin1String("sig="))) {
132                 int separator = urlParam.indexOf('=');
133                 sig = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
134             } else if (urlParam.startsWith(QLatin1String("s="))) {
135                 if (fromWebPage || ageGate) {
136                     int separator = urlParam.indexOf('=');
137                     sig = QByteArray::fromPercentEncoding(urlParam.mid(separator + 1).toUtf8());
138                     if (ageGate)
139                         sig = JsFunctions::instance()->decryptAgeSignature(sig);
140                     else {
141                         sig = decryptSignature(sig);
142                         if (sig.isEmpty()) sig = JsFunctions::instance()->decryptSignature(sig);
143                     }
144                 } else {
145                     QUrl url("https://www.youtube.com/watch");
146                     QUrlQuery q;
147                     q.addQueryItem("v", videoId);
148                     q.addQueryItem("gl", "US");
149                     q.addQueryItem("hl", "en");
150                     q.addQueryItem("has_verified", "1");
151                     url.setQuery(q);
152                     qDebug() << "Loading webpage" << url;
153                     QObject *reply = HttpUtils::yt().get(url);
154                     connect(reply, SIGNAL(data(QByteArray)), SLOT(scrapeWebPage(QByteArray)));
155                     connect(reply, SIGNAL(error(QString)), SLOT(errorVideoInfo(QString)));
156                     // see you in scrapWebPage(QByteArray)
157                     return;
158                 }
159             }
160         }
161         if (format == -1 || url.isNull()) continue;
162
163         url += QLatin1String("&signature=") + sig;
164
165         if (!url.contains(QLatin1String("ratebypass"))) url += QLatin1String("&ratebypass=yes");
166
167         qDebug() << url;
168
169         if (format == definition.getCode()) {
170             qDebug() << "Found format" << definitionCode;
171             saveDefinitionForUrl(url, definition);
172             return;
173         }
174
175         urlMap.insert(format, url);
176     }
177
178     const QVector<VideoDefinition> &definitions = VideoDefinition::getDefinitions();
179     int previousIndex = std::max(definitions.indexOf(definition) - 1, 0);
180     for (; previousIndex >= 0; previousIndex--) {
181         const VideoDefinition &previousDefinition = definitions.at(previousIndex);
182         if (urlMap.contains(previousDefinition.getCode())) {
183             // qDebug() << "Found format" << definitionCode;
184             saveDefinitionForUrl(urlMap.value(previousDefinition.getCode()), previousDefinition);
185             return;
186         }
187     }
188
189     emit errorStreamUrl(tr("Cannot get video stream for %1").arg(videoId));
190 }
191
192 void YTVideo::errorVideoInfo(const QString &message) {
193     loadingStreamUrl = false;
194     emit errorStreamUrl(message);
195 }
196
197 void YTVideo::scrapeWebPage(const QByteArray &bytes) {
198     const QString html = QString::fromUtf8(bytes);
199
200     static const QRegExp ageGateRE(JsFunctions::instance()->ageGateRE());
201     if (ageGateRE.indexIn(html) != -1) {
202         // qDebug() << "Found ageGate";
203         ageGate = true;
204         elIndex = 4;
205         getVideoInfo();
206         return;
207     }
208
209     static const QRegExp fmtMapRE(JsFunctions::instance()->webPageFmtMapRE());
210     if (fmtMapRE.indexIn(html) == -1) {
211         qWarning() << "Error parsing video page";
212         // emit errorStreamUrl("Error parsing video page");
213         // loadingStreamUrl = false;
214         elIndex++;
215         getVideoInfo();
216         return;
217     }
218     fmtUrlMap = fmtMapRE.cap(1);
219     fmtUrlMap.replace("\\u0026", "&");
220 // parseFmtUrlMap(fmtUrlMap, true);
221
222 #ifdef APP_DASH
223     QSettings settings;
224     QString definitionName = settings.value("definition", "360p").toString();
225     if (definitionName == QLatin1String("1080p")) {
226         QRegExp dashManifestRe("\"dashmpd\":\\s*\"([^\"]+)\"");
227         if (dashManifestRe.indexIn(html) != -1) {
228             dashManifestUrl = dashManifestRe.cap(1);
229             dashManifestUrl.remove('\\');
230             qDebug() << "dashManifestUrl" << dashManifestUrl;
231         } else {
232             qWarning() << "DASH manifest not found in webpage";
233             if (dashManifestRe.indexIn(fmtUrlMap) != -1) {
234                 dashManifestUrl = dashManifestRe.cap(1);
235                 dashManifestUrl.remove('\\');
236                 qDebug() << "dashManifestUrl" << dashManifestUrl;
237             } else
238                 qWarning() << "DASH manifest not found in fmtUrlMap" << fmtUrlMap;
239         }
240     }
241 #endif
242
243     static const QRegExp jsPlayerRe(JsFunctions::instance()->jsPlayerRE());
244     if (jsPlayerRe.indexIn(html) != -1) {
245         QString jsPlayerUrl = jsPlayerRe.cap(1);
246         jsPlayerUrl.remove('\\');
247         if (jsPlayerUrl.startsWith(QLatin1String("//"))) {
248             jsPlayerUrl = QLatin1String("https:") + jsPlayerUrl;
249         } else if (jsPlayerUrl.startsWith("/")) {
250             jsPlayerUrl = QLatin1String("https://youtube.com") + jsPlayerUrl;
251         }
252         // qDebug() << "jsPlayerUrl" << jsPlayerUrl;
253         /*
254                     QRegExp jsPlayerIdRe("-(.+)\\.js");
255                     jsPlayerIdRe.indexIn(jsPlayerUrl);
256                     QString jsPlayerId = jsPlayerRe.cap(1);
257                     */
258         QObject *reply = HttpUtils::yt().get(jsPlayerUrl);
259         connect(reply, SIGNAL(data(QByteArray)), SLOT(parseJsPlayer(QByteArray)));
260         connect(reply, SIGNAL(error(QString)), SLOT(errorVideoInfo(QString)));
261     }
262 }
263
264 void YTVideo::parseJsPlayer(const QByteArray &bytes) {
265     jsPlayer = QString::fromUtf8(bytes);
266     // qDebug() << "jsPlayer" << jsPlayer;
267
268     // QRegExp funcNameRe("[\"']signature[\"']\\s*,\\s*([" + jsNameChars + "]+)\\(");
269     static const QRegExp funcNameRe(
270             JsFunctions::instance()->signatureFunctionNameRE().arg(jsNameChars));
271
272     if (funcNameRe.indexIn(jsPlayer) == -1) {
273         qWarning() << "Cannot capture signature function name" << jsPlayer;
274     } else {
275         sigFuncName = funcNameRe.cap(1);
276         captureFunction(sigFuncName, jsPlayer);
277         // qWarning() << sigFunctions << sigObjects;
278     }
279
280 #ifdef APP_DASH
281     if (!dashManifestUrl.isEmpty()) {
282         QRegExp sigRe("/s/([\\w\\.]+)");
283         if (sigRe.indexIn(dashManifestUrl) != -1) {
284             qDebug() << "Decrypting signature for dash manifest";
285             QString sig = sigRe.cap(1);
286             sig = decryptSignature(sig);
287             dashManifestUrl.replace(sigRe, "/signature/" + sig);
288             qWarning() << "dash manifest" << dashManifestUrl;
289
290             if (true) {
291                 // let phonon play the manifest
292                 m_streamUrl = dashManifestUrl;
293                 this->definitionCode = 37;
294                 emit gotStreamUrl(m_streamUrl);
295                 loadingStreamUrl = false;
296             } else {
297                 // download the manifest
298                 QObject *reply = HttpUtils::yt().get(QUrl::fromEncoded(dashManifestUrl.toUtf8()));
299                 connect(reply, SIGNAL(data(QByteArray)), SLOT(parseDashManifest(QByteArray)));
300                 connect(reply, SIGNAL(error(QString)), SLOT(errorVideoInfo(QString)));
301             }
302
303             return;
304         }
305     }
306 #endif
307
308     parseFmtUrlMap(fmtUrlMap, true);
309 }
310
311 void YTVideo::parseDashManifest(const QByteArray &bytes) {
312     QFile file(Temporary::filename() + ".mpd");
313     if (!file.open(QIODevice::WriteOnly)) qWarning() << file.errorString() << file.fileName();
314     QDataStream stream(&file);
315     stream.writeRawData(bytes.constData(), bytes.size());
316
317     m_streamUrl = "file://" + file.fileName();
318     this->definitionCode = 37;
319     emit gotStreamUrl(m_streamUrl);
320     loadingStreamUrl = false;
321 }
322
323 void YTVideo::captureFunction(const QString &name, const QString &js) {
324     qDebug() << __PRETTY_FUNCTION__ << name;
325     const QString argsAndBody =
326             QLatin1String("\\s*\\([") + jsNameChars + QLatin1String(",\\s]*\\)\\s*\\{[^\\}]+\\}");
327     QString func;
328     QRegExp funcRe(QLatin1String("function\\s+") + QRegExp::escape(name) + argsAndBody);
329     if (funcRe.indexIn(js) != -1) {
330         func = funcRe.cap(0);
331     } else {
332         // try var foo = function(bar) { };
333         funcRe = QRegExp(QLatin1String("var\\s+") + QRegExp::escape(name) +
334                          QLatin1String("\\s*=\\s*function") + argsAndBody);
335         if (funcRe.indexIn(js) != -1) {
336             func = funcRe.cap(0);
337         } else {
338             // try ,gr= function(bar) { };
339             funcRe = QRegExp(QLatin1String("[,\\s;}\\.\\)](") + QRegExp::escape(name) +
340                              QLatin1String("\\s*=\\s*function") + argsAndBody + ")");
341             if (funcRe.indexIn(js) != -1) {
342                 func = funcRe.cap(1);
343             } else {
344                 qWarning() << "Cannot capture function" << name;
345                 return;
346             }
347         }
348     }
349     sigFunctions.insert(name, func);
350
351     // capture inner functions
352     static const QRegExp invokedFuncRe(QLatin1String("[\\s=;\\(]([") + jsNameChars +
353                                        QLatin1String("]+)\\s*\\([") + jsNameChars +
354                                        QLatin1String(",\\s]+\\)"));
355     int pos = name.length() + 9;
356     while ((pos = invokedFuncRe.indexIn(func, pos)) != -1) {
357         QString funcName = invokedFuncRe.cap(1);
358         if (!sigFunctions.contains(funcName)) captureFunction(funcName, js);
359         pos += invokedFuncRe.matchedLength();
360     }
361
362     // capture referenced objects
363     static const QRegExp objRe(QLatin1String("[\\s=;\\(]([") + jsNameChars +
364                                QLatin1String("]+)\\.[") + jsNameChars + QLatin1String("]+"));
365     pos = name.length() + 9;
366     while ((pos = objRe.indexIn(func, pos)) != -1) {
367         QString objName = objRe.cap(1);
368         if (!sigObjects.contains(objName)) captureObject(objName, js);
369         pos += objRe.matchedLength();
370     }
371 }
372
373 void YTVideo::captureObject(const QString &name, const QString &js) {
374     QRegExp re(QLatin1String("var\\s+") + QRegExp::escape(name) +
375                QLatin1String("\\s*=\\s*\\{.*\\}\\s*;"));
376     re.setMinimal(true);
377     if (re.indexIn(js) == -1) {
378         qWarning() << "Cannot capture object" << name;
379         return;
380     }
381     QString obj = re.cap(0);
382     sigObjects.insert(name, obj);
383 }
384
385 QString YTVideo::decryptSignature(const QString &s) {
386     qDebug() << "decryptSignature" << sigFuncName << sigFunctions << sigObjects;
387     if (sigFuncName.isEmpty()) return QString();
388     QJSEngine engine;
389     for (const QString &f : sigObjects) {
390         QJSValue value = engine.evaluate(f);
391         if (value.isError()) qWarning() << "Error in" << f << value.toString();
392     }
393     for (const QString &f : sigFunctions) {
394         QJSValue value = engine.evaluate(f);
395         if (value.isError()) qWarning() << "Error in" << f << value.toString();
396     }
397     QString js = sigFuncName + "('" + s + "');";
398     QJSValue value = engine.evaluate(js);
399     bool error = false;
400     if (value.isUndefined()) {
401         qWarning() << "Undefined result for" << js;
402         error = true;
403     }
404     if (value.isError()) {
405         qWarning() << "Error in" << js << value.toString();
406         error = true;
407     }
408     if (error) {
409         QJSEngine engine2;
410         engine2.evaluate(jsPlayer);
411         value = engine2.evaluate(js);
412         if (value.isUndefined()) {
413             qWarning() << "Undefined result for" << js;
414             error = true;
415         }
416         if (value.isError()) {
417             qWarning() << "Error in" << js << value.toString();
418             error = true;
419         }
420     }
421     if (error) return QString();
422     return value.toString();
423 }
424
425 void YTVideo::saveDefinitionForUrl(const QString &url, const VideoDefinition &definition) {
426     m_streamUrl = QUrl::fromEncoded(url.toUtf8(), QUrl::StrictMode);
427     definitionCode = definition.getCode();
428     emit gotStreamUrl(m_streamUrl);
429     loadingStreamUrl = false;
430 }