aboutsummaryrefslogtreecommitdiffstats
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Types.hx6
-rw-r--r--src/client/Main.hx12
-rw-r--r--src/client/Player.hx4
-rw-r--r--src/client/players/Youtube.hx69
-rw-r--r--src/server/HttpServer.hx24
-rw-r--r--src/server/Main.hx18
-rw-r--r--src/server/YoutubeFallback.hx144
-rw-r--r--src/utils/YoutubeUtils.hx83
8 files changed, 324 insertions, 36 deletions
diff --git a/src/Types.hx b/src/Types.hx
index 4ef233b..eeae641 100644
--- a/src/Types.hx
+++ b/src/Types.hx
@@ -1,6 +1,7 @@
package;
import Client.ClientData;
+import utils.YoutubeUtils.YouTubeVideoInfo;
typedef VideoDataRequest = {
url:String,
@@ -204,6 +205,10 @@ typedef WsEvent = {
},
?dump:{
data:String
+ },
+ ?getYoutubeVideoInfo:{
+ url:String,
+ ?response:YouTubeVideoInfo
}
}
@@ -242,4 +247,5 @@ enum abstract WsEventType(String) {
var UpdatePlaylist;
var TogglePlaylistLock;
var Dump;
+ var GetYoutubeVideoInfo;
}
diff --git a/src/client/Main.hx b/src/client/Main.hx
index b1e0eb6..85db713 100644
--- a/src/client/Main.hx
+++ b/src/client/Main.hx
@@ -18,6 +18,7 @@ import js.html.Event;
import js.html.InputElement;
import js.html.KeyboardEvent;
import js.html.MouseEvent;
+import js.html.URL;
import js.html.VideoElement;
import js.html.WebSocket;
@@ -387,7 +388,13 @@ class Main {
public function tryLocalIp(url:String):String {
if (host == globalIp) return url;
- return url.replace(globalIp, host);
+ try {
+ final url = new URL(url);
+ url.hostname = url.hostname.replace(globalIp, host);
+ return '$url';
+ } catch (e) {
+ return url;
+ }
}
function onMessage(e):Void {
@@ -557,6 +564,9 @@ class Main {
case Dump:
Utils.saveFile("dump.json", ApplicationJson, data.dump.data);
+
+ case GetYoutubeVideoInfo:
+ // handled by event listeners like `JsApi.once`
}
}
diff --git a/src/client/Player.hx b/src/client/Player.hx
index 75b44b5..06a8f3e 100644
--- a/src/client/Player.hx
+++ b/src/client/Player.hx
@@ -89,6 +89,10 @@ class Player {
setItemElementType(el, videoList.getItem(pos).isTemp);
}
+ public function getCurrentItem():VideoItem {
+ return videoList.currentItem;
+ }
+
function setPlayer(newPlayer:IPlayer):Void {
if (player != newPlayer) {
if (player != null) {
diff --git a/src/client/players/Youtube.hx b/src/client/players/Youtube.hx
index a728012..5308b63 100644
--- a/src/client/players/Youtube.hx
+++ b/src/client/players/Youtube.hx
@@ -10,15 +10,11 @@ import js.Browser.document;
import js.html.Element;
import js.youtube.Youtube as YtInit;
import js.youtube.YoutubePlayer;
+import utils.YoutubeUtils;
using StringTools;
class Youtube implements IPlayer {
- final matchId = ~/youtube\.com.*v=([A-z0-9_-]+)/;
- final matchShort = ~/youtu\.be\/([A-z0-9_-]+)/;
- final matchShorts = ~/youtube\.com\/shorts\/([A-z0-9_-]+)/;
- final matchEmbed = ~/youtube\.com\/embed\/([A-z0-9_-]+)/;
- final matchPlaylist = ~/youtube\.com.*list=([A-z0-9_-]+)/;
final videosUrl = "https://www.googleapis.com/youtube/v3/videos";
final playlistUrl = "https://www.googleapis.com/youtube/v3/playlistItems";
final urlTitleDuration = "?part=snippet,contentDetails&fields=items(snippet/title,contentDetails/duration)";
@@ -41,25 +37,12 @@ class Youtube implements IPlayer {
return extractVideoId(url) != "" || extractPlaylistId(url) != "";
}
- public function extractVideoId(url:String):String {
- if (matchId.match(url)) {
- return matchId.matched(1);
- }
- if (matchShort.match(url)) {
- return matchShort.matched(1);
- }
- if (matchShorts.match(url)) {
- return matchShorts.matched(1);
- }
- if (matchEmbed.match(url)) {
- return matchEmbed.matched(1);
- }
- return "";
+ public function extractVideoId(url:String) {
+ return YoutubeUtils.extractVideoId(url);
}
- function extractPlaylistId(url:String):String {
- if (!matchPlaylist.match(url)) return "";
- return matchPlaylist.matched(1);
+ public function extractPlaylistId(url:String) {
+ return YoutubeUtils.extractPlaylistId(url);
}
final matchHours = ~/([0-9]+)H/;
@@ -256,11 +239,53 @@ class Youtube implements IPlayer {
},
onPlaybackRateChange: e -> {
player.onRateChange();
+ },
+ onError: e -> {
+ // TODO message error codes
+ trace('Error ${e.data}');
+ rawSourceFallback(item.url);
}
}
});
}
+ function rawSourceFallback(url:String):Void {
+ JsApi.once(GetYoutubeVideoInfo, event -> {
+ final data = event.getYoutubeVideoInfo;
+ final info = data.response;
+ final format = getBestStreamFormat(info) ?? {
+ trace("format not found in response info:");
+ trace(info);
+ return;
+ };
+ final item = player.getCurrentItem();
+ item.url = format.url;
+ player.refresh();
+ });
+ main.send({
+ type: GetYoutubeVideoInfo,
+ getYoutubeVideoInfo: {
+ url: url
+ }
+ });
+ }
+
+ function getBestStreamFormat(info:YouTubeVideoInfo):Null<YoutubeVideoFormat> {
+ info.formats ??= [];
+ info.adaptiveFormats ??= [];
+ final formats = info.adaptiveFormats.concat(info.formats);
+ final qPriority = [1080, 720, 480, 360, 240];
+ for (q in qPriority) {
+ final quality = '${q}p';
+ for (format in formats) {
+ if (format.audioQuality == null) continue; // no sound
+ if (format.width == null) continue; // no video
+ if (format.qualityLabel == quality) return format;
+ }
+ }
+ return null;
+ }
+
public function removeVideo():Void {
if (video == null) return;
isLoaded = false;
diff --git a/src/server/HttpServer.hx b/src/server/HttpServer.hx
index c953bb0..1a0c4d5 100644
--- a/src/server/HttpServer.hx
+++ b/src/server/HttpServer.hx
@@ -187,9 +187,9 @@ class HttpServer {
static function proxyUrl(req:IncomingMessage, res:ServerResponse):Bool {
final url = req.url.replace("/proxy?url=", "");
- final proxy = proxyRequest(url, req, res, proxyReq -> {
- final url = proxyReq.headers["location"] ?? return false;
- final proxy2 = proxyRequest(url, req, res, proxyReq -> false);
+ final proxy = proxyRequest(url, req, res, proxyRes -> {
+ final url = proxyRes.headers["location"] ?? return false;
+ final proxy2 = proxyRequest(url, req, res, proxyRes -> false);
if (proxy2 == null) {
res.end('Proxy error: multiple redirects for url $url');
return true;
@@ -203,8 +203,10 @@ class HttpServer {
}
static function proxyRequest(
- url:String, req:IncomingMessage, res:ServerResponse,
- fn:(req:IncomingMessage) -> Bool
+ url:String,
+ req:IncomingMessage,
+ res:ServerResponse,
+ cancelProxyRequest:(proxyRes:IncomingMessage) -> Bool
):Null<ClientRequest> {
final url = try {
new URL(safeDecodeURI(url));
@@ -216,12 +218,14 @@ class HttpServer {
path: url.pathname + url.search,
method: req.method
};
+ req.headers["referer"] = url.toString();
+ req.headers["host"] = url.hostname;
final request = url.protocol == "https:" ? Https.request : Http.request;
- final proxy = request(options, proxyReq -> {
- if (fn(proxyReq)) return;
- proxyReq.headers["Content-Type"] = "application/octet-stream";
- res.writeHead(proxyReq.statusCode, proxyReq.headers);
- proxyReq.pipe(res);
+ final proxy = request(options, proxyRes -> {
+ if (cancelProxyRequest(proxyRes)) return;
+ proxyRes.headers["Content-Type"] = "application/octet-stream";
+ res.writeHead(proxyRes.statusCode, proxyRes.headers);
+ proxyRes.pipe(res);
});
proxy.on("error", err -> {
res.end('Proxy error: ${url.href}');
diff --git a/src/server/Main.hx b/src/server/Main.hx
index c5eb36f..e7c38a6 100644
--- a/src/server/Main.hx
+++ b/src/server/Main.hx
@@ -531,8 +531,7 @@ class Main {
if (userList.admins.exists(
a -> a.name.toLowerCase() == lcName && a.hash == hash)) {
client.isAdmin = true;
- }
- else {
+ } else {
serverMessage(client, "passwordMatchError");
send(client, {type: LoginError});
return;
@@ -587,6 +586,17 @@ class Main {
broadcast(data);
case ServerMessage:
+ case GetYoutubeVideoInfo:
+ final url = data.getYoutubeVideoInfo.url;
+ YoutubeFallback.getInfo(url, info -> {
+ send(client, {
+ type: data.type,
+ getYoutubeVideoInfo: {
+ url: url,
+ response: info
+ }
+ });
+ });
case AddVideo:
if (isPlaylistLockedFor(client)) return;
if (!checkPermission(client, AddVideoPerm)) return;
@@ -1038,7 +1048,9 @@ class Main {
duration: duration,
time: time
});
- while (flashbacks.length > FLASHBACKS_COUNT) flashbacks.pop();
+ while (flashbacks.length > FLASHBACKS_COUNT) {
+ flashbacks.pop();
+ }
}
function isPlaylistLockedFor(client:Client):Bool {
diff --git a/src/server/YoutubeFallback.hx b/src/server/YoutubeFallback.hx
new file mode 100644
index 0000000..34ea8a9
--- /dev/null
+++ b/src/server/YoutubeFallback.hx
@@ -0,0 +1,144 @@
+package server;
+
+import haxe.Json;
+import js.lib.Function;
+import js.lib.Object;
+import js.lib.Promise;
+import js.node.Https.Https;
+import js.node.Https.HttpsRequestOptions;
+import js.node.url.URLSearchParams;
+import utils.YoutubeUtils;
+
+using Lambda;
+
+class YoutubeFallback {
+ static function httpsGet(
+ url:String,
+ ?options:HttpsRequestOptions,
+ ?callback:(status:Int, data:String) -> Void
+ ):Void {
+ final request = Https.get(url, options, res -> {
+ var data = "";
+ res.on("data", chunk -> data += chunk.toString());
+ res.on("end", () -> callback(res.statusCode, data));
+ });
+ request.on("error", err -> {
+ trace(url);
+ trace("request error: ", err);
+ });
+ }
+
+ public static function resolvePlayerResponse(watchHtml:String):String {
+ if (watchHtml == null) return "";
+ final resReg = ~/ytInitialPlayerResponse = (.*)}}};/;
+ var matches = resReg.match(watchHtml);
+ return matches ? resReg.matched(1) + "}}}" : "";
+ }
+
+ public static function resoleM3U8Link(watchHtml:String):Null<String> {
+ if (watchHtml == null) return null;
+ final hlsReg = ~/hlsManifestUrl":"(.*\/file\/index\.m3u8)/;
+ return hlsReg.match(watchHtml) ? hlsReg.matched(1) : null;
+ }
+
+ public static function buildDecoder(watchHtml:String, callback:(decoder:(cipher:String) -> String) -> Void):Void {
+ if (watchHtml == null) return callback(null);
+
+ final jsFileUrlReg = ~/\/s\/player\/[A-Za-z0-9]+\/[A-Za-z0-9_.]+\/[A-Za-z0-9_]+\/base\.js/;
+ if (!jsFileUrlReg.match(watchHtml)) return callback(null);
+
+ final url = "https://www.youtube.com" + jsFileUrlReg.matched(0);
+ httpsGet(url, {}, (status, jsFileContent) -> {
+ final funcReg = ~/function.*\.split\(""\).*\.join\(""\)}/;
+ if (!funcReg.match(jsFileContent)) return callback(null);
+
+ final decodeFunction = funcReg.matched(0);
+ final varNameReg = ~/\.split\(""\);([a-zA-Z0-9]+)\./i;
+ if (!varNameReg.match(decodeFunction)) return callback(null);
+
+ final varStartIndex = jsFileContent.indexOf("var " + varNameReg.matched(1) + "={");
+ if (varStartIndex < 0) return callback(null);
+
+ final varEndIndex = jsFileContent.indexOf("}};", varStartIndex);
+ if (varEndIndex < 0) return callback(null);
+
+ final varDeclares = jsFileContent.substring(varStartIndex, varEndIndex + 3);
+ if (varDeclares.length == 0) return callback(null);
+
+ callback(signatureCipher -> {
+ final params = new URLSearchParams(signatureCipher);
+ final obj = Object.fromEntries(params);
+ final signature = obj.s;
+ final signatureParam = obj.sp ?? "signature";
+ final url = obj.url;
+ final decodedSignature = new Function('
+ "use strict";
+ $varDeclares
+ return ($decodeFunction)("$signature");
+ ').call(null);
+ return '$url&$signatureParam=${untyped encodeURIComponent(decodedSignature)}';
+ });
+ });
+ }
+
+ public static function getInfo(url:String, callback:(info:Null<YouTubeVideoInfo>) -> Void):Void {
+ final videoId = YoutubeUtils.extractVideoId(url);
+ if (videoId.length == 0) {
+ trace("youtube videoId is not found");
+ return callback(null);
+ }
+
+ final url = 'https://www.youtube.com/watch?v=$videoId';
+ httpsGet(url, {}, (status, data) -> {
+ if (status != 200 || data.length == 0) {
+ trace("Cannot get youtube video response");
+ return callback(null);
+ }
+
+ final ytInitialPlayerResponse = resolvePlayerResponse(data);
+ final parsedResponse = Json.parse(ytInitialPlayerResponse);
+ final streamingData:YouTubeVideoInfo = parsedResponse.streamingData ?? cast {};
+ streamingData.formats ??= [];
+ streamingData.adaptiveFormats ??= [];
+ var formats:Array<YoutubeVideoFormat> = streamingData.formats.concat(streamingData.adaptiveFormats);
+
+ final promises:Array<Promise<Any>> = [];
+
+ final isEncryptedVideo = formats.exists(it -> it.signatureCipher != null);
+ if (isEncryptedVideo) {
+ final promise = new Promise((resolve, reject) -> {
+ buildDecoder(data, decoder -> {
+ if (decoder != null) {
+ formats = formats.map(item -> {
+ if (item.url != null || item.signatureCipher == null) return item;
+
+ item.url = decoder(item.signatureCipher);
+ item.signatureCipher = null;
+ return item;
+ });
+ }
+ resolve(null);
+ });
+ });
+ promises.push(promise);
+ }
+
+ Promise.all(promises).then(_ -> {
+ final result:YouTubeVideoInfo = {
+ videoDetails: parsedResponse.videoDetails ?? cast {},
+ formats: formats.filter(format -> format.url != null)
+ };
+ if (result.videoDetails.isLiveContent) {
+ final m3u8Link = resoleM3U8Link(data);
+ try {
+ result.liveData = {
+ manifestUrl: m3u8Link,
+ // data: m3u8Parser.getResult()
+ };
+ }
+ }
+ callback(result);
+ });
+ });
+ }
+}
diff --git a/src/utils/YoutubeUtils.hx b/src/utils/YoutubeUtils.hx
new file mode 100644
index 0000000..7429565
--- /dev/null
+++ b/src/utils/YoutubeUtils.hx
@@ -0,0 +1,83 @@
+package utils;
+
+typedef YoutubeVideoDetails = {
+ viewCount:String,
+ videoId:String,
+ title:String,
+ thumbnail:{
+ thumbnails:Array<{
+ url:String,
+ width:Int,
+ height:Int,
+ }>
+ },
+ shortDescription:String,
+ lengthSeconds:String,
+ keywords:Array<String>,
+ isUnpluggedCorpus:Bool,
+ isPrivate:Bool,
+ isOwnerViewing:Bool,
+ isLiveContent:Bool,
+ isCrawlable:Bool,
+ channelId:String,
+ author:String,
+ allowRatings:Bool
+}
+
+typedef YoutubeVideoFormat = {
+ ?signatureCipher:String,
+ itag:Int,
+ width:Int,
+ height:Int,
+ url:String,
+ qualityLabel:String, // 240p, 1080p, etc
+ quality:String,
+ projectionType:String,
+ mimeType:String,
+ lastModified:String,
+ bitrate:Int,
+ approxDurationMs:String,
+ ?initRange:{start:Int, end:Int},
+ ?indexRange:{start:Int, end:Int},
+ ?audioQuality:String, // AUDIO_QUALITY_LOW
+ ?audioSampleRate:Int,
+ ?audioChannels:Int
+}
+
+typedef YouTubeVideoInfo = {
+ public var videoDetails:YoutubeVideoDetails;
+ public var ?formats:Array<YoutubeVideoFormat>;
+ public var ?adaptiveFormats:Array<YoutubeVideoFormat>;
+ public var ?liveData:{
+ manifestUrl:String,
+ };
+}
+
+class YoutubeUtils {
+ static final matchId = ~/youtube\.com.*v=([A-z0-9_-]+)/;
+ static final matchShort = ~/youtu\.be\/([A-z0-9_-]+)/;
+ static final matchShorts = ~/youtube\.com\/shorts\/([A-z0-9_-]+)/;
+ static final matchEmbed = ~/youtube\.com\/embed\/([A-z0-9_-]+)/;
+ static final matchPlaylist = ~/youtube\.com.*list=([A-z0-9_-]+)/;
+
+ public static function extractVideoId(url:String):String {
+ if (matchId.match(url)) {
+ return matchId.matched(1);
+ }
+ if (matchShort.match(url)) {
+ return matchShort.matched(1);
+ }
+ if (matchShorts.match(url)) {
+ return matchShorts.matched(1);
+ }
+ if (matchEmbed.match(url)) {
+ return matchEmbed.matched(1);
+ }
+ return "";
+ }
+
+ public static function extractPlaylistId(url:String):String {
+ if (!matchPlaylist.match(url)) return "";
+ return matchPlaylist.matched(1);
+ }
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage