diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/Types.hx | 6 | ||||
| -rw-r--r-- | src/client/Main.hx | 12 | ||||
| -rw-r--r-- | src/client/Player.hx | 4 | ||||
| -rw-r--r-- | src/client/players/Youtube.hx | 69 | ||||
| -rw-r--r-- | src/server/HttpServer.hx | 24 | ||||
| -rw-r--r-- | src/server/Main.hx | 18 | ||||
| -rw-r--r-- | src/server/YoutubeFallback.hx | 144 | ||||
| -rw-r--r-- | src/utils/YoutubeUtils.hx | 83 |
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); + } +} |
